Initial commit: Hördle Web App
This commit is contained in:
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
/app/generated/prisma
|
||||||
|
|
||||||
|
# Hördle specific
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
/public/uploads/*
|
||||||
|
!/public/uploads/.gitkeep
|
||||||
|
/data
|
||||||
67
Dockerfile
Normal file
67
Dockerfile
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Generate Prisma Client
|
||||||
|
ENV DATABASE_URL="file:./dev.db"
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Set the correct permission for prerender cache
|
||||||
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
|
|
||||||
|
# Create uploads directory and set permissions
|
||||||
|
RUN mkdir -p public/uploads && chown nextjs:nodejs public/uploads
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
# set hostname to localhost
|
||||||
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
|
# Start command: migrate DB and start server
|
||||||
|
# Note: In production, migrations should ideally be run in a separate step or init container,
|
||||||
|
# but for simplicity with SQLite we can run push here or assume the volume has the DB.
|
||||||
|
# We'll use a custom start script or just run server, assuming user handles migration or we use prisma db push on start.
|
||||||
|
# Let's use a simple entrypoint script to ensure DB exists.
|
||||||
|
CMD ["node", "server.js"]
|
||||||
64
README.md
Normal file
64
README.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Hördle
|
||||||
|
|
||||||
|
Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand kurzer Audio-Schnipsel erraten müssen.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Tägliches Rätsel:** Jeden Tag ein neuer Song für alle Nutzer.
|
||||||
|
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, bis 30s.
|
||||||
|
- **Admin Dashboard:**
|
||||||
|
- Upload von MP3-Dateien.
|
||||||
|
- Automatische Extraktion von ID3-Tags (Titel, Interpret).
|
||||||
|
- Bearbeitung von Metadaten.
|
||||||
|
- Sortierbare Song-Bibliothek.
|
||||||
|
- **Teilen-Funktion:** Ergebnisse können als Emoji-Grid geteilt werden.
|
||||||
|
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework:** Next.js 14 (App Router)
|
||||||
|
- **Styling:** Vanilla CSS
|
||||||
|
- **Datenbank:** SQLite (via Prisma ORM)
|
||||||
|
- **Deployment:** Docker & Docker Compose
|
||||||
|
|
||||||
|
## Lokale Entwicklung
|
||||||
|
|
||||||
|
1. Abhängigkeiten installieren:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Datenbank initialisieren:
|
||||||
|
```bash
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma db push
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Entwicklungsserver starten:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Die App läuft unter `http://localhost:3000`.
|
||||||
|
|
||||||
|
## Deployment mit Docker
|
||||||
|
|
||||||
|
Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||||
|
|
||||||
|
1. **Starten:**
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
Die App ist unter `http://localhost:3010` erreichbar (Port in `docker-compose.yml` konfiguriert).
|
||||||
|
|
||||||
|
2. **Daten-Persistenz:**
|
||||||
|
- Die SQLite-Datenbank wird im Ordner `./data` gespeichert.
|
||||||
|
- Hochgeladene Songs liegen in `./public/uploads`.
|
||||||
|
- Beide Ordner werden als Docker Volumes eingebunden, sodass Daten auch bei Container-Neustarts erhalten bleiben.
|
||||||
|
|
||||||
|
3. **Admin-Zugang:**
|
||||||
|
- URL: `/admin`
|
||||||
|
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern!)
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT
|
||||||
281
app/admin/page.tsx
Normal file
281
app/admin/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
filename: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortField = 'title' | 'artist';
|
||||||
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [artist, setArtist] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [songs, setSongs] = useState<Song[]>([]);
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editArtist, setEditArtist] = useState('');
|
||||||
|
|
||||||
|
// Sort state
|
||||||
|
const [sortField, setSortField] = useState<SortField>('artist');
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
const res = await fetch('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
fetchSongs();
|
||||||
|
} else {
|
||||||
|
alert('Wrong password');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSongs = async () => {
|
||||||
|
const res = await fetch('/api/songs');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSongs(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (title) formData.append('title', title);
|
||||||
|
if (artist) formData.append('artist', artist);
|
||||||
|
|
||||||
|
setMessage('Uploading...');
|
||||||
|
const res = await fetch('/api/songs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage('Song uploaded successfully!');
|
||||||
|
setTitle('');
|
||||||
|
setArtist('');
|
||||||
|
setFile(null);
|
||||||
|
fetchSongs();
|
||||||
|
} else {
|
||||||
|
setMessage('Upload failed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = (song: Song) => {
|
||||||
|
setEditingId(song.id);
|
||||||
|
setEditTitle(song.title);
|
||||||
|
setEditArtist(song.artist);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditTitle('');
|
||||||
|
setEditArtist('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEditing = async (id: number) => {
|
||||||
|
const res = await fetch('/api/songs', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, title: editTitle, artist: editArtist }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setEditingId(null);
|
||||||
|
fetchSongs();
|
||||||
|
} else {
|
||||||
|
alert('Failed to update song');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedSongs = [...songs].sort((a, b) => {
|
||||||
|
const valA = a[sortField].toLowerCase();
|
||||||
|
const valB = b[sortField].toLowerCase();
|
||||||
|
|
||||||
|
if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
|
||||||
|
if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="container" style={{ justifyContent: 'center' }}>
|
||||||
|
<h1 className="title" style={{ marginBottom: '1rem', fontSize: '1.5rem' }}>Admin Login</h1>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ marginBottom: '1rem', maxWidth: '300px' }}
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
<button onClick={handleLogin} className="btn-primary">Login</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-container">
|
||||||
|
<h1 className="title" style={{ marginBottom: '2rem' }}>Admin Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload New Song</h2>
|
||||||
|
<form onSubmit={handleUpload}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">MP3 File (Required)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="audio/mpeg"
|
||||||
|
onChange={e => setFile(e.target.files?.[0] || null)}
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Title (Optional - extracted from file if empty)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Artist (Optional - extracted from file if empty)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={artist}
|
||||||
|
onChange={e => setArtist(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
Upload Song
|
||||||
|
</button>
|
||||||
|
{message && <p style={{ textAlign: 'center', marginTop: '0.5rem' }}>{message}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-card">
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Song Library</h2>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
|
||||||
|
<th style={{ padding: '0.75rem' }}>ID</th>
|
||||||
|
<th
|
||||||
|
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
onClick={() => handleSort('title')}
|
||||||
|
>
|
||||||
|
Title {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
onClick={() => handleSort('artist')}
|
||||||
|
>
|
||||||
|
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '0.75rem' }}>Filename</th>
|
||||||
|
<th style={{ padding: '0.75rem' }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedSongs.map(song => (
|
||||||
|
<tr key={song.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<td style={{ padding: '0.75rem' }}>{song.id}</td>
|
||||||
|
|
||||||
|
{editingId === song.id ? (
|
||||||
|
<>
|
||||||
|
<td style={{ padding: '0.75rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={e => setEditTitle(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ padding: '0.25rem' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.75rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editArtist}
|
||||||
|
onChange={e => setEditArtist(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ padding: '0.25rem' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.75rem', color: '#666' }}>{song.filename}</td>
|
||||||
|
<td style={{ padding: '0.75rem' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => saveEditing(song.id)}
|
||||||
|
style={{ color: 'green', cursor: 'pointer', border: 'none', background: 'none', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelEditing}
|
||||||
|
style={{ color: 'red', cursor: 'pointer', border: 'none', background: 'none' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{song.title}</td>
|
||||||
|
<td style={{ padding: '0.75rem' }}>{song.artist}</td>
|
||||||
|
<td style={{ padding: '0.75rem', color: '#666' }}>{song.filename}</td>
|
||||||
|
<td style={{ padding: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => startEditing(song)}
|
||||||
|
style={{ color: 'blue', cursor: 'pointer', border: 'none', background: 'none', textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{songs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
|
||||||
|
No songs uploaded yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/api/admin/login/route.ts
Normal file
16
app/api/admin/login/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { password } = await request.json();
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; // Default for dev if not set
|
||||||
|
|
||||||
|
if (password === adminPassword) {
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/api/daily/route.ts
Normal file
72
app/api/daily/route.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
let dailyPuzzle = await prisma.dailyPuzzle.findUnique({
|
||||||
|
where: { date: today },
|
||||||
|
include: { song: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dailyPuzzle) {
|
||||||
|
// Find a random song to set as today's puzzle
|
||||||
|
const songsCount = await prisma.song.count();
|
||||||
|
if (songsCount === 0) {
|
||||||
|
return NextResponse.json({ error: 'No songs available' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = Math.floor(Math.random() * songsCount);
|
||||||
|
const randomSong = await prisma.song.findFirst({
|
||||||
|
skip: skip,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (randomSong) {
|
||||||
|
dailyPuzzle = await prisma.dailyPuzzle.create({
|
||||||
|
data: {
|
||||||
|
date: today,
|
||||||
|
songId: randomSong.id,
|
||||||
|
},
|
||||||
|
include: { song: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dailyPuzzle) {
|
||||||
|
return NextResponse.json({ error: 'Failed to create puzzle' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return only necessary info to client (hide title/artist initially if we want strict security,
|
||||||
|
// but for this app we might need it for validation or just return the audio URL and ID)
|
||||||
|
// Actually, we should probably NOT return the title/artist here if we want to prevent cheating via network tab,
|
||||||
|
// but the requirement says "guess the title", so we need to validate on server or client.
|
||||||
|
// For simplicity in this prototype, we'll return the ID and audio URL.
|
||||||
|
// Validation can happen in a separate "guess" endpoint or client-side if we trust the user not to inspect too much.
|
||||||
|
// Let's return the audio URL. The client will request the full song info ONLY when they give up or guess correctly?
|
||||||
|
// Or we can just return the ID and have a separate "check" endpoint.
|
||||||
|
// For now, let's return the ID and the filename (public URL).
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: dailyPuzzle.id,
|
||||||
|
audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
|
||||||
|
// We might need a hash or something to validate guesses without revealing the answer,
|
||||||
|
// but for now let's keep it simple. The client needs to know if the guess is correct.
|
||||||
|
// We can send the answer hash? Or just handle checking on the client for now (easiest but insecure).
|
||||||
|
// Let's send the answer for now, assuming this is a fun app not a competitive e-sport.
|
||||||
|
// Wait, if I send the answer, it's too easy to cheat.
|
||||||
|
// Better: The client sends a guess, the server validates.
|
||||||
|
// But the requirements didn't specify a complex backend validation.
|
||||||
|
// Let's stick to: Client gets audio. Client has a list of all songs (for autocomplete).
|
||||||
|
// Client checks if selected song ID matches the daily puzzle song ID.
|
||||||
|
// So we need to return the song ID.
|
||||||
|
songId: dailyPuzzle.songId
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching daily puzzle:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/api/songs/route.ts
Normal file
93
app/api/songs/route.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { writeFile } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { parseBuffer } from 'music-metadata';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const songs = await prisma.song.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
artist: true,
|
||||||
|
filename: true,
|
||||||
|
createdAt: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return NextResponse.json(songs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
let title = formData.get('title') as string;
|
||||||
|
let artist = formData.get('artist') as string;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// Try to extract metadata if title or artist are missing
|
||||||
|
if (!title || !artist) {
|
||||||
|
try {
|
||||||
|
const metadata = await parseBuffer(buffer, file.type);
|
||||||
|
if (!title && metadata.common.title) {
|
||||||
|
title = metadata.common.title;
|
||||||
|
}
|
||||||
|
if (!artist && metadata.common.artist) {
|
||||||
|
artist = metadata.common.artist;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse metadata:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if still missing
|
||||||
|
if (!title) title = 'Unknown Title';
|
||||||
|
if (!artist) artist = 'Unknown Artist';
|
||||||
|
|
||||||
|
const filename = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.]/g, '_')}`;
|
||||||
|
const uploadDir = path.join(process.cwd(), 'public/uploads');
|
||||||
|
|
||||||
|
await writeFile(path.join(uploadDir, filename), buffer);
|
||||||
|
|
||||||
|
const song = await prisma.song.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
filename,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(song);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading song:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const { id, title, artist } = await request.json();
|
||||||
|
|
||||||
|
if (!id || !title || !artist) {
|
||||||
|
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSong = await prisma.song.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: { title, artist },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updatedSong);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating song:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
274
app/globals.css
Normal file
274
app/globals.css
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
:root {
|
||||||
|
--foreground-rgb: 0, 0, 0;
|
||||||
|
--background-start-rgb: 214, 219, 220;
|
||||||
|
--background-end-rgb: 255, 255, 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: rgb(var(--foreground-rgb));
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
rgb(var(--background-end-rgb))
|
||||||
|
)
|
||||||
|
rgb(var(--background-start-rgb));
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout Utilities */
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game Board */
|
||||||
|
.game-board {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio Player */
|
||||||
|
.audio-player {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
flex: 1;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: #22c55e;
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Guess List */
|
||||||
|
.guess-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guess-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guess-number {
|
||||||
|
width: 1.5rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guess-text {
|
||||||
|
color: #ef4444; /* Red for wrong */
|
||||||
|
}
|
||||||
|
|
||||||
|
.guess-text.skipped {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guess-text.correct {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
.input-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guess-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guess-input:focus {
|
||||||
|
outline: 2px solid #000;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-list {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
max-height: 15rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 10;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-artist {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #666;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-button:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.message-box {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-box.success {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-box.failure {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin */
|
||||||
|
.admin-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
32
app/layout.tsx
Normal file
32
app/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Next App",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
app/page.module.css
Normal file
141
app/page.module.css
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
.page {
|
||||||
|
--background: #fafafa;
|
||||||
|
--foreground: #fff;
|
||||||
|
|
||||||
|
--text-primary: #000;
|
||||||
|
--text-secondary: #666;
|
||||||
|
|
||||||
|
--button-primary-hover: #383838;
|
||||||
|
--button-secondary-hover: #f2f2f2;
|
||||||
|
--button-secondary-border: #ebebeb;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-geist-sans);
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: var(--foreground);
|
||||||
|
padding: 120px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro h1 {
|
||||||
|
max-width: 320px;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 48px;
|
||||||
|
letter-spacing: -2.4px;
|
||||||
|
text-wrap: balance;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro p {
|
||||||
|
max-width: 440px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-wrap: balance;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctas {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctas a {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 128px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
width: fit-content;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.primary {
|
||||||
|
background: var(--text-primary);
|
||||||
|
color: var(--background);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.secondary {
|
||||||
|
border-color: var(--button-secondary-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enable hover only on non-touch devices */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
a.primary:hover {
|
||||||
|
background: var(--button-primary-hover);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.secondary:hover {
|
||||||
|
background: var(--button-secondary-hover);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.main {
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 40px;
|
||||||
|
letter-spacing: -1.92px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.logo {
|
||||||
|
filter: invert();
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
--background: #000;
|
||||||
|
--foreground: #000;
|
||||||
|
|
||||||
|
--text-primary: #ededed;
|
||||||
|
--text-secondary: #999;
|
||||||
|
|
||||||
|
--button-primary-hover: #ccc;
|
||||||
|
--button-secondary-hover: #1a1a1a;
|
||||||
|
--button-secondary-border: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/page.tsx
Normal file
57
app/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Game from '@/components/Game';
|
||||||
|
|
||||||
|
async function getDailyPuzzle() {
|
||||||
|
try {
|
||||||
|
// In a real app, use absolute URL or internal API call
|
||||||
|
// Since we are server-side, we can call the DB directly or fetch the API
|
||||||
|
// Calling API requires full URL (http://localhost:3000...), which is tricky in some envs
|
||||||
|
// Better to call DB directly here or use a helper function shared with the API
|
||||||
|
// But for simplicity, let's fetch the API if we can, or just use Prisma directly.
|
||||||
|
// Using Prisma directly is better for Server Components.
|
||||||
|
|
||||||
|
const { PrismaClient } = await import('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
let dailyPuzzle = await prisma.dailyPuzzle.findUnique({
|
||||||
|
where: { date: today },
|
||||||
|
include: { song: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dailyPuzzle) {
|
||||||
|
// Trigger generation logic (same as API)
|
||||||
|
// Duplicating logic is bad, but for now it's quickest.
|
||||||
|
// Ideally extract "getOrCreateDailyPuzzle" to a lib.
|
||||||
|
const songsCount = await prisma.song.count();
|
||||||
|
if (songsCount > 0) {
|
||||||
|
const skip = Math.floor(Math.random() * songsCount);
|
||||||
|
const randomSong = await prisma.song.findFirst({ skip });
|
||||||
|
if (randomSong) {
|
||||||
|
dailyPuzzle = await prisma.dailyPuzzle.create({
|
||||||
|
data: { date: today, songId: randomSong.id },
|
||||||
|
include: { song: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dailyPuzzle) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dailyPuzzle.id,
|
||||||
|
audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
|
||||||
|
songId: dailyPuzzle.songId
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const dailyPuzzle = await getDailyPuzzle();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Game dailyPuzzle={dailyPuzzle} />
|
||||||
|
);
|
||||||
|
}
|
||||||
87
components/AudioPlayer.tsx
Normal file
87
components/AudioPlayer.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface AudioPlayerProps {
|
||||||
|
src: string;
|
||||||
|
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
|
||||||
|
onPlay?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AudioPlayer({ src, unlockedSeconds, onPlay }: AudioPlayerProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
setIsPlaying(false);
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
|
}, [src, unlockedSeconds]);
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current.play();
|
||||||
|
onPlay?.();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
const current = audioRef.current.currentTime;
|
||||||
|
const percent = (current / unlockedSeconds) * 100;
|
||||||
|
setProgress(Math.min(percent, 100));
|
||||||
|
|
||||||
|
if (current >= unlockedSeconds) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
setIsPlaying(false);
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="audio-player">
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={src}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="player-controls">
|
||||||
|
<button onClick={togglePlay} className="play-button">
|
||||||
|
{isPlaying ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="24" height="24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="24" height="24" style={{ marginLeft: '4px' }}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="progress-bar-container">
|
||||||
|
<div
|
||||||
|
className="progress-bar"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.875rem', color: '#4b5563' }}>
|
||||||
|
{unlockedSeconds}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
components/Game.tsx
Normal file
145
components/Game.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import AudioPlayer from './AudioPlayer';
|
||||||
|
import GuessInput from './GuessInput';
|
||||||
|
import { useGameState } from '../lib/gameState';
|
||||||
|
|
||||||
|
interface GameProps {
|
||||||
|
dailyPuzzle: {
|
||||||
|
id: number;
|
||||||
|
audioUrl: string;
|
||||||
|
songId: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30];
|
||||||
|
|
||||||
|
export default function Game({ dailyPuzzle }: GameProps) {
|
||||||
|
const { gameState, addGuess } = useGameState();
|
||||||
|
const [hasWon, setHasWon] = useState(false);
|
||||||
|
const [hasLost, setHasLost] = useState(false);
|
||||||
|
const [shareText, setShareText] = useState('Share Result');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameState && dailyPuzzle) {
|
||||||
|
setHasWon(gameState.isSolved);
|
||||||
|
setHasLost(gameState.isFailed);
|
||||||
|
}
|
||||||
|
}, [gameState, dailyPuzzle]);
|
||||||
|
|
||||||
|
if (!dailyPuzzle) return <div>Loading puzzle...</div>;
|
||||||
|
if (!gameState) return <div>Loading state...</div>;
|
||||||
|
|
||||||
|
const handleGuess = (song: any) => {
|
||||||
|
if (song.id === dailyPuzzle.songId) {
|
||||||
|
addGuess(song.title, true);
|
||||||
|
setHasWon(true);
|
||||||
|
} else {
|
||||||
|
addGuess(song.title, false);
|
||||||
|
if (gameState.guesses.length + 1 >= 6) {
|
||||||
|
setHasLost(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlockedSeconds = UNLOCK_STEPS[Math.min(gameState.guesses.length, 5)];
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
let emojiGrid = '';
|
||||||
|
const totalGuesses = 6;
|
||||||
|
|
||||||
|
// Build the grid
|
||||||
|
for (let i = 0; i < totalGuesses; i++) {
|
||||||
|
if (i < gameState.guesses.length) {
|
||||||
|
// If this was the winning guess (last one and won)
|
||||||
|
if (hasWon && i === gameState.guesses.length - 1) {
|
||||||
|
emojiGrid += '🟩';
|
||||||
|
} else {
|
||||||
|
// Wrong or skipped
|
||||||
|
emojiGrid += '⬛';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unused attempts
|
||||||
|
emojiGrid += '⬜';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const speaker = hasWon ? '🔉' : '🔇';
|
||||||
|
const text = `Hördle #${dailyPuzzle.id}\n\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\nhttps://hoerdle.elpatron.me`;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setShareText('Copied!');
|
||||||
|
setTimeout(() => setShareText('Share Result'), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<header className="header">
|
||||||
|
<h1 className="title">Hördle #{dailyPuzzle.id}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="game-board">
|
||||||
|
|
||||||
|
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||||
|
<div className="status-bar">
|
||||||
|
<span>Attempt {gameState.guesses.length + 1} / 6</span>
|
||||||
|
<span>{unlockedSeconds}s unlocked</span>
|
||||||
|
</div>
|
||||||
|
<AudioPlayer
|
||||||
|
src={dailyPuzzle.audioUrl}
|
||||||
|
unlockedSeconds={unlockedSeconds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="guess-list">
|
||||||
|
{gameState.guesses.map((guess, i) => {
|
||||||
|
const isCorrect = hasWon && i === gameState.guesses.length - 1;
|
||||||
|
return (
|
||||||
|
<div key={i} className="guess-item">
|
||||||
|
<span className="guess-number">#{i + 1}</span>
|
||||||
|
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
|
||||||
|
{isCorrect ? 'Correct!' : guess}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasWon && !hasLost && (
|
||||||
|
<>
|
||||||
|
<GuessInput onGuess={handleGuess} disabled={false} />
|
||||||
|
<button
|
||||||
|
onClick={() => addGuess("SKIPPED", false)}
|
||||||
|
className="skip-button"
|
||||||
|
>
|
||||||
|
Skip (+{UNLOCK_STEPS[Math.min(gameState.guesses.length + 1, 5)] - unlockedSeconds}s)
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasWon && (
|
||||||
|
<div className="message-box success">
|
||||||
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
|
||||||
|
<p>Come back tomorrow for a new song.</p>
|
||||||
|
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||||
|
{shareText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasLost && (
|
||||||
|
<div className="message-box failure">
|
||||||
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2>
|
||||||
|
<p>The song was hidden.</p>
|
||||||
|
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||||
|
{shareText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
components/GuessInput.tsx
Normal file
76
components/GuessInput.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GuessInputProps {
|
||||||
|
onGuess: (song: Song) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [songs, setSongs] = useState<Song[]>([]);
|
||||||
|
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/songs')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setSongs(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.length > 1) {
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
const filtered = songs.filter(s =>
|
||||||
|
s.title.toLowerCase().includes(lower) ||
|
||||||
|
s.artist.toLowerCase().includes(lower)
|
||||||
|
);
|
||||||
|
setFilteredSongs(filtered);
|
||||||
|
setIsOpen(true);
|
||||||
|
} else {
|
||||||
|
setFilteredSongs([]);
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}, [query, songs]);
|
||||||
|
|
||||||
|
const handleSelect = (song: Song) => {
|
||||||
|
onGuess(song);
|
||||||
|
setQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="input-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={disabled ? "Game Over" : "Know it? Search for the artist / title"}
|
||||||
|
className="guess-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && filteredSongs.length > 0 && (
|
||||||
|
<ul className="suggestions-list">
|
||||||
|
{filteredSongs.map(song => (
|
||||||
|
<li
|
||||||
|
key={song.id}
|
||||||
|
onClick={() => handleSelect(song)}
|
||||||
|
className="suggestion-item"
|
||||||
|
>
|
||||||
|
<div className="suggestion-title">{song.title}</div>
|
||||||
|
<div className="suggestion-artist">{song.artist}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
hoerdle:
|
||||||
|
container_name: hoerdle
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3010:3000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=file:/app/data/prod.db
|
||||||
|
- ADMIN_PASSWORD=admin123 # Change this!
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./public/uploads:/app/public/uploads
|
||||||
|
# Initialize DB if needed
|
||||||
|
command: >
|
||||||
|
sh -c "npx prisma db push && node server.js"
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
66
lib/gameState.ts
Normal file
66
lib/gameState.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
date: string;
|
||||||
|
guesses: string[]; // Array of song titles or IDs guessed
|
||||||
|
isSolved: boolean;
|
||||||
|
isFailed: boolean;
|
||||||
|
lastPlayed: number; // Timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'hoerdle_game_state';
|
||||||
|
|
||||||
|
export function useGameState() {
|
||||||
|
const [gameState, setGameState] = useState<GameState | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
const parsed: GameState = JSON.parse(stored);
|
||||||
|
if (parsed.date === today) {
|
||||||
|
setGameState(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New day or no state
|
||||||
|
const newState: GameState = {
|
||||||
|
date: today,
|
||||||
|
guesses: [],
|
||||||
|
isSolved: false,
|
||||||
|
isFailed: false,
|
||||||
|
lastPlayed: Date.now(),
|
||||||
|
};
|
||||||
|
setGameState(newState);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveState = (newState: GameState) => {
|
||||||
|
setGameState(newState);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addGuess = (guess: string, correct: boolean) => {
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const newGuesses = [...gameState.guesses, guess];
|
||||||
|
const isSolved = correct;
|
||||||
|
const isFailed = !correct && newGuesses.length >= 6;
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
...gameState,
|
||||||
|
guesses: newGuesses,
|
||||||
|
isSolved,
|
||||||
|
isFailed,
|
||||||
|
lastPlayed: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
saveState(newState);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { gameState, addGuess };
|
||||||
|
}
|
||||||
9
next.config.ts
Normal file
9
next.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
reactCompiler: true,
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6544
package-lock.json
generated
Normal file
6544
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "hoerdle",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.19.0",
|
||||||
|
"music-metadata": "^11.10.2",
|
||||||
|
"next": "16.0.3",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.0.3",
|
||||||
|
"prisma": "^6.19.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
prisma.config.ts
Normal file
12
prisma.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
engine: "classic",
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
27
prisma/schema.prisma
Normal file
27
prisma/schema.prisma
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Song {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
artist String
|
||||||
|
filename String // Filename in public/uploads
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
puzzles DailyPuzzle[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model DailyPuzzle {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
date String @unique // Format: YYYY-MM-DD
|
||||||
|
songId Int
|
||||||
|
song Song @relation(fields: [songId], references: [id])
|
||||||
|
}
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
0
public/uploads/.gitkeep
Normal file
0
public/uploads/.gitkeep
Normal file
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
41
walkthrough.md
Normal file
41
walkthrough.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Hördle - Walkthrough
|
||||||
|
|
||||||
|
Die Hördle Webapp ist nun einsatzbereit. Hier ist eine Anleitung, wie du sie nutzt und verwaltest.
|
||||||
|
|
||||||
|
## Starten der App
|
||||||
|
|
||||||
|
1. Öffne ein Terminal im Projektverzeichnis: `/home/markus/hördle`
|
||||||
|
2. Starte den Entwicklungsserver:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
3. Öffne `http://localhost:3000` im Browser.
|
||||||
|
|
||||||
|
## Admin-Bereich (Songs hochladen)
|
||||||
|
|
||||||
|
1. Gehe zu `http://localhost:3000/admin`
|
||||||
|
2. Logge dich ein. Das Standard-Passwort ist `admin123` (kann in `.env` geändert werden).
|
||||||
|
3. **Upload**: Wähle eine MP3-Datei aus. Titel und Interpret werden automatisch aus den ID3-Tags ausgelesen, falls du die Felder leer lässt.
|
||||||
|
4. **Bibliothek**: Unter dem Upload-Formular siehst du eine Tabelle aller verfügbaren Songs.
|
||||||
|
|
||||||
|
## Spielablauf
|
||||||
|
|
||||||
|
- Das Spiel wählt jeden Tag (um Mitternacht) automatisch einen neuen Song aus der Datenbank.
|
||||||
|
- Wenn noch kein Song für den heutigen Tag festgelegt wurde, wird beim ersten Aufruf der Seite zufällig einer ausgewählt.
|
||||||
|
- Der Spieler hat 6 Versuche.
|
||||||
|
- Der Fortschritt wird im LocalStorage des Browsers gespeichert.
|
||||||
|
|
||||||
|
## Technologien
|
||||||
|
|
||||||
|
- **Framework**: Next.js 14 (App Router)
|
||||||
|
- **Datenbank**: SQLite (via Prisma)
|
||||||
|
- **Styling**: Vanilla CSS (in `app/globals.css`)
|
||||||
|
- **State**: React Hooks + LocalStorage
|
||||||
|
|
||||||
|
## Wichtige Dateien
|
||||||
|
|
||||||
|
- `app/page.tsx`: Hauptseite des Spiels.
|
||||||
|
- `components/Game.tsx`: Die Spiellogik.
|
||||||
|
- `components/AudioPlayer.tsx`: Der Audio-Player mit Segment-Logik.
|
||||||
|
- `app/api/daily/route.ts`: API für das tägliche Rätsel.
|
||||||
|
- `prisma/schema.prisma`: Datenbank-Schema.
|
||||||
Reference in New Issue
Block a user