Compare commits

...

43 Commits

Author SHA1 Message Date
Hördle Bot
52a15b7504 chore: Version auf v0.1.6.1 erhöht 2025-12-04 00:43:27 +01:00
Hördle Bot
00160d9602 feat: Batch-Edit Übersetzungen hinzugefügt
- Englische und deutsche Übersetzungen für alle Batch-Edit-Funktionen
- Keys für Toolbar, Buttons, Meldungen und Fehlerbehandlung
- Unterstützt Genre/Special Toggle, Artist-Änderung und Exclude Global
2025-12-04 00:42:19 +01:00
Hördle Bot
296a227d22 feat: Batch-Edit-Funktionalität für Curator Track-Liste
- Neue API-Route /api/songs/batch für Batch-Updates
- Checkbox-Spalte in Tabelle mit Select-All-Funktionalität
- Batch-Edit-Toolbar mit Genre/Special Toggle, Artist-Änderung und Exclude Global Flag
- Visuelle Hervorhebung ausgewählter Zeilen
- Unterstützt Toggle-Modus für Genres/Specials (hinzufügen/entfernen)
- Validiert Kurator-Berechtigungen für jeden Song
- Transaktionsbasierte Updates für Konsistenz
2025-12-04 00:38:08 +01:00
Hördle Bot
50ca51b143 Enhance special ID handling in song API routes
- Updated logic to prioritize specialId and special.id for SpecialSong objects.
- Added comments for clarity on ID usage and conditions for retrieving special IDs.
- Modified API response to include related special details for better data integrity.
2025-12-04 00:24:14 +01:00
Hördle Bot
afe6e12afc Implement special selection feature in CuratorPageClient
- Added a new section for curators to select specials associated with their account.
- Introduced checkboxes for editing special selections, allowing for dynamic updates.
- Updated the display logic for specials to differentiate between selected and unselected items.
2025-12-04 00:14:27 +01:00
Hördle Bot
91b12ad859 Erweitere README.md um Kuratoren-System und Analytics-Funktionen
- Einführung eines Kuratoren-Managements mit separaten Accounts, Genre- und Special-Zuweisungen.
- Kuratoren können Songs verwalten und Spieler-Kommentare einsehen.
- Integration von Plausible Analytics für anonyme Nutzungsstatistiken und automatisches Domain-Tracking.
- Aktualisierung der Anweisungen für Kurator-Zugang und -Funktionen.
2025-12-03 23:30:31 +01:00
Hördle Bot
d2548c2870 Bump version to 0.1.6.0 2025-12-03 23:17:56 +01:00
Hördle Bot
40d6ea75f0 Behebe zwei Bugs im Kurator-Kommentar-System
Bug 1: Special-Kuratoren für Special-Puzzles berücksichtigen
- Kommentare zu Special-Puzzles werden jetzt korrekt an Special-Kuratoren geroutet
- Logik erweitert: Prüft zuerst puzzle.specialId, dann genreId, dann global
- Special-Kuratoren werden über CuratorSpecial abgerufen

Bug 2: Prisma Schema konsistent mit Migration
- onDelete: SetNull zur genre-Relation in CuratorComment hinzugefügt
- Entspricht jetzt dem Foreign Key Constraint in der Migration
2025-12-03 23:15:18 +01:00
Hördle Bot
0054facbe7 Füge Puzzle-Kontext zu Kommentaren hinzu und verbessere Sichtbarkeit neuer Kommentare
- Puzzle-Kontext: Hördle #, Genre/Special, Titel werden jetzt angezeigt
- API erweitert: puzzleNumber wird berechnet, special-Informationen inkludiert
- Badge für neue Kommentare: zeigt Anzahl ungelesener Kommentare
- Verbesserte Kommentar-Anzeige mit vollständigem Rätsel-Kontext
- UI-Anpassungen: nur Badge für neue Kommentare, keine übermäßige Hervorhebung
2025-12-03 23:09:45 +01:00
Hördle Bot
95bcf9ed1e Füge Archivierungs-Funktion für Kommentare hinzu und fixe initiales Laden
- Archivierungs-Funktionalität: Kuratoren können Kommentare archivieren
- archived-Flag in CuratorCommentRecipient hinzugefügt
- API-Route für Archivieren: /api/curator-comments/[id]/archive
- Kommentare werden beim initialen Laden automatisch abgerufen
- Archivierte Kommentare werden nicht mehr in der Liste angezeigt
- Archivieren-Button in der UI hinzugefügt
- Migration für archived-Feld
- Übersetzungen für Archivierung (DE/EN)
2025-12-03 22:57:28 +01:00
Hördle Bot
08fedf9881 Verschiebe Kommentare-Sektion ganz nach oben in Kuratoren-Seite 2025-12-03 22:47:40 +01:00
Hördle Bot
cd564b5d8c Implementiere Kurator-Kommentar-System
- Benutzer können nach Rätsel-Abschluss optional Nachricht an Kuratoren senden
- Kommentare werden in Datenbank gespeichert und in /curator angezeigt
- Neue Datenbank-Modelle: CuratorComment und CuratorCommentRecipient
- API-Routen für Kommentar-Versand, Abfrage und Markierung als gelesen
- Rate-Limiting: 1 Kommentar pro Spieler pro Rätsel (persistent in DB)
- Sicherheitsschutz: PlayerIdentifier-Validierung, Puzzle-Validierung
- Automatische Zuordnung zu Kuratoren (Genre-basiert + globale Kuratoren)
- Frontend: Kommentar-Formular in Game-Komponente
- Frontend: Kommentare-Anzeige in Kuratoren-Seite mit Markierung als gelesen
- Übersetzungen für DE und EN hinzugefügt
2025-12-03 22:46:02 +01:00
Hördle Bot
863539a5e9 Add Restic backup to remote repository in deploy script 2025-12-03 19:19:11 +01:00
Hördle Bot
2fa8aa0042 Bump version to v0.1.5.2 2025-12-03 18:36:47 +01:00
Hördle Bot
8ecf430bf5 Wrap song updates and deletes in database transactions for consistency 2025-12-03 18:36:32 +01:00
Hördle Bot
71abb7c322 Bump version to v0.1.5.1 2025-12-03 17:34:40 +01:00
Hördle Bot
b730c6637a Fix random song selection bias in daily puzzle generation 2025-12-03 17:34:23 +01:00
Hördle Bot
6e93529bc3 Add backup metadata and restore script for full DB rollback 2025-12-03 16:25:50 +01:00
Hördle Bot
990e1927e9 Curator: Client-Komponente ausgelagert, Server-Wrapper für stabilen Build 2025-12-03 15:28:17 +01:00
Hördle Bot
d7fee047c2 Deploy: shallow fetch + dynamische /curator-Seite für Docker-Build 2025-12-03 15:16:38 +01:00
Hördle Bot
28d14ff099 chore: bump version to v0.1.5.0 2025-12-03 15:12:50 +01:00
Hördle Bot
b1493b44bf Game: Share-Button unter Rating platziert und kurz erläutert 2025-12-03 15:03:32 +01:00
Hördle Bot
b8a803b76e Songs-API: robuste Behandlung möglicher verwaister SpecialSong-Relationen 2025-12-03 14:56:40 +01:00
Hördle Bot
e2bdf0fc88 Game: Attempt-Anzeige nach Rätsel-Ende nicht auf nächsten Versuch springen lassen 2025-12-03 14:09:31 +01:00
Hördle Bot
2cb9af8d2b Game: öffentliche Song-Liste für GuessInput statt geschütztem /api/songs 2025-12-03 14:06:32 +01:00
Hördle Bot
d6ad01b00e Curator-UI: sichere Optional-Chains für Genre-Filter 2025-12-03 13:46:58 +01:00
Hördle Bot
693817b18c Curator-Song-Update: Genre-Zuordnungen auch bei leerem Array korrekt übernehmen 2025-12-03 13:42:02 +01:00
Hördle Bot
41336e3af3 Curators API: aussagekräftige Fehler bei doppelten Usernames (P2002) 2025-12-03 13:37:59 +01:00
Hördle Bot
d7ec691469 Curator: Optional Chaining für Genre/Special-Filter abgesichert 2025-12-03 13:31:38 +01:00
Hördle Bot
5e1700712e Fix: Kuratoren-Scope für Specials & Audio-Playback im Curator-Dashboard 2025-12-03 13:25:43 +01:00
Hördle Bot
f691384a34 API: Auth & Scope für Song-GET, Kommentar für Kurator-Wrapper 2025-12-03 13:17:31 +01:00
Hördle Bot
f0d75c591a Admin: Validierung für Kuratoren-Passwort bei Neuanlage 2025-12-03 13:13:02 +01:00
Hördle Bot
1f34d5813e Fix: Kuratoren-Berechtigungscheck für Specials vereinheitlicht 2025-12-03 13:11:12 +01:00
Hördle Bot
33f8080aa8 Curator: Lokalisierung und einstellbare Paginierung 2025-12-03 13:09:20 +01:00
Hördle Bot
8a102afc0e Admin: Song Library und Upload entfernt 2025-12-03 12:59:26 +01:00
Hördle Bot
38148ace8d Kuratoren-Accounts und Anpassungen im Admin- und Kuratoren-Dashboard 2025-12-03 12:52:38 +01:00
Hördle Bot
49e98ade3c Update credits text in configuration to include a heart emoji for improved sentiment 2025-12-02 19:36:47 +01:00
Hördle Bot
397839cc1f Update credits text in configuration to enhance clarity 2025-12-02 19:29:55 +01:00
Hördle Bot
3fe805129b Change deploy-remote.sh file permissions to make it executable 2025-12-02 15:03:03 +01:00
Hördle Bot
bf9a49a9ac Update SSH password environment variable in remote deployment script 2025-12-02 14:56:36 +01:00
Hördle Bot
9b89cbf8ed Handle temporary SQLite DB during Docker build to prevent errors in sitemap generation 2025-12-02 14:55:50 +01:00
Hördle Bot
7f33e98fb5 Update donation note in costs section and increment version to v0.1.4.12 2025-12-02 14:54:26 +01:00
Hördle Bot
72f8b99092 Adjust costs section donation note and bump version to v0.1.4.11 2025-12-02 14:50:02 +01:00
34 changed files with 4464 additions and 1702 deletions

View File

