22 Commits

Author SHA1 Message Date
Hördle Bot
50511f11ac chore: bump version to 0.1.0.14 2025-11-27 11:20:15 +01:00
Hördle Bot
d69ac28bb3 feat: white label transformation and bugfix for audio stream 2025-11-27 11:19:32 +01:00
Hördle Bot
7a65c58214 feat: add healthcheck endpoint and bump version to 0.1.0.13 2025-11-26 23:39:06 +01:00
Hördle Bot
1a8177430d feat: add health check API endpoint 2025-11-26 23:37:40 +01:00
Hördle Bot
0ebb61515d docs: Add 'prototype' to footer disclaimer in AppFooter. 2025-11-26 20:42:55 +01:00
Hördle Bot
dede11d22b fix: correct plausible score calculation 2025-11-26 18:00:59 +01:00
Hördle Bot
4b96b95bff Feat: Add Plausible event tracking for puzzle completion 2025-11-26 11:29:30 +01:00
Hördle Bot
89fb296564 Feat: Add visual feedback to bonus year question 2025-11-26 11:06:34 +01:00
Hördle Bot
301dce4c97 Fix: Audio player skip behavior and range requests 2025-11-26 10:58:04 +01:00
Hördle Bot
b66bab48bd Feat: Add Onboarding Assistant with driver.js 2025-11-26 10:13:40 +01:00
Hördle Bot
fea8384e60 fix: Adjust vertical spacing for next puzzle timer. 2025-11-26 09:27:20 +01:00
Hördle Bot
de8813da3e feat: filter genres by active status when fetching from Prisma 2025-11-26 09:25:45 +01:00
Hördle Bot
0877842107 feat: add plausible.elpatron.me to CSP script-src and connect-src directives. 2025-11-25 22:34:32 +01:00
Hördle Bot
a5cbbffc20 chore: remove Content-Security-Policy header configuration 2025-11-25 22:31:13 +01:00
Hördle Bot
ffb7be602f feat: Add Content Security Policy header and move Plausible script to HTML head with beforeInteractive strategy. 2025-11-25 22:19:34 +01:00
Hördle Bot
1d62aca2fb feat: add Plausible Analytics script to layout for tracking. 2025-11-25 22:13:46 +01:00
Hördle Bot
9bf7e72a6c Fix: Properly handle async play() and remove autoPlay conflict 2025-11-25 15:28:22 +01:00
Hördle Bot
f8b5dcf300 Fix: Start button now actually starts audio playback 2025-11-25 15:26:20 +01:00
Hördle Bot
072158f4ed Feature: Skip button becomes Start button on first attempt if audio not played 2025-11-25 15:18:25 +01:00
Hördle Bot
898d2f5959 Add NewsSection to genre and special pages 2025-11-25 14:22:07 +01:00
Hördle Bot
a7aec80f39 Fix: Link special in news section 2025-11-25 13:59:32 +01:00
Hördle Bot
0e313db2e3 Implement News System with Admin UI and Homepage Integration 2025-11-25 11:52:52 +01:00
23 changed files with 2575 additions and 161 deletions

View File

@@ -41,7 +41,23 @@ 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.
## White Labeling
Hördle ist "White Label Ready". Das bedeutet, du kannst das Branding (Name, Farben, Logos) komplett anpassen, ohne den Code zu ändern.
👉 **[Anleitung zur Anpassung (White Label Guide)](WHITE_LABEL.md)**
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
## Spielregeln & Punktesystem ## Spielregeln & Punktesystem

67
WHITE_LABEL.md Normal file
View File

