Initial commit: Hördle Web App

This commit is contained in:
Hördle Bot
2025-11-21 12:25:19 +01:00
commit c1bd141042
31 changed files with 8259 additions and 0 deletions

50
.gitignore vendored Normal file
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

274
app/globals.css Normal file
View 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
View 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
View 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
View 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} />
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

1
public/vercel.svg Normal file
View 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
View 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
View 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
View 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.