12 Commits

15 changed files with 1924 additions and 15 deletions

View File

@@ -41,7 +41,15 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Live-Vorschau beim Hovern über die Waveform. - Live-Vorschau beim Hovern über die Waveform.
- Playback-Cursor zeigt aktuelle Abspielposition. - Playback-Cursor zeigt aktuelle Abspielposition.
- Einzelne Segmente zum Testen abspielen. - Einzelne Segmente zum Testen abspielen.
- Einzelne Segmente zum Testen abspielen.
- Manuelle Speicherung mit visueller Bestätigung. - Manuelle Speicherung mit visueller Bestätigung.
- **News & Announcements:**
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
- **Markdown Support:** Formatierung von Texten, Links und Listen.
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
- **Special-Verknüpfung:** Direkte Links zu Specials in News-Beiträgen.
- Verwaltung über das Admin-Dashboard.
## Spielregeln & Punktesystem ## Spielregeln & Punktesystem

View File

@@ -1,4 +1,5 @@
import Game from '@/components/Game'; import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle'; import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link'; import Link from 'next/link';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@@ -102,6 +103,7 @@ export default async function GenrePage({ params }: PageProps) {
</div> </div>
)} )}
</div> </div>
<NewsSection />
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} /> <Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
</> </>
); );

View File

@@ -51,6 +51,20 @@ interface Song {
excludeFromGlobal: boolean; excludeFromGlobal: boolean;
} }
interface News {
id: number;
title: string;
content: string;
author: string | null;
publishedAt: string;
featured: boolean;
specialId: number | null;
special: {
id: number;
name: string;
} | null;
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating'; type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
@@ -92,6 +106,20 @@ export default function AdminPage() {
const [editSpecialEndDate, setEditSpecialEndDate] = useState(''); const [editSpecialEndDate, setEditSpecialEndDate] = useState('');
const [editSpecialCurator, setEditSpecialCurator] = useState(''); const [editSpecialCurator, setEditSpecialCurator] = useState('');
// News state
const [news, setNews] = useState<News[]>([]);
const [newNewsTitle, setNewNewsTitle] = useState('');
const [newNewsContent, setNewNewsContent] = useState('');
const [newNewsAuthor, setNewNewsAuthor] = useState('');
const [newNewsFeatured, setNewNewsFeatured] = useState(false);
const [newNewsSpecialId, setNewNewsSpecialId] = useState<number | null>(null);
const [editingNewsId, setEditingNewsId] = useState<number | null>(null);
const [editNewsTitle, setEditNewsTitle] = useState('');
const [editNewsContent, setEditNewsContent] = useState('');
const [editNewsAuthor, setEditNewsAuthor] = useState('');
const [editNewsFeatured, setEditNewsFeatured] = useState(false);
const [editNewsSpecialId, setEditNewsSpecialId] = useState<number | null>(null);
// Edit state // Edit state
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState(''); const [editTitle, setEditTitle] = useState('');
@@ -142,6 +170,8 @@ export default function AdminPage() {
fetchSongs(); fetchSongs();
fetchGenres(); fetchGenres();
fetchDailyPuzzles(); fetchDailyPuzzles();
fetchSpecials();
fetchNews();
} }
}, []); }, []);
@@ -156,6 +186,8 @@ export default function AdminPage() {
fetchSongs(); fetchSongs();
fetchGenres(); fetchGenres();
fetchDailyPuzzles(); fetchDailyPuzzles();
fetchSpecials();
fetchNews();
} else { } else {
alert('Wrong password'); alert('Wrong password');
} }
@@ -394,6 +426,94 @@ export default function AdminPage() {
} }
}; };
// News functions
const fetchNews = async () => {
const res = await fetch('/api/news', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setNews(data);
}
};
const handleCreateNews = async (e: React.FormEvent) => {
e.preventDefault();
if (!newNewsTitle.trim() || !newNewsContent.trim()) return;
const res = await fetch('/api/news', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
title: newNewsTitle,
content: newNewsContent,
author: newNewsAuthor || null,
featured: newNewsFeatured,
specialId: newNewsSpecialId
}),
});
if (res.ok) {
setNewNewsTitle('');
setNewNewsContent('');
setNewNewsAuthor('');
setNewNewsFeatured(false);
setNewNewsSpecialId(null);
fetchNews();
} else {
alert('Failed to create news');
}
};
const startEditNews = (newsItem: News) => {
setEditingNewsId(newsItem.id);
setEditNewsTitle(newsItem.title);
setEditNewsContent(newsItem.content);
setEditNewsAuthor(newsItem.author || '');
setEditNewsFeatured(newsItem.featured);
setEditNewsSpecialId(newsItem.specialId);
};
const saveEditedNews = async () => {
if (editingNewsId === null) return;
const res = await fetch('/api/news', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
id: editingNewsId,
title: editNewsTitle,
content: editNewsContent,
author: editNewsAuthor || null,
featured: editNewsFeatured,
specialId: editNewsSpecialId
}),
});
if (res.ok) {
setEditingNewsId(null);
fetchNews();
} else {
alert('Failed to update news');
}
};
const handleDeleteNews = async (id: number) => {
if (!confirm('Delete this news item?')) return;
const res = await fetch('/api/news', {
method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) {
fetchNews();
} else {
alert('Failed to delete news');
}
};
// Load specials after auth // Load specials after auth
useEffect(() => { useEffect(() => {
if (isAuthenticated) fetchSpecials(); if (isAuthenticated) fetchSpecials();
@@ -1182,6 +1302,163 @@ export default function AdminPage() {
)} )}
</div> </div>
{/* News Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage News & Announcements</h2>
<form onSubmit={handleCreateNews} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<input
type="text"
value={newNewsTitle}
onChange={e => setNewNewsTitle(e.target.value)}
placeholder="News Title"
className="form-input"
required
/>
<textarea
value={newNewsContent}
onChange={e => setNewNewsContent(e.target.value)}
placeholder="Content (Markdown supported)"
className="form-input"
rows={4}
required
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
/>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
value={newNewsAuthor}
onChange={e => setNewNewsAuthor(e.target.value)}
placeholder="Author (optional)"
className="form-input"
style={{ maxWidth: '200px' }}
/>
<select
value={newNewsSpecialId || ''}
onChange={e => setNewNewsSpecialId(e.target.value ? Number(e.target.value) : null)}
className="form-input"
style={{ maxWidth: '200px' }}
>
<option value="">No Special Link</option>
{specials.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={newNewsFeatured}
onChange={e => setNewNewsFeatured(e.target.checked)}
/>
Featured
</label>
<button type="submit" className="btn-primary">Add News</button>
</div>
</div>
</form>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{news.map(newsItem => (
<div key={newsItem.id} style={{
background: newsItem.featured ? '#fef3c7' : '#f3f4f6',
padding: '0.75rem',
borderRadius: '0.5rem',
border: newsItem.featured ? '2px solid #f59e0b' : '1px solid #e5e7eb'
}}>
{editingNewsId === newsItem.id ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<input
type="text"
value={editNewsTitle}
onChange={e => setEditNewsTitle(e.target.value)}
className="form-input"
/>
<textarea
value={editNewsContent}
onChange={e => setEditNewsContent(e.target.value)}
className="form-input"
rows={4}
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
/>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
value={editNewsAuthor}
onChange={e => setEditNewsAuthor(e.target.value)}
placeholder="Author"
className="form-input"
style={{ maxWidth: '200px' }}
/>
<select
value={editNewsSpecialId || ''}
onChange={e => setEditNewsSpecialId(e.target.value ? Number(e.target.value) : null)}
className="form-input"
style={{ maxWidth: '200px' }}
>
<option value="">No Special Link</option>
{specials.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={editNewsFeatured}
onChange={e => setEditNewsFeatured(e.target.checked)}
/>
Featured
</label>
<button onClick={saveEditedNews} className="btn-primary">Save</button>
<button onClick={() => setEditingNewsId(null)} className="btn-secondary">Cancel</button>
</div>
</div>
) : (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
{newsItem.featured && (
<span style={{
background: '#f59e0b',
color: 'white',
padding: '0.125rem 0.375rem',
borderRadius: '0.25rem',
fontSize: '0.625rem',
fontWeight: '600'
}}>
FEATURED
</span>
)}
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: '600' }}>{newsItem.title}</h3>
</div>
<div style={{ fontSize: '0.75rem', color: '#666', marginBottom: '0.5rem' }}>
{new Date(newsItem.publishedAt).toLocaleDateString('de-DE')}
{newsItem.author && ` • by ${newsItem.author}`}
{newsItem.special && ` • ★ ${newsItem.special.name}`}
</div>
<p style={{ margin: 0, fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>
{newsItem.content.length > 150
? newsItem.content.substring(0, 150) + '...'
: newsItem.content}
</p>
</div>
<div style={{ display: 'flex', gap: '0.25rem', marginLeft: '1rem' }}>
<button onClick={() => startEditNews(newsItem)} className="btn-secondary" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>Edit</button>
<button onClick={() => handleDeleteNews(newsItem.id)} className="btn-danger" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>Delete</button>
</div>
</div>
</>
)}
</div>
))}
{news.length === 0 && (
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', padding: '1rem' }}>
No news items yet. Create one above!
</p>
)}
</div>
</div>
<div className="admin-card" style={{ marginBottom: '2rem' }}> <div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload Songs</h2> <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload Songs</h2>
<form onSubmit={handleBatchUpload}> <form onSubmit={handleBatchUpload}>

146
app/api/news/route.ts Normal file
View File

@@ -0,0 +1,146 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
// GET /api/news - Public endpoint to fetch news
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get('limit') || '10');
const featuredOnly = searchParams.get('featured') === 'true';
const where = featuredOnly ? { featured: true } : {};
const news = await prisma.news.findMany({
where,
orderBy: { publishedAt: 'desc' },
take: limit,
include: {
special: {
select: {
id: true,
name: true
}
}
}
});
return NextResponse.json(news);
} catch (error) {
console.error('Error fetching news:', error);
return NextResponse.json({ error: 'Failed to fetch news' }, { status: 500 });
}
}
// POST /api/news - Create news (requires auth)
export async function POST(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { title, content, author, featured, specialId } = body;
if (!title || !content) {
return NextResponse.json(
{ error: 'Title and content are required' },
{ status: 400 }
);
}
const news = await prisma.news.create({
data: {
title,
content,
author: author || null,
featured: featured || false,
specialId: specialId || null
},
include: {
special: {
select: {
id: true,
name: true
}
}
}
});
return NextResponse.json(news, { status: 201 });
} catch (error) {
console.error('Error creating news:', error);
return NextResponse.json({ error: 'Failed to create news' }, { status: 500 });
}
}
// PUT /api/news - Update news (requires auth)
export async function PUT(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { id, title, content, author, featured, specialId } = body;
if (!id) {
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
}
const updateData: any = {};
if (title !== undefined) updateData.title = title;
if (content !== undefined) updateData.content = content;
if (author !== undefined) updateData.author = author || null;
if (featured !== undefined) updateData.featured = featured;
if (specialId !== undefined) updateData.specialId = specialId || null;
const news = await prisma.news.update({
where: { id },
data: updateData,
include: {
special: {
select: {
id: true,
name: true
}
}
}
});
return NextResponse.json(news);
} catch (error) {
console.error('Error updating news:', error);
return NextResponse.json({ error: 'Failed to update news' }, { status: 500 });
}
}
// DELETE /api/news - Delete news (requires auth)
export async function DELETE(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { id } = body;
if (!id) {
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
}
await prisma.news.delete({
where: { id }
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting news:', error);
return NextResponse.json({ error: 'Failed to delete news' }, { status: 500 });
}
}

View File

@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -34,6 +35,14 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<head>
<Script
defer
data-domain="hoerdle.elpatron.me"
src="https://plausible.elpatron.me/js/script.js"
strategy="beforeInteractive"
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}> <body className={`${geistSans.variable} ${geistMono.variable}`}>
{children} {children}
<InstallPrompt /> <InstallPrompt />

View File

@@ -1,4 +1,5 @@
import Game from '@/components/Game'; import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle'; import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link'; import Link from 'next/link';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@@ -93,6 +94,9 @@ export default async function Home() {
</div> </div>
)} )}
</div> </div>
<NewsSection />
<Game dailyPuzzle={dailyPuzzle} genre={null} /> <Game dailyPuzzle={dailyPuzzle} genre={null} />
</> </>
); );

View File

@@ -1,4 +1,5 @@
import Game from '@/components/Game'; import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle'; import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link'; import Link from 'next/link';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@@ -44,7 +45,10 @@ export default async function SpecialPage({ params }: PageProps) {
} }
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName); const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } }); const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } }); const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const activeSpecials = specials.filter(s => { const activeSpecials = specials.filter(s => {
@@ -94,6 +98,7 @@ export default async function SpecialPage({ params }: PageProps) {
))} ))}
</div> </div>
</div> </div>
<NewsSection />
<Game <Game
dailyPuzzle={dailyPuzzle} dailyPuzzle={dailyPuzzle}
genre={decodedName} genre={decodedName}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
interface AudioPlayerProps { interface AudioPlayerProps {
src: string; src: string;
@@ -9,9 +9,14 @@ interface AudioPlayerProps {
onPlay?: () => void; onPlay?: () => void;
onReplay?: () => void; onReplay?: () => void;
autoPlay?: boolean; autoPlay?: boolean;
onHasPlayedChange?: (hasPlayed: boolean) => void;
} }
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false }: AudioPlayerProps) { export interface AudioPlayerRef {
play: () => void;
}
const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false, onHasPlayedChange }, ref) => {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
@@ -24,6 +29,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
setIsPlaying(false); setIsPlaying(false);
setProgress(0); setProgress(0);
setHasPlayedOnce(false); // Reset for new segment setHasPlayedOnce(false); // Reset for new segment
onHasPlayedChange?.(false); // Notify parent
if (autoPlay) { if (autoPlay) {
const playPromise = audioRef.current.play(); const playPromise = audioRef.current.play();
@@ -33,6 +39,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
setIsPlaying(true); setIsPlaying(true);
onPlay?.(); onPlay?.();
setHasPlayedOnce(true); setHasPlayedOnce(true);
onHasPlayedChange?.(true); // Notify parent
}) })
.catch(error => { .catch(error => {
console.log("Autoplay prevented:", error); console.log("Autoplay prevented:", error);
@@ -43,6 +50,30 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
} }
}, [src, unlockedSeconds, startTime, autoPlay]); }, [src, unlockedSeconds, startTime, autoPlay]);
// Expose play method to parent component
useImperativeHandle(ref, () => ({
play: () => {
if (!audioRef.current) return;
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
setIsPlaying(true);
onPlay?.();
if (!hasPlayedOnce) {
setHasPlayedOnce(true);
onHasPlayedChange?.(true);
}
})
.catch(error => {
console.error("Play failed:", error);
setIsPlaying(false);
});
}
}
}));
const togglePlay = () => { const togglePlay = () => {
if (!audioRef.current) return; if (!audioRef.current) return;
@@ -56,6 +87,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
onReplay?.(); onReplay?.();
} else { } else {
setHasPlayedOnce(true); setHasPlayedOnce(true);
onHasPlayedChange?.(true); // Notify parent
} }
} }
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
@@ -112,4 +144,8 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
</div> </div>
</div> </div>
); );
} });
AudioPlayer.displayName = 'AudioPlayer';
export default AudioPlayer;

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import AudioPlayer from './AudioPlayer'; import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
import GuessInput from './GuessInput'; import GuessInput from './GuessInput';
import Statistics from './Statistics'; import Statistics from './Statistics';
import { useGameState } from '../lib/gameState'; import { useGameState } from '../lib/gameState';
@@ -37,6 +37,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [timeUntilNext, setTimeUntilNext] = useState(''); const [timeUntilNext, setTimeUntilNext] = useState('');
const [hasRated, setHasRated] = useState(false); const [hasRated, setHasRated] = useState(false);
const [showYearModal, setShowYearModal] = useState(false); const [showYearModal, setShowYearModal] = useState(false);
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
const audioPlayerRef = useRef<AudioPlayerRef>(null);
useEffect(() => { useEffect(() => {
const updateCountdown = () => { const updateCountdown = () => {
@@ -116,7 +118,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
setTimeout(() => setIsProcessingGuess(false), 500); setTimeout(() => setIsProcessingGuess(false), 500);
}; };
const handleStartAudio = () => {
// This will be called when user clicks "Start" button on first attempt
// Trigger the audio player to start playing
audioPlayerRef.current?.play();
setHasPlayedAudio(true);
};
const handleSkip = () => { const handleSkip = () => {
// If user hasn't played audio yet on first attempt, start it instead of skipping
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
handleStartAudio();
return;
}
setLastAction('SKIP'); setLastAction('SKIP');
addGuess("SKIPPED", false); addGuess("SKIPPED", false);
@@ -237,7 +252,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
<div className="container"> <div className="container">
<header className="header"> <header className="header">
<h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1> <h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '-0.5rem', marginBottom: '1rem' }}> <div style={{ fontSize: '0.9rem', color: '#666', marginTop: '0.5rem', marginBottom: '1rem' }}>
Next puzzle in: {timeUntilNext} Next puzzle in: {timeUntilNext}
</div> </div>
</header> </header>
@@ -252,11 +267,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} /> <ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
<AudioPlayer <AudioPlayer
ref={audioPlayerRef}
src={dailyPuzzle.audioUrl} src={dailyPuzzle.audioUrl}
unlockedSeconds={unlockedSeconds} unlockedSeconds={unlockedSeconds}
startTime={dailyPuzzle.startTime} startTime={dailyPuzzle.startTime}
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)} autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
onReplay={addReplay} onReplay={addReplay}
onHasPlayedChange={setHasPlayedAudio}
/> />
</div> </div>
@@ -282,7 +299,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
onClick={handleSkip} onClick={handleSkip}
className="skip-button" className="skip-button"
> >
Skip (+{unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s) {gameState.guesses.length === 0 && !hasPlayedAudio
? 'Start'
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
}
</button> </button>
) : ( ) : (
<button <button

199
components/NewsSection.tsx Normal file
View File

@@ -0,0 +1,199 @@
'use client';
import { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import Link from 'next/link';
interface NewsItem {
id: number;
title: string;
content: string;
author: string | null;
publishedAt: string;
featured: boolean;
special: {
id: number;
name: string;
} | null;
}
export default function NewsSection() {
const [news, setNews] = useState<NewsItem[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchNews();
}, []);
const fetchNews = async () => {
try {
const res = await fetch('/api/news?limit=3');
if (res.ok) {
const data = await res.json();
setNews(data);
}
} catch (error) {
console.error('Failed to fetch news:', error);
} finally {
setLoading(false);
}
};
if (loading || news.length === 0) {
return null; // Don't show anything if no news
}
return (
<div style={{
background: '#f9fafb',
borderRadius: '0.5rem',
margin: '1rem auto',
maxWidth: '800px',
overflow: 'hidden',
border: '1px solid #e5e7eb'
}}>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
style={{
width: '100%',
padding: '0.75rem 1rem',
background: 'transparent',
border: 'none',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: '600',
color: '#374151'
}}
>
<span>📰 News & Updates</span>
<span style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
{isExpanded ? '▼' : '▶'}
</span>
</button>
{/* Content */}
{isExpanded && (
<div style={{
padding: '0 1rem 1rem 1rem',
borderTop: '1px solid #e5e7eb'
}}>
{news.map((item, index) => (
<div
key={item.id}
style={{
padding: '0.75rem 0',
borderBottom: index < news.length - 1 ? '1px solid #e5e7eb' : 'none'
}}
>
{/* Title */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '0.25rem'
}}>
{item.featured && (
<span style={{
background: '#fef3c7',
color: '#92400e',
padding: '0.125rem 0.375rem',
borderRadius: '0.25rem',
fontSize: '0.625rem',
fontWeight: '600'
}}>
FEATURED
</span>
)}
<h3 style={{
margin: 0,
fontSize: '0.875rem',
fontWeight: '600',
color: '#111827'
}}>
{item.title}
</h3>
</div>
{/* Metadata */}
<div style={{
fontSize: '0.75rem',
color: '#6b7280',
marginBottom: '0.5rem',
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap'
}}>
<span>
{new Date(item.publishedAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</span>
{item.author && (
<>
<span></span>
<span>by {item.author}</span>
</>
)}
{item.special && (
<>
<span></span>
<Link
href={`/special/${item.special.name}`}
style={{
color: '#be185d',
textDecoration: 'none',
fontWeight: '500'
}}
>
{item.special.name}
</Link>
</>
)}
</div>
{/* Content */}
<div
className="news-content"
style={{
fontSize: '0.875rem',
color: '#374151',
lineHeight: '1.5'
}}
>
<ReactMarkdown
components={{
p: ({ children }) => <p style={{ margin: '0.5rem 0' }}>{children}</p>,
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#4f46e5', textDecoration: 'underline' }}
>
{children}
</a>
),
strong: ({ children }) => <strong style={{ fontWeight: '600' }}>{children}</strong>,
em: ({ children }) => <em style={{ fontStyle: 'italic' }}>{children}</em>,
ul: ({ children }) => <ul style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ul>,
ol: ({ children }) => <ol style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ol>,
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
}}
>
{item.content}
</ReactMarkdown>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -25,11 +25,11 @@ export function middleware(request: NextRequest) {
// Content Security Policy // Content Security Policy
const csp = [ const csp = [
"default-src 'self'", "default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires unsafe-inline/eval "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me", // Next.js requires unsafe-inline/eval
"style-src 'self' 'unsafe-inline'", // Allow inline styles "style-src 'self' 'unsafe-inline'", // Allow inline styles
"img-src 'self' data: blob:", "img-src 'self' data: blob:",
"font-src 'self' data:", "font-src 'self' data:",
"connect-src 'self' https://openrouter.ai https://gotify.example.com", "connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
"media-src 'self' blob:", "media-src 'self' blob:",
"frame-ancestors 'self'", "frame-ancestors 'self'",
].join('; '); ].join('; ');

1180
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,8 @@
"next": "16.0.3", "next": "16.0.3",
"prisma": "^6.19.0", "prisma": "^6.19.0",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0" "react-dom": "19.2.0",
"react-markdown": "^10.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "News" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"author" TEXT,
"publishedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"featured" BOOLEAN NOT NULL DEFAULT false,
"specialId" INTEGER,
CONSTRAINT "News_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "News_publishedAt_idx" ON "News"("publishedAt");

View File

@@ -47,6 +47,7 @@ model Special {
curator String? curator String?
songs SpecialSong[] songs SpecialSong[]
puzzles DailyPuzzle[] puzzles DailyPuzzle[]
news News[]
} }
model SpecialSong { model SpecialSong {
@@ -73,3 +74,17 @@ model DailyPuzzle {
@@unique([date, genreId, specialId]) @@unique([date, genreId, specialId])
} }
model News {
id Int @id @default(autoincrement())
title String
content String // Markdown format
author String? // Optional: curator/admin name
publishedAt DateTime @default(now())
updatedAt DateTime @updatedAt
featured Boolean @default(false) // Highlight important news
specialId Int? // Optional: link to a special
special Special? @relation(fields: [specialId], references: [id], onDelete: SetNull)
@@index([publishedAt])
}