@@ -0,0 +1,67 @@
# White Labeling Guide
This application is designed to be easily white-labeled. You can customize the branding, colors, and configuration without modifying the core code.
## Configuration
The application is configured via environment variables. You can set these in a `.env` or `.env.local` file.
### Branding
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_APP_NAME` | The name of the application. | `Hördle` |
| `NEXT_PUBLIC_APP_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.elpatron.me` |
| `NEXT_PUBLIC_TWITTER_HANDLE` | Twitter handle for metadata. | `@elpatron` |
### Analytics (Plausible)
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | The domain to track in Plausible. | `hoerdle.elpatron.me` |
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.elpatron.me/js/script.js` |
### Credits
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_CREDITS_ENABLED` | Enable/disable footer credits (`true`/`false`). | `true` |
| `NEXT_PUBLIC_CREDITS_TEXT` | Text before the link. | `Vibe coded with ☕ and 🍺 by` |
| `NEXT_PUBLIC_CREDITS_LINK_TEXT` | Text of the link. | `@elpatron@digitalcourage.social` |
| `NEXT_PUBLIC_CREDITS_LINK_URL` | URL of the link. | `https://digitalcourage.social/@elpatron` |
## Theming
The application uses CSS variables for theming. You can override these variables in your own CSS file or by modifying `app/globals.css`.
### Key Colors
| Variable | Description | Default |
|----------|-------------|---------|
| `--primary` | Main action color (buttons). | `#000000` |
| `--secondary` | Secondary actions. | `#4b5563` |
| `--accent` | Accent color. | `#667eea` |
| `--success` | Success state (correct guess). | `#22c55e` |
| `--danger` | Error state (wrong guess). | `#ef4444` |
| `--warning` | Warning state (stars). | `#ffc107` |
| `--muted` | Muted backgrounds. | `#f3f4f6` |
### Example: Red Theme
To create a red-themed version, add this to your CSS:
```css
:root {
--primary: #dc2626;
--accent: #ef4444;
--accent-gradient: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
}
```
## Assets
To replace the logo and icons:
1. Replace `public/favicon.ico`.
2. Replace `public/icon.png` (if it exists).
3. Update `app/manifest.ts` if you have custom icon paths.

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

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { readFile, stat } from 'fs/promises'; import { stat } from 'fs/promises';
import { createReadStream } from 'fs';
import path from 'path'; import path from 'path';
export async function GET( export async function GET(
@@ -30,24 +31,106 @@ export async function GET(
return new NextResponse('Forbidden', { status: 403 }); return new NextResponse('Forbidden', { status: 403 });
} }
// Check if file exists const stats = await stat(filePath);
try { const fileSize = stats.size;
await stat(filePath); const range = request.headers.get('range');
} catch {
return new NextResponse('File not found', { status: 404 }); if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
const stream = createReadStream(filePath, { start, end });
// Convert Node stream to Web stream
const readable = new ReadableStream({
start(controller) {
let isClosed = false;
stream.on('data', (chunk: any) => {
if (isClosed) return;
try {
controller.enqueue(chunk);
} catch (e) {
isClosed = true;
stream.destroy();
}
});
stream.on('end', () => {
if (isClosed) return;
isClosed = true;
controller.close();
});
stream.on('error', (err: any) => {
if (isClosed) return;
isClosed = true;
controller.error(err);
});
},
cancel() {
stream.destroy();
}
});
return new NextResponse(readable, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize.toString(),
'Content-Type': 'audio/mpeg',
'Cache-Control': 'public, max-age=3600, must-revalidate',
},
});
} else {
const stream = createReadStream(filePath);
// Convert Node stream to Web stream
const readable = new ReadableStream({
start(controller) {
let isClosed = false;
stream.on('data', (chunk: any) => {
if (isClosed) return;
try {
controller.enqueue(chunk);
} catch (e) {
isClosed = true;
stream.destroy();
}
});
stream.on('end', () => {
if (isClosed) return;
isClosed = true;
controller.close();
});
stream.on('error', (err: any) => {
if (isClosed) return;
isClosed = true;
controller.error(err);
});
},
cancel() {
stream.destroy();
}
});
return new NextResponse(readable, {
status: 200,
headers: {
'Content-Length': fileSize.toString(),
'Content-Type': 'audio/mpeg',
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=3600, must-revalidate',
},
});
} }
// Read file
const fileBuffer = await readFile(filePath);
// Return with proper headers
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': 'audio/mpeg',
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=3600, must-revalidate',
},
});
} catch (error) { } catch (error) {
console.error('Error serving audio file:', error); console.error('Error serving audio file:', error);
return new NextResponse('Internal Server Error', { status: 500 }); return new NextResponse('Internal Server Error', { status: 500 });

5
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'ok' }, { status: 200 });
}

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

