23 Commits

Author SHA1 Message Date
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
Hördle Bot
3e647cd44b Fix version API to read version.txt directly 2025-11-25 10:22:12 +01:00
Hördle Bot
54af256e91 feat: Enhance Docker build versioning with a build argument, fetch git tags during deployment, and add comprehensive deployment documentation. 2025-11-25 10:15:47 +01:00
Hördle Bot
ce413cf6bc feat: Implement Docker version reporting by extracting git tag to an environment variable for API consumption. 2025-11-25 09:41:50 +01:00
25 changed files with 2675 additions and 106 deletions

185
DEBUG_VERSION.md Normal file
View File

@@ -0,0 +1,185 @@
# Debug Version Display - Remote Server Checklist
## 1. Überprüfe Git-Tags auf dem Remote-Server
```bash
# Im Projekt-Verzeichnis auf dem Remote-Server
cd /path/to/hoerdle
# Zeige alle Tags
git tag -l
# Zeige aktuellen Tag/Version
git describe --tags --always
# Wenn keine Tags angezeigt werden:
git fetch --tags
git describe --tags --always
```
**Erwartetes Ergebnis:** Sollte `v0.1.0.2` oder ähnlich zeigen
---
## 2. Überprüfe die version.txt im Container
```bash
# Zeige den Inhalt der Version-Datei im laufenden Container
docker exec hoerdle cat /app/version.txt
# Sollte die Version zeigen, z.B. "v0.1.0.2"
```
**Erwartetes Ergebnis:** Die aktuelle Version, nicht "unknown" oder "dev"
---
## 3. Überprüfe die Umgebungsvariable im Container
```bash
# Zeige alle Umgebungsvariablen
docker exec hoerdle env | grep APP_VERSION
# Sollte APP_VERSION=v0.1.0.2 oder ähnlich zeigen
```
**Erwartetes Ergebnis:** `APP_VERSION=v0.1.0.2`
---
## 4. Überprüfe die Container-Logs beim Start
```bash
# Zeige die letzten Logs beim Container-Start
docker logs hoerdle | head -20
# Suche speziell nach Version-Ausgaben
docker logs hoerdle | grep -i version
```
**Erwartetes Ergebnis:** Eine Zeile wie "App version: v0.1.0.2"
---
## 5. Teste die API direkt
```bash
# Rufe die Version-API auf
curl http://localhost:3010/api/version
# Sollte JSON zurückgeben: {"version":"v0.1.0.2"}
```
**Erwartetes Ergebnis:** `{"version":"v0.1.0.2"}`
---
## 6. Überprüfe wann der Container gebaut wurde
```bash
# Zeige Image-Informationen
docker images | grep hoerdle
# Zeige detaillierte Container-Informationen
docker inspect hoerdle | grep -i created
```
**Wichtig:** Wenn das Image vor deinem letzten Deployment erstellt wurde, wurde es noch nicht neu gebaut!
---
## 7. Überprüfe Build-Logs
```bash
# Baue das Image neu und beobachte die Ausgabe
docker compose build --no-cache 2>&1 | tee build.log
# Suche nach der Version-Ausgabe im Build
grep -i "Building version" build.log
```
**Erwartetes Ergebnis:** Eine Zeile wie "Building version: v0.1.0.2"
---
## Häufige Probleme und Lösungen
### Problem 1: Tags nicht auf dem Server
```bash
git fetch --tags
git describe --tags --always
```
### Problem 2: Container wurde nicht neu gebaut
```bash
docker compose build --no-cache
docker compose up -d
```
### Problem 3: Alte version.txt im Container
```bash
# Stoppe Container, lösche Image, baue neu
docker compose down
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
docker compose build --no-cache
docker compose up -d
```
### Problem 4: .git Verzeichnis nicht im Build-Context
```bash
# Überprüfe ob .git existiert
ls -la .git
# Überprüfe .dockerignore (sollte .git NICHT ausschließen)
cat .dockerignore 2>/dev/null || echo "Keine .dockerignore Datei"
```
---
## Vollständiger Neustart (wenn nichts anderes hilft)
```bash
# 1. Stoppe alles
docker compose down
# 2. Lösche alte Images
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
# 3. Hole neueste Änderungen und Tags
git pull
git fetch --tags
# 4. Überprüfe Version lokal
git describe --tags --always
# 5. Baue komplett neu
docker compose build --no-cache
# 6. Starte Container
docker compose up -d
# 7. Überprüfe Logs
docker logs hoerdle | grep -i version
# 8. Teste API
curl http://localhost:3010/api/version
```
---
## Debugging-Befehl für alle Checks auf einmal
```bash
echo "=== Git Tags ===" && \
git describe --tags --always && \
echo -e "\n=== version.txt im Container ===" && \
docker exec hoerdle cat /app/version.txt 2>/dev/null || echo "Container läuft nicht oder Datei fehlt" && \
echo -e "\n=== APP_VERSION Env ===" && \
docker exec hoerdle env | grep APP_VERSION || echo "Variable nicht gesetzt" && \
echo -e "\n=== API Response ===" && \
curl -s http://localhost:3010/api/version && \
echo -e "\n\n=== Container Created ===" && \
docker inspect hoerdle | grep -i created | head -1
```
Kopiere diesen Befehl und führe ihn auf dem Remote-Server aus. Schicke mir die Ausgabe!

