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

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} />
);
}