@@ -15,6 +15,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Bearbeitung von Metadaten.
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
- **Kuratoren-Verwaltung:** Erstellen und Verwalten von Kurator-Accounts mit Zuweisung zu Genres und Specials.
- **Cover Art:**
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
@@ -42,7 +43,6 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Live-Vorschau beim Hovern über die Waveform.
- Playback-Cursor zeigt aktuelle Abspielposition.
- Einzelne Segmente zum Testen abspielen.
- Einzelne Segmente zum Testen abspielen.
- Manuelle Speicherung mit visueller Bestätigung.
- **News & Announcements:**
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
@@ -51,6 +51,24 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
- Verwaltung über das Admin-Dashboard.
- **Kurator-System:**
- **Kurator-Accounts:** Separate Login-Accounts für Kuratoren (nicht Admins).
- **Genre- & Special-Zuweisung:** Kuratoren können einzelnen Genres oder Specials zugewiesen werden.
- **Global-Kuratoren:** Optionale globale Kuratoren, die für alle Rätsel zuständig sind.
- **Kurator-Dashboard:** Eigene Dashboard-Seite (`/curator` oder `/de/curator`, `/en/curator`) für Kuratoren.
- **Song-Verwaltung:** Kuratoren können Songs hochladen, bearbeiten und Genres/Specials zuweisen.
- **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren.
- **Spieler-Kommentare:**
- **Feedback an Kuratoren:** Spieler können nach Abschluss eines Rätsels optional eine Nachricht an die Kuratoren senden.
- **Automatische Zuordnung:** Kommentare werden automatisch an relevante Kuratoren verteilt (Genre-Kuratoren, Special-Kuratoren, Global-Kuratoren).
- **Rate-Limiting:** Pro Spieler nur ein Kommentar pro Puzzle möglich.
- **Kontext-Informationen:** Kommentare enthalten vollständigen Rätsel-Kontext (Hördle #, Genre/Special, Titel/Artist).
- **Kommentar-Verwaltung:** Kuratoren sehen Kommentare in ihrem Dashboard mit Badge für neue/ungelesene Nachrichten.
- **Analytics:**
- **Plausible Analytics:** Integration mit Plausible Analytics für anonyme Nutzungsstatistiken.
- **Automatisches Domain-Tracking:** Unterstützt mehrere Domains mit automatischer Erkennung.
- **Privacy-First:** Keine Cookies, kein Cross-Site-Tracking.
- 👉 **[Plausible Setup-Dokumentation](docs/PLAUSIBLE_SETUP.md)**
## Internationalisierung (i18n)
@@ -139,6 +157,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
- `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`: URL zum Plausible Analytics Script (z.B. `https://plausible.example.com/js/script.js`, optional)
2. **Starten:**
```bash
@@ -156,7 +175,12 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
- URL: `/de/admin` oder `/en/admin`
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
5. **Special Curation & Scheduling verwenden:**
5. **Kurator-Zugang:**
- URL: `/de/curator` oder `/en/curator`
- Kurator-Accounts werden vom Admin erstellt und verwaltet.
- Kuratoren können Songs hochladen und verwalten, sowie Kommentare von Spielern einsehen.
6. **Special Curation & Scheduling verwenden:**
- Erstelle ein Special im Admin-Dashboard:
- Gib Name, Max Attempts und Unlock Steps ein.
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.

View File

@@ -70,20 +70,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
{t("costsTitle")}
</h2>
<p style={{ marginBottom: "0.5rem" }}>{t("costsIntro")}</p>
<p style={{ marginBottom: "0.5rem" }}>
{t.rich("costsDonationNote", {
link: (chunks) => (
<a
href="https://politicalbeauty.de/ueber-das-ZPS.html"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "underline" }}
>
{chunks}
</a>
),
})}
</p>
<ul
style={{
marginLeft: "1.25rem",
@@ -112,13 +98,27 @@ export default async function AboutPage({ params }: AboutPageProps) {
</p>
<p
style={{
marginBottom: "0.75rem",
marginBottom: "0.5rem",
fontSize: "0.9rem",
color: "#6b7280",
}}
>
{t("costsSheetPrivacyNote")}
</p>
<p style={{ marginBottom: "0.75rem" }}>
{t.rich("costsDonationNote", {
link: (chunks) => (
<a
href="https://politicalbeauty.de/ueber-das-ZPS.html"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "underline" }}
>
{chunks}
</a>
),
})}
</p>
</section>
<section style={{ marginBottom: "2rem" }}>

View File

@@ -76,6 +76,14 @@ interface PoliticalStatement {
locale: string;
}
interface Curator {
id: number;
username: string;
isGlobalCurator: boolean;
genreIds: number[];
specialIds: number[];
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc';
@@ -159,14 +167,18 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const [sortField, setSortField] = useState<SortField>('artist');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
// Search and pagination state
// Search and pagination state (wird nur noch in Resten der alten Song Library verwendet, kann später entfernt werden)
const [searchQuery, setSearchQuery] = useState('');
const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// Audio state
// Legacy Song-Library-Helper (Liste selbst ist obsolet; wir halten diese Werte nur, damit altes JSX nicht crasht)
const paginatedSongs: Song[] = [];
const totalPages = 1;
// Audio state (für Daily Puzzles)
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
@@ -184,6 +196,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
// Curators state
const [curators, setCurators] = useState<Curator[]>([]);
const [showCurators, setShowCurators] = useState(false);
const [editingCuratorId, setEditingCuratorId] = useState<number | null>(null);
const [curatorUsername, setCuratorUsername] = useState('');
const [curatorPassword, setCuratorPassword] = useState('');
const [curatorIsGlobal, setCuratorIsGlobal] = useState(false);
const [curatorGenreIds, setCuratorGenreIds] = useState<number[]>([]);
const [curatorSpecialIds, setCuratorSpecialIds] = useState<number[]>([]);
// Check for existing auth on mount
useEffect(() => {
const authToken = localStorage.getItem('hoerdle_admin_auth');
@@ -194,6 +216,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
fetchDailyPuzzles();
fetchSpecials();
fetchNews();
fetchCurators();
}
}, []);
@@ -210,6 +233,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
fetchDailyPuzzles();
fetchSpecials();
fetchNews();
fetchCurators();
} else {
alert(t('wrongPassword'));
}
@@ -224,6 +248,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
setGenres([]);
setSpecials([]);
setDailyPuzzles([]);
setCurators([]);
};
// Helper function to add auth headers to requests
@@ -245,6 +270,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}
};
const fetchCurators = async () => {
const res = await fetch('/api/curators', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setCurators(data);
}
};
const fetchGenres = async () => {
const res = await fetch('/api/genres', {
headers: getAuthHeaders()
@@ -719,6 +754,94 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}
};
const resetCuratorForm = () => {
setEditingCuratorId(null);
setCuratorUsername('');
setCuratorPassword('');
setCuratorIsGlobal(false);
setCuratorGenreIds([]);
setCuratorSpecialIds([]);
};
const startEditCurator = (curator: Curator) => {
setEditingCuratorId(curator.id);
setCuratorUsername(curator.username);
setCuratorPassword('');
setCuratorIsGlobal(curator.isGlobalCurator);
setCuratorGenreIds(curator.genreIds || []);
setCuratorSpecialIds(curator.specialIds || []);
};
const toggleCuratorGenre = (genreId: number) => {
setCuratorGenreIds(prev =>
prev.includes(genreId) ? prev.filter(id => id !== genreId) : [...prev, genreId]
);
};
const toggleCuratorSpecial = (specialId: number) => {
setCuratorSpecialIds(prev =>
prev.includes(specialId) ? prev.filter(id => id !== specialId) : [...prev, specialId]
);
};
const handleSaveCurator = async (e: React.FormEvent) => {
e.preventDefault();
if (!curatorUsername.trim()) {
alert('Bitte einen Benutzernamen eingeben.');
return;
}
// Beim Anlegen eines neuen Kurators ist ein Passwort Pflicht.
if (!editingCuratorId && !curatorPassword.trim()) {
alert('Für neue Kuratoren muss ein Passwort gesetzt werden.');
return;
}
const payload: any = {
username: curatorUsername.trim(),
isGlobalCurator: curatorIsGlobal,
genreIds: curatorGenreIds,
specialIds: curatorSpecialIds,
};
if (curatorPassword.trim()) {
payload.password = curatorPassword;
}
const url = '/api/curators';
const method = editingCuratorId ? 'PUT' : 'POST';
if (editingCuratorId) {
payload.id = editingCuratorId;
}
const res = await fetch(url, {
method,
headers: getAuthHeaders(),
body: JSON.stringify(payload),
});
if (res.ok) {
resetCuratorForm();
fetchCurators();
} else {
alert('Failed to save curator');
}
};
const handleDeleteCurator = async (id: number) => {
if (!confirm('Kurator wirklich löschen?')) return;
const res = await fetch('/api/curators', {
method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) {
fetchCurators();
} else {
alert('Failed to delete curator');
}
};
const handleBatchUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (files.length === 0) return;
@@ -1019,15 +1142,6 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}
};
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const handlePlayPause = (song: Song) => {
if (playingSongId === song.id) {
// Pause current song
@@ -1067,70 +1181,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}
};
// Filter and sort songs
const filteredSongs = songs.filter(song => {
// Text search filter
const matchesSearch = song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
song.artist.toLowerCase().includes(searchQuery.toLowerCase());
// Genre filter
// Unified Filter
let matchesFilter = true;
if (selectedGenreFilter) {
if (selectedGenreFilter.startsWith('genre:')) {
const genreId = Number(selectedGenreFilter.split(':')[1]);
matchesFilter = genreId === -1
? song.genres.length === 0
: song.genres.some(g => g.id === genreId);
} else if (selectedGenreFilter.startsWith('special:')) {
const specialId = Number(selectedGenreFilter.split(':')[1]);
matchesFilter = song.specials?.some(s => s.id === specialId) || false;
} else if (selectedGenreFilter === 'daily') {
const today = new Date().toISOString().split('T')[0];
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
} else if (selectedGenreFilter === 'no-global') {
matchesFilter = song.excludeFromGlobal === true;
}
}
return matchesSearch && matchesFilter;
});
const sortedSongs = [...filteredSongs].sort((a, b) => {
// Handle numeric sorting for ID, Release Year, Activations, and Rating
if (sortField === 'id') {
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
}
if (sortField === 'releaseYear') {
const yearA = a.releaseYear || 0;
const yearB = b.releaseYear || 0;
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
}
if (sortField === 'activations') {
return sortDirection === 'asc' ? a.activations - b.activations : b.activations - a.activations;
}
if (sortField === 'averageRating') {
return sortDirection === 'asc' ? a.averageRating - b.averageRating : b.averageRating - a.averageRating;
}
// String sorting for other fields
const valA = String(a[sortField]).toLowerCase();
const valB = String(b[sortField]).toLowerCase();
if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
// Pagination
const totalPages = Math.ceil(sortedSongs.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedSongs = sortedSongs.slice(startIndex, startIndex + itemsPerPage);
// Reset to page 1 when search changes
useEffect(() => {
setCurrentPage(1);
}, [searchQuery]);
// Song Library ist in das Kuratoren-Dashboard umgezogen, daher keine Song-Filter/Pagination mehr im Admin nötig.
if (!isAuthenticated) {
return (
@@ -1826,155 +1877,193 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
)}
</div>
{/* Curator Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>{t('uploadSongs')}</h2>
<form onSubmit={handleBatchUpload}>
{/* Drag & Drop Zone */}
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{t('manageCurators')}
</h2>
<button
onClick={() => setShowCurators(!showCurators)}
style={{
border: isDragging ? '2px solid #4f46e5' : '2px dashed #d1d5db',
borderRadius: '0.5rem',
padding: '2rem',
textAlign: 'center',
background: isDragging ? '#eef2ff' : '#f9fafb',
marginBottom: '1rem',
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
transition: 'all 0.2s'
fontSize: '0.875rem'
}}
onClick={() => fileInputRef.current?.click()}
>
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
{files.length > 0 ? `${files.length} file(s) selected` : 'Drag & drop MP3 files here'}
</p>
<p style={{ fontSize: '0.875rem', color: '#666' }}>
or click to browse
</p>
<input
ref={fileInputRef}
type="file"
accept="audio/mpeg"
multiple
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
{/* File List */}
{files.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>Selected Files:</p>
<div style={{ maxHeight: '200px', overflowY: 'auto', background: '#f9fafb', padding: '0.5rem', borderRadius: '0.25rem' }}>
{files.map((file, index) => (
<div key={index} style={{ padding: '0.25rem 0', fontSize: '0.875rem' }}>
📄 {file.name}
{showCurators ? t('hide') : t('show')}
</button>
</div>
{showCurators && (
<>
<form onSubmit={handleSaveCurator} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<input
type="text"
value={curatorUsername}
onChange={e => setCuratorUsername(e.target.value)}
placeholder={t('curatorUsername')}
className="form-input"
style={{ minWidth: '200px', flex: '1 1 200px' }}
required
/>
<input
type="password"
value={curatorPassword}
onChange={e => setCuratorPassword(e.target.value)}
placeholder={t('curatorPassword')}
className="form-input"
style={{ minWidth: '200px', flex: '1 1 200px' }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={curatorIsGlobal}
onChange={e => setCuratorIsGlobal(e.target.checked)}
/>
{t('isGlobalCurator')}
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ flex: '1 1 200px' }}>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignedGenres')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres.map(genre => (
<label
key={genre.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: curatorGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={curatorGenreIds.includes(genre.id)}
onChange={() => toggleCuratorGenre(genre.id)}
/>
{typeof genre.name === 'string'
? genre.name
: getLocalizedValue(genre.name, activeTab)}
</label>
))}
</div>
</div>
))}
<div style={{ flex: '1 1 200px' }}>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignedSpecials')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{specials.map(special => (
<label
key={special.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: curatorSpecialIds.includes(special.id) ? '#fee2e2' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={curatorSpecialIds.includes(special.id)}
onChange={() => toggleCuratorSpecial(special.id)}
/>
{typeof special.name === 'string'
? special.name
: getLocalizedValue(special.name, activeTab)}
</label>
))}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<button type="submit" className="btn-primary">
{editingCuratorId ? t('save') : t('addCurator')}
</button>
{editingCuratorId && (
<button
type="button"
className="btn-secondary"
onClick={resetCuratorForm}
>
{t('cancel')}
</button>
)}
</div>
</div>
</div>
)}
</form>
{/* Upload Progress */}
{isUploading && (
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#eef2ff', borderRadius: '0.5rem' }}>
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>
Uploading: {uploadProgress.current} / {uploadProgress.total}
</p>
<div style={{ width: '100%', height: '8px', background: '#d1d5db', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{
width: `${(uploadProgress.current / uploadProgress.total) * 100}%`,
height: '100%',
background: '#4f46e5',
transition: 'width 0.3s'
}} />
</div>
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>
Assign Genres (optional)
</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{genres.map(genre => (
<label
key={genre.id}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{curators.length === 0 && (
<p style={{ color: '#666', fontSize: '0.875rem' }}>{t('noCurators')}</p>
)}
{curators.map(curator => (
<div
key={curator.id}
style={{
padding: '0.75rem',
borderRadius: '0.5rem',
border: '1px solid #e5e7eb',
background: curator.isGlobalCurator ? '#eff6ff' : '#f9fafb',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
background: batchUploadGenreIds.includes(genre.id) ? '#dbeafe' : '#f3f4f6',
border: batchUploadGenreIds.includes(genre.id) ? '2px solid #3b82f6' : '2px solid transparent',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem',
transition: 'all 0.2s'
gap: '0.5rem',
flexWrap: 'wrap'
}}
>
<input
type="checkbox"
checked={batchUploadGenreIds.includes(genre.id)}
onChange={e => {
if (e.target.checked) {
setBatchUploadGenreIds([...batchUploadGenreIds, genre.id]);
} else {
setBatchUploadGenreIds(batchUploadGenreIds.filter(id => id !== genre.id));
}
}}
style={{ margin: 0 }}
/>
{getLocalizedValue(genre.name, activeTab)}
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div style={{ fontWeight: 600 }}>{curator.username}</div>
<div style={{ fontSize: '0.8rem', color: '#4b5563' }}>
{curator.isGlobalCurator && <span>Globaler Kurator · </span>}
<span>
{t('assignedGenres')}: {curator.genreIds.length}
</span>
{' · '}
<span>
{t('assignedSpecials')}: {curator.specialIds.length}
</span>
</div>
</div>
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button
type="button"
className="btn-secondary"
style={{ padding: '0.25rem 0.6rem', fontSize: '0.8rem' }}
onClick={() => startEditCurator(curator)}
>
{t('edit')}
</button>
<button
type="button"
className="btn-danger"
style={{ padding: '0.25rem 0.6rem', fontSize: '0.8rem' }}
onClick={() => handleDeleteCurator(curator.id)}
>
{t('delete')}
</button>
</div>
</div>
))}
</div>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
Selected genres will be assigned to all uploaded songs.
</p>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={uploadExcludeFromGlobal}
onChange={e => setUploadExcludeFromGlobal(e.target.checked)}
style={{ width: '1.25rem', height: '1.25rem' }}
/>
<span style={{ fontWeight: '500' }}>Exclude from Global Daily Puzzle</span>
</label>
<p style={{ fontSize: '0.875rem', color: '#666', marginLeft: '1.75rem', marginTop: '0.25rem' }}>
If checked, these songs will only appear in Genre or Special puzzles.
</p>
</div>
<button
type="submit"
className="btn-primary"
disabled={files.length === 0 || isUploading}
style={{ opacity: files.length === 0 || isUploading ? 0.5 : 1 }}
>
{isUploading ? 'Uploading...' : `Upload ${files.length} Song(s)`}
</button>
{message && (
<div style={{
marginTop: '1rem',
padding: '0.75rem',
background: '#d1fae5',
color: '#065f46',
borderRadius: '0.25rem'
}}>
{message}
</div>
)}
</form>
</>
)}
</div>
{/* Upload Songs wurde in das Kuratoren-Dashboard verlagert */}
{/* Today's Daily Puzzles */}
<div className="admin-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
@@ -2049,397 +2138,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
))}
</div>
<div className="admin-card">
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Song Library ({songs.length} songs)
</h2>
{/* Search and Filter */}
<div style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Search by title or artist..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="form-input"
style={{ flex: '1', minWidth: '200px' }}
/>
<select
value={selectedGenreFilter}
onChange={e => setSelectedGenreFilter(e.target.value)}
className="form-input"
style={{ minWidth: '150px' }}
>
<option value="">All Content</option>
<option value="daily">📅 Song of the Day</option>
<option value="no-global">🚫 No Global</option>
<optgroup label="Genres">
<option value="genre:-1">No Genre</option>
{genres.map(genre => (
<option key={genre.id} value={`genre:${genre.id}`}>
{getLocalizedValue(genre.name, activeTab)} ({genre._count?.songs || 0})
</option>
))}
</optgroup>
<optgroup label="Specials">
{specials.map(special => (
<option key={special.id} value={`special:${special.id}`}>
{getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})
</option>
))}
</optgroup>
</select>
{(searchQuery || selectedGenreFilter) && (
<button
onClick={() => {
setSearchQuery('');
setSelectedGenreFilter('');
}}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
Clear Filters
</button>
)}
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('id')}
>
ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('title')}
>
Song {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('releaseYear')}
>
Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.75rem' }}>Genres / Specials</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('createdAt')}
>
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('activations')}
>
Activations {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('averageRating')}
>
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.75rem' }}>Actions</th>
</tr>
</thead>
<tbody>
{paginatedSongs.map(song => (
<tr key={song.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '0.75rem' }}>{song.id}</td>
{editingId === song.id ? (
<>
<td style={{ padding: '0.75rem' }}>
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className="form-input"
style={{ padding: '0.25rem', marginBottom: '0.5rem', width: '100%' }}
placeholder="Title"
/>
<input
type="text"
value={editArtist}
onChange={e => setEditArtist(e.target.value)}
className="form-input"
style={{ padding: '0.25rem', width: '100%' }}
placeholder="Artist"
/>
</td>
<td style={{ padding: '0.75rem' }}>
<input
type="number"
value={editReleaseYear}
onChange={e => setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))}
className="form-input"
style={{ padding: '0.25rem', width: '80px' }}
placeholder="Year"
/>
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres.map(genre => (
<label key={genre.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem' }}>
<input
type="checkbox"
checked={editGenreIds.includes(genre.id)}
onChange={e => {
if (e.target.checked) {
setEditGenreIds([...editGenreIds, genre.id]);
} else {
setEditGenreIds(editGenreIds.filter(id => id !== genre.id));
}
}}
/>
{getLocalizedValue(genre.name, activeTab)}
</label>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.25rem' }}>
{specials.map(special => (
<label key={special.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem', color: '#4b5563' }}>
<input
type="checkbox"
checked={editSpecialIds.includes(special.id)}
onChange={e => {
if (e.target.checked) {
setEditSpecialIds([...editSpecialIds, special.id]);
} else {
setEditSpecialIds(editSpecialIds.filter(id => id !== special.id));
}
}}
/>
{getLocalizedValue(special.name, activeTab)}
</label>
))}
</div>
<div style={{ marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', cursor: 'pointer', color: '#b91c1c' }}>
<input
type="checkbox"
checked={editExcludeFromGlobal}
onChange={e => setEditExcludeFromGlobal(e.target.checked)}
/>
Exclude from Global
</label>
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')}
</td>
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
<td style={{ padding: '0.75rem', color: '#666' }}>
{song.averageRating > 0 ? (
<span title={`${song.ratingCount} ratings`}>
{song.averageRating.toFixed(1)} <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
</span>
) : (
<span style={{ color: '#ccc' }}>-</span>
)}
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => saveEditing(song.id)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title="Save"
>
</button>
<button
onClick={cancelEditing}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title="Cancel"
>
</button>
</div>
</td>
</>
) : (
<>
<td style={{ padding: '0.75rem' }}>
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</div>
{song.excludeFromGlobal && (
<div style={{ marginTop: '0.25rem' }}>
<span style={{
background: '#fee2e2',
color: '#991b1b',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem',
border: '1px solid #fecaca',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem'
}}>
🚫 No Global
</span>
</div>
)}
{/* Daily Puzzle Badges */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
if (!p.genreId && !p.specialId) {
return (
<span key={p.id} style={{ background: '#dbeafe', color: '#1e40af', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #93c5fd' }}>
🌍 Global Daily
</span>
);
}
if (p.genreId) {
const genreName = genres.find(g => g.id === p.genreId)?.name;
return (
<span key={p.id} style={{ background: '#f3f4f6', color: '#374151', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #d1d5db' }}>
🏷 {getLocalizedValue(genreName, activeTab)} Daily
</span>
);
}
if (p.specialId) {
const specialName = specials.find(s => s.id === p.specialId)?.name;
return (
<span key={p.id} style={{ background: '#fce7f3', color: '#be185d', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #fbcfe8' }}>
{getLocalizedValue(specialName, activeTab)} Daily
</span>
);
}
return null;
})}
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666' }}>
{song.releaseYear || '-'}
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres?.map(g => (
<span key={g.id} style={{
background: '#e5e7eb',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem'
}}>
{getLocalizedValue(g.name, activeTab)}
</span>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{song.specials?.map(s => (
<span key={s.id} style={{
background: '#fce7f3',
color: '#9d174d',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem'
}}>
{getLocalizedValue(s.name, activeTab)}
</span>
))}
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')}
</td>
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
<td style={{ padding: '0.75rem', color: '#666' }}>
{song.averageRating > 0 ? (
<span title={`${song.ratingCount} ratings`}>
{song.averageRating.toFixed(1)} <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
</span>
) : (
<span style={{ color: '#ccc' }}>-</span>
)}
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => handlePlayPause(song)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title={playingSongId === song.id ? "Pause" : "Play"}
>
{playingSongId === song.id ? '⏸️' : '▶️'}
</button>
<button
onClick={() => startEditing(song)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title="Edit"
>
</button>
<button
onClick={() => handleDelete(song.id, song.title)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title={t('deletePuzzle')}
>
🗑
</button>
</div>
</td>
</>
)}
</tr>
))}
{paginatedSongs.length === 0 && (
<tr>
<td colSpan={7} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
{searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', alignItems: 'center' }}>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
style={{
padding: '0.5rem 1rem',
border: '1px solid #d1d5db',
background: currentPage === 1 ? '#f3f4f6' : '#fff',
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
borderRadius: '0.25rem'
}}
>
Previous
</button>
<span style={{ color: '#666' }}>
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
style={{
padding: '0.5rem 1rem',
border: '1px solid #d1d5db',
background: currentPage === totalPages ? '#f3f4f6' : '#fff',
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
borderRadius: '0.25rem'
}}
>
Next
</button>
</div>
)}
</div>
{/* Song Library wurde in das Kuratoren-Dashboard verlagert */}
<div className="admin-card" style={{ marginTop: '2rem', border: '1px solid #ef4444' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem', color: '#ef4444' }}>

View File

@@ -0,0 +1,11 @@
'use client';
import CuratorPageInner from '../../curator/page';
export default function CuratorPage() {
// Wrapper für die lokalisierte Route /[locale]/curator
// Hinweis: Pfad '../../curator/page' zeigt von 'app/[locale]/curator' korrekt auf 'app/curator/page'.
return <CuratorPageInner />;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { rateLimit } from '@/lib/rateLimit';
const prisma = new PrismaClient();
export async function POST(request: NextRequest) {
// Rate limiting: 3 requests per minute
const rateLimitError = rateLimit(request, { windowMs: 60000, maxRequests: 3 });
if (rateLimitError) return rateLimitError;
try {
const { puzzleId, genreId, message, playerIdentifier } = await request.json();
// Validate required fields
if (!puzzleId || !message || !playerIdentifier) {
return NextResponse.json(
{ error: 'puzzleId, message, and playerIdentifier are required' },
{ status: 400 }
);
}
// Message validation: max 2000 characters, no empty message
const trimmedMessage = message.trim();
if (trimmedMessage.length === 0) {
return NextResponse.json(
{ error: 'Message cannot be empty' },
{ status: 400 }
);
}
if (trimmedMessage.length > 2000) {
return NextResponse.json(
{ error: 'Message too long. Maximum 2000 characters allowed.' },
{ status: 400 }
);
}
// PlayerIdentifier validation: Check if it exists in PlayerState
const playerState = await prisma.playerState.findFirst({
where: {
identifier: playerIdentifier
}
});
if (!playerState) {
return NextResponse.json(
{ error: 'Invalid player identifier' },
{ status: 400 }
);
}
// Puzzle validation: Check if puzzle exists and matches genreId
const puzzle = await prisma.dailyPuzzle.findUnique({
where: { id: Number(puzzleId) },
include: {
song: true
}
});
if (!puzzle) {
return NextResponse.json(
{ error: 'Puzzle not found' },
{ status: 404 }
);
}
// Validate genreId matches puzzle (if genreId is provided)
if (genreId !== null && genreId !== undefined) {
if (puzzle.genreId !== Number(genreId)) {
return NextResponse.json(
{ error: 'Puzzle does not match the provided genre' },
{ status: 400 }
);
}
} else {
// If no genreId provided, use puzzle's genreId
// For global puzzles, genreId is null
}
// Rate limit check: Check if comment already exists for this playerIdentifier + puzzleId
const existingComment = await prisma.curatorComment.findUnique({
where: {
playerIdentifier_puzzleId: {
playerIdentifier: playerIdentifier,
puzzleId: Number(puzzleId)
}
}
});
if (existingComment) {
return NextResponse.json(
{ error: 'You have already sent a comment for this puzzle' },
{ status: 429 }
);
}
// Determine responsible curators
const finalGenreId = genreId !== null && genreId !== undefined ? Number(genreId) : puzzle.genreId;
const specialId = puzzle.specialId;
let curatorIds: number[] = [];
const allCuratorIds = new Set<number>();
// Get all global curators (always included)
const globalCurators = await prisma.curator.findMany({
where: {
isGlobalCurator: true
},
select: {
id: true
}
});
globalCurators.forEach(gc => allCuratorIds.add(gc.id));
// Check for special puzzle first (takes precedence)
if (specialId !== null) {
// Special puzzle: Get curators for this special + all global curators
const specialCurators = await prisma.curatorSpecial.findMany({
where: {
specialId: specialId
},
select: {
curatorId: true
}
});
specialCurators.forEach(cs => allCuratorIds.add(cs.curatorId));
} else if (finalGenreId !== null) {
// Genre puzzle: Get curators for this genre + all global curators
const genreCurators = await prisma.curatorGenre.findMany({
where: {
genreId: finalGenreId
},
select: {
curatorId: true
}
});
genreCurators.forEach(cg => allCuratorIds.add(cg.curatorId));
}
// else: Global puzzle - only global curators (already added above)
curatorIds = Array.from(allCuratorIds);
if (curatorIds.length === 0) {
return NextResponse.json(
{ error: 'No curators found for this puzzle' },
{ status: 500 }
);
}
// Create comment and recipients in a transaction
const result = await prisma.$transaction(async (tx) => {
// Create the comment
const comment = await tx.curatorComment.create({
data: {
playerIdentifier: playerIdentifier,
puzzleId: Number(puzzleId),
genreId: finalGenreId,
message: trimmedMessage
}
});
// Create recipients for all curators
await tx.curatorCommentRecipient.createMany({
data: curatorIds.map(curatorId => ({
commentId: comment.id,
curatorId: curatorId
}))
});
return comment;
});
return NextResponse.json({
success: true,
commentId: result.id
});
} catch (error) {
console.error('Error creating curator comment:', error);
// Handle unique constraint violation (shouldn't happen due to our check, but just in case)
if (error instanceof Error && error.message.includes('Unique constraint')) {
return NextResponse.json(
{ error: 'You have already sent a comment for this puzzle' },
{ status: 429 }
);
}
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireStaffAuth } from '@/lib/auth';
const prisma = new PrismaClient();
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
// Require curator authentication
const { error, context } = await requireStaffAuth(request);
if (error || !context) {
return error!;
}
// Only curators can archive comments
if (context.role !== 'curator') {
return NextResponse.json(
{ error: 'Only curators can archive comments' },
{ status: 403 }
);
}
try {
const { id } = await params;
const commentId = Number(id);
const curatorId = context.curator.id;
// Verify that this comment belongs to this curator
const recipient = await prisma.curatorCommentRecipient.findUnique({
where: {
commentId_curatorId: {
commentId: commentId,
curatorId: curatorId
}
}
});
if (!recipient) {
return NextResponse.json(
{ error: 'Comment not found or access denied' },
{ status: 404 }
);
}
// Update archived flag
await prisma.curatorCommentRecipient.update({
where: {
id: recipient.id
},
data: {
archived: true
}
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error archiving comment:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireStaffAuth } from '@/lib/auth';
const prisma = new PrismaClient();
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
// Require curator authentication
const { error, context } = await requireStaffAuth(request);
if (error || !context) {
return error!;
}
// Only curators can mark comments as read
if (context.role !== 'curator') {
return NextResponse.json(
{ error: 'Only curators can mark comments as read' },
{ status: 403 }
);
}
try {
const { id } = await params;
const commentId = Number(id);
const curatorId = context.curator.id;
// Verify that this comment belongs to this curator
const recipient = await prisma.curatorCommentRecipient.findUnique({
where: {
commentId_curatorId: {
commentId: commentId,
curatorId: curatorId
}
}
});
if (!recipient) {
return NextResponse.json(
{ error: 'Comment not found or access denied' },
{ status: 404 }
);
}
// Update readAt timestamp
await prisma.curatorCommentRecipient.update({
where: {
id: recipient.id
},
data: {
readAt: new Date()
}
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error marking comment as read:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireStaffAuth } from '@/lib/auth';
const prisma = new PrismaClient();
export async function GET(request: NextRequest) {
// Require curator authentication
const { error, context } = await requireStaffAuth(request);
if (error || !context) {
return error!;
}
// Only curators can view comments (not admins directly)
if (context.role !== 'curator') {
return NextResponse.json(
{ error: 'Only curators can view comments' },
{ status: 403 }
);
}
try {
const curatorId = context.curator.id;
// Get all non-archived comments for this curator, ordered by creation date (newest first)
const comments = await prisma.curatorCommentRecipient.findMany({
where: {
curatorId: curatorId,
archived: false
},
include: {
comment: {
include: {
puzzle: {
include: {
song: {
select: {
title: true,
artist: true
}
},
genre: {
select: {
id: true,
name: true
}
},
special: {
select: {
id: true,
name: true
}
}
}
}
}
}
},
orderBy: {
comment: {
createdAt: 'desc'
}
}
});
// Format the response with puzzle context
const formattedComments = await Promise.all(comments.map(async (recipient) => {
const puzzle = recipient.comment.puzzle;
// Calculate puzzle number
let puzzleNumber = 0;
if (puzzle.specialId) {
// Special puzzle
puzzleNumber = await prisma.dailyPuzzle.count({
where: {
specialId: puzzle.specialId,
date: {
lte: puzzle.date
}
}
});
} else if (puzzle.genreId) {
// Genre puzzle
puzzleNumber = await prisma.dailyPuzzle.count({
where: {
genreId: puzzle.genreId,
date: {
lte: puzzle.date
}
}
});
} else {
// Global puzzle
puzzleNumber = await prisma.dailyPuzzle.count({
where: {
genreId: null,
specialId: null,
date: {
lte: puzzle.date
}
}
});
}
return {
id: recipient.comment.id,
message: recipient.comment.message,
createdAt: recipient.comment.createdAt,
readAt: recipient.readAt,
puzzle: {
id: puzzle.id,
date: puzzle.date,
puzzleNumber: puzzleNumber,
song: {
title: puzzle.song.title,
artist: puzzle.song.artist
},
genre: puzzle.genre ? {
id: puzzle.genre.id,
name: puzzle.genre.name
} : null,
special: puzzle.special ? {
id: puzzle.special.id,
name: puzzle.special.name
} : null
}
};
}));
return NextResponse.json(formattedComments);
} catch (error) {
console.error('Error fetching curator comments:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
export async function POST(request: NextRequest) {
try {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
}
const curator = await prisma.curator.findUnique({
where: { username },
});
if (!curator) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const isValid = await bcrypt.compare(password, curator.passwordHash);
if (!isValid) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
return NextResponse.json({
success: true,
curator: {
id: curator.id,
username: curator.username,
isGlobalCurator: curator.isGlobalCurator,
},
});
} catch (error) {
console.error('Curator login error:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireStaffAuth } from '@/lib/auth';
const prisma = new PrismaClient();
export async function GET(request: NextRequest) {
const { error, context } = await requireStaffAuth(request);
if (error || !context) return error!;
if (context.role !== 'curator') {
return NextResponse.json(
{ error: 'Only curators can access this endpoint' },
{ status: 403 }
);
}
const [genres, specials] = await Promise.all([
prisma.curatorGenre.findMany({
where: { curatorId: context.curator.id },
select: { genreId: true },
}),
prisma.curatorSpecial.findMany({
where: { curatorId: context.curator.id },
select: { specialId: true },
}),
]);
return NextResponse.json({
id: context.curator.id,
username: context.curator.username,
isGlobalCurator: context.curator.isGlobalCurator,
genreIds: genres.map(g => g.genreId),
specialIds: specials.map(s => s.specialId),
});
}

200
app/api/curators/route.ts Normal file
View File

@@ -0,0 +1,200 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient, Prisma } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
export async function GET(request: NextRequest) {
// Only admin may list and manage curators
const authError = await requireAdminAuth(request);
if (authError) return authError;
const curators = await prisma.curator.findMany({
include: {
genres: true,
specials: true,
},
orderBy: { username: 'asc' },
});
return NextResponse.json(
curators.map(c => ({
id: c.id,
username: c.username,
isGlobalCurator: c.isGlobalCurator,
genreIds: c.genres.map(g => g.genreId),
specialIds: c.specials.map(s => s.specialId),
}))
);
}
export async function POST(request: NextRequest) {
const authError = await requireAdminAuth(request);
if (authError) return authError;
const { username, password, isGlobalCurator = false, genreIds = [], specialIds = [] } = await request.json();
if (!username || !password) {
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
}
const passwordHash = await bcrypt.hash(password, 10);
try {
const curator = await prisma.curator.create({
data: {
username,
passwordHash,
isGlobalCurator: Boolean(isGlobalCurator),
genres: {
create: (genreIds as number[]).map(id => ({ genreId: id })),
},
specials: {
create: (specialIds as number[]).map(id => ({ specialId: id })),
},
},
include: {
genres: true,
specials: true,
},
});
return NextResponse.json({
id: curator.id,
username: curator.username,
isGlobalCurator: curator.isGlobalCurator,
genreIds: curator.genres.map(g => g.genreId),
specialIds: curator.specials.map(s => s.specialId),
});
} catch (error) {
console.error('Error creating curator:', error);
// Handle unique username constraint violation explicitly
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return NextResponse.json(
{ error: 'A curator with this username already exists.' },
{ status: 409 }
);
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
const authError = await requireAdminAuth(request);
if (authError) return authError;
const { id, username, password, isGlobalCurator, genreIds, specialIds } = await request.json();
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 });
}
const data: any = {};
if (username !== undefined) data.username = username;
if (isGlobalCurator !== undefined) data.isGlobalCurator = Boolean(isGlobalCurator);
if (password) {
data.passwordHash = await bcrypt.hash(password, 10);
}
try {
const updated = await prisma.$transaction(async (tx) => {
const curator = await tx.curator.update({
where: { id: Number(id) },
data,
include: {
genres: true,
specials: true,
},
});
if (Array.isArray(genreIds)) {
await tx.curatorGenre.deleteMany({
where: { curatorId: curator.id },
});
if (genreIds.length > 0) {
await tx.curatorGenre.createMany({
data: (genreIds as number[]).map(gid => ({
curatorId: curator.id,
genreId: gid,
})),
});
}
}
if (Array.isArray(specialIds)) {
await tx.curatorSpecial.deleteMany({
where: { curatorId: curator.id },
});
if (specialIds.length > 0) {
await tx.curatorSpecial.createMany({
data: (specialIds as number[]).map(sid => ({
curatorId: curator.id,
specialId: sid,
})),
});
}
}
const finalCurator = await tx.curator.findUnique({
where: { id: curator.id },
include: {
genres: true,
specials: true,
},
});
if (!finalCurator) {
throw new Error('Curator not found after update');
}
return finalCurator;
});
return NextResponse.json({
id: updated.id,
username: updated.username,
isGlobalCurator: updated.isGlobalCurator,
genreIds: updated.genres.map(g => g.genreId),
specialIds: updated.specials.map(s => s.specialId),
});
} catch (error) {
console.error('Error updating curator:', error);
// Handle unique username constraint violation explicitly for updates
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return NextResponse.json(
{ error: 'A curator with this username already exists.' },
{ status: 409 }
);
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const authError = await requireAdminAuth(request);
if (authError) return authError;
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 });
}
try {
await prisma.curator.delete({
where: { id: Number(id) },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting curator:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Öffentliche, schreibgeschützte Song-Liste für das Spiel (GuessInput etc.).
// Kein Auth, nur Lesen der nötigsten Felder.
export async function GET() {
const songs = await prisma.song.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
artist: true,
},
});
return NextResponse.json(songs);
}

View File

@@ -0,0 +1,265 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireStaffAuth, StaffContext } from '@/lib/auth';
const prisma = new PrismaClient();
async function getCuratorAssignments(curatorId: number) {
const [genres, specials] = await Promise.all([
prisma.curatorGenre.findMany({
where: { curatorId },
select: { genreId: true },
}),
prisma.curatorSpecial.findMany({
where: { curatorId },
select: { specialId: true },
}),
]);
return {
genreIds: new Set(genres.map(g => g.genreId)),
specialIds: new Set(specials.map(s => s.specialId)),
};
}
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
if (context.role === 'admin') return true;
const songGenreIds = (song.genres || []).map((g: any) => g.id);
const songSpecialIds = (song.specials || [])
.map((s: any) => {
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.id;
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
return undefined;
})
.filter((id: any): id is number => typeof id === 'number');
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
return true;
}
const hasGenre = songGenreIds.some((id: number) => assignments.genreIds.has(id));
const hasSpecial = songSpecialIds.some((id: number) => assignments.specialIds.has(id));
return hasGenre || hasSpecial;
}
export async function POST(request: Request) {
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (error || !context) return error!;
try {
const { songIds, genreToggleIds, specialToggleIds, artist, excludeFromGlobal } = await request.json();
if (!songIds || !Array.isArray(songIds) || songIds.length === 0) {
return NextResponse.json({ error: 'Missing or invalid songIds array' }, { status: 400 });
}
// Validate that at least one operation is requested
const hasGenreToggle = genreToggleIds && Array.isArray(genreToggleIds) && genreToggleIds.length > 0;
const hasSpecialToggle = specialToggleIds && Array.isArray(specialToggleIds) && specialToggleIds.length > 0;
const hasArtistChange = artist !== undefined && artist !== null && artist.trim() !== '';
const hasExcludeGlobalChange = excludeFromGlobal !== undefined;
if (!hasGenreToggle && !hasSpecialToggle && !hasArtistChange && !hasExcludeGlobalChange) {
return NextResponse.json({ error: 'No update operations specified' }, { status: 400 });
}
// Validate artist if provided
if (hasArtistChange && artist.trim() === '') {
return NextResponse.json({ error: 'Artist cannot be empty' }, { status: 400 });
}
// Validate excludeFromGlobal permission
if (hasExcludeGlobalChange && context.role === 'curator' && !context.curator.isGlobalCurator) {
return NextResponse.json(
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
{ status: 403 }
);
}
let assignments: { genreIds: Set<number>; specialIds: Set<number> } | null = null;
if (context.role === 'curator') {
assignments = await getCuratorAssignments(context.curator.id);
// Validate genre/special toggles are within curator's assignments
if (hasGenreToggle) {
const invalidGenre = genreToggleIds.some((id: number) => !assignments.genreIds.has(id));
if (invalidGenre) {
return NextResponse.json(
{ error: 'Curators may only toggle their own genres' },
{ status: 403 }
);
}
}
if (hasSpecialToggle) {
const invalidSpecial = specialToggleIds.some((id: number) => !assignments.specialIds.has(id));
if (invalidSpecial) {
return NextResponse.json(
{ error: 'Curators may only toggle their own specials' },
{ status: 403 }
);
}
}
}
// Load all songs with relations for permission checks
const songs = await prisma.song.findMany({
where: { id: { in: songIds.map((id: any) => Number(id)) } },
include: {
genres: true,
specials: {
include: {
special: true
}
},
},
});
if (songs.length === 0) {
return NextResponse.json({ error: 'No songs found' }, { status: 404 });
}
// Filter songs that can be edited
const editableSongs = context.role === 'admin'
? songs
: songs.filter(song => curatorCanEditSong(context, song, assignments!));
if (editableSongs.length === 0) {
return NextResponse.json(
{ error: 'No songs can be edited with current permissions' },
{ status: 403 }
);
}
const results = {
total: songIds.length,
processed: editableSongs.length,
skipped: songs.length - editableSongs.length,
success: 0,
errors: [] as Array<{ songId: number; error: string }>,
};
// Process each song in a transaction
for (const song of editableSongs) {
try {
await prisma.$transaction(async (tx) => {
const updateData: any = {};
// Handle artist change
if (hasArtistChange) {
updateData.artist = artist.trim();
}
// Handle excludeFromGlobal change
if (hasExcludeGlobalChange) {
updateData.excludeFromGlobal = excludeFromGlobal;
}
// Handle genre toggles
if (hasGenreToggle) {
const currentGenreIds = song.genres.map(g => g.id);
const genreIdsToToggle = genreToggleIds as number[];
// Determine which genres to add/remove
const genresToAdd = genreIdsToToggle.filter(id => !currentGenreIds.includes(id));
const genresToRemove = genreIdsToToggle.filter(id => currentGenreIds.includes(id));
// For curators, preserve genres they can't manage
let finalGenreIds: number[];
if (context.role === 'curator') {
const fixedGenreIds = currentGenreIds.filter(gid => !assignments!.genreIds.has(gid));
const managedGenreIds = currentGenreIds
.filter(gid => assignments!.genreIds.has(gid) && !genresToRemove.includes(gid))
.concat(genresToAdd);
finalGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
} else {
const newGenreIds = currentGenreIds
.filter(id => !genresToRemove.includes(id))
.concat(genresToAdd);
finalGenreIds = Array.from(new Set(newGenreIds));
}
updateData.genres = {
set: finalGenreIds.map(gId => ({ id: gId }))
};
}
// Update song basic data
if (Object.keys(updateData).length > 0) {
await tx.song.update({
where: { id: song.id },
data: updateData,
});
}
// Handle special toggles
if (hasSpecialToggle) {
const currentSpecials = await tx.specialSong.findMany({
where: { songId: song.id }
});
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
const specialIdsToToggle = specialToggleIds as number[];
// Determine which specials to add/remove
const specialsToAdd = specialIdsToToggle.filter(id => !currentSpecialIds.includes(id));
const specialsToRemove = specialIdsToToggle.filter(id => currentSpecialIds.includes(id));
// For curators, preserve specials they can't manage
let finalSpecialIds: number[];
if (context.role === 'curator') {
const fixedSpecialIds = currentSpecialIds.filter(sid => !assignments!.specialIds.has(sid));
const managedSpecialIds = currentSpecialIds
.filter(sid => assignments!.specialIds.has(sid) && !specialsToRemove.includes(sid))
.concat(specialsToAdd);
finalSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
} else {
const newSpecialIds = currentSpecialIds
.filter(id => !specialsToRemove.includes(id))
.concat(specialsToAdd);
finalSpecialIds = Array.from(new Set(newSpecialIds));
}
// Delete removed specials
const toDelete = currentSpecialIds.filter(sid => !finalSpecialIds.includes(sid));
if (toDelete.length > 0) {
await tx.specialSong.deleteMany({
where: {
songId: song.id,
specialId: { in: toDelete }
}
});
}
// Add new specials
const toAdd = finalSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
if (toAdd.length > 0) {
await tx.specialSong.createMany({
data: toAdd.map(specialId => ({
songId: song.id,
specialId,
startTime: 0
}))
});
}
}
});
results.success++;
} catch (error: any) {
results.errors.push({
songId: song.id,
error: error.message || 'Unknown error'
});
}
}
return NextResponse.json(results);
} catch (error) {
console.error('Error in batch update:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -1,18 +1,96 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { writeFile, unlink } from 'fs/promises';
import path from 'path';
import { parseBuffer } from 'music-metadata';
import { isDuplicateSong } from '@/lib/fuzzyMatch';
import { requireAdminAuth } from '@/lib/auth';
import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth';
const prisma = new PrismaClient();
async function getCuratorAssignments(curatorId: number) {
const [genres, specials] = await Promise.all([
prisma.curatorGenre.findMany({
where: { curatorId },
select: { genreId: true },
}),
prisma.curatorSpecial.findMany({
where: { curatorId },
select: { specialId: true },
}),
]);
return {
genreIds: new Set(genres.map(g => g.genreId)),
specialIds: new Set(specials.map(s => s.specialId)),
};
}
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
if (context.role === 'admin') return true;
const songGenreIds = (song.genres || []).map((g: any) => g.id);
// `song.specials` kann je nach Context entweder ein Array von
// - `Special` (mit `id`)
// - `SpecialSong` (mit `specialId`)
// - `SpecialSong` (mit Relation `special.id`)
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
// Daher zuerst specialId oder special.id prüfen.
const songSpecialIds = (song.specials || [])
.map((s: any) => {
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.id;
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
return undefined;
})
.filter((id: any): id is number => typeof id === 'number');
// Songs ohne Genres/Specials sind für Kuratoren generell editierbar
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
return true;
}
const hasGenre = songGenreIds.some((id: number) => assignments.genreIds.has(id));
const hasSpecial = songSpecialIds.some((id: number) => assignments.specialIds.has(id));
return hasGenre || hasSpecial;
}
function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
if (context.role === 'admin') return true;
const songGenreIds = (song.genres || []).map((g: any) => g.id);
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
// Daher zuerst specialId oder special.id prüfen.
const songSpecialIds = (song.specials || [])
.map((s: any) => {
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.id;
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
return undefined;
})
.filter((id: any): id is number => typeof id === 'number');
const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id));
const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id));
return allGenresAllowed && allSpecialsAllowed;
}
// Configure route to handle large file uploads
export const runtime = 'nodejs';
export const maxDuration = 60; // 60 seconds timeout for uploads
export async function GET() {
export async function GET(request: NextRequest) {
// Alle Zugriffe auf die Songliste erfordern Staff-Auth (Admin oder Kurator)
const { error, context } = await requireStaffAuth(request);
if (error || !context) return error!;
const songs = await prisma.song.findMany({
orderBy: { createdAt: 'desc' },
include: {
@@ -26,8 +104,33 @@ export async function GET() {
},
});
let visibleSongs = songs;
if (context.role === 'curator') {
const assignments = await getCuratorAssignments(context.curator.id);
visibleSongs = songs.filter(song => {
const songGenreIds = song.genres.map(g => g.id);
// `song.specials` ist hier ein Array von SpecialSong mit Relation `special`.
// Es kann theoretisch verwaiste Einträge ohne `special` geben → defensiv optional chainen.
const songSpecialIds = song.specials
.map(ss => ss.special?.id)
.filter((id): id is number => typeof id === 'number');
// Songs ohne Genres/Specials sind immer sichtbar
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
return true;
}
const hasGenre = songGenreIds.some(id => assignments.genreIds.has(id));
const hasSpecial = songSpecialIds.some(id => assignments.specialIds.has(id));
return hasGenre || hasSpecial;
});
}
// Map to include activation count and flatten specials
const songsWithActivations = songs.map(song => ({
const songsWithActivations = visibleSongs.map(song => ({
id: song.id,
title: song.title,
artist: song.artist,
@@ -38,7 +141,10 @@ export async function GET() {
activations: song.puzzles.length,
puzzles: song.puzzles,
genres: song.genres,
specials: song.specials.map(ss => ss.special),
// Nur Specials mit existierender Relation durchreichen, um undefinierte Einträge zu vermeiden.
specials: song.specials
.map(ss => ss.special)
.filter((s): s is any => !!s),
averageRating: song.averageRating,
ratingCount: song.ratingCount,
excludeFromGlobal: song.excludeFromGlobal,
@@ -50,11 +156,11 @@ export async function GET() {
export async function POST(request: Request) {
console.log('[UPLOAD] Starting song upload request');
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) {
// Check authentication (admin or curator)
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (error || !context) {
console.log('[UPLOAD] Authentication failed');
return authError;
return error!;
}
try {
@@ -63,10 +169,17 @@ export async function POST(request: Request) {
const file = formData.get('file') as File;
let title = '';
let artist = '';
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
let excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
console.log('[UPLOAD] excludeFromGlobal (raw):', excludeFromGlobal);
// Apply global playlist rules:
// - Admin: may control the flag via form data
// - Curator: uploads are always excluded from global by default
if (context.role === 'curator') {
excludeFromGlobal = true;
}
if (!file) {
console.error('[UPLOAD] No file provided');
@@ -261,9 +374,9 @@ export async function POST(request: Request) {
}
export async function PUT(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
// Check authentication (admin or curator)
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (error || !context) return error!;
try {
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
@@ -272,6 +385,73 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
}
// Load current song with relations for permission checks
const existingSong = await prisma.song.findUnique({
where: { id: Number(id) },
include: {
genres: true,
specials: {
include: {
special: true
}
},
},
});
if (!existingSong) {
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
}
let effectiveGenreIds = genreIds as number[] | undefined;
let effectiveSpecialIds = specialIds as number[] | undefined;
if (context.role === 'curator') {
const assignments = await getCuratorAssignments(context.curator.id);
if (!curatorCanEditSong(context, existingSong, assignments)) {
return NextResponse.json({ error: 'Forbidden: You are not allowed to edit this song' }, { status: 403 });
}
// Curators may assign genres, but only within their own assignments.
// Genres außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
if (effectiveGenreIds !== undefined) {
const invalidGenre = effectiveGenreIds.some(id => !assignments.genreIds.has(id));
if (invalidGenre) {
return NextResponse.json(
{ error: 'Curators may only assign their own genres' },
{ status: 403 }
);
}
const fixedGenreIds = existingSong.genres
.filter(g => !assignments.genreIds.has(g.id))
.map(g => g.id);
const managedGenreIds = effectiveGenreIds.filter(id => assignments.genreIds.has(id));
effectiveGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
}
// Curators may assign specials, but only within their own assignments.
// Specials außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
if (effectiveSpecialIds !== undefined) {
const invalidSpecial = effectiveSpecialIds.some(id => !assignments.specialIds.has(id));
if (invalidSpecial) {
return NextResponse.json(
{ error: 'Curators may only assign their own specials' },
{ status: 403 }
);
}
const currentSpecials = await prisma.specialSong.findMany({
where: { songId: Number(id) }
});
const fixedSpecialIds = currentSpecials
.map(ss => ss.specialId)
.filter(sid => !assignments.specialIds.has(sid));
const managedSpecialIds = effectiveSpecialIds.filter(id => assignments.specialIds.has(id));
effectiveSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
}
}
const data: any = { title, artist };
// Update releaseYear if provided (can be null to clear it)
@@ -280,60 +460,76 @@ export async function PUT(request: Request) {
}
if (excludeFromGlobal !== undefined) {
data.excludeFromGlobal = excludeFromGlobal;
if (context.role === 'admin') {
data.excludeFromGlobal = excludeFromGlobal;
} else {
// Curators may only change the flag if they are global curators
if (!context.curator.isGlobalCurator) {
return NextResponse.json(
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
{ status: 403 }
);
}
data.excludeFromGlobal = excludeFromGlobal;
}
}
if (genreIds) {
// Wenn effectiveGenreIds definiert ist, auch leere Arrays übernehmen (löscht alle Zuordnungen).
if (effectiveGenreIds !== undefined) {
data.genres = {
set: genreIds.map((gId: number) => ({ id: gId }))
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
};
}
// Handle SpecialSong relations separately
if (specialIds !== undefined) {
// First, get current special assignments
const currentSpecials = await prisma.specialSong.findMany({
where: { songId: Number(id) }
});
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
const newSpecialIds = specialIds as number[];
// Delete removed specials
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
if (toDelete.length > 0) {
await prisma.specialSong.deleteMany({
where: {
songId: Number(id),
specialId: { in: toDelete }
}
// Execute all database write operations in a transaction to ensure consistency
const updatedSong = await prisma.$transaction(async (tx) => {
// Handle SpecialSong relations separately
if (effectiveSpecialIds !== undefined) {
// First, get current special assignments (within transaction)
const currentSpecials = await tx.specialSong.findMany({
where: { songId: Number(id) }
});
}
// Add new specials
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
if (toAdd.length > 0) {
await prisma.specialSong.createMany({
data: toAdd.map(specialId => ({
songId: Number(id),
specialId,
startTime: 0
}))
});
}
}
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
const newSpecialIds = effectiveSpecialIds as number[];
const updatedSong = await prisma.song.update({
where: { id: Number(id) },
data,
include: {
genres: true,
specials: {
include: {
special: true
}
// Delete removed specials
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
if (toDelete.length > 0) {
await tx.specialSong.deleteMany({
where: {
songId: Number(id),
specialId: { in: toDelete }
}
});
}
// Add new specials
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
if (toAdd.length > 0) {
await tx.specialSong.createMany({
data: toAdd.map(specialId => ({
songId: Number(id),
specialId,
startTime: 0
}))
});
}
}
// Update song (this also handles genre relations via Prisma's set operation)
return await tx.song.update({
where: { id: Number(id) },
data,
include: {
genres: true,
specials: {
include: {
special: true
}
}
}
});
});
return NextResponse.json(updatedSong);
@@ -344,9 +540,9 @@ export async function PUT(request: Request) {
}
export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
// Check authentication (admin or curator)
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (error || !context) return error!;
try {
const { id } = await request.json();
@@ -355,16 +551,31 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
// Get song to find filename
// Get song to find filename and relations for permission checks
const song = await prisma.song.findUnique({
where: { id: Number(id) },
include: {
genres: true,
specials: true,
},
});
if (!song) {
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
}
// Delete file
if (context.role === 'curator') {
const assignments = await getCuratorAssignments(context.curator.id);
if (!curatorCanDeleteSong(context, song, assignments)) {
return NextResponse.json(
{ error: 'Forbidden: You are not allowed to delete this song' },
{ status: 403 }
);
}
}
// Delete files first (outside transaction, as file system operations can't be rolled back)
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
try {
await unlink(filePath);
@@ -383,9 +594,11 @@ export async function DELETE(request: Request) {
}
}
// Delete from database (will cascade delete related puzzles)
await prisma.song.delete({
where: { id: Number(id) },
// Delete from database in transaction (will cascade delete related puzzles, SpecialSong, etc.)
await prisma.$transaction(async (tx) => {
await tx.song.delete({
where: { id: Number(id) },
});
});
return NextResponse.json({ success: true });

File diff suppressed because it is too large Load Diff

11
app/curator/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
// Server-Wrapper für die Kuratoren-Seite.
// Markiert die Route als dynamisch und rendert die eigentliche Client-Komponente.
export const dynamic = 'force-dynamic';
import CuratorPageClient from './CuratorPageClient';
export default function CuratorPage() {
return <CuratorPageClient />;
}

View File

@@ -70,6 +70,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Dynamic genre pages
try {
// Während des Docker-Builds wird häufig eine temporäre SQLite-DB (file:./dev.db)
// ohne migrierte Tabellen verwendet. In diesem Fall überspringen wir die
// Datenbankabfrage und liefern nur die statischen Seiten, um Build-Fehler zu vermeiden.
const dbUrl = process.env.DATABASE_URL;
if (dbUrl && dbUrl.startsWith('file:./')) {
return staticPages;
}
const genres = await prisma.genre.findMany({
where: { active: true },
});

View File

@@ -13,6 +13,7 @@ import type { ExternalPuzzle } from '@/lib/externalPuzzles';
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
import { sendGotifyNotification, submitRating } from '../app/actions';
import { getOrCreatePlayerId } from '@/lib/playerId';
// Plausible Analytics
declare global {
@@ -60,6 +61,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
const audioPlayerRef = useRef<AudioPlayerRef>(null);
const [commentText, setCommentText] = useState('');
const [commentSending, setCommentSending] = useState(false);
const [commentSent, setCommentSent] = useState(false);
const [commentError, setCommentError] = useState<string | null>(null);
useEffect(() => {
const updateCountdown = () => {
@@ -134,6 +139,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
} else {
setHasRated(false);
}
// Check if comment already sent for this puzzle
const playerIdentifier = getOrCreatePlayerId();
if (playerIdentifier) {
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
if (commentedPuzzles.includes(dailyPuzzle.id)) {
setCommentSent(true);
}
}
}
}, [dailyPuzzle]);
@@ -300,6 +314,59 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
};
const handleCommentSubmit = async () => {
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle) {
return;
}
setCommentSending(true);
setCommentError(null);
try {
const playerIdentifier = getOrCreatePlayerId();
if (!playerIdentifier) {
throw new Error('Could not get player identifier');
}
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle
const genreId = isSpecial ? null : null; // API will determine from puzzle
const response = await fetch('/api/curator-comment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
puzzleId: dailyPuzzle.id,
genreId: genreId,
message: commentText.trim(),
playerIdentifier: playerIdentifier
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to send comment');
}
setCommentSent(true);
setCommentText('');
// Store in localStorage that comment was sent
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
if (!commentedPuzzles.includes(dailyPuzzle.id)) {
commentedPuzzles.push(dailyPuzzle.id);
localStorage.setItem(`${config.appName.toLowerCase()}_commented_puzzles`, JSON.stringify(commentedPuzzles));
}
} catch (error) {
console.error('Error sending comment:', error);
setCommentError(error instanceof Error ? error.message : 'Failed to send comment');
} finally {
setCommentSending(false);
}
};
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
const handleShare = async () => {
@@ -391,6 +458,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
};
// Aktuelle Attempt-Anzeige:
// - Während des Spiels: nächster Versuch = guesses.length + 1
// - Nach Spielende (gelöst oder verloren): letzter Versuch = guesses.length
const currentAttempt = (gameState.isSolved || gameState.isFailed)
? gameState.guesses.length
: gameState.guesses.length + 1;
return (
<div className="container">
<header className="header">
@@ -403,7 +477,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div id="tour-status" className="status-bar">
<span>{t('attempt')} {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{t('attempt')} {currentAttempt} / {maxAttempts}</span>
<span>{unlockedSeconds}s {t('unlocked')}</span>
</div>
@@ -512,14 +586,80 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</audio>
</div>
<div style={{ marginBottom: '1rem' }}>
<div style={{ marginBottom: '1.25rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</div>
<div style={{ marginBottom: '1.25rem', textAlign: 'center' }}>
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>
{t('shareExplanation')}
</p>
<button onClick={handleShare} className="btn-primary">
{shareText}
</button>
</div>
{/* Comment Form */}
{!commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{t('sendComment')}
</h3>
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
{t('commentHelp')}
</p>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={t('commentPlaceholder')}
maxLength={2000}
style={{
width: '100%',
minHeight: '100px',
padding: '0.75rem',
borderRadius: '0.5rem',
border: '1px solid var(--border)',
fontSize: '0.9rem',
fontFamily: 'inherit',
resize: 'vertical',
marginBottom: '0.5rem'
}}
disabled={commentSending}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
{commentText.length}/2000
</span>
{commentError && (
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
{commentError}
</span>
)}
</div>
<button
onClick={handleCommentSubmit}
disabled={!commentText.trim() || commentSending || commentSent}
className="btn-primary"
style={{
width: '100%',
opacity: (!commentText.trim() || commentSending || commentSent) ? 0.5 : 1,
cursor: (!commentText.trim() || commentSending || commentSent) ? 'not-allowed' : 'pointer'
}}
>
{commentSending ? t('sending') : t('sendComment')}
</button>
</div>
)}
{commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center' }}>
{t('commentSent')}
</p>
</div>
)}
{statistics && <Statistics statistics={statistics} />}
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText}
</button>
</div>
)}
</main>

View File

@@ -22,9 +22,25 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
fetch('/api/songs')
.then(res => res.json())
.then(data => setSongs(data));
fetch('/api/public-songs')
.then(res => {
if (!res.ok) {
throw new Error(`Failed to load songs: ${res.status}`);
}
return res.json();
})
.then(data => {
if (Array.isArray(data)) {
setSongs(data);
} else {
console.error('Unexpected songs payload in GuessInput:', data);
setSongs([]);
}
})
.catch(err => {
console.error('Error loading songs for GuessInput:', err);
setSongs([]);
});
}, []);
useEffect(() => {

View File

@@ -1,4 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient, Curator } from '@prisma/client';
const prisma = new PrismaClient();
export type StaffContext =
| { role: 'admin' }
| { role: 'curator'; curator: Curator };
/**
* Authentication middleware for admin API routes
@@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise<NextRespon
return null; // Auth successful
}
/**
* Resolve current staff (admin or curator) from headers.
*
* Admin:
* - x-admin-auth: 'authenticated'
*
* Curator:
* - x-curator-auth: 'authenticated'
* - x-curator-username: <username>
*/
export async function getStaffContext(request: NextRequest): Promise<StaffContext | null> {
const adminHeader = request.headers.get('x-admin-auth');
if (adminHeader === 'authenticated') {
return { role: 'admin' };
}
const curatorAuth = request.headers.get('x-curator-auth');
const curatorUsername = request.headers.get('x-curator-username');
if (curatorAuth === 'authenticated' && curatorUsername) {
const curator = await prisma.curator.findUnique({
where: { username: curatorUsername },
});
if (curator) {
return { role: 'curator', curator };
}
}
return null;
}
/**
* Require that the current request is authenticated as staff (admin or curator).
* Returns either an error response or a resolved context.
*/
export async function requireStaffAuth(request: NextRequest): Promise<{ error?: NextResponse; context?: StaffContext }> {
const context = await getStaffContext(request);
if (!context) {
return {
error: NextResponse.json(
{ error: 'Unauthorized - Staff authentication required' },
{ status: 401 }
),
};
}
return { context };
}
/**
* Helper to verify admin password
*/

View File

@@ -9,7 +9,7 @@ export const config = {
},
credits: {
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Made 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

@@ -49,13 +49,15 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
// Calculate total weight
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
// Pick a random song based on weights
// Pick a random song based on weights using cumulative weights
// This ensures proper distribution and handles edge cases
let random = Math.random() * totalWeight;
let selectedSong = weightedSongs[0].song;
let selectedSong = weightedSongs[weightedSongs.length - 1].song; // Fallback to last song
let cumulativeWeight = 0;
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
cumulativeWeight += item.weight;
if (random <= cumulativeWeight) {
selectedSong = item.song;
break;
}
@@ -156,11 +158,13 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
let selectedSpecialSong = weightedSongs[0].specialSong;
let selectedSpecialSong = weightedSongs[weightedSongs.length - 1].specialSong; // Fallback to last song
// Pick a random song based on weights using cumulative weights
let cumulativeWeight = 0;
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
cumulativeWeight += item.weight;
if (random <= cumulativeWeight) {
selectedSpecialSong = item.specialSong;
break;
}

View File

@@ -41,6 +41,7 @@
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
"theSongWas": "Das Lied war:",
"score": "Punkte",
"shareExplanation": "Teile dein Ergebnis mit Freund:innen so hilfst du, Hördle bekannter zu machen.",
"scoreBreakdown": "Punkteaufschlüsselung",
"albumCover": "Album-Cover",
"released": "Veröffentlicht",
@@ -56,6 +57,13 @@
"points": "Punkte",
"skipBonus": "Bonus überspringen",
"notQuite": "Nicht ganz!",
"sendComment": "Nachricht an Kurator senden",
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres...",
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
"commentError": "Fehler beim Senden der Nachricht",
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
"sending": "Wird gesendet...",
"youGuessed": "Du hast geraten",
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
"skipped": "Übersprungen",
@@ -157,8 +165,115 @@
"artist": "Interpret",
"actions": "Aktionen",
"deletePuzzle": "Löschen",
"wrongPassword": "Falsches Passwort"
"wrongPassword": "Falsches Passwort",
"manageCurators": "Kuratoren verwalten",
"addCurator": "Kurator hinzufügen",
"curatorUsername": "Benutzername",
"curatorPassword": "Passwort (bei Leer lassen: nicht ändern)",
"isGlobalCurator": "Globaler Kurator (darf Global-Flag ändern)",
"assignedGenres": "Zugeordnete Genres",
"assignedSpecials": "Zugeordnete Specials",
"noCurators": "Noch keine Kuratoren angelegt."
},
"Curator": {
"loginTitle": "Kuratoren-Login",
"loginUsername": "Benutzername",
"loginPassword": "Passwort",
"loginButton": "Einloggen",
"logout": "Abmelden",
"loginFailed": "Login fehlgeschlagen.",
"loginNetworkError": "Netzwerkfehler beim Login.",
"loadCuratorError": "Fehler beim Laden der Kuratoren-Informationen.",
"loadSongsError": "Fehler beim Laden der Songs.",
"songUpdated": "Song erfolgreich aktualisiert.",
"saveError": "Fehler beim Speichern: {error}",
"saveNetworkError": "Netzwerkfehler beim Speichern.",
"noDeletePermission": "Du darfst diesen Song nicht löschen.",
"deleteConfirm": "Möchtest du \"{title}\" wirklich löschen?",
"songDeleted": "Song gelöscht.",
"deleteError": "Fehler beim Löschen: {error}",
"deleteNetworkError": "Netzwerkfehler beim Löschen.",
"uploadSectionTitle": "Titel hochladen",
"uploadSectionDescription": "Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert (inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens eines deiner Genres aus, um die Titel zuzuordnen.",
"dropzoneTitleEmpty": "MP3-Dateien hierher ziehen",
"dropzoneTitleWithFiles": "{count} Datei(en) ausgewählt",
"dropzoneSubtitle": "oder klicken, um Dateien auszuwählen",
"selectedFilesTitle": "Ausgewählte Dateien:",
"uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Genres zuordnen",
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
"uploadButtonIdle": "Upload starten",
"uploadButtonUploading": "Lade hoch...",
"uploadSummary": "✅ {success}/{total} Uploads erfolgreich.",
"uploadSummaryDuplicates": "⚠️ {count} Duplikat(e) übersprungen.",
"uploadSummaryFailed": "❌ {count} fehlgeschlagen.",
"uploadResultSuccess": "✅ erfolgreich",
"uploadResultDuplicate": "⚠️ Duplikat: {error}",
"uploadResultError": "❌ Fehler: {error}",
"tracklistTitle": "Titel in deinen Genres & Specials ({count} Titel)",
"tracklistDescription": "Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist. Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden.",
"searchPlaceholder": "Nach Titel oder Artist suchen...",
"filterAll": "Alle Inhalte",
"filterNoGlobal": "🚫 Ohne Global",
"filterReset": "Filter zurücksetzen",
"noSongsInScope": "Keine passenden Songs in deinen Genres/Specials gefunden.",
"columnId": "ID",
"columnPlay": "Play",
"columnTitle": "Titel",
"columnArtist": "Artist",
"columnYear": "Jahr",
"columnGenresSpecials": "Genres / Specials",
"columnAdded": "Hinzugefügt",
"columnActivations": "Aktivierungen",
"columnRating": "Rating",
"columnExcludeGlobal": "Exclude Global",
"columnActions": "Aktionen",
"play": "Abspielen",
"pause": "Pause",
"excludeGlobalYes": "Ja",
"excludeGlobalNo": "Nein",
"excludeGlobalInfo": "Nur globale Kuratoren dürfen dieses Flag ändern.",
"paginationPrev": "Zurück",
"paginationNext": "Weiter",
"paginationLabel": "Seite {page} von {total}",
"loadingData": "Lade Daten...",
"loggedInAs": "Eingeloggt als {username}",
"globalCuratorSuffix": " (Globaler Kurator)",
"pageSizeLabel": "Pro Seite:",
"commentsTitle": "Kommentare",
"showComments": "Kommentare anzeigen",
"hideComments": "Kommentare ausblenden",
"loadingComments": "Kommentare werden geladen...",
"noComments": "Keine Kommentare vorhanden.",
"loadCommentsError": "Fehler beim Laden der Kommentare.",
"commentFromPuzzle": "Kommentar zu Puzzle",
"commentGenre": "Genre",
"unreadComment": "Ungelesen",
"archiveComment": "Archivieren",
"archiveCommentConfirm": "Möchtest du diesen Kommentar wirklich archivieren?",
"archiveCommentError": "Fehler beim Archivieren des Kommentars.",
"newComments": "neu",
"batchEditTitle": "Batch-Bearbeitung",
"clearSelection": "Auswahl aufheben",
"batchToggleGenres": "Genres umschalten",
"batchToggleSpecials": "Specials umschalten",
"batchChangeArtist": "Artist ändern",
"batchArtistPlaceholder": "Neuen Artist-Namen eingeben",
"batchExcludeGlobal": "Von Global ausschließen",
"batchNoChange": "Keine Änderung",
"batchExclude": "Ausschließen",
"batchInclude": "Einschließen",
"batchUpdating": "Aktualisiere...",
"batchApply": "Änderungen anwenden",
"selectAll": "Alle auswählen",
"selectSong": "Titel auswählen",
"cannotEditSong": "Dieser Titel kann nicht bearbeitet werden",
"noSongsSelected": "Keine Titel ausgewählt",
"noBatchOperations": "Keine Batch-Operationen angegeben",
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
"batchUpdateError": "Fehler: {error}",
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung"
},
"About": {
"title": "Über Hördle & Impressum",
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
@@ -177,7 +292,7 @@
"costsEmail": "E-Mail-Hosting",
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
"costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.",
"costsSheetPrivacyNote": "Beim Aufruf oder Einbetten der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
"costsSheetPrivacyNote": "Beim Aufruf der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
"supportTitle": "Hördle unterstützen",
"supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:",
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",

View File

@@ -41,6 +41,7 @@
"comeBackTomorrow": "Come back tomorrow for a new song.",
"theSongWas": "The song was:",
"score": "Score",
"shareExplanation": "Share your result with friends your support helps Hördle grow.",
"scoreBreakdown": "Score Breakdown",
"albumCover": "Album Cover",
"released": "Released",
@@ -56,6 +57,13 @@
"points": "points",
"skipBonus": "Skip Bonus",
"notQuite": "Not quite!",
"sendComment": "Send message to curator",
"commentPlaceholder": "Write a message to the curators of this genre...",
"commentHelp": "Share your thoughts about the puzzle with the curators. Your message will be displayed to them.",
"commentSent": "✓ Message sent! Thank you for your feedback.",
"commentError": "Error sending message",
"commentRateLimited": "You have already sent a message for this puzzle.",
"sending": "Sending...",
"youGuessed": "You guessed",
"actuallyReleasedIn": "Actually released in",
"skipped": "Skipped",
@@ -157,8 +165,115 @@
"artist": "Artist",
"actions": "Actions",
"deletePuzzle": "Delete",
"wrongPassword": "Wrong password"
"wrongPassword": "Wrong password",
"manageCurators": "Manage Curators",
"addCurator": "Add Curator",
"curatorUsername": "Username",
"curatorPassword": "Password (leave empty to keep)",
"isGlobalCurator": "Global curator (may change global flag)",
"assignedGenres": "Assigned genres",
"assignedSpecials": "Assigned specials",
"noCurators": "No curators created yet."
},
"Curator": {
"loginTitle": "Curator Login",
"loginUsername": "Username",
"loginPassword": "Password",
"loginButton": "Log in",
"logout": "Logout",
"loginFailed": "Login failed.",
"loginNetworkError": "Network error during login.",
"loadCuratorError": "Failed to load curator information.",
"loadSongsError": "Failed to load songs.",
"songUpdated": "Song updated successfully.",
"saveError": "Error while saving: {error}",
"saveNetworkError": "Network error while saving.",
"noDeletePermission": "You are not allowed to delete this song.",
"deleteConfirm": "Do you really want to delete \"{title}\"?",
"songDeleted": "Song deleted.",
"deleteError": "Error while deleting: {error}",
"deleteNetworkError": "Network error while deleting.",
"uploadSectionTitle": "Upload titles",
"uploadSectionDescription": "Drag one or more MP3 files here or select them. The titles will be analysed automatically (including detection of the release year) and excluded from the global playlist. Select at least one of your genres to assign the titles.",
"dropzoneTitleEmpty": "Drag MP3 files here",
"dropzoneTitleWithFiles": "{count} file(s) selected",
"dropzoneSubtitle": "or click to select files",
"selectedFilesTitle": "Selected files:",
"uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Assign genres",
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
"uploadButtonIdle": "Start upload",
"uploadButtonUploading": "Uploading...",
"uploadSummary": "✅ {success}/{total} uploads successful.",
"uploadSummaryDuplicates": "⚠️ {count} duplicate(s) skipped.",
"uploadSummaryFailed": "❌ {count} failed.",
"uploadResultSuccess": "✅ successful",
"uploadResultDuplicate": "⚠️ Duplicate: {error}",
"uploadResultError": "❌ Error: {error}",
"tracklistTitle": "Titles in your genres & specials ({count} titles)",
"tracklistDescription": "You can edit songs that are assigned to at least one of your genres or specials. Deletion is only allowed if a song is assigned exclusively to your genres/specials. Genres, specials, news and political statements can only be managed by the admin.",
"searchPlaceholder": "Search by title or artist...",
"filterAll": "All content",
"filterNoGlobal": "🚫 No global",
"filterReset": "Reset filters",
"noSongsInScope": "No matching songs in your genres/specials.",
"columnId": "ID",
"columnPlay": "Play",
"columnTitle": "Title",
"columnArtist": "Artist",
"columnYear": "Year",
"columnGenresSpecials": "Genres / Specials",
"columnAdded": "Added",
"columnActivations": "Activations",
"columnRating": "Rating",
"columnExcludeGlobal": "Exclude global",
"columnActions": "Actions",
"play": "Play",
"pause": "Pause",
"excludeGlobalYes": "Yes",
"excludeGlobalNo": "No",
"excludeGlobalInfo": "Only global curators may change this flag.",
"paginationPrev": "Previous",
"paginationNext": "Next",
"paginationLabel": "Page {page} of {total}",
"loadingData": "Loading data...",
"loggedInAs": "Logged in as {username}",
"globalCuratorSuffix": " (Global curator)",
"pageSizeLabel": "Per page:",
"commentsTitle": "Comments",
"showComments": "Show comments",
"hideComments": "Hide comments",
"loadingComments": "Loading comments...",
"noComments": "No comments available.",
"loadCommentsError": "Error loading comments.",
"commentFromPuzzle": "Comment from puzzle",
"commentGenre": "Genre",
"unreadComment": "Unread",
"archiveComment": "Archive",
"archiveCommentConfirm": "Do you really want to archive this comment?",
"archiveCommentError": "Error archiving comment.",
"newComments": "new",
"batchEditTitle": "Batch Edit",
"clearSelection": "Clear Selection",
"batchToggleGenres": "Toggle Genres",
"batchToggleSpecials": "Toggle Specials",
"batchChangeArtist": "Change Artist",
"batchArtistPlaceholder": "Enter new artist name",
"batchExcludeGlobal": "Exclude from Global",
"batchNoChange": "No change",
"batchExclude": "Exclude",
"batchInclude": "Include",
"batchUpdating": "Updating...",
"batchApply": "Apply Changes",
"selectAll": "Select all",
"selectSong": "Select song",
"cannotEditSong": "Cannot edit this song",
"noSongsSelected": "No songs selected",
"noBatchOperations": "No batch operations specified",
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
"batchUpdateError": "Error: {error}",
"batchUpdateNetworkError": "Network error during batch update"
},
"About": {
"title": "About Hördle & Imprint",
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",
@@ -177,7 +292,7 @@
"costsEmail": "Email hosting",
"costsLicenses": "Possible fees for copyrights or other licenses",
"costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.",
"costsSheetPrivacyNote": "When accessing or embedding the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
"costsSheetPrivacyNote": "When accessing the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
"supportTitle": "Support Hördle",
"supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:",
"supportSepaTitle": "SEPA Bank Transfer (preferred)",

View File

@@ -1,6 +1,6 @@
{
"name": "hoerdle",
"version": "0.1.4.10",
"version": "0.1.6.1",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -0,0 +1,36 @@
-- CreateTable
CREATE TABLE "Curator" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"isGlobalCurator" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "CuratorGenre" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"curatorId" INTEGER NOT NULL,
"genreId" INTEGER NOT NULL,
CONSTRAINT "CuratorGenre_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorGenre_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CuratorSpecial" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"curatorId" INTEGER NOT NULL,
"specialId" INTEGER NOT NULL,
CONSTRAINT "CuratorSpecial_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorSpecial_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Curator_username_key" ON "Curator"("username");
-- CreateIndex
CREATE UNIQUE INDEX "CuratorGenre_curatorId_genreId_key" ON "CuratorGenre"("curatorId", "genreId");
-- CreateIndex
CREATE UNIQUE INDEX "CuratorSpecial_curatorId_specialId_key" ON "CuratorSpecial"("curatorId", "specialId");

View File

@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "CuratorComment" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"playerIdentifier" TEXT NOT NULL,
"puzzleId" INTEGER NOT NULL,
"genreId" INTEGER,
"message" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CuratorComment_puzzleId_fkey" FOREIGN KEY ("puzzleId") REFERENCES "DailyPuzzle" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorComment_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CuratorCommentRecipient" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"commentId" INTEGER NOT NULL,
"curatorId" INTEGER NOT NULL,
"readAt" DATETIME,
CONSTRAINT "CuratorCommentRecipient_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "CuratorComment" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorCommentRecipient_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "CuratorComment_playerIdentifier_puzzleId_key" ON "CuratorComment"("playerIdentifier", "puzzleId");
-- CreateIndex
CREATE INDEX "CuratorComment_genreId_idx" ON "CuratorComment"("genreId");
-- CreateIndex
CREATE UNIQUE INDEX "CuratorCommentRecipient_commentId_curatorId_key" ON "CuratorCommentRecipient"("commentId", "curatorId");
-- CreateIndex
CREATE INDEX "CuratorCommentRecipient_curatorId_idx" ON "CuratorCommentRecipient"("curatorId");

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CuratorCommentRecipient" ADD COLUMN "archived" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -33,6 +33,8 @@ model Genre {
active Boolean @default(true)
songs Song[]
dailyPuzzles DailyPuzzle[]
curatorGenres CuratorGenre[]
comments CuratorComment[]
}
model Special {
@@ -48,6 +50,7 @@ model Special {
songs SpecialSong[]
puzzles DailyPuzzle[]
news News[]
curatorSpecials CuratorSpecial[]
}
model SpecialSong {
@@ -71,6 +74,7 @@ model DailyPuzzle {
genre Genre? @relation(fields: [genreId], references: [id])
specialId Int?
special Special? @relation(fields: [specialId], references: [id])
comments CuratorComment[]
@@unique([date, genreId, specialId])
}
@@ -102,6 +106,41 @@ model PlayerState {
@@index([identifier])
}
model Curator {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
isGlobalCurator Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
genres CuratorGenre[]
specials CuratorSpecial[]
commentRecipients CuratorCommentRecipient[]
}
model CuratorGenre {
id Int @id @default(autoincrement())
curatorId Int
genreId Int
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
@@unique([curatorId, genreId])
}
model CuratorSpecial {
id Int @id @default(autoincrement())
curatorId Int
specialId Int
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
special Special @relation(fields: [specialId], references: [id], onDelete: Cascade)
@@unique([curatorId, specialId])
}
model PoliticalStatement {
id Int @id @default(autoincrement())
locale String
@@ -113,3 +152,32 @@ model PoliticalStatement {
@@index([locale, active])
}
model CuratorComment {
id Int @id @default(autoincrement())
playerIdentifier String
puzzleId Int
puzzle DailyPuzzle @relation(fields: [puzzleId], references: [id], onDelete: Cascade)
genreId Int?
genre Genre? @relation(fields: [genreId], references: [id], onDelete: SetNull)
message String
createdAt DateTime @default(now())
recipients CuratorCommentRecipient[]
@@unique([playerIdentifier, puzzleId])
@@index([genreId])
}
model CuratorCommentRecipient {
id Int @id @default(autoincrement())
commentId Int
comment CuratorComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
curatorId Int
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
readAt DateTime?
archived Boolean @default(false)
@@unique([commentId, curatorId])
@@index([curatorId])
}

77
scripts/backup-restic.sh Executable file
View File

@@ -0,0 +1,77 @@
#!/bin/bash
# Restic backup script for Hördle deployment
# Creates a backup snapshot with tags and handles errors gracefully
set -e
echo "💾 Creating Restic backup..."
if ! command -v restic >/dev/null 2>&1; then
echo "⚠️ restic not found in PATH, skipping Restic backup"
exit 0
fi
# Check required environment variables
if [ -z "$RESTIC_PASSWORD" ]; then
echo "⚠️ RESTIC_PASSWORD not set, skipping Restic backup"
exit 0
fi
if [ -z "$RESTIC_AUTH_USER" ] || [ -z "$RESTIC_AUTH_PASSWORD" ]; then
echo "⚠️ RESTIC_AUTH_USER or RESTIC_AUTH_PASSWORD not set, skipping Restic backup"
exit 0
fi
# Build repository URL
RESTIC_REPO="rest:https://${RESTIC_AUTH_USER}:${RESTIC_AUTH_PASSWORD}@restic.elpatron.me/"
# Get current commit hash for tagging
CURRENT_COMMIT_SHORT="$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')"
CURRENT_DATE="$(date +%Y-%m-%d)"
# Export password for restic
export RESTIC_PASSWORD
# Check if repository exists, initialize if not
if ! restic -r "$RESTIC_REPO" snapshots >/dev/null 2>&1; then
echo " Initializing Restic repository..."
if ! restic -r "$RESTIC_REPO" init >/dev/null 2>&1; then
echo "⚠️ Failed to initialize Restic repository, skipping backup"
exit 0
fi
fi
# Create backup with tags
# Backup important directories: backups, config files, but exclude node_modules, .git, etc.
echo " Creating Restic snapshot..."
RESTIC_EXIT_CODE=0
restic -r "$RESTIC_REPO" backup \
--tag deployment \
--tag hoerdle \
--tag "date:${CURRENT_DATE}" \
--tag "commit:${CURRENT_COMMIT_SHORT}" \
--exclude='.git' \
--exclude='node_modules' \
--exclude='.next' \
--exclude='*.log' \
./backups \
./data \
./public/uploads \
docker-compose.yml \
.env \
package.json \
prisma/schema.prisma \
prisma/migrations \
scripts/ || RESTIC_EXIT_CODE=$?
if [ $RESTIC_EXIT_CODE -eq 0 ]; then
echo "✅ Restic backup completed successfully"
exit 0
elif [ $RESTIC_EXIT_CODE -eq 3 ]; then
echo "⚠️ Restic backup completed with warnings (some files could not be read), continuing..."
exit 0
else
echo "⚠️ Restic backup failed (exit code: $RESTIC_EXIT_CODE), continuing deployment..."
exit 0
fi

38
scripts/deploy-remote.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
# Remote-Deployment-Skript für Hördle
# Führt auf dem entfernten Host den Befehl
# ssh docker@100.116.245.76 "cd ~/hoerdle && ./scripts/deploy.sh"
# aus und liest das SSH-Passwort aus der Umgebungsvariablen PROD_SSH_PASSWORD.
#
# Voraussetzungen:
# - sshpass ist lokal installiert (z.B. `sudo apt-get install sshpass`)
# - PROD_SSH_PASSWORD ist im Environment gesetzt
# 1) Passwort im Environment setzen (nur für diese Session)
# export PROD_SSH_PASSWORD='dein-sehr-geheimes-passwort'
# 2) Skript ausführen: ./scripts/deploy-remote.sh
REMOTE_USER="docker"
REMOTE_HOST="100.116.245.76"
REMOTE_CMD='cd ~/hoerdle && ./scripts/deploy.sh'
if ! command -v sshpass >/dev/null 2>&1; then
echo "Fehler: sshpass ist nicht installiert. Bitte mit z.B. 'sudo apt-get install sshpass' nachinstallieren." >&2
exit 1;
fi
if [[ -z "${PROD_SSH_PASSWORD:-}" ]]; then
echo "Fehler: Umgebungsvariable PROD_SSH_PASSWORD ist nicht gesetzt." >&2
echo "Bitte setze sie z.B.: export PROD_SSH_PASSWORD='dein-passwort'" >&2
exit 1
fi
echo "🚀 Starte Remote-Deployment auf ${REMOTE_USER}@${REMOTE_HOST} ..."
sshpass -p "${PROD_SSH_PASSWORD}" \
ssh -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "${REMOTE_CMD}"
echo "✅ Remote-Deployment abgeschlossen."

View File

@@ -1,10 +1,10 @@
#!/bin/bash
set -e
echo "🚀 Starting optimized deployment..."
echo "🚀 Starting optimized deployment with full rollback support..."
# Backup database
echo "💾 Creating database backup..."
# Backup database (per Deployment, inkl. Metadaten für Rollback)
echo "💾 Creating database backup for this deployment..."
# Try to find database path from docker-compose.yml or .env
DB_PATH=""
@@ -26,16 +26,29 @@ if [ -n "$DB_PATH" ]; then
# Convert container path to host path if needed
# /app/data/prod.db -> ./data/prod.db
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
if [ -f "$DB_PATH" ]; then
# Create backups directory
mkdir -p ./backups
# Create timestamped backup
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_$(date +%Y%m%d_%H%M%S).db"
DEPLOY_TS="$(date +%Y%m%d_%H%M%S)"
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_${DEPLOY_TS}.db"
cp "$DB_PATH" "$BACKUP_FILE"
echo "✅ Database backed up to: $BACKUP_FILE"
# Store metadata for restore (Timestamp, DB-Path, Git-Commit)
CURRENT_COMMIT="$(git rev-parse HEAD || echo 'unknown')"
{
echo "timestamp=${DEPLOY_TS}"
echo "db_path=${DB_PATH}"
echo "backup_file=${BACKUP_FILE}"
echo "git_commit=${CURRENT_COMMIT}"
} > "./backups/last_deploy.meta"
# Append to history manifest (eine Zeile pro Deployment)
echo "${DEPLOY_TS}|${DB_PATH}|${BACKUP_FILE}|${CURRENT_COMMIT}" >> "./backups/deploy_history.log"
# Keep only last 10 backups
ls -t ./backups/*.db | tail -n +11 | xargs -r rm
echo "🧹 Cleaned old backups (keeping last 10)"
@@ -46,13 +59,13 @@ else
echo "⚠️ Could not determine database path from config files"
fi
# Pull latest changes
echo "📥 Pulling latest changes from git..."
git pull
# Restic backup to remote repository
./scripts/backup-restic.sh
# Fetch all tags
echo "🏷️ Fetching git tags..."
git fetch --tags
# Nur neueste Version holen (shallow fetch), vollständiges Repo ist im Deployment nicht nötig
echo "📥 Fetching latest commit (shallow clone) from git..."
git fetch --prune --tags --depth=1 origin master
git reset --hard origin/master
# Prüfe und erstelle/repariere Netzwerk falls nötig
echo "🌐 Prüfe Docker-Netzwerk..."

93
scripts/restore.sh Normal file
View File

@@ -0,0 +1,93 @@
#!/bin/bash
set -e
echo "🧯 Hördle restore script Rollback auf früheres Datenbank-Backup"
# Hilfsfunktion für Fehlerausgabe
die() {
echo "$1" >&2
exit 1
}
# Backup-Verzeichnis
BACKUP_DIR="./backups"
if [ ! -d "$BACKUP_DIR" ]; then
die "Kein Backup-Verzeichnis gefunden (${BACKUP_DIR}). Es scheint noch kein Deployment-Backup erstellt worden zu sein."
fi
# Argument: gewünschter Backup-Timestamp oder 'latest'
TARGET="$1"
if [ -z "$TARGET" ]; then
echo "⚙️ Nutzung:"
echo " ./scripts/restore.sh latest # neuestes Backup zurückspielen"
echo " ./scripts/restore.sh 20250101_120000 # bestimmtes Backup (Timestamp aus Dateiname)"
echo ""
echo "Verfügbare Backups:"
ls -1 "${BACKUP_DIR}"/*.db 2>/dev/null || echo " (keine .db-Backups gefunden)"
exit 1
fi
# DB-Pfad wie in deploy.sh bestimmen
DB_PATH=""
if [ -f "docker-compose.yml" ]; then
DB_PATH=$(grep -oP 'DATABASE_URL=file:\K[^\s]+' docker-compose.yml | head -1)
fi
if [ -z "$DB_PATH" ] && [ -f ".env" ]; then
DB_PATH=$(grep -oP '^DATABASE_URL=file:\K.+' .env | head -1)
fi
DB_PATH=$(echo "$DB_PATH" | tr -d '"' | tr -d "'")
if [ -z "$DB_PATH" ]; then
die "Konnte den Datenbank-Pfad aus docker-compose.yml oder .env nicht ermitteln."
fi
# Containerpfad zu Hostpfad umbauen (/app/... -> ./...)
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
echo "📁 Ziel-Datenbank-Datei: $DB_PATH"
# Backup-Datei bestimmen
if [ "$TARGET" = "latest" ]; then
BACKUP_FILE=$(ls -t "${BACKUP_DIR}"/*.db 2>/dev/null | head -1 || true)
[ -z "$BACKUP_FILE" ] && die "Kein Backup gefunden."
else
# Versuchen, exakten Dateinamen zu finden
if [ -f "${BACKUP_DIR}/${TARGET}" ]; then
BACKUP_FILE="${BACKUP_DIR}/${TARGET}"
else
# Versuchen, anhand des Timestamps ein Backup zu finden
BACKUP_FILE=$(ls "${BACKUP_DIR}"/*"${TARGET}"*.db 2>/dev/null | head -1 || true)
fi
[ -z "$BACKUP_FILE" ] && die "Kein Backup für '${TARGET}' gefunden."
fi
echo "⏪ Verwende Backup-Datei: $BACKUP_FILE"
if [ ! -f "$BACKUP_FILE" ]; then
die "Backup-Datei existiert nicht: $BACKUP_FILE"
fi
read -p "❗ Dies überschreibt die aktuelle Datenbank-Datei. Fortfahren? [y/N] " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "Abgebrochen."
exit 0
fi
echo "📦 Kopiere Backup nach: $DB_PATH"
cp "$BACKUP_FILE" "$DB_PATH"
echo "🔄 Starte Docker-Container neu..."
docker compose restart hoerdle
echo "✅ Restore abgeschlossen."
echo " Hinweis: Der Code-Stand (Git-Commit) ist nicht automatisch zurückgedreht."
echo " Falls du auch die App-Version zurückrollen möchtest, checke lokal den passenden Commit/Tag aus"
echo " und führe anschließend wieder ./scripts/deploy.sh aus."