84
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,84 @@
# Deployment Guide
## Automated Deployment
Use the deployment script for zero-downtime deployments:
```bash
./scripts/deploy.sh
```
This script will:
1. Create a database backup
2. Pull latest changes from git
3. Fetch all git tags (for version display)
4. Build the new Docker image
5. Restart the container with minimal downtime
6. Clean up old images
## Manual Deployment
If you need to deploy manually:
```bash
# Pull latest changes
git pull
# Fetch tags (important for version display!)
git fetch --tags
# Build and restart
docker compose build
docker compose up -d
```
## Version Display
The app displays the current version in the footer. The version is determined as follows:
1. **During Docker build**: The version is extracted from git tags using `git describe --tags --always`
2. **At runtime**: The version is read from `/app/version.txt` and exposed via the `/api/version` endpoint
3. **Local development**: The version is extracted directly from git on each request
### Building with a specific version
You can override the version during build:
```bash
docker compose build --build-arg APP_VERSION=v1.2.3
```
### Troubleshooting
If the version shows as "dev" or "unknown":
1. Make sure git tags are pushed to the remote repository:
```bash
git push --tags
```
2. On the deployment server, fetch the tags:
```bash
git fetch --tags
```
3. Verify tags are available:
```bash
git describe --tags --always
```
4. Rebuild the Docker image:
```bash
docker compose build --no-cache
docker compose up -d
```
## Health Check
The container includes a health check that monitors the `/api/daily` endpoint. Check the health status:
```bash
docker ps
```
Look for the "healthy" status in the STATUS column.

View File

@@ -13,9 +13,24 @@ RUN npm ci
# Rebuild the source code only when needed # Rebuild the source code only when needed
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
# Accept version as build argument (optional)
ARG APP_VERSION=""
# Install git to extract version information
RUN apk add --no-cache git
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Extract version: use build arg if provided, otherwise get from git
RUN if [ -n "$APP_VERSION" ]; then \
echo "$APP_VERSION" > /tmp/version.txt; \
else \
git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt; \
fi && \
echo "Building version: $(cat /tmp/version.txt)"
# Next.js collects completely anonymous telemetry data about general usage. # Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry # Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build. # Uncomment the following line in case you want to disable telemetry during the build.
@@ -53,6 +68,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
# Create uploads directory and set permissions # Create uploads directory and set permissions
RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads
# Copy version file from builder
COPY --from=builder /tmp/version.txt /app/version.txt
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000

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

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,59 @@ 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) {
stream.on('data', (chunk: any) => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', (err: any) => controller.error(err));
}
});
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) {
stream.on('data', (chunk: any) => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', (err: any) => controller.error(err));
}
});
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

@@ -1,9 +1,32 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
export async function GET() { export async function GET() {
try { try {
// Try to get the git tag/version // First check if version file exists (Docker deployment)
// Try both /app/version.txt (Docker) and ./version.txt (local)
const versionPaths = [
'/app/version.txt',
join(process.cwd(), 'version.txt')
];
for (const versionFilePath of versionPaths) {
if (existsSync(versionFilePath)) {
const version = readFileSync(versionFilePath, 'utf-8').trim();
if (version && version !== 'unknown') {
return NextResponse.json({ version });
}
}
}
// Fallback: check environment variable
if (process.env.APP_VERSION) {
return NextResponse.json({ version: process.env.APP_VERSION });
}
// Fallback: try to get from git (local development)
let version = 'dev'; let version = 'dev';
try { try {

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

@@ -19,7 +19,7 @@ export default function AppFooter() {
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer"> <a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
@elpatron@digitalcourage.social @elpatron@digitalcourage.social
</a> </a>
{' '}- for personal use among friends only! {' '}- prototype 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,19 @@
'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';
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 +44,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 = () => {
@@ -101,6 +110,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 +130,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 +188,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 +207,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 +227,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);
}; };
@@ -236,28 +329,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">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>
<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 +375,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
@@ -399,6 +504,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 +533,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 +574,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: '#1f2937' }}>Bonus Round!</h3>
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+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: '#f3f4f6',
border: '2px solid #e5e7eb',
borderRadius: '0.5rem',
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#374151',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
>
{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: '#6b7280',
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: '#10b981', marginBottom: '0.5rem' }}>Correct!</h3>
color: '#6b7280', <p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
textDecoration: 'underline', <p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#10b981', 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: '#ef4444', marginBottom: '0.5rem' }}>Not quite!</h3>
</button> <p style={{ fontSize: '1.2rem', color: '#4b5563' }}>You guessed {feedback.guessedYear}</p>
<p style={{ fontSize: '1.2rem', color: '#4b5563', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></p>
</>
)
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}></div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#6b7280', marginBottom: '0.5rem' }}>Skipped</h3>
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
</>
)}
</div>
)}
</div> </div>
</div> </div>
); );

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

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.13",
"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",

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

View File

@@ -50,6 +50,10 @@ fi
echo "📥 Pulling latest changes from git..." echo "📥 Pulling latest changes from git..."
git pull git pull
# Fetch all tags
echo "🏷️ Fetching git tags..."
git fetch --tags
# Build new image in background (doesn't stop running container) # Build new image in background (doesn't stop running container)
echo "🔨 Building new Docker image (this runs while app is still online)..." echo "🔨 Building new Docker image (this runs while app is still online)..."
docker compose build docker compose build

View File

@@ -1,6 +1,12 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Export version if available
if [ -f /app/version.txt ]; then
export APP_VERSION=$(cat /app/version.txt)
echo "App version: $APP_VERSION"
fi
echo "Starting deployment..." echo "Starting deployment..."
# Run migrations # Run migrations