@@ -2,6 +2,24 @@
--foreground-rgb: 0, 0, 0; --foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220; --background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255; --background-end-rgb: 255, 255, 255;
/* Theme Colors */
--primary: #000000;
--primary-foreground: #ffffff;
--secondary: #4b5563;
--secondary-foreground: #ffffff;
--accent: #667eea;
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--success: #22c55e;
--success-foreground: #ffffff;
--danger: #ef4444;
--danger-foreground: #ffffff;
--warning: #ffc107;
--muted: #f3f4f6;
--muted-foreground: #6b7280;
--border: #e5e7eb;
--input: #d1d5db;
--ring: #000000;
} }
body { body {
@@ -51,13 +69,13 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 0.875rem; font-size: 0.875rem;
color: #666; color: var(--muted-foreground);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
/* Audio Player */ /* Audio Player */
.audio-player { .audio-player {
background: #f3f4f6; background: var(--muted);
padding: 1rem; padding: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
@@ -73,8 +91,8 @@ body {
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
border-radius: 50%; border-radius: 50%;
background: #000; background: var(--primary);
color: #fff; color: var(--primary-foreground);
border: none; border: none;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -85,19 +103,20 @@ body {
.play-button:hover { .play-button:hover {
background: #333; background: #333;
/* Keep for now or add --primary-hover */
} }
.progress-bar-container { .progress-bar-container {
flex: 1; flex: 1;
height: 0.5rem; height: 0.5rem;
background: #d1d5db; background: var(--input);
border-radius: 999px; border-radius: 999px;
overflow: hidden; overflow: hidden;
} }
.progress-bar { .progress-bar {
height: 100%; height: 100%;
background: #22c55e; background: var(--success);
transition: width 0.1s linear; transition: width 0.1s linear;
} }
@@ -114,7 +133,7 @@ body {
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem; padding: 0.5rem;
background: #f9fafb; background: #f9fafb;
border: 1px solid #e5e7eb; border: 1px solid var(--border);
border-radius: 0.25rem; border-radius: 0.25rem;
font-size: 0.875rem; font-size: 0.875rem;
} }
@@ -125,7 +144,7 @@ body {
} }
.guess-text { .guess-text {
color: #ef4444; color: var(--danger);
/* Red for wrong */ /* Red for wrong */
} }
@@ -135,7 +154,7 @@ body {
} }
.guess-text.correct { .guess-text.correct {
color: #22c55e; color: var(--success);
} }
/* Input */ /* Input */
@@ -148,14 +167,14 @@ body {
.guess-input { .guess-input {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid #d1d5db; border: 1px solid var(--input);
border-radius: 0.25rem; border-radius: 0.25rem;
font-size: 1rem; font-size: 1rem;
box-sizing: border-box; box-sizing: border-box;
} }
.guess-input:focus { .guess-input:focus {
outline: 2px solid #000; outline: 2px solid var(--ring);
border-color: transparent; border-color: transparent;
} }
@@ -163,7 +182,7 @@ body {
position: absolute; position: absolute;
width: 100%; width: 100%;
background: #fff; background: #fff;
border: 1px solid #d1d5db; border: 1px solid var(--input);
border-radius: 0.25rem; border-radius: 0.25rem;
margin-top: 0.25rem; margin-top: 0.25rem;
max-height: 15rem; max-height: 15rem;
@@ -177,11 +196,11 @@ body {
.suggestion-item { .suggestion-item {
padding: 0.75rem; padding: 0.75rem;
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid var(--muted);
} }
.suggestion-item:hover { .suggestion-item:hover {
background: #f3f4f6; background: var(--muted);
} }
.suggestion-title { .suggestion-title {
@@ -190,14 +209,14 @@ body {
.suggestion-artist { .suggestion-artist {
font-size: 0.875rem; font-size: 0.875rem;
color: #666; color: var(--muted-foreground);
} }
.skip-button { .skip-button {
width: 100%; width: 100%;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
margin-top: 1rem; margin-top: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--accent-gradient);
color: white; color: white;
border: none; border: none;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -246,7 +265,7 @@ body {
} }
.admin-card { .admin-card {
background: #f3f4f6; background: var(--muted);
padding: 2rem; padding: 2rem;
border-radius: 0.5rem; border-radius: 0.5rem;
} }
@@ -265,14 +284,14 @@ body {
.form-input { .form-input {
width: 100%; width: 100%;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #d1d5db; border: 1px solid var(--input);
border-radius: 0.25rem; border-radius: 0.25rem;
box-sizing: border-box; box-sizing: border-box;
} }
.btn-primary { .btn-primary {
background: #000; background: var(--primary);
color: #fff; color: var(--primary-foreground);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
@@ -292,8 +311,8 @@ body {
} }
.btn-secondary { .btn-secondary {
background: #4b5563; background: var(--secondary);
color: #fff; color: var(--secondary-foreground);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
@@ -312,8 +331,8 @@ body {
} }
.btn-danger { .btn-danger {
background: #ef4444; background: var(--danger);
color: #fff; color: var(--danger-foreground);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
@@ -337,8 +356,8 @@ body {
padding: 2rem 1rem 1rem; padding: 2rem 1rem 1rem;
text-align: center; text-align: center;
font-size: 0.875rem; font-size: 0.875rem;
color: #666; color: var(--muted-foreground);
border-top: 1px solid #e5e7eb; border-top: 1px solid var(--border);
width: 100%; width: 100%;
} }
@@ -347,7 +366,7 @@ body {
} }
.app-footer a { .app-footer a {
color: #000; color: var(--primary);
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
} }
@@ -375,7 +394,7 @@ body {
font-size: 0.875rem; font-size: 0.875rem;
text-align: center; text-align: center;
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #666; color: var(--muted-foreground);
} }
.statistics-grid { .statistics-grid {
@@ -391,7 +410,7 @@ body {
padding: 0.75rem 0.5rem; padding: 0.75rem 0.5rem;
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.8);
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid #e5e7eb; border: 1px solid var(--border);
} }
.stat-badge { .stat-badge {
@@ -401,7 +420,7 @@ body {
.stat-label { .stat-label {
font-size: 0.75rem; font-size: 0.75rem;
color: #666; color: var(--muted-foreground);
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
text-align: center; text-align: center;
} }
@@ -409,7 +428,7 @@ body {
.stat-count { .stat-count {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: bold; font-weight: bold;
color: #000; color: var(--primary);
} }
/* Tooltip */ /* Tooltip */

View File

@@ -1,7 +1,10 @@
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";
import { config } from "@/lib/config";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin"],
@@ -13,12 +16,12 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Hördle", title: config.appName,
description: "Daily music guessing game - Guess the song from short audio clips", description: config.appDescription,
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: "#000000", themeColor: config.colors.themeColor,
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 1,
@@ -34,6 +37,14 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<head>
<Script
defer
data-domain={config.plausibleDomain}
src={config.plausibleScriptSrc}
strategy="beforeInteractive"
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}> <body className={`${geistSans.variable} ${geistMono.variable}`}>
{children} {children}
<InstallPrompt /> <InstallPrompt />

View File

@@ -1,14 +1,15 @@
import type { MetadataRoute } from 'next' import type { MetadataRoute } from 'next'
import { config } from '@/lib/config'
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: 'Hördle', name: config.appName,
short_name: 'Hördle', short_name: config.appName,
description: 'Daily music guessing game - Guess the song from short audio clips', description: config.appDescription,
start_url: '/', start_url: '/',
display: 'standalone', display: 'standalone',
background_color: '#ffffff', background_color: config.colors.backgroundColor,
theme_color: '#000000', theme_color: config.colors.themeColor,
icons: [ icons: [
{ {
src: '/favicon.ico', src: '/favicon.ico',

View File

@@ -1,4 +1,6 @@
import Game from '@/components/Game'; import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import OnboardingTour from '@/components/OnboardingTour';
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';
@@ -29,7 +31,7 @@ export default async function Home() {
return ( return (
<> <>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}> <div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<div className="tooltip"> <div className="tooltip">
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link> <Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
@@ -93,7 +95,13 @@ export default async function Home() {
</div> </div>
)} )}
</div> </div>
<div id="tour-news">
<NewsSection />
</div>
<Game dailyPuzzle={dailyPuzzle} genre={null} /> <Game dailyPuzzle={dailyPuzzle} genre={null} />
<OnboardingTour />
</> </>
); );
} }

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,5 +1,6 @@
'use client'; 'use client';
import { config } from '@/lib/config';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export default function AppFooter() { export default function AppFooter() {
@@ -12,14 +13,15 @@ export default function AppFooter() {
.catch(() => setVersion('')); .catch(() => setVersion(''));
}, []); }, []);
if (!config.credits.enabled) return null;
return ( return (
<footer className="app-footer"> <footer className="app-footer">
<p> <p>
Vibe coded with and 🍺 by{' '} {config.credits.text}{' '}
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer"> <a href={config.credits.linkUrl} target="_blank" rel="noopener noreferrer">
@elpatron@digitalcourage.social {config.credits.linkText}
</a> </a>
{' '}- for personal use among friends only!
{version && ( {version && (
<> <>
{' '}·{' '} {' '}·{' '}

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,39 +9,112 @@ 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);
const [hasPlayedOnce, setHasPlayedOnce] = useState(false); const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
const [processedSrc, setProcessedSrc] = useState(src);
const [processedUnlockedSeconds, setProcessedUnlockedSeconds] = useState(unlockedSeconds);
useEffect(() => {
console.log('[AudioPlayer] MOUNTED');
return () => console.log('[AudioPlayer] UNMOUNTED');
}, []);
useEffect(() => { useEffect(() => {
if (audioRef.current) { if (audioRef.current) {
audioRef.current.pause(); // Check if props changed compared to what we last processed
audioRef.current.currentTime = startTime; const hasChanged = src !== processedSrc || unlockedSeconds !== processedUnlockedSeconds;
setIsPlaying(false);
setProgress(0);
setHasPlayedOnce(false); // Reset for new segment
if (autoPlay) { if (hasChanged) {
const playPromise = audioRef.current.play(); audioRef.current.pause();
if (playPromise !== undefined) {
playPromise let startPos = startTime;
.then(() => {
setIsPlaying(true); // If same song but more time unlocked, start from where previous segment ended
onPlay?.(); if (src === processedSrc && unlockedSeconds > processedUnlockedSeconds) {
setHasPlayedOnce(true); startPos = startTime + processedUnlockedSeconds;
}) }
.catch(error => {
console.log("Autoplay prevented:", error); const targetPos = startPos;
setIsPlaying(false); audioRef.current.currentTime = targetPos;
});
// Ensure position is set correctly even if browser resets it
setTimeout(() => {
if (audioRef.current && Math.abs(audioRef.current.currentTime - targetPos) > 0.5) {
audioRef.current.currentTime = targetPos;
}
}, 50);
setIsPlaying(false);
// Calculate initial progress
const initialElapsed = startPos - startTime;
const initialPercent = unlockedSeconds > 0 ? (initialElapsed / unlockedSeconds) * 100 : 0;
setProgress(Math.min(initialPercent, 100));
setHasPlayedOnce(false); // Reset for new segment
onHasPlayedChange?.(false); // Notify parent
// Update processed state
setProcessedSrc(src);
setProcessedUnlockedSeconds(unlockedSeconds);
if (autoPlay) {
// Delay play slightly to ensure currentTime sticks
setTimeout(() => {
const playPromise = audioRef.current?.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
setIsPlaying(true);
onPlay?.();
setHasPlayedOnce(true);
onHasPlayedChange?.(true); // Notify parent
})
.catch(error => {
console.log("Autoplay prevented:", error);
setIsPlaying(false);
});
}
}, 150);
} }
} }
} }
}, [src, unlockedSeconds, startTime, autoPlay]); }, [src, unlockedSeconds, startTime, autoPlay, processedSrc, processedUnlockedSeconds]);
// 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 +129,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 +186,10 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
</div> </div>
</div> </div>
); );
} });
AudioPlayer.displayName = 'AudioPlayer';
export default AudioPlayer;

View File

@@ -1,12 +1,20 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { config } from '@/lib/config';
import AudioPlayer from './AudioPlayer'; import { useEffect, useState, useRef } from 'react';
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';
import { sendGotifyNotification, submitRating } from '../app/actions'; import { sendGotifyNotification, submitRating } from '../app/actions';
// Plausible Analytics
declare global {
interface Window {
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
}
}
interface GameProps { interface GameProps {
dailyPuzzle: { dailyPuzzle: {
id: number; id: number;
@@ -37,6 +45,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 = () => {
@@ -74,7 +84,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
useEffect(() => { useEffect(() => {
if (dailyPuzzle) { if (dailyPuzzle) {
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]'); const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
if (ratedPuzzles.includes(dailyPuzzle.id)) { if (ratedPuzzles.includes(dailyPuzzle.id)) {
setHasRated(true); setHasRated(true);
} else { } else {
@@ -101,6 +111,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (song.id === dailyPuzzle.songId) { if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true); addGuess(song.title, true);
setHasWon(true); setHasWon(true);
// Track puzzle solved event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length + 1,
score: gameState.score + 20, // Include the win bonus
outcome: 'won'
}
});
}
// Notification sent after year guess or skip // Notification sent after year guess or skip
if (!dailyPuzzle.releaseYear) { if (!dailyPuzzle.releaseYear) {
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score); sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
@@ -110,19 +131,54 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (gameState.guesses.length + 1 >= maxAttempts) { if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true); setHasLost(true);
setHasWon(false); setHasWon(false);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: maxAttempts,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
} }
} }
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);
if (gameState.guesses.length + 1 >= maxAttempts) { if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true); setHasLost(true);
setHasWon(false); setHasWon(false);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: maxAttempts,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
} }
}; };
@@ -133,6 +189,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
giveUp(); // Ensure game is marked as failed and score reset to 0 giveUp(); // Ensure game is marked as failed and score reset to 0
setHasLost(true); setHasLost(true);
setHasWon(false); setHasWon(false);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length + 1,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
}; };
@@ -141,6 +208,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
addYearBonus(correct); addYearBonus(correct);
setShowYearModal(false); setShowYearModal(false);
// Update the puzzle_solved event with year bonus result
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length,
score: gameState.score + (correct ? 10 : 0), // Include year bonus if correct
outcome: 'won',
year_bonus: correct ? 'correct' : 'incorrect'
}
});
}
// Send notification now that game is fully complete // Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0)); sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
}; };
@@ -148,6 +228,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const handleYearSkip = () => { const handleYearSkip = () => {
skipYearBonus(); skipYearBonus();
setShowYearModal(false); setShowYearModal(false);
// Update the puzzle_solved event with year bonus result
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length,
score: gameState.score, // Score already includes win bonus
outcome: 'won',
year_bonus: 'skipped'
}
});
}
// Send notification now that game is fully complete // Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score); sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
}; };
@@ -176,7 +270,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : ''; const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : ''; const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
let shareUrl = 'https://hoerdle.elpatron.me'; let shareUrl = `https://${config.domain}`;
if (genre) { if (genre) {
if (isSpecial) { if (isSpecial) {
shareUrl += `/special/${encodeURIComponent(genre)}`; shareUrl += `/special/${encodeURIComponent(genre)}`;
@@ -185,7 +279,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
} }
} }
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`; const text = `${config.appName} #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#${config.appName} #Music\n\n${shareUrl}`;
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
@@ -223,10 +317,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber); await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
setHasRated(true); setHasRated(true);
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]'); const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
if (!ratedPuzzles.includes(dailyPuzzle.id)) { if (!ratedPuzzles.includes(dailyPuzzle.id)) {
ratedPuzzles.push(dailyPuzzle.id); ratedPuzzles.push(dailyPuzzle.id);
localStorage.setItem('hoerdle_rated_puzzles', JSON.stringify(ratedPuzzles)); localStorage.setItem(`${config.appName.toLowerCase()}_rated_puzzles`, JSON.stringify(ratedPuzzles));
} }
} catch (error) { } catch (error) {
console.error('Failed to submit rating', error); console.error('Failed to submit rating', error);
@@ -236,28 +330,34 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
return ( return (
<div className="container"> <div className="container">
<header className="header"> <header className="header">
<h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1> <h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '-0.5rem', marginBottom: '1rem' }}> <div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
Next puzzle in: {timeUntilNext} Next puzzle in: {timeUntilNext}
</div> </div>
</header> </header>
<main className="game-board"> <main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}> <div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div className="status-bar"> <div id="tour-status" className="status-bar">
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span> <span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s unlocked</span> <span>{unlockedSeconds}s unlocked</span>
</div> </div>
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} /> <div id="tour-score">
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
</div>
<AudioPlayer <div id="tour-player">
src={dailyPuzzle.audioUrl} <AudioPlayer
unlockedSeconds={unlockedSeconds} ref={audioPlayerRef}
startTime={dailyPuzzle.startTime} src={dailyPuzzle.audioUrl}
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)} unlockedSeconds={unlockedSeconds}
onReplay={addReplay} startTime={dailyPuzzle.startTime}
/> autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
onReplay={addReplay}
onHasPlayedChange={setHasPlayedAudio}
/>
</div>
</div> </div>
<div className="guess-list"> <div className="guess-list">
@@ -276,13 +376,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{!hasWon && !hasLost && ( {!hasWon && !hasLost && (
<> <>
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} /> <div id="tour-input">
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
</div>
{gameState.guesses.length < maxAttempts - 1 ? ( {gameState.guesses.length < maxAttempts - 1 ? (
<button <button
id="tour-controls"
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
@@ -305,11 +411,11 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{hasWon ? 'You won!' : 'Game Over'} {hasWon ? 'You won!' : 'Game Over'}
</h2> </h2>
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}> <div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? 'var(--success)' : 'var(--danger)' }}>
Score: {gameState.score} Score: {gameState.score}
</div> </div>
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}> <details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: 'var(--muted-foreground)' }}>
<summary>Score Breakdown</summary> <summary>Score Breakdown</summary>
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}> <ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
{gameState.scoreBreakdown.map((item, i) => ( {gameState.scoreBreakdown.map((item, i) => (
@@ -332,9 +438,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }} style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/> />
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3> <h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p> <p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
{dailyPuzzle.releaseYear && gameState.yearGuessed && ( {dailyPuzzle.releaseYear && gameState.yearGuessed && (
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p> <p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
)} )}
<audio controls style={{ width: '100%' }}> <audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" /> <source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
@@ -385,13 +491,13 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
textAlign: 'center', textAlign: 'center',
margin: '0.5rem 0', margin: '0.5rem 0',
padding: '0.5rem', padding: '0.5rem',
background: '#f3f4f6', background: 'var(--muted)',
borderRadius: '0.5rem', borderRadius: '0.5rem',
fontSize: '0.9rem', fontSize: '0.9rem',
fontFamily: 'monospace', fontFamily: 'monospace',
cursor: 'help' cursor: 'help'
}}> }}>
<span style={{ color: '#666' }}>{expression} = </span> <span style={{ color: 'var(--muted-foreground)' }}>{expression} = </span>
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span> <span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
</div> </div>
); );
@@ -399,6 +505,7 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) { function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
const [options, setOptions] = useState<number[]>([]); const [options, setOptions] = useState<number[]>([]);
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
useEffect(() => { useEffect(() => {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -427,6 +534,24 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
setOptions(Array.from(allOptions).sort((a, b) => a - b)); setOptions(Array.from(allOptions).sort((a, b) => a - b));
}, [correctYear]); }, [correctYear]);
const handleGuess = (year: number) => {
const correct = year === correctYear;
setFeedback({ show: true, correct, guessedYear: year });
// Close modal after showing feedback
setTimeout(() => {
onGuess(year);
}, 2500);
};
const handleSkip = () => {
setFeedback({ show: true, correct: false });
setTimeout(() => {
onSkip();
}, 2000);
};
return ( return (
<div style={{ <div style={{
position: 'fixed', position: 'fixed',
@@ -450,51 +575,81 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
textAlign: 'center', textAlign: 'center',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)' boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
}}> }}>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3> {!feedback.show ? (
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p> <>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: 'var(--primary)' }}>Bonus Round!</h3>
<p style={{ marginBottom: '1.5rem', color: 'var(--secondary)' }}>Guess the release year for <strong style={{ color: 'var(--success)' }}>+10 points</strong>!</p>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
gap: '0.75rem',
marginBottom: '1.5rem'
}}>
{options.map(year => (
<button
key={year}
onClick={() => handleGuess(year)}
style={{
padding: '0.75rem',
background: 'var(--muted)',
border: '2px solid var(--border)',
borderRadius: '0.5rem',
fontSize: '1.1rem',
fontWeight: 'bold',
color: 'var(--secondary)',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={e => e.currentTarget.style.borderColor = 'var(--success)'}
onMouseOut={e => e.currentTarget.style.borderColor = 'var(--border)'}
>
{year}
</button>
))}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
gap: '0.75rem',
marginBottom: '1.5rem'
}}>
{options.map(year => (
<button <button
key={year} onClick={handleSkip}
onClick={() => onGuess(year)}
style={{ style={{
padding: '0.75rem', background: 'none',
background: '#f3f4f6', border: 'none',
border: '2px solid #e5e7eb', color: 'var(--muted-foreground)',
borderRadius: '0.5rem', textDecoration: 'underline',
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#374151',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s' fontSize: '0.9rem'
}} }}
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
> >
{year} Skip Bonus
</button> </button>
))} </>
</div> ) : (
<div style={{ padding: '2rem 0' }}>
<button {feedback.guessedYear ? (
onClick={onSkip} feedback.correct ? (
style={{ <>
background: 'none', <div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
border: 'none', <h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>Correct!</h3>
color: '#6b7280', <p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
textDecoration: 'underline', <p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 Points!</p>
cursor: 'pointer', </>
fontSize: '0.9rem' ) : (
}} <>
> <div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
Skip Bonus <h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>Not quite!</h3>
</button> <p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>You guessed {feedback.guessedYear}</p>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></p>
</>
)
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}></div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>Skipped</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
</>
)}
</div>
)}
</div> </div>
</div> </div>
); );
@@ -505,12 +660,12 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
const [rating, setRating] = useState(0); const [rating, setRating] = useState(0);
if (hasRated) { if (hasRated) {
return <div style={{ color: '#666', fontStyle: 'italic' }}>Thanks for rating!</div>; return <div style={{ color: 'var(--muted-foreground)', fontStyle: 'italic' }}>Thanks for rating!</div>;
} }
return ( return (
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}> <div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', color: '#666', fontWeight: '500' }}>Rate this puzzle:</span> <span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>Rate this puzzle:</span>
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}> <div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
{[...Array(5)].map((_, index) => { {[...Array(5)].map((_, index) => {
const ratingValue = index + 1; const ratingValue = index + 1;
@@ -523,7 +678,7 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
border: 'none', border: 'none',
cursor: 'pointer', cursor: 'pointer',
fontSize: '2rem', fontSize: '2rem',
color: ratingValue <= (hover || rating) ? '#ffc107' : '#9ca3af', color: ratingValue <= (hover || rating) ? 'var(--warning)' : 'var(--muted-foreground)',
transition: 'color 0.2s', transition: 'color 0.2s',
padding: '0 0.25rem' padding: '0 0.25rem'
}} }}

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

@@ -0,0 +1,109 @@
'use client';
import { useEffect } from 'react';
import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
export default function OnboardingTour() {
useEffect(() => {
const hasCompletedOnboarding = localStorage.getItem('hoerdle_onboarding_completed');
if (hasCompletedOnboarding) {
return;
}
const driverObj = driver({
showProgress: true,
animate: true,
allowClose: true,
doneBtnText: 'Done',
nextBtnText: 'Next',
prevBtnText: 'Previous',
onDestroyed: () => {
localStorage.setItem('hoerdle_onboarding_completed', 'true');
},
steps: [
{
element: '#tour-genres',
popover: {
title: 'Genres & Specials',
description: 'Choose a specific genre or a curated special event here.',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-news',
popover: {
title: 'News',
description: 'Stay updated with the latest news and announcements.',
side: 'top',
align: 'start'
}
},
{
element: '#tour-title',
popover: {
title: 'Hördle',
description: 'This is the daily puzzle. One new song every day per genre.',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-status',
popover: {
title: 'Attempts',
description: 'You have a limited number of attempts to guess the song.',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-score',
popover: {
title: 'Score',
description: 'Your current score. Try to keep it high!',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-player',
popover: {
title: 'Player',
description: 'Listen to the snippet. Each additional play reduces your potential score.',
side: 'top',
align: 'start'
}
},
{
element: '#tour-input',
popover: {
title: 'Input',
description: 'Type your guess here. Search for artist or title.',
side: 'top',
align: 'start'
}
},
{
element: '#tour-controls',
popover: {
title: 'Controls',
description: 'Start the music or skip to the next snippet if you\'re stuck.',
side: 'top',
align: 'start'
}
}
]
});
// Small delay to ensure DOM is ready
setTimeout(() => {
driverObj.drive();
}, 1000);
}, []);
return null;
}

18
lib/config.ts Normal file
View File

@@ -0,0 +1,18 @@
export const config = {
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.elpatron.me',
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'hoerdle.elpatron.me',
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.elpatron.me/js/script.js',
colors: {
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
},
credits: {
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
}
};

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('; ');

1187
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.0", "version": "0.1.0.14",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -11,11 +11,13 @@
"dependencies": { "dependencies": {
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"driver.js": "^1.4.0",
"music-metadata": "^11.10.2", "music-metadata": "^11.10.2",
"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",
@@ -27,4 +29,4 @@
"eslint-config-next": "16.0.3", "eslint-config-next": "16.0.3",
"typescript": "^5" "typescript": "^5"
} }
} }

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])
}