Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b204a35628 | ||
|
|
c62f8f91e5 | ||
|
|
6fbb3f4718 | ||
|
|
5136c3add1 | ||
|
|
c250b5fff9 | ||
|
|
4074cdfe00 | ||
|
|
65425ac15c | ||
|
|
7879b63498 | ||
|
|
91ebaa0e44 | ||
|
|
a61caa2d13 | ||
|
|
52a15b7504 | ||
|
|
00160d9602 | ||
|
|
296a227d22 | ||
|
|
50ca51b143 | ||
|
|
afe6e12afc | ||
|
|
91b12ad859 | ||
|
|
d2548c2870 | ||
|
|
40d6ea75f0 | ||
|
|
0054facbe7 | ||
|
|
95bcf9ed1e | ||
|
|
08fedf9881 | ||
|
|
cd564b5d8c | ||
|
|
863539a5e9 | ||
|
|
2fa8aa0042 | ||
|
|
8ecf430bf5 | ||
|
|
71abb7c322 | ||
|
|
b730c6637a | ||
|
|
6e93529bc3 | ||
|
|
990e1927e9 | ||
|
|
d7fee047c2 | ||
|
|
28d14ff099 | ||
|
|
b1493b44bf | ||
|
|
b8a803b76e | ||
|
|
e2bdf0fc88 | ||
|
|
2cb9af8d2b | ||
|
|
d6ad01b00e | ||
|
|
693817b18c | ||
|
|
41336e3af3 | ||
|
|
d7ec691469 | ||
|
|
5e1700712e | ||
|
|
f691384a34 | ||
|
|
f0d75c591a | ||
|
|
1f34d5813e | ||
|
|
33f8080aa8 | ||
|
|
8a102afc0e | ||
|
|
38148ace8d | ||
|
|
49e98ade3c | ||
|
|
397839cc1f | ||
|
|
3fe805129b | ||
|
|
bf9a49a9ac | ||
|
|
9b89cbf8ed | ||
|
|
7f33e98fb5 | ||
|
|
72f8b99092 | ||
|
|
e60daa511b | ||
|
|
19706abacb | ||
|
|
170e7b5402 | ||
|
|
ade1043c3c | ||
|
|
d69af49e24 | ||
|
|
63687524e7 | ||
|
|
0246cb58ee | ||
|
|
d76aa9f4e9 | ||
|
|
28afaf598b | ||
|
|
8239753911 | ||
|
|
0bfcf0737e | ||
|
|
5409196008 | ||
|
|
a59f6f747e | ||
|
|
dc763c88a3 | ||
|
|
1613bf0dda | ||
|
|
b872e87b50 | ||
|
|
87c1ee63ec |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ next-env.d.ts
|
|||||||
.release-years-migrated
|
.release-years-migrated
|
||||||
.covers-migrated
|
.covers-migrated
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
scripts/scrape-bahn-expert-statements.js
|
||||||
|
docs/bahn-expert-statements.txt
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -15,6 +15,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- Bearbeitung von Metadaten.
|
- Bearbeitung von Metadaten.
|
||||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||||
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
- 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:**
|
- **Cover Art:**
|
||||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
- 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.
|
- Live-Vorschau beim Hovern über die Waveform.
|
||||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
- Einzelne Segmente zum Testen abspielen.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
|
||||||
- Manuelle Speicherung mit visueller Bestätigung.
|
- Manuelle Speicherung mit visueller Bestätigung.
|
||||||
- **News & Announcements:**
|
- **News & Announcements:**
|
||||||
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||||
@@ -51,6 +51,25 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||||
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||||
- Verwaltung über das Admin-Dashboard.
|
- 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.
|
||||||
|
- **Batch-Edit:** Mehrere Titel gleichzeitig bearbeiten (Genre/Special Toggle, Artist ändern, Exclude Global Flag setzen).
|
||||||
|
- **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)
|
## Internationalisierung (i18n)
|
||||||
|
|
||||||
@@ -77,13 +96,15 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
|||||||
|
|
||||||
- **Start-Punktestand:** 90 Punkte
|
- **Start-Punktestand:** 90 Punkte
|
||||||
- **Richtige Antwort:** +20 Punkte
|
- **Richtige Antwort:** +20 Punkte
|
||||||
- **Falsche Antwort:** -3 Punkte
|
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
|
||||||
- **Überspringen (Skip):** -5 Punkte
|
- **Überspringen (Skip):** -5 Punkte
|
||||||
- **Snippet erneut abspielen (Replay):** -1 Punkt
|
- **Snippet erneut abspielen (Replay):** -1 Punkt
|
||||||
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
|
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
|
||||||
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
|
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
|
||||||
- **Minimum:** Der Punktestand kann nicht unter 0 fallen.
|
- **Minimum:** Der Punktestand kann nicht unter 0 fallen.
|
||||||
|
|
||||||
|
**Hinweis:** Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die automatische Verlängerung des Audio-Snippets (unlockSteps) abgezogen, um die Verwendung dieses Hilfsmittels zu reflektieren.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework:** Next.js 16 (App Router)
|
- **Framework:** Next.js 16 (App Router)
|
||||||
@@ -137,6 +158,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
||||||
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||||
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
- `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:**
|
2. **Starten:**
|
||||||
```bash
|
```bash
|
||||||
@@ -154,7 +176,18 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
- URL: `/de/admin` oder `/en/admin`
|
- URL: `/de/admin` oder `/en/admin`
|
||||||
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
- 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.
|
||||||
|
- **Batch-Edit-Funktionalität:**
|
||||||
|
- Mehrere Titel über Checkboxen auswählen
|
||||||
|
- Genre/Special Toggle (hinzufügen/entfernen)
|
||||||
|
- Artist-Änderung für alle ausgewählten Titel
|
||||||
|
- Exclude Global Flag setzen/entfernen (nur für Global-Kuratoren)
|
||||||
|
- Toolbar erscheint automatisch bei Auswahl von Titeln
|
||||||
|
|
||||||
|
6. **Special Curation & Scheduling verwenden:**
|
||||||
- Erstelle ein Special im Admin-Dashboard:
|
- Erstelle ein Special im Admin-Dashboard:
|
||||||
- Gib Name, Max Attempts und Unlock Steps ein.
|
- Gib Name, Max Attempts und Unlock Steps ein.
|
||||||
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export default async function GenrePage({ params }: PageProps) {
|
|||||||
return s.launchDate && s.launchDate > now;
|
return s.launchDate && s.launchDate > now;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||||
|
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||||
@@ -156,7 +159,7 @@ export default async function GenrePage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<NewsSection locale={locale} />
|
<NewsSection locale={locale} />
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} requiredDailyKeys={requiredDailyKeys} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,13 +98,27 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "0.75rem",
|
marginBottom: "0.5rem",
|
||||||
fontSize: "0.9rem",
|
fontSize: "0.9rem",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("costsSheetPrivacyNote")}
|
{t("costsSheetPrivacyNote")}
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<section style={{ marginBottom: "2rem" }}>
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
@@ -206,6 +220,58 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("supportCuratorTitle")}
|
||||||
|
</h3>
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
{t("supportCuratorText")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("supportReportBugTitle")}
|
||||||
|
</h3>
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
{t.rich("supportReportBugText", {
|
||||||
|
email: (chunks) => (
|
||||||
|
<a
|
||||||
|
href="mailto:admin@hoerdle.de"
|
||||||
|
style={{ textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
{chunks}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ marginBottom: "2rem" }}>
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
8
app/[locale]/curator/help/page.tsx
Normal file
8
app/[locale]/curator/help/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import CuratorHelpInner from '../../../curator/help/page';
|
||||||
|
|
||||||
|
export default function CuratorHelpPage() {
|
||||||
|
return <CuratorHelpInner />;
|
||||||
|
}
|
||||||
|
|
||||||
11
app/[locale]/curator/page.tsx
Normal file
11
app/[locale]/curator/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import { config } from "@/lib/config";
|
|||||||
import { generateBaseMetadata } from "@/lib/metadata";
|
import { generateBaseMetadata } from "@/lib/metadata";
|
||||||
import InstallPrompt from "@/components/InstallPrompt";
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
import AppFooter from "@/components/AppFooter";
|
import AppFooter from "@/components/AppFooter";
|
||||||
|
import PoliticalStatementBanner from "@/components/PoliticalStatementBanner";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -89,6 +90,7 @@ export default async function LocaleLayout({
|
|||||||
{children}
|
{children}
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
|
<PoliticalStatementBanner />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export default async function Home({
|
|||||||
return s.launchDate && s.launchDate > now;
|
return s.launchDate && s.launchDate > now;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||||
|
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
||||||
@@ -149,7 +152,7 @@ export default async function Home({
|
|||||||
<NewsSection locale={locale} />
|
<NewsSection locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
<Game dailyPuzzle={dailyPuzzle} genre={null} requiredDailyKeys={requiredDailyKeys} />
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
194
app/api/curator-comment/route.ts
Normal file
194
app/api/curator-comment/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
app/api/curator-comments/[id]/archive/route.ts
Normal file
66
app/api/curator-comments/[id]/archive/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
app/api/curator-comments/[id]/read/route.ts
Normal file
66
app/api/curator-comments/[id]/read/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
139
app/api/curator-comments/route.ts
Normal file
139
app/api/curator-comments/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
42
app/api/curator/login/route.ts
Normal file
42
app/api/curator/login/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
38
app/api/curator/me/route.ts
Normal file
38
app/api/curator/me/route.ts
Normal 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
200
app/api/curators/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,19 +6,21 @@ const prisma = new PrismaClient();
|
|||||||
/**
|
/**
|
||||||
* POST /api/player-id/suggest
|
* POST /api/player-id/suggest
|
||||||
*
|
*
|
||||||
* Tries to find a player ID based on recently updated states for a genre.
|
* Tries to find a base player ID based on recently updated states for a genre and device.
|
||||||
* This helps synchronize player IDs across different domains (hoerdle.de and hördle.de).
|
* This helps synchronize player IDs across different domains (hoerdle.de and hördle.de)
|
||||||
|
* on the same device.
|
||||||
*
|
*
|
||||||
* Request body:
|
* Request body:
|
||||||
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
||||||
|
* - deviceId: Device identifier (UUID)
|
||||||
*
|
*
|
||||||
* Returns:
|
* Returns:
|
||||||
* - playerId: Suggested player ID (UUID) if found, null otherwise
|
* - basePlayerId: Suggested base player ID (UUID) if found, null otherwise
|
||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { genreKey } = body;
|
const { genreKey, deviceId } = body;
|
||||||
|
|
||||||
if (!genreKey || typeof genreKey !== 'string') {
|
if (!genreKey || typeof genreKey !== 'string') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -32,6 +34,41 @@ export async function POST(request: Request) {
|
|||||||
const cutoffDate = new Date();
|
const cutoffDate = new Date();
|
||||||
cutoffDate.setHours(cutoffDate.getHours() - 48);
|
cutoffDate.setHours(cutoffDate.getHours() - 48);
|
||||||
|
|
||||||
|
// If deviceId is provided, search for states with matching device ID
|
||||||
|
// Format: {basePlayerId}:{deviceId}
|
||||||
|
if (deviceId && typeof deviceId === 'string') {
|
||||||
|
// Search for states with the same device ID
|
||||||
|
const recentStates = await prisma.playerState.findMany({
|
||||||
|
where: {
|
||||||
|
genreKey: genreKey,
|
||||||
|
lastPlayed: {
|
||||||
|
gte: cutoffDate,
|
||||||
|
},
|
||||||
|
identifier: {
|
||||||
|
endsWith: `:${deviceId}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
lastPlayed: 'desc',
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentStates.length > 0) {
|
||||||
|
const recentState = recentStates[0];
|
||||||
|
// Extract base player ID from full identifier
|
||||||
|
const colonIndex = recentState.identifier.indexOf(':');
|
||||||
|
if (colonIndex !== -1) {
|
||||||
|
const basePlayerId = recentState.identifier.substring(0, colonIndex);
|
||||||
|
return NextResponse.json({
|
||||||
|
basePlayerId: basePlayerId,
|
||||||
|
lastPlayed: recentState.lastPlayed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Find any recent state for this genre (legacy support)
|
||||||
const recentState = await prisma.playerState.findFirst({
|
const recentState = await prisma.playerState.findFirst({
|
||||||
where: {
|
where: {
|
||||||
genreKey: genreKey,
|
genreKey: genreKey,
|
||||||
@@ -45,16 +82,26 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (recentState) {
|
if (recentState) {
|
||||||
// Return the player ID from the most recent state
|
// Extract base player ID if format is basePlayerId:deviceId
|
||||||
return NextResponse.json({
|
const colonIndex = recentState.identifier.indexOf(':');
|
||||||
playerId: recentState.identifier,
|
if (colonIndex !== -1) {
|
||||||
lastPlayed: recentState.lastPlayed,
|
const basePlayerId = recentState.identifier.substring(0, colonIndex);
|
||||||
});
|
return NextResponse.json({
|
||||||
|
basePlayerId: basePlayerId,
|
||||||
|
lastPlayed: recentState.lastPlayed,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Legacy format: return as-is
|
||||||
|
return NextResponse.json({
|
||||||
|
basePlayerId: recentState.identifier,
|
||||||
|
lastPlayed: recentState.lastPlayed,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No recent state found
|
// No recent state found
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
playerId: null,
|
basePlayerId: null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[player-id/suggest] Error finding player ID:', error);
|
console.error('[player-id/suggest] Error finding player ID:', error);
|
||||||
|
|||||||
@@ -7,10 +7,30 @@ const prisma = new PrismaClient();
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate UUID format (basic check)
|
* Validate UUID format (basic check)
|
||||||
|
* Supports both legacy format (single UUID) and new format (basePlayerId:deviceId)
|
||||||
*/
|
*/
|
||||||
function isValidUUID(uuid: string): boolean {
|
function isValidPlayerId(playerId: string): boolean {
|
||||||
|
// Legacy format: single UUID
|
||||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
return uuidRegex.test(uuid);
|
|
||||||
|
// New format: basePlayerId:deviceId (two UUIDs separated by colon)
|
||||||
|
const combinedRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
return uuidRegex.test(playerId) || combinedRegex.test(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract base player ID from full player ID
|
||||||
|
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
|
||||||
|
* Legacy: {uuid} -> {uuid}
|
||||||
|
*/
|
||||||
|
function extractBasePlayerId(fullPlayerId: string): string {
|
||||||
|
const colonIndex = fullPlayerId.indexOf(':');
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
// Legacy format (no device ID) - return as is
|
||||||
|
return fullPlayerId;
|
||||||
|
}
|
||||||
|
return fullPlayerId.substring(0, colonIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +53,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
// Get player identifier from header
|
// Get player identifier from header
|
||||||
const playerId = request.headers.get('X-Player-Id');
|
const playerId = request.headers.get('X-Player-Id');
|
||||||
if (!playerId || !isValidUUID(playerId)) {
|
if (!playerId || !isValidPlayerId(playerId)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid or missing player identifier' },
|
{ error: 'Invalid or missing player identifier' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -109,7 +129,7 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
// Get player identifier from header
|
// Get player identifier from header
|
||||||
const playerId = request.headers.get('X-Player-Id');
|
const playerId = request.headers.get('X-Player-Id');
|
||||||
if (!playerId || !isValidUUID(playerId)) {
|
if (!playerId || !isValidPlayerId(playerId)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid or missing player identifier' },
|
{ error: 'Invalid or missing player identifier' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
|
|||||||
113
app/api/political-statements/route.ts
Normal file
113
app/api/political-statements/route.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import {
|
||||||
|
getRandomActiveStatement,
|
||||||
|
getAllStatements,
|
||||||
|
createStatement,
|
||||||
|
updateStatement,
|
||||||
|
deleteStatement,
|
||||||
|
} from '@/lib/politicalStatements';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const locale = searchParams.get('locale') || 'en';
|
||||||
|
const admin = searchParams.get('admin') === 'true';
|
||||||
|
|
||||||
|
if (admin) {
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) {
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
const statements = await getAllStatements(locale);
|
||||||
|
return NextResponse.json(statements);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statement = await getRandomActiveStatement(locale);
|
||||||
|
return NextResponse.json(statement);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[political-statements] GET failed:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to load political statements' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) {
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { locale, text, active = true, source } = body;
|
||||||
|
|
||||||
|
if (!locale || typeof text !== 'string' || !text.trim()) {
|
||||||
|
return NextResponse.json({ error: 'locale and text are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createStatement(locale, { text: text.trim(), active, source });
|
||||||
|
return NextResponse.json(created, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[political-statements] POST failed:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to create statement' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) {
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { locale, id, text, active, source } = body;
|
||||||
|
|
||||||
|
if (!locale || typeof id !== 'number') {
|
||||||
|
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateStatement(locale, id, {
|
||||||
|
text: typeof text === 'string' ? text.trim() : undefined,
|
||||||
|
active,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[political-statements] PUT failed:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update statement' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) {
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { locale, id } = body;
|
||||||
|
|
||||||
|
if (!locale || typeof id !== 'number') {
|
||||||
|
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await deleteStatement(locale, id);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[political-statements] DELETE failed:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to delete statement' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
21
app/api/public-songs/route.ts
Normal file
21
app/api/public-songs/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
266
app/api/songs/batch/route.ts
Normal file
266
app/api/songs/batch/route.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
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') {
|
||||||
|
const curatorAssignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
assignments = curatorAssignments;
|
||||||
|
|
||||||
|
// Validate genre/special toggles are within curator's assignments
|
||||||
|
if (hasGenreToggle) {
|
||||||
|
const invalidGenre = genreToggleIds.some((id: number) => !curatorAssignments.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) => !curatorAssignments.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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,18 +1,96 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { writeFile, unlink } from 'fs/promises';
|
import { writeFile, unlink } from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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
|
// Configure route to handle large file uploads
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
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({
|
const songs = await prisma.song.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
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
|
// Map to include activation count and flatten specials
|
||||||
const songsWithActivations = songs.map(song => ({
|
const songsWithActivations = visibleSongs.map(song => ({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.title,
|
title: song.title,
|
||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
@@ -38,7 +141,10 @@ export async function GET() {
|
|||||||
activations: song.puzzles.length,
|
activations: song.puzzles.length,
|
||||||
puzzles: song.puzzles,
|
puzzles: song.puzzles,
|
||||||
genres: song.genres,
|
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,
|
averageRating: song.averageRating,
|
||||||
ratingCount: song.ratingCount,
|
ratingCount: song.ratingCount,
|
||||||
excludeFromGlobal: song.excludeFromGlobal,
|
excludeFromGlobal: song.excludeFromGlobal,
|
||||||
@@ -50,11 +156,11 @@ export async function GET() {
|
|||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
console.log('[UPLOAD] Starting song upload request');
|
console.log('[UPLOAD] Starting song upload request');
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) {
|
if (error || !context) {
|
||||||
console.log('[UPLOAD] Authentication failed');
|
console.log('[UPLOAD] Authentication failed');
|
||||||
return authError;
|
return error!;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -63,10 +169,17 @@ export async function POST(request: Request) {
|
|||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
let title = '';
|
let title = '';
|
||||||
let artist = '';
|
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] 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) {
|
if (!file) {
|
||||||
console.error('[UPLOAD] No file provided');
|
console.error('[UPLOAD] No file provided');
|
||||||
@@ -261,9 +374,9 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) return authError;
|
if (error || !context) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
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 });
|
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 };
|
const data: any = { title, artist };
|
||||||
|
|
||||||
// Update releaseYear if provided (can be null to clear it)
|
// Update releaseYear if provided (can be null to clear it)
|
||||||
@@ -280,60 +460,76 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (excludeFromGlobal !== undefined) {
|
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 = {
|
data.genres = {
|
||||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle SpecialSong relations separately
|
// Execute all database write operations in a transaction to ensure consistency
|
||||||
if (specialIds !== undefined) {
|
const updatedSong = await prisma.$transaction(async (tx) => {
|
||||||
// First, get current special assignments
|
// Handle SpecialSong relations separately
|
||||||
const currentSpecials = await prisma.specialSong.findMany({
|
if (effectiveSpecialIds !== undefined) {
|
||||||
where: { songId: Number(id) }
|
// First, get current special assignments (within transaction)
|
||||||
});
|
const currentSpecials = await tx.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 }
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Add new specials
|
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||||
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
const newSpecialIds = effectiveSpecialIds as number[];
|
||||||
if (toAdd.length > 0) {
|
|
||||||
await prisma.specialSong.createMany({
|
|
||||||
data: toAdd.map(specialId => ({
|
|
||||||
songId: Number(id),
|
|
||||||
specialId,
|
|
||||||
startTime: 0
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedSong = await prisma.song.update({
|
// Delete removed specials
|
||||||
where: { id: Number(id) },
|
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||||
data,
|
if (toDelete.length > 0) {
|
||||||
include: {
|
await tx.specialSong.deleteMany({
|
||||||
genres: true,
|
where: {
|
||||||
specials: {
|
songId: Number(id),
|
||||||
include: {
|
specialId: { in: toDelete }
|
||||||
special: true
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
return NextResponse.json(updatedSong);
|
||||||
@@ -344,9 +540,9 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) return authError;
|
if (error || !context) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await request.json();
|
const { id } = await request.json();
|
||||||
@@ -355,16 +551,31 @@ export async function DELETE(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
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({
|
const song = await prisma.song.findUnique({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!song) {
|
if (!song) {
|
||||||
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
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);
|
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||||
try {
|
try {
|
||||||
await unlink(filePath);
|
await unlink(filePath);
|
||||||
@@ -383,9 +594,11 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from database (will cascade delete related puzzles)
|
// Delete from database in transaction (will cascade delete related puzzles, SpecialSong, etc.)
|
||||||
await prisma.song.delete({
|
await prisma.$transaction(async (tx) => {
|
||||||
where: { id: Number(id) },
|
await tx.song.delete({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
|
|||||||
2049
app/curator/CuratorPageClient.tsx
Normal file
2049
app/curator/CuratorPageClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
149
app/curator/help/CuratorHelpClient.tsx
Normal file
149
app/curator/help/CuratorHelpClient.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import { Link } from '@/lib/navigation';
|
||||||
|
|
||||||
|
export default function CuratorHelpClient() {
|
||||||
|
const t = useTranslations('CuratorHelp');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
|
||||||
|
<header style={{ marginBottom: '2rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>{t('title')}</h1>
|
||||||
|
<Link
|
||||||
|
href="/curator"
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#6b7280',
|
||||||
|
color: 'white',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('backToDashboard')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||||
|
{/* Einführung */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('introductionTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>{t('introductionText')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('permissionsTitle')}</h3>
|
||||||
|
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('permission1')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('permission2')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('permission3')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('permission4')}</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#fef3c7', borderRadius: '0.375rem', border: '1px solid #fbbf24' }}>
|
||||||
|
<strong>{t('note')}:</strong> {t('permissionNote')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Song-Upload */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('uploadTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('uploadStepsTitle')}</h3>
|
||||||
|
<ol style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep1')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep2')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep3')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep4')}</li>
|
||||||
|
</ol>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('uploadBestPracticesTitle')}</h3>
|
||||||
|
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice1')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice2')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice3')}</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#dbeafe', borderRadius: '0.375rem', border: '1px solid #3b82f6' }}>
|
||||||
|
<strong>{t('tip')}:</strong> {t('uploadTip')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Song-Bearbeitung */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('editingTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('singleEditTitle')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>{t('singleEditText')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('batchEditTitle')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>{t('batchEditText')}</p>
|
||||||
|
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature1')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature2')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature3')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature4')}</li>
|
||||||
|
</ul>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('genreSpecialAssignmentTitle')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>{t('genreSpecialAssignmentText')}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Kommentar-Verwaltung */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('commentsTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>{t('commentsText')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('commentsActionsTitle')}</h3>
|
||||||
|
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}><strong>{t('markAsRead')}:</strong> {t('markAsReadText')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}><strong>{t('archive')}:</strong> {t('archiveText')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Best Practices */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('bestPracticesTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice1')}</li>
|
||||||
|
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice2')}</li>
|
||||||
|
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice3')}</li>
|
||||||
|
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice4')}</li>
|
||||||
|
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice5')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Troubleshooting */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('troubleshootingTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ1')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA1')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ2')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA2')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ3')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA3')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ4')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA4')}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
8
app/curator/help/page.tsx
Normal file
8
app/curator/help/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
import CuratorHelpClient from './CuratorHelpClient';
|
||||||
|
|
||||||
|
export default function CuratorHelpPage() {
|
||||||
|
return <CuratorHelpClient />;
|
||||||
|
}
|
||||||
|
|
||||||
11
app/curator/page.tsx
Normal file
11
app/curator/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +70,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
|
|
||||||
// Dynamic genre pages
|
// Dynamic genre pages
|
||||||
try {
|
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({
|
const genres = await prisma.genre.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
});
|
});
|
||||||
|
|||||||
98
components/ExtraPuzzlesPopover.tsx
Normal file
98
components/ExtraPuzzlesPopover.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
|
||||||
|
|
||||||
|
interface ExtraPuzzlesPopoverProps {
|
||||||
|
puzzle: ExternalPuzzle;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExtraPuzzlesPopover({ puzzle, onClose }: ExtraPuzzlesPopoverProps) {
|
||||||
|
const t = useTranslations('ExtraPuzzles');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const name = locale === 'de' ? puzzle.nameDe : puzzle.nameEn;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('extra_puzzles_click', {
|
||||||
|
props: {
|
||||||
|
partner: puzzle.id,
|
||||||
|
url: puzzle.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '1.5rem',
|
||||||
|
right: '1.5rem',
|
||||||
|
zIndex: 1100,
|
||||||
|
maxWidth: '320px',
|
||||||
|
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
background: 'white',
|
||||||
|
padding: '1rem 1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>
|
||||||
|
{t('title')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('close')}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
color: '#6b7280',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ margin: 0, fontSize: '0.9rem', color: '#4b5563' }}>
|
||||||
|
{t('message', { name })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={puzzle.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
marginTop: '0.25rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'linear-gradient(135deg, #4f46e5, #ec4899)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('cta', { name })}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,8 +6,14 @@ import { useTranslations, useLocale } from 'next-intl';
|
|||||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||||
import GuessInput from './GuessInput';
|
import GuessInput from './GuessInput';
|
||||||
import Statistics from './Statistics';
|
import Statistics from './Statistics';
|
||||||
|
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
|
||||||
import { useGameState } from '../lib/gameState';
|
import { useGameState } from '../lib/gameState';
|
||||||
|
import { getGenreKey } from '@/lib/playerStorage';
|
||||||
|
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 { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
import { getOrCreatePlayerId } from '@/lib/playerId';
|
||||||
|
|
||||||
// Plausible Analytics
|
// Plausible Analytics
|
||||||
declare global {
|
declare global {
|
||||||
@@ -32,11 +38,14 @@ interface GameProps {
|
|||||||
isSpecial?: boolean;
|
isSpecial?: boolean;
|
||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
unlockSteps?: number[];
|
unlockSteps?: number[];
|
||||||
|
// List of genre keys that zusammen alle Tagesrätsel des Tages repräsentieren (z. B. ['global', 'Rock', 'Pop']).
|
||||||
|
// Wird genutzt, um zu prüfen, ob der Spieler alle Tagesrätsel gespielt hat.
|
||||||
|
requiredDailyKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||||
|
|
||||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS, requiredDailyKeys }: GameProps) {
|
||||||
const t = useTranslations('Game');
|
const t = useTranslations('Game');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||||
@@ -49,7 +58,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const [hasRated, setHasRated] = useState(false);
|
const [hasRated, setHasRated] = useState(false);
|
||||||
const [showYearModal, setShowYearModal] = useState(false);
|
const [showYearModal, setShowYearModal] = useState(false);
|
||||||
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
||||||
|
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||||
|
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||||
const audioPlayerRef = useRef<AudioPlayerRef>(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(() => {
|
useEffect(() => {
|
||||||
const updateCountdown = () => {
|
const updateCountdown = () => {
|
||||||
@@ -81,6 +96,37 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
}, [gameState, dailyPuzzle]);
|
}, [gameState, dailyPuzzle]);
|
||||||
|
|
||||||
|
// Track gespielte Tagesrätsel & entscheide, ob das Partner-Popover gezeigt werden soll
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gameState || !dailyPuzzle) return;
|
||||||
|
|
||||||
|
const gameEnded = gameState.isSolved || gameState.isFailed;
|
||||||
|
if (!gameEnded) return;
|
||||||
|
|
||||||
|
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||||
|
markDailyPuzzlePlayedToday(genreKey);
|
||||||
|
|
||||||
|
if (!requiredDailyKeys || requiredDailyKeys.length === 0) return;
|
||||||
|
if (hasSeenExtraPuzzlesPopoverToday()) return;
|
||||||
|
if (!hasPlayedAllDailyPuzzlesForToday(requiredDailyKeys)) return;
|
||||||
|
|
||||||
|
const partnerPuzzle = getRandomExternalPuzzle();
|
||||||
|
if (!partnerPuzzle) return;
|
||||||
|
|
||||||
|
setExtraPuzzle(partnerPuzzle);
|
||||||
|
setShowExtraPuzzlesPopover(true);
|
||||||
|
markExtraPuzzlesPopoverShownToday();
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('extra_puzzles_popover_shown', {
|
||||||
|
props: {
|
||||||
|
partner: partnerPuzzle.id,
|
||||||
|
url: partnerPuzzle.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [gameState?.isSolved, gameState?.isFailed, dailyPuzzle?.id, genre, isSpecial, requiredDailyKeys]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLastAction(null);
|
setLastAction(null);
|
||||||
}, [dailyPuzzle?.id]);
|
}, [dailyPuzzle?.id]);
|
||||||
@@ -93,6 +139,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
} else {
|
} else {
|
||||||
setHasRated(false);
|
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]);
|
}, [dailyPuzzle]);
|
||||||
|
|
||||||
@@ -259,6 +314,59 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
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 unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
@@ -284,8 +392,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||||
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||||
|
|
||||||
// Use current domain from window.location to support both hoerdle.de and hördle.de
|
// Use current domain from window.location to support both hoerdle.de and hördle.de,
|
||||||
const currentHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
// but always share the pretty Unicode-Domain "hördle.de" instead of the Punycode variant.
|
||||||
|
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
||||||
|
const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
|
||||||
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
||||||
let shareUrl = `${protocol}//${currentHost}`;
|
let shareUrl = `${protocol}//${currentHost}`;
|
||||||
// Add locale prefix if not default (en)
|
// Add locale prefix if not default (en)
|
||||||
@@ -348,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 (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
@@ -360,7 +477,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
<main className="game-board">
|
<main className="game-board">
|
||||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||||
<div id="tour-status" className="status-bar">
|
<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>
|
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -469,14 +586,80 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||||
</div>
|
</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} />}
|
{statistics && <Statistics statistics={statistics} />}
|
||||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
|
||||||
{shareText}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
@@ -488,6 +671,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
onSkip={handleYearSkip}
|
onSkip={handleYearSkip}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showExtraPuzzlesPopover && extraPuzzle && (
|
||||||
|
<ExtraPuzzlesPopover
|
||||||
|
puzzle={extraPuzzle}
|
||||||
|
onClose={() => setShowExtraPuzzlesPopover(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -687,7 +877,11 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
<div
|
||||||
|
className="star-rating"
|
||||||
|
title={t('ratingTooltip')}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
|
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
|
||||||
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
||||||
{[...Array(5)].map((_, index) => {
|
{[...Array(5)].map((_, index) => {
|
||||||
|
|||||||
@@ -22,9 +22,25 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/songs')
|
fetch('/api/public-songs')
|
||||||
.then(res => res.json())
|
.then(res => {
|
||||||
.then(data => setSongs(data));
|
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(() => {
|
useEffect(() => {
|
||||||
|
|||||||
175
components/HelpTooltip.tsx
Normal file
175
components/HelpTooltip.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
interface HelpTooltipProps {
|
||||||
|
shortText: string; // Text für Hover
|
||||||
|
longText: string; // Text für Click/Modal
|
||||||
|
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HelpTooltip({ shortText, longText, position = 'top' }: HelpTooltipProps) {
|
||||||
|
const t = useTranslations('CuratorHelp');
|
||||||
|
const [showHover, setShowHover] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
tooltipRef.current &&
|
||||||
|
!tooltipRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setShowModal(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showModal) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [showModal]);
|
||||||
|
|
||||||
|
const positionStyles = {
|
||||||
|
top: { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: '0.5rem' },
|
||||||
|
bottom: { top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: '0.5rem' },
|
||||||
|
left: { right: '100%', top: '50%', transform: 'translateY(-50%)', marginRight: '0.5rem' },
|
||||||
|
right: { left: '100%', top: '50%', transform: 'translateY(-50%)', marginLeft: '0.5rem' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(!showModal)}
|
||||||
|
onMouseEnter={() => setShowHover(true)}
|
||||||
|
onMouseLeave={() => setShowHover(false)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: '1rem',
|
||||||
|
padding: '0.25rem',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '1.5rem',
|
||||||
|
height: '1.5rem',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
aria-label="Help"
|
||||||
|
>
|
||||||
|
ℹ
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hover Tooltip */}
|
||||||
|
{showHover && !showModal && (
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
...positionStyles[position],
|
||||||
|
background: '#1f2937',
|
||||||
|
color: 'white',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
maxWidth: '250px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shortText}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
...(position === 'top' && { top: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderTop: '6px solid #1f2937' }),
|
||||||
|
...(position === 'bottom' && { bottom: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderBottom: '6px solid #1f2937' }),
|
||||||
|
...(position === 'left' && { left: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: '6px solid transparent', borderBottom: '6px solid transparent', borderLeft: '6px solid #1f2937' }),
|
||||||
|
...(position === 'right' && { right: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: '6px solid transparent', borderBottom: '6px solid transparent', borderRight: '6px solid #1f2937' }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal für detaillierte Informationen */}
|
||||||
|
{showModal && (
|
||||||
|
<>
|
||||||
|
{/* Overlay */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
zIndex: 9998,
|
||||||
|
}}
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
/>
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
background: 'white',
|
||||||
|
padding: '1.5rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '90%',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 'bold' }}>{t('modalTitle')}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#6b7280',
|
||||||
|
padding: '0',
|
||||||
|
lineHeight: '1',
|
||||||
|
}}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.9rem', lineHeight: '1.6', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{longText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
95
components/PoliticalStatementBanner.tsx
Normal file
95
components/PoliticalStatementBanner.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
|
|
||||||
|
interface ApiStatement {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoliticalStatementBanner() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const [statement, setStatement] = useState<ApiStatement | null>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const storageKey = `hoerdle_political_statement_shown_${today}_${locale}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const alreadyShown = typeof window !== 'undefined' && window.localStorage.getItem(storageKey);
|
||||||
|
if (alreadyShown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage errors
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutId: number | undefined;
|
||||||
|
|
||||||
|
const fetchStatement = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/political-statements?locale=${encodeURIComponent(locale)}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data || !data.text) return;
|
||||||
|
setStatement(data);
|
||||||
|
setVisible(true);
|
||||||
|
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
setVisible(false);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, 'true');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[PoliticalStatementBanner] Failed to load statement', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStatement();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
if (!visible || !statement) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '1.25rem',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
maxWidth: '640px',
|
||||||
|
width: 'calc(100% - 2.5rem)',
|
||||||
|
zIndex: 1050,
|
||||||
|
background: 'rgba(17,24,39,0.95)',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
boxShadow: '0 10px 25px rgba(0,0,0,0.45)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.9rem' }}>✊</span>
|
||||||
|
<span style={{ flex: 1 }}>{statement.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
293
docs/SCORING_OPTIONS.md
Normal file
293
docs/SCORING_OPTIONS.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# Scoring-System Optionen
|
||||||
|
|
||||||
|
## Problem-Analyse
|
||||||
|
|
||||||
|
### Aktuelle Situation
|
||||||
|
- **Start:** 90 Punkte
|
||||||
|
- **Richtige Antwort:** +20 Punkte
|
||||||
|
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
|
||||||
|
- **Skip:** -5 Punkte
|
||||||
|
- **Replay:** -1 Punkt
|
||||||
|
|
||||||
|
### Problem (vor der Änderung)
|
||||||
|
Bei vielen Versuchen kam man mit einem relativ hohen Score heraus:
|
||||||
|
- Beispiel (alt): 7 Versuche = 90 + 20 - (6 × 3) = **92 Punkte**
|
||||||
|
|
||||||
|
### Lösung (aktuell implementiert)
|
||||||
|
Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die Track-Verlängerung (unlockSteps) abgezogen:
|
||||||
|
- Beispiel (neu): 7 Versuche = 90 + 20 - (6 × 8) = **62 Punkte**
|
||||||
|
- Start: 90 Punkte
|
||||||
|
- 6 falsche Versuche: -48 Punkte (6 × -8, bestehend aus -3 für falsch + -5 für Verlängerung)
|
||||||
|
- 1 richtiger Versuch: +20 Punkte
|
||||||
|
- **Ergebnis: 62 Punkte**
|
||||||
|
|
||||||
|
Dies spiegelt nun besser die tatsächliche Leistung wider. Das System bleibt motivierend, da richtige Antworten weiterhin belohnt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 1: Progressive Abzüge ⚠️ (Intransparent)
|
||||||
|
|
||||||
|
### Konzept
|
||||||
|
Abzüge steigen mit jedem Versuch, aber das System ist schwer nachvollziehbar.
|
||||||
|
|
||||||
|
```
|
||||||
|
- Versuch 1-2: -2 Punkte pro falscher Antwort
|
||||||
|
- Versuch 3-4: -4 Punkte pro falscher Antwort
|
||||||
|
- Versuch 5-6: -6 Punkte pro falscher Antwort
|
||||||
|
- Versuch 7: -8 Punkte
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel
|
||||||
|
Bei 7 Versuchen: 90 + 20 - (2+2+4+4+6+6) = **86 Punkte**
|
||||||
|
|
||||||
|
### Probleme
|
||||||
|
- **Intransparent**: Spieler müssen sich merken, welche Abzüge in welcher Runde gelten
|
||||||
|
- **Schwer erklärbar**: Das Regelwerk ist komplex
|
||||||
|
- **Unklar im UI**: Aktuelle Abzüge sind nicht sofort ersichtlich
|
||||||
|
|
||||||
|
### Vorteile
|
||||||
|
- Progressive Bestrafung für viele Versuche
|
||||||
|
- Fairer als aktuelles System
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 2: Bonus-Malus-System
|
||||||
|
|
||||||
|
### Konzept
|
||||||
|
Höhere Belohnungen für frühe Erfolge + progressive Abzüge.
|
||||||
|
|
||||||
|
```
|
||||||
|
Start: 90 Punkte
|
||||||
|
|
||||||
|
Richtige Antwort (Bonus abhängig vom Versuch):
|
||||||
|
- Versuch 1: +30 Punkte (sehr gut!)
|
||||||
|
- Versuch 2: +25 Punkte (gut!)
|
||||||
|
- Versuch 3: +20 Punkte (okay)
|
||||||
|
- Versuch 4: +15 Punkte
|
||||||
|
- Versuch 5+: +10 Punkte
|
||||||
|
|
||||||
|
Falsche Antwort (progressive Abzüge):
|
||||||
|
- Versuch 1-2: -3 Punkte
|
||||||
|
- Versuch 3-4: -5 Punkte
|
||||||
|
- Versuch 5-6: -8 Punkte
|
||||||
|
- Versuch 7: -10 Punkte
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiele
|
||||||
|
- Gelöst in Versuch 1: 90 + 30 = **120 Punkte** ⭐
|
||||||
|
- Gelöst in Versuch 4 (nach 3 Fehlern): 90 + 15 - (3+5+5) = **92 Punkte**
|
||||||
|
- Gelöst in Versuch 7 (nach 6 Fehlern): 90 + 10 - (3+5+5+8+8+10) = **61 Punkte**
|
||||||
|
|
||||||
|
### Vorteile
|
||||||
|
- **Transparent**: Klare Regeln pro Versuch
|
||||||
|
- **Motivierend**: Hohe Belohnungen für schnelles Lösen
|
||||||
|
- **Fair**: Späte Erfolge werden abgewertet
|
||||||
|
|
||||||
|
### Nachteile
|
||||||
|
- Etwas komplexer als aktuelles System
|
||||||
|
- Muss im UI klar kommuniziert werden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 3: Effizienz-Multiplikator
|
||||||
|
|
||||||
|
### Konzept
|
||||||
|
Basis-System bleibt, aber Multiplikator basierend auf Versuchszahl.
|
||||||
|
|
||||||
|
```
|
||||||
|
Basis-System (wie aktuell, aber mit höheren Abzügen):
|
||||||
|
- Falsche Antwort: -5 Punkte (statt -3)
|
||||||
|
- Skip: -7 Punkte (statt -5)
|
||||||
|
|
||||||
|
Bonus-Multiplikatoren (basierend auf Versuch, in dem gelöst wurde):
|
||||||
|
- Gelöst in 1-2 Versuchen: ×1.2 (20% Bonus)
|
||||||
|
- Gelöst in 3-4 Versuchen: ×1.1 (10% Bonus)
|
||||||
|
- Gelöst in 5-6 Versuchen: ×1.0 (kein Bonus)
|
||||||
|
- Gelöst in 7 Versuchen: ×0.9 (10% Abzug)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiele
|
||||||
|
- Gelöst in Versuch 2 (1 Fehler): (90 + 20 - 5) × 1.2 = **126 Punkte**
|
||||||
|
- Gelöst in Versuch 4 (3 Fehler): (90 + 20 - 15) × 1.1 = **104.5 → 105 Punkte**
|
||||||
|
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
|
||||||
|
|
||||||
|
### Vorteile
|
||||||
|
- Multiplikator ist einfach zu verstehen ("20% Bonus für schnelles Lösen")
|
||||||
|
- Basis-System bleibt ähnlich
|
||||||
|
- Gerechte Bestrafung für viele Versuche
|
||||||
|
|
||||||
|
### Nachteile
|
||||||
|
- Multiplikatoren müssen berechnet werden (könnte kompliziert wirken)
|
||||||
|
- Kombination aus Basis + Multiplikator kann verwirrend sein
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 4: Kombiniertes System
|
||||||
|
|
||||||
|
### Konzept
|
||||||
|
Höhere Abzüge + kleine Motivations-Boni.
|
||||||
|
|
||||||
|
```
|
||||||
|
Basis-System (höhere Abzüge):
|
||||||
|
- Falsche Antwort: -5 Punkte (statt -3)
|
||||||
|
- Skip: -7 Punkte (statt -5)
|
||||||
|
- Richtige Antwort: +20 Punkte (bleibt)
|
||||||
|
|
||||||
|
Motivations-Boni:
|
||||||
|
- "Erstversuch" Bonus: +2 Punkte wenn erster Versuch nicht skipped wurde
|
||||||
|
- "Perfekter Durchlauf": +5 Bonus wenn kein Skip verwendet wurde
|
||||||
|
- "Knapp daneben": +1 Punkt für Versuche, die fast richtig waren (optional, komplex)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiele
|
||||||
|
- Gelöst in Versuch 1: 90 + 20 + 2 + 5 = **117 Punkte**
|
||||||
|
- Gelöst in Versuch 4 (3 Fehler, kein Skip): 90 + 20 - 15 + 5 = **100 Punkte**
|
||||||
|
- Gelöst in Versuch 7 (6 Fehler, 2 Skips): 90 + 20 - 30 - 14 = **66 Punkte**
|
||||||
|
|
||||||
|
### Vorteile
|
||||||
|
- **Einfach verständlich**: Basis + kleine Boni
|
||||||
|
- **Motivierend**: Positive Verstärkung für gutes Verhalten
|
||||||
|
- **Fair**: Höhere Abzüge sorgen für differenzierten Score
|
||||||
|
|
||||||
|
### Nachteile
|
||||||
|
- Mehrere kleine Boni können unübersichtlich werden
|
||||||
|
- "Knapp daneben" ist schwer zu implementieren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 5: Streak-System (Langfristige Motivation)
|
||||||
|
|
||||||
|
### Konzept
|
||||||
|
Zusätzliche Belohnungen für konsequentes Spielen über mehrere Tage.
|
||||||
|
|
||||||
|
```
|
||||||
|
Tägliche Streaks:
|
||||||
|
- 3 Tage in Folge gelöst: +5 Bonus-Punkte
|
||||||
|
- 7 Tage: +10 Bonus-Punkte
|
||||||
|
- 30 Tage: +15 Bonus-Punkte
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kombiniert mit einem der anderen Systeme** (z.B. Option 2 oder 4).
|
||||||
|
|
||||||
|
### Vorteile
|
||||||
|
- Langfristige Spielermotivation
|
||||||
|
- Belohnt Engagement
|
||||||
|
|
||||||
|
### Nachteile
|
||||||
|
- Braucht Tracking über mehrere Tage
|
||||||
|
- Löst nicht das Hauptproblem (zu hoher Score bei vielen Versuchen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 6: Multiplikator-System (Vereinfacht)
|
||||||
|
|
||||||
|
### Konzept
|
||||||
|
Höhere Abzüge + einfache Multiplikatoren für Versuchszahl.
|
||||||
|
|
||||||
|
```
|
||||||
|
Höhere Basis-Abzüge:
|
||||||
|
- Falsche Antwort: -5 Punkte
|
||||||
|
- Skip: -7 Punkte
|
||||||
|
|
||||||
|
Multiplikator basierend auf Versuch, in dem gelöst wurde:
|
||||||
|
- Versuch 1: ×1.5 (50% Bonus) → Sehr schnelles Lösen
|
||||||
|
- Versuch 2: ×1.3 (30% Bonus)
|
||||||
|
- Versuch 3: ×1.1 (10% Bonus)
|
||||||
|
- Versuch 4: ×1.0 (kein Bonus/Aufschlag)
|
||||||
|
- Versuch 5+: ×0.9 (10% Abzug)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiele
|
||||||
|
- Gelöst in Versuch 1: (90 + 20) × 1.5 = **165 Punkte** ⭐⭐⭐
|
||||||
|
- Gelöst in Versuch 3 (2 Fehler): (90 + 20 - 10) × 1.1 = **110 Punkte**
|
||||||
|
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
|
||||||
|
|
||||||
|
### Vorteile
|
||||||
|
- **Sehr transparent**: "50% Bonus für Erstversuch" ist einfach zu verstehen
|
||||||
|
- **Stark motivierend**: Hohe Belohnungen für schnelles Lösen
|
||||||
|
- **Fair**: Viele Versuche = niedriger Score
|
||||||
|
|
||||||
|
### Nachteile
|
||||||
|
- Multiplikatoren könnten als zu komplex empfunden werden
|
||||||
|
- Hohe Scores bei frühen Erfolgen (könnte als "zu leicht" empfunden werden)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfehlungen
|
||||||
|
|
||||||
|
### Für Transparenz und Einfachheit: **Option 2 oder Option 4**
|
||||||
|
|
||||||
|
**Option 2 (Bonus-Malus)** ist am transparentesten:
|
||||||
|
- Klare Werte pro Versuch
|
||||||
|
- Einfach zu kommunizieren: "Erstversuch gibt +30, jeder weitere Versuch reduziert den Bonus"
|
||||||
|
- Fair und motivierend
|
||||||
|
|
||||||
|
**Option 4 (Kombiniert)** ist am einfachsten:
|
||||||
|
- Basis-System bleibt ähnlich (nur höhere Abzüge)
|
||||||
|
- Zusätzliche kleine Boni sind optional und motivierend
|
||||||
|
- Sehr einfach zu verstehen
|
||||||
|
|
||||||
|
### Für maximale Motivation: **Option 6**
|
||||||
|
|
||||||
|
- Hohe Belohnungen für schnelles Lösen
|
||||||
|
- Einfache Multiplikatoren ("50% Bonus")
|
||||||
|
- Sehr fair für viele Versuche
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementierungs-Hinweise
|
||||||
|
|
||||||
|
### UI-Kommunikation
|
||||||
|
Welche Option auch gewählt wird - sie muss im Spiel klar kommuniziert werden:
|
||||||
|
- Tooltips bei Versuchen
|
||||||
|
- Score-Breakdown zeigt Abzüge/Boni pro Versuch
|
||||||
|
- Vorschau: "Dieser Versuch würde X Punkte kosten/geben"
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
Vor der Implementierung sollten verschiedene Szenarien durchgespielt werden:
|
||||||
|
- Erstversuch-Lösung
|
||||||
|
- Mittlere Versuche (3-4)
|
||||||
|
- Knappe Lösung (6-7 Versuche)
|
||||||
|
- Mit/ohne Skips
|
||||||
|
- Mit/ohne Replays
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
- Bestehende Scores können nicht einfach migriert werden
|
||||||
|
- Neue Regeln gelten ab Start des neuen Systems
|
||||||
|
- Eventuell: "New Scoring System" Ankündigung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Implementiert: Abzüge für zusätzliche Sekunden
|
||||||
|
|
||||||
|
**Status:** ✅ **Aktuell implementiert**
|
||||||
|
|
||||||
|
Bei falschen Rateversuchen werden zusätzlich **-5 Punkte für die Track-Verlängerung** abgezogen:
|
||||||
|
- Falsche Antwort (Rateversuch): -3 Punkte (falsch) + -5 Punkte (Verlängerung) = **-8 Punkte total**
|
||||||
|
- Skip: -5 Punkte (kein zusätzlicher Abzug, da Skip keine Verlängerung bedeutet)
|
||||||
|
|
||||||
|
**Vorteile:**
|
||||||
|
- ✅ Reflektiert den "Hilfsmittel"-Charakter der zusätzlichen Sekunden
|
||||||
|
- ✅ Macht viele Versuche deutlich teurer
|
||||||
|
- ✅ Fairer Score bei vielen Versuchen
|
||||||
|
- ✅ Transparent: Klar getrennt als "Wrong guess" und "Track extension"
|
||||||
|
|
||||||
|
**Hinweis:** Dies ist die erste Anpassung des Scoring-Systems. Weitere Optionen (siehe oben) können in Zukunft ergänzt werden.
|
||||||
|
|
||||||
|
## Offene Fragen
|
||||||
|
|
||||||
|
1. Sollen Replays weiterhin -1 Punkt kosten?
|
||||||
|
2. Soll das Jahr-Bonus-System (+10) beibehalten werden?
|
||||||
|
3. Wie wichtig ist Backward-Compatibility mit bestehenden Scores?
|
||||||
|
4. Soll es eine "Preview"-Funktion geben ("Dieser Versuch kostet X Punkte")?
|
||||||
|
5. Sollen zusätzlich freigeschaltete Sekunden (Unlock-Steps) zusätzlich Punkte kosten?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
📝 **Erstellt:** 2024-12-01
|
||||||
|
✅ **Erste Änderung implementiert:** 2024-12-01 - Track-Verlängerung kostet jetzt -5 Punkte bei falschen Rateversuchen
|
||||||
|
🔄 **Status:** Teilweise umgesetzt
|
||||||
|
💡 **Nächste Schritte:** Weitere Optionen können bei Bedarf ergänzt werden (siehe Optionen oben)
|
||||||
|
|
||||||
58
lib/auth.ts
58
lib/auth.ts
@@ -1,4 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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
|
* Authentication middleware for admin API routes
|
||||||
@@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise<NextRespon
|
|||||||
return null; // Auth successful
|
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
|
* Helper to verify admin password
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const config = {
|
|||||||
},
|
},
|
||||||
credits: {
|
credits: {
|
||||||
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
|
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',
|
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,13 +49,15 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
|
|||||||
// Calculate total weight
|
// Calculate total weight
|
||||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
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 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) {
|
for (const item of weightedSongs) {
|
||||||
random -= item.weight;
|
cumulativeWeight += item.weight;
|
||||||
if (random <= 0) {
|
if (random <= cumulativeWeight) {
|
||||||
selectedSong = item.song;
|
selectedSong = item.song;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -156,11 +158,13 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
|
|||||||
|
|
||||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||||
let random = Math.random() * totalWeight;
|
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) {
|
for (const item of weightedSongs) {
|
||||||
random -= item.weight;
|
cumulativeWeight += item.weight;
|
||||||
if (random <= 0) {
|
if (random <= cumulativeWeight) {
|
||||||
selectedSpecialSong = item.specialSong;
|
selectedSpecialSong = item.specialSong;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
47
lib/externalPuzzles.ts
Normal file
47
lib/externalPuzzles.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export type ExternalPuzzle = {
|
||||||
|
id: string;
|
||||||
|
nameDe: string;
|
||||||
|
nameEn: string;
|
||||||
|
url: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Liste externer Rätselangebote.
|
||||||
|
*
|
||||||
|
* Erweiterung: Einfach neuen Eintrag in dieses Array hinzufügen.
|
||||||
|
*/
|
||||||
|
export const externalPuzzles: ExternalPuzzle[] = [
|
||||||
|
{
|
||||||
|
id: 'pastpuzzle',
|
||||||
|
nameDe: 'Past Puzzle',
|
||||||
|
nameEn: 'Past Puzzle',
|
||||||
|
url: 'https://www.pastpuzzle.de/#/',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'woerdle',
|
||||||
|
nameDe: 'Wördle',
|
||||||
|
nameEn: 'Wördle',
|
||||||
|
url: 'https://www.wördle.de',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ciddle',
|
||||||
|
nameDe: 'Ciddle',
|
||||||
|
nameEn: 'Ciddle',
|
||||||
|
url: 'https://ciddle.winklerweb.net',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getRandomExternalPuzzle(): ExternalPuzzle | null {
|
||||||
|
const activePuzzles = externalPuzzles.filter(p => p.isActive !== false);
|
||||||
|
if (activePuzzles.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const index = Math.floor(Math.random() * activePuzzles.length);
|
||||||
|
return activePuzzles[index] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
68
lib/extraPuzzlesTracker.ts
Normal file
68
lib/extraPuzzlesTracker.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { getTodayISOString } from './dateUtils';
|
||||||
|
|
||||||
|
const DAILY_PLAYED_PREFIX = 'hoerdle_daily_played_';
|
||||||
|
const EXTRA_POPOVER_PREFIX = 'hoerdle_extra_puzzles_shown_';
|
||||||
|
|
||||||
|
function getTodayKey(prefix: string): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
const today = getTodayISOString();
|
||||||
|
return `${prefix}${today}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markDailyPuzzlePlayedToday(genreKey: string) {
|
||||||
|
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
|
||||||
|
if (!storageKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey);
|
||||||
|
const list: string[] = raw ? JSON.parse(raw) : [];
|
||||||
|
if (!list.includes(genreKey)) {
|
||||||
|
list.push(genreKey);
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(list));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[extraPuzzles] Failed to mark daily puzzle as played', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPlayedAllDailyPuzzlesForToday(requiredGenreKeys: string[]): boolean {
|
||||||
|
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
|
||||||
|
if (!storageKey) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey);
|
||||||
|
const played: string[] = raw ? JSON.parse(raw) : [];
|
||||||
|
if (!Array.isArray(played) || played.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return requiredGenreKeys.every(key => played.includes(key));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[extraPuzzles] Failed to read played puzzles', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSeenExtraPuzzlesPopoverToday(): boolean {
|
||||||
|
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
|
||||||
|
if (!storageKey) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(storageKey) === 'true';
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[extraPuzzles] Failed to read popover state', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markExtraPuzzlesPopoverShownToday() {
|
||||||
|
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
|
||||||
|
if (!storageKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, 'true');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[extraPuzzles] Failed to persist popover state', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -200,6 +200,9 @@ export function useGameState(
|
|||||||
} else {
|
} else {
|
||||||
newScore -= 3;
|
newScore -= 3;
|
||||||
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
|
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
|
||||||
|
// Additional penalty for track extension (unlock steps)
|
||||||
|
newScore -= 5;
|
||||||
|
newBreakdown.push({ value: -5, reason: 'Track extension' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
175
lib/playerId.ts
175
lib/playerId.ts
@@ -4,14 +4,20 @@
|
|||||||
* Generates and manages a unique player identifier (UUID) that is stored
|
* Generates and manages a unique player identifier (UUID) that is stored
|
||||||
* in localStorage. This identifier is used to sync game states across
|
* in localStorage. This identifier is used to sync game states across
|
||||||
* different domains (hoerdle.de and hördle.de).
|
* different domains (hoerdle.de and hördle.de).
|
||||||
|
*
|
||||||
|
* Device-specific isolation:
|
||||||
|
* - Each device has its own device ID stored in localStorage
|
||||||
|
* - Player ID format: {basePlayerId}:{deviceId}
|
||||||
|
* - This allows cross-domain sync on the same device while keeping devices isolated
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_KEY = 'hoerdle_player_id';
|
const STORAGE_KEY_PLAYER = 'hoerdle_player_id';
|
||||||
|
const STORAGE_KEY_DEVICE = 'hoerdle_device_id';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a UUID v4
|
* Generate a UUID v4
|
||||||
*/
|
*/
|
||||||
function generatePlayerId(): string {
|
function generateUUID(): string {
|
||||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
const r = Math.random() * 16 | 0;
|
const r = Math.random() * 16 | 0;
|
||||||
@@ -21,68 +27,143 @@ function generatePlayerId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to find an existing player ID from the backend
|
* Get or create a device ID (unique per device)
|
||||||
|
*
|
||||||
|
* The device ID is stored in localStorage and persists across sessions.
|
||||||
|
* This allows device-specific isolation of game states.
|
||||||
|
*
|
||||||
|
* @returns Device identifier (UUID v4)
|
||||||
|
*/
|
||||||
|
export function getOrCreateDeviceId(): string {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let deviceId = localStorage.getItem(STORAGE_KEY_DEVICE);
|
||||||
|
if (!deviceId) {
|
||||||
|
deviceId = generateUUID();
|
||||||
|
localStorage.setItem(STORAGE_KEY_DEVICE, deviceId);
|
||||||
|
}
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the device ID without creating a new one
|
||||||
|
*
|
||||||
|
* @returns Device identifier or null if not set
|
||||||
|
*/
|
||||||
|
export function getDeviceId(): string | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return localStorage.getItem(STORAGE_KEY_DEVICE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a base player ID (for cross-domain sync)
|
||||||
|
*/
|
||||||
|
function generateBasePlayerId(): string {
|
||||||
|
return generateUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to find an existing base player ID from the backend
|
||||||
|
*
|
||||||
|
* Extracts the base player ID from a full player ID (format: {basePlayerId}:{deviceId})
|
||||||
*
|
*
|
||||||
* @param genreKey - Genre key to search for
|
* @param genreKey - Genre key to search for
|
||||||
* @returns Player ID if found, null otherwise
|
* @returns Base player ID if found, null otherwise
|
||||||
*/
|
*/
|
||||||
async function findExistingPlayerId(genreKey: string): Promise<string | null> {
|
async function findExistingBasePlayerId(genreKey: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
|
const deviceId = getOrCreateDeviceId();
|
||||||
const response = await fetch('/api/player-id/suggest', {
|
const response = await fetch('/api/player-id/suggest', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ genreKey }),
|
body: JSON.stringify({ genreKey, deviceId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.playerId) {
|
if (data.basePlayerId) {
|
||||||
return data.playerId;
|
return data.basePlayerId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[playerId] Failed to find existing player ID:', error);
|
console.warn('[playerId] Failed to find existing base player ID:', error);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine base player ID and device ID into full player ID
|
||||||
|
* Format: {basePlayerId}:{deviceId}
|
||||||
|
*/
|
||||||
|
function combinePlayerId(basePlayerId: string, deviceId: string): string {
|
||||||
|
return `${basePlayerId}:${deviceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract base player ID from full player ID
|
||||||
|
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
|
||||||
|
*/
|
||||||
|
function extractBasePlayerId(fullPlayerId: string): string {
|
||||||
|
const colonIndex = fullPlayerId.indexOf(':');
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
// Legacy format (no device ID) - return as is
|
||||||
|
return fullPlayerId;
|
||||||
|
}
|
||||||
|
return fullPlayerId.substring(0, colonIndex);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a player identifier
|
* Get or create a player identifier
|
||||||
*
|
*
|
||||||
* If no identifier exists in localStorage, tries to find an existing one from the backend
|
* Player ID format: {basePlayerId}:{deviceId}
|
||||||
* (based on recently updated states). If none found, generates a new UUID.
|
|
||||||
* This enables cross-domain synchronization between hoerdle.de and hördle.de.
|
|
||||||
*
|
*
|
||||||
* @param genreKey - Optional genre key to search for existing player ID
|
* If no identifier exists in localStorage, tries to find an existing base player ID
|
||||||
* @returns Player identifier (UUID v4)
|
* from the backend (for cross-domain sync). If none found, generates a new base ID.
|
||||||
|
* The device ID is always device-specific.
|
||||||
|
*
|
||||||
|
* This enables:
|
||||||
|
* - Cross-domain synchronization on the same device (same base player ID)
|
||||||
|
* - Device isolation (different device IDs)
|
||||||
|
*
|
||||||
|
* @param genreKey - Optional genre key to search for existing base player ID
|
||||||
|
* @returns Full player identifier ({basePlayerId}:{deviceId})
|
||||||
*/
|
*/
|
||||||
export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<string> {
|
export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<string> {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
// Server-side: return empty string (not used on server)
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
let playerId = localStorage.getItem(STORAGE_KEY);
|
// Always get/create device ID (device-specific)
|
||||||
|
const deviceId = getOrCreateDeviceId();
|
||||||
|
|
||||||
if (!playerId) {
|
// Try to get base player ID from localStorage
|
||||||
// Try to find an existing player ID from backend if genreKey is provided
|
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||||
|
|
||||||
|
if (!basePlayerId) {
|
||||||
|
// Try to find an existing base player ID from backend if genreKey is provided
|
||||||
if (genreKey) {
|
if (genreKey) {
|
||||||
const existingId = await findExistingPlayerId(genreKey);
|
const existingBaseId = await findExistingBasePlayerId(genreKey);
|
||||||
if (existingId) {
|
if (existingBaseId) {
|
||||||
playerId = existingId;
|
basePlayerId = existingBaseId;
|
||||||
localStorage.setItem(STORAGE_KEY, playerId);
|
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||||
return playerId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new UUID if no existing ID found
|
// Generate new base player ID if no existing one found
|
||||||
playerId = generatePlayerId();
|
if (!basePlayerId) {
|
||||||
localStorage.setItem(STORAGE_KEY, playerId);
|
basePlayerId = generateBasePlayerId();
|
||||||
|
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return playerId;
|
// Combine base player ID with device ID
|
||||||
|
return combinePlayerId(basePlayerId, deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,31 +171,53 @@ export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<strin
|
|||||||
*
|
*
|
||||||
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
|
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
|
||||||
*
|
*
|
||||||
* @returns Player identifier (UUID v4)
|
* @returns Full player identifier ({basePlayerId}:{deviceId})
|
||||||
*/
|
*/
|
||||||
export function getOrCreatePlayerId(): string {
|
export function getOrCreatePlayerId(): string {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
// Server-side: return empty string (not used on server)
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
let playerId = localStorage.getItem(STORAGE_KEY);
|
const deviceId = getOrCreateDeviceId();
|
||||||
if (!playerId) {
|
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||||
playerId = generatePlayerId();
|
|
||||||
localStorage.setItem(STORAGE_KEY, playerId);
|
if (!basePlayerId) {
|
||||||
|
basePlayerId = generateBasePlayerId();
|
||||||
|
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||||
}
|
}
|
||||||
return playerId;
|
|
||||||
|
return combinePlayerId(basePlayerId, deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current player identifier without creating a new one
|
* Get the current player identifier without creating a new one
|
||||||
*
|
*
|
||||||
* @returns Player identifier or null if not set
|
* @returns Full player identifier ({basePlayerId}:{deviceId}) or null if not set
|
||||||
*/
|
*/
|
||||||
export function getPlayerId(): string | null {
|
export function getPlayerId(): string | null {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return localStorage.getItem(STORAGE_KEY);
|
|
||||||
|
const deviceId = getDeviceId();
|
||||||
|
const basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||||
|
|
||||||
|
if (!deviceId || !basePlayerId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return combinePlayerId(basePlayerId, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get base player ID (for debugging/logging)
|
||||||
|
*
|
||||||
|
* @returns Base player ID or null if not set
|
||||||
|
*/
|
||||||
|
export function getBasePlayerId(): string | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ export async function savePlayerState(
|
|||||||
statistics: Statistics
|
statistics: Statistics
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const playerId = getOrCreatePlayerId();
|
// Use async version to ensure device ID is included
|
||||||
|
const { getOrCreatePlayerIdAsync } = await import('./playerId');
|
||||||
|
const playerId = await getOrCreatePlayerIdAsync();
|
||||||
if (!playerId) {
|
if (!playerId) {
|
||||||
console.warn('[playerStorage] No player ID available, cannot save state');
|
console.warn('[playerStorage] No player ID available, cannot save state');
|
||||||
return;
|
return;
|
||||||
|
|||||||
94
lib/politicalStatements.ts
Normal file
94
lib/politicalStatements.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { PrismaClient, PoliticalStatement as PrismaPoliticalStatement } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export type PoliticalStatement = {
|
||||||
|
id: number;
|
||||||
|
locale: string;
|
||||||
|
text: string;
|
||||||
|
active: boolean;
|
||||||
|
source?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapFromPrisma(stmt: PrismaPoliticalStatement): PoliticalStatement {
|
||||||
|
return {
|
||||||
|
id: stmt.id,
|
||||||
|
locale: stmt.locale,
|
||||||
|
text: stmt.text,
|
||||||
|
active: stmt.active,
|
||||||
|
source: stmt.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
|
||||||
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
|
const all = await prisma.politicalStatement.findMany({
|
||||||
|
where: {
|
||||||
|
locale: safeLocale,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (all.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = Math.floor(Math.random() * all.length);
|
||||||
|
return mapFromPrisma(all[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllStatements(locale: string): Promise<PoliticalStatement[]> {
|
||||||
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
|
const all = await prisma.politicalStatement.findMany({
|
||||||
|
where: { locale: safeLocale },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
return all.map(mapFromPrisma);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStatement(locale: string, input: Omit<PoliticalStatement, 'id' | 'locale'>): Promise<PoliticalStatement> {
|
||||||
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
|
const created = await prisma.politicalStatement.create({
|
||||||
|
data: {
|
||||||
|
locale: safeLocale,
|
||||||
|
text: input.text,
|
||||||
|
active: input.active ?? true,
|
||||||
|
source: input.source ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapFromPrisma(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id' | 'locale'>>): Promise<PoliticalStatement | null> {
|
||||||
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
|
|
||||||
|
// Optional: sicherstellen, dass das Statement zu dieser Locale gehört
|
||||||
|
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.locale !== safeLocale) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.politicalStatement.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
text: input.text ?? existing.text,
|
||||||
|
active: input.active ?? existing.active,
|
||||||
|
source: input.source !== undefined ? input.source : existing.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapFromPrisma(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteStatement(locale: string, id: number): Promise<boolean> {
|
||||||
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
|
|
||||||
|
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.locale !== safeLocale) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.politicalStatement.delete({ where: { id } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
215
messages/de.json
215
messages/de.json
@@ -41,12 +41,14 @@
|
|||||||
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
|
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
|
||||||
"theSongWas": "Das Lied war:",
|
"theSongWas": "Das Lied war:",
|
||||||
"score": "Punkte",
|
"score": "Punkte",
|
||||||
|
"shareExplanation": "Teile dein Ergebnis mit Freund:innen – so hilfst du, Hördle bekannter zu machen.",
|
||||||
"scoreBreakdown": "Punkteaufschlüsselung",
|
"scoreBreakdown": "Punkteaufschlüsselung",
|
||||||
"albumCover": "Album-Cover",
|
"albumCover": "Album-Cover",
|
||||||
"released": "Veröffentlicht",
|
"released": "Veröffentlicht",
|
||||||
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
|
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
|
||||||
"thanksForRating": "Danke für die Bewertung!",
|
"thanksForRating": "Danke für die Bewertung!",
|
||||||
"rateThisPuzzle": "Bewerte dieses Rätsel:",
|
"rateThisPuzzle": "Bewerte dieses Rätsel:",
|
||||||
|
"ratingTooltip": "Hilf unseren Kuratoren, gute Rätsel zu machen!",
|
||||||
"shared": "✓ Geteilt!",
|
"shared": "✓ Geteilt!",
|
||||||
"copied": "✓ Kopiert!",
|
"copied": "✓ Kopiert!",
|
||||||
"shareFailed": "✗ Fehlgeschlagen",
|
"shareFailed": "✗ Fehlgeschlagen",
|
||||||
@@ -55,6 +57,13 @@
|
|||||||
"points": "Punkte",
|
"points": "Punkte",
|
||||||
"skipBonus": "Bonus überspringen",
|
"skipBonus": "Bonus überspringen",
|
||||||
"notQuite": "Nicht ganz!",
|
"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",
|
"youGuessed": "Du hast geraten",
|
||||||
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
||||||
"skipped": "Übersprungen",
|
"skipped": "Übersprungen",
|
||||||
@@ -63,6 +72,12 @@
|
|||||||
"special": "Special",
|
"special": "Special",
|
||||||
"genre": "Genre"
|
"genre": "Genre"
|
||||||
},
|
},
|
||||||
|
"ExtraPuzzles": {
|
||||||
|
"title": "Noch nicht genug Rätsel?",
|
||||||
|
"message": "Hey, hast du Lust auf weitere Rätsel? Dann schau doch mal bei {name} vorbei!",
|
||||||
|
"cta": "Zu {name}",
|
||||||
|
"close": "Schließen"
|
||||||
|
},
|
||||||
"Statistics": {
|
"Statistics": {
|
||||||
"yourStatistics": "Deine Statistiken",
|
"yourStatistics": "Deine Statistiken",
|
||||||
"totalPuzzles": "Gesamte Rätsel",
|
"totalPuzzles": "Gesamte Rätsel",
|
||||||
@@ -150,9 +165,198 @@
|
|||||||
"artist": "Interpret",
|
"artist": "Interpret",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"deletePuzzle": "Löschen",
|
"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."
|
||||||
},
|
},
|
||||||
"About": {
|
"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"
|
||||||
|
},
|
||||||
|
"CuratorHelp": {
|
||||||
|
"title": "Kurator-Hilfe & Handbuch",
|
||||||
|
"backToDashboard": "Zurück zum Dashboard",
|
||||||
|
"helpButton": "Hilfe",
|
||||||
|
"modalTitle": "Hilfe",
|
||||||
|
"introductionTitle": "Einführung",
|
||||||
|
"introductionText": "Als Kurator bist du verantwortlich für die Verwaltung von Songs in deinen zugewiesenen Genres und Specials. Dieses Dashboard ermöglicht es dir, Musik für das Hördle-Spiel hochzuladen, zu bearbeiten und zu organisieren.",
|
||||||
|
"permissionsTitle": "Deine Berechtigungen",
|
||||||
|
"permission1": "MP3-Dateien hochladen und deinen Genres zuordnen",
|
||||||
|
"permission2": "Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind",
|
||||||
|
"permission3": "Songs löschen, die ausschließlich deinen Genres/Specials zugeordnet sind",
|
||||||
|
"permission4": "Kommentare von Spielern zu deinen Rätseln einsehen und verwalten",
|
||||||
|
"note": "Hinweis",
|
||||||
|
"permissionNote": "Du kannst nur Songs bearbeiten oder löschen, die deinen Genres/Specials zugeordnet sind. Songs, die anderen Kuratoren zugeordnet sind, kannst du nicht ändern.",
|
||||||
|
"uploadTitle": "Songs hochladen",
|
||||||
|
"uploadStepsTitle": "Schritt-für-Schritt-Anleitung",
|
||||||
|
"uploadStep1": "MP3-Dateien in den Upload-Bereich ziehen oder klicken, um Dateien auszuwählen",
|
||||||
|
"uploadStep2": "Ein oder mehrere Genres auswählen, um sie den hochgeladenen Songs zuzuordnen",
|
||||||
|
"uploadStep3": "Auf 'Upload starten' klicken, um den Upload-Prozess zu beginnen",
|
||||||
|
"uploadStep4": "Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus den Dateien",
|
||||||
|
"uploadBestPracticesTitle": "Best Practices",
|
||||||
|
"uploadBestPractice1": "Stelle sicher, dass MP3-Dateien korrekte ID3-Tags (Titel, Artist) für die automatische Metadaten-Extraktion haben",
|
||||||
|
"uploadBestPractice2": "Passende Genres vor dem Upload auswählen, um spätere manuelle Zuordnung zu vermeiden",
|
||||||
|
"uploadBestPractice3": "Vor dem Upload auf Duplikate prüfen - das System warnt dich, wenn ein Song bereits existiert",
|
||||||
|
"tip": "Tipp",
|
||||||
|
"uploadTip": "Alle von Kuratoren hochgeladenen Songs werden automatisch von der globalen Playlist ausgeschlossen. Nur Admins können diese Einstellung ändern.",
|
||||||
|
"editingTitle": "Songs bearbeiten",
|
||||||
|
"singleEditTitle": "Einzelne Song-Bearbeitung",
|
||||||
|
"singleEditText": "Klicke auf den Bearbeiten-Button (✏️) neben einem Song, um Titel, Artist, Erscheinungsjahr, Genres, Specials oder das Exclude-Global-Flag zu ändern. Nur Songs, die du bearbeiten kannst, haben einen aktiven Bearbeiten-Button.",
|
||||||
|
"batchEditTitle": "Batch-Bearbeitung",
|
||||||
|
"batchEditText": "Wähle mehrere Songs über die Checkboxen aus, dann nutze die Batch-Bearbeitungs-Toolbar, um Änderungen auf alle ausgewählten Songs gleichzeitig anzuwenden:",
|
||||||
|
"batchEditFeature1": "Genre Toggle: Genres zu allen ausgewählten Songs hinzufügen oder entfernen",
|
||||||
|
"batchEditFeature2": "Special Toggle: Specials zu allen ausgewählten Songs hinzufügen oder entfernen",
|
||||||
|
"batchEditFeature3": "Artist ändern: Gleichen Artist-Namen für alle ausgewählten Songs setzen",
|
||||||
|
"batchEditFeature4": "Exclude Global Flag: Exclude-Global-Flag setzen oder entfernen (nur Global-Kuratoren)",
|
||||||
|
"genreSpecialAssignmentTitle": "Genre & Special Zuordnung",
|
||||||
|
"genreSpecialAssignmentText": "Du kannst Songs nur Genres und Specials zuordnen, für die du verantwortlich bist. Songs können mehrere Genres und Specials haben. Beim Bearbeiten kannst du Zuordnungen umschalten - wenn ein Genre/Special bereits zugeordnet ist, wird es entfernt; wenn nicht, wird es hinzugefügt.",
|
||||||
|
"commentsTitle": "Kommentare verwalten",
|
||||||
|
"commentsText": "Spieler können dir Feedback zu Rätseln in deinen Genres oder Specials senden. Kommentare erscheinen in deinem Dashboard mit einem Badge für ungelesene Nachrichten.",
|
||||||
|
"commentsActionsTitle": "Verfügbare Aktionen",
|
||||||
|
"markAsRead": "Als gelesen markieren",
|
||||||
|
"markAsReadText": "Klicke auf einen Kommentar, um ihn als gelesen zu markieren. Gelesene Kommentare werden mit einem grauen Rahmen angezeigt.",
|
||||||
|
"archive": "Archivieren",
|
||||||
|
"archiveText": "Archiviere Kommentare, die du nicht mehr benötigst. Archivierte Kommentare werden aus deiner Ansicht entfernt.",
|
||||||
|
"bestPracticesTitle": "Best Practices für Kuratoren",
|
||||||
|
"bestPractice1": "Metadaten korrekt halten: Stelle sicher, dass Song-Titel und Artist-Namen korrekt und konsistent sind",
|
||||||
|
"bestPractice2": "Passende Genres verwenden: Ordne Songs den relevantesten Genres zu, um Spielern zu helfen, Musik zu entdecken",
|
||||||
|
"bestPractice3": "Auf Kommentare reagieren: Prüfe Kommentare regelmäßig und berücksichtige Spieler-Feedback beim Kuratieren",
|
||||||
|
"bestPractice4": "Qualität wahren: Überprüfe hochgeladene Songs auf Audio-Qualität und Metadaten-Genauigkeit",
|
||||||
|
"bestPractice5": "Batch-Bearbeitung effizient nutzen: Wenn du ähnliche Änderungen an mehreren Songs vornehmen musst, nutze die Batch-Bearbeitung, um Zeit zu sparen",
|
||||||
|
"troubleshootingTitle": "Troubleshooting",
|
||||||
|
"troubleshootingQ1": "Warum kann ich einen Song nicht bearbeiten?",
|
||||||
|
"troubleshootingA1": "Du kannst nur Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Wenn ein Song keine Genres/Specials zugeordnet hat, kannst du ihn bearbeiten. Wenn er nur anderen Kuratoren zugeordnet ist, kannst du ihn nicht bearbeiten.",
|
||||||
|
"troubleshootingQ2": "Warum kann ich einen Song nicht löschen?",
|
||||||
|
"troubleshootingA2": "Du kannst nur Songs löschen, die ausschließlich deinen Genres/Specials zugeordnet sind. Wenn ein Song Genres oder Specials hat, die anderen Kuratoren zugeordnet sind, kannst du ihn nicht löschen.",
|
||||||
|
"troubleshootingQ3": "Warum kann ich ein Genre/Special nicht zuordnen?",
|
||||||
|
"troubleshootingA3": "Du kannst Songs nur Genres und Specials zuordnen, für die du verantwortlich bist. Wende dich an den Admin, wenn du Zugriff auf zusätzliche Genres oder Specials benötigst.",
|
||||||
|
"troubleshootingQ4": "Warum ist die Exclude-Global-Checkbox deaktiviert?",
|
||||||
|
"troubleshootingA4": "Nur Global-Kuratoren können das Exclude-Global-Flag ändern. Wenn du diese Berechtigung benötigst, wende dich an den Admin.",
|
||||||
|
"tooltipDashboardShort": "Übersicht über dein Kuratoren-Dashboard",
|
||||||
|
"tooltipDashboardLong": "Dies ist dein Haupt-Dashboard, wo du Songs hochladen, deine Track-Liste verwalten und Kommentare von Spielern einsehen kannst. Nutze den Hilfe-Button (❓), um auf das vollständige Handbuch zuzugreifen.",
|
||||||
|
"tooltipUploadShort": "MP3-Dateien zu deinen Genres hochladen",
|
||||||
|
"tooltipUploadLong": "Ziehe MP3-Dateien per Drag & Drop oder klicke, um sie auszuwählen. Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus ID3-Tags. Wähle Genres vor dem Upload aus, um Songs automatisch zuzuordnen. Alle Kuratoren-Uploads sind standardmäßig von der globalen Playlist ausgeschlossen.",
|
||||||
|
"tooltipGenreAssignmentShort": "Genres zu hochgeladenen Songs zuordnen",
|
||||||
|
"tooltipGenreAssignmentLong": "Wähle ein oder mehrere Genres vor dem Upload aus. Die ausgewählten Genres werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Genres zuordnen, für die du verantwortlich bist. Wenn du keine Genres auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.",
|
||||||
|
"tooltipTracklistShort": "Deine Songs verwalten",
|
||||||
|
"tooltipTracklistLong": "Diese Tabelle zeigt alle Songs in deinen Genres und Specials. Du kannst suchen, filtern, sortieren und Songs bearbeiten. Nutze die Checkboxen, um mehrere Songs für die Batch-Bearbeitung auszuwählen. Nur Songs, die du bearbeiten kannst, haben eine aktive Checkbox.",
|
||||||
|
"tooltipSearchShort": "Nach Titel oder Artist suchen",
|
||||||
|
"tooltipSearchLong": "Tippe in das Suchfeld, um Songs nach Titel oder Artist-Namen zu filtern. Die Suche ist nicht case-sensitive und findet Teiltexte. Leere das Suchfeld, um alle Songs wieder anzuzeigen.",
|
||||||
|
"tooltipFilterShort": "Nach Genre, Special oder Global-Flag filtern",
|
||||||
|
"tooltipFilterLong": "Nutze das Filter-Dropdown, um nur Songs aus einem bestimmten Genre, Special oder Songs, die von der globalen Playlist ausgeschlossen sind, anzuzeigen. Kombiniere mit der Suche für präzisere Filterung.",
|
||||||
|
"tooltipBatchEditShort": "Mehrere Songs gleichzeitig bearbeiten",
|
||||||
|
"tooltipBatchEditLong": "Wähle mehrere Songs über Checkboxen aus, dann nutze die Batch-Bearbeitungs-Toolbar, um Änderungen auf alle ausgewählten Songs gleichzeitig anzuwenden. Du kannst Genres/Specials umschalten, Artist-Namen ändern oder das Exclude-Global-Flag ändern (nur Global-Kuratoren).",
|
||||||
|
"tooltipBatchGenreToggleShort": "Genres hinzufügen oder entfernen",
|
||||||
|
"tooltipBatchGenreToggleLong": "Wähle Genres zum Umschalten aus. Wenn ein ausgewählter Song das Genre bereits hat, wird es entfernt. Wenn nicht, wird es hinzugefügt. Dies ermöglicht es dir, schnell Genres zu mehreren Songs hinzuzufügen oder zu entfernen.",
|
||||||
|
"tooltipBatchSpecialToggleShort": "Specials hinzufügen oder entfernen",
|
||||||
|
"tooltipBatchSpecialToggleLong": "Wähle Specials zum Umschalten aus. Wenn ein ausgewählter Song das Special bereits hat, wird es entfernt. Wenn nicht, wird es hinzugefügt. Du kannst nur Specials umschalten, für die du verantwortlich bist.",
|
||||||
|
"tooltipBatchArtistShort": "Artist für alle ausgewählten Songs ändern",
|
||||||
|
"tooltipBatchArtistLong": "Gib einen neuen Artist-Namen ein, um ihn für alle ausgewählten Songs zu setzen. Dies ist nützlich, um Artist-Namen zu korrigieren oder Namenskonventionen über mehrere Songs hinweg zu standardisieren.",
|
||||||
|
"tooltipCommentsShort": "Spieler-Feedback und Kommentare",
|
||||||
|
"tooltipCommentsLong": "Spieler können dir Nachrichten zu Rätseln in deinen Genres oder Specials senden. Ungelesene Kommentare sind mit einem blauen Rahmen und Badge hervorgehoben. Klicke auf einen Kommentar, um ihn als gelesen zu markieren, oder archiviere ihn, wenn du ihn nicht mehr benötigst."
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
"title": "Über Hördle & Impressum",
|
"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.",
|
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
|
||||||
"projectTitle": "Über dieses Projekt",
|
"projectTitle": "Über dieses Projekt",
|
||||||
@@ -164,12 +368,13 @@
|
|||||||
"imprintEmailLabel": "E-Mail:",
|
"imprintEmailLabel": "E-Mail:",
|
||||||
"costsTitle": "Laufende Kosten des Projekts",
|
"costsTitle": "Laufende Kosten des Projekts",
|
||||||
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
||||||
|
"costsDonationNote": "Alle Einnahmen, die die Betriebskosten des Projekts übersteigen, werden am Jahresende an die Aktion <link>Zentrum für politische Schönheit</link> gespendet.",
|
||||||
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
||||||
"costsServer": "Server / vServer für App und Tracking",
|
"costsServer": "Server / vServer für App und Tracking",
|
||||||
"costsEmail": "E-Mail-Hosting",
|
"costsEmail": "E-Mail-Hosting",
|
||||||
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
|
"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>.",
|
"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",
|
"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:",
|
"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)",
|
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",
|
||||||
@@ -179,6 +384,10 @@
|
|||||||
"supportPaypalLink": "paypal.me/MBusche",
|
"supportPaypalLink": "paypal.me/MBusche",
|
||||||
"supportSteadyTitle": "Steady",
|
"supportSteadyTitle": "Steady",
|
||||||
"supportSteadyDescription": "Regelmäßige Unterstützung über Steady",
|
"supportSteadyDescription": "Regelmäßige Unterstützung über Steady",
|
||||||
|
"supportCuratorTitle": "Als Kurator bewerben",
|
||||||
|
"supportCuratorText": "Du hast gute Kenntnisse in einem Genre und möchtest dich als Kurator bewerben? Wir freuen uns über deine Nachricht!",
|
||||||
|
"supportReportBugTitle": "Fehler melden",
|
||||||
|
"supportReportBugText": "Fehler in der App gefunden? Bitte melde sie per E-Mail an <email>admin@hoerdle.de</email>.",
|
||||||
"privacyTitle": "Datenschutz",
|
"privacyTitle": "Datenschutz",
|
||||||
"privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.",
|
"privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.",
|
||||||
"privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics",
|
"privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics",
|
||||||
|
|||||||
213
messages/en.json
213
messages/en.json
@@ -41,12 +41,14 @@
|
|||||||
"comeBackTomorrow": "Come back tomorrow for a new song.",
|
"comeBackTomorrow": "Come back tomorrow for a new song.",
|
||||||
"theSongWas": "The song was:",
|
"theSongWas": "The song was:",
|
||||||
"score": "Score",
|
"score": "Score",
|
||||||
|
"shareExplanation": "Share your result with friends – your support helps Hördle grow.",
|
||||||
"scoreBreakdown": "Score Breakdown",
|
"scoreBreakdown": "Score Breakdown",
|
||||||
"albumCover": "Album Cover",
|
"albumCover": "Album Cover",
|
||||||
"released": "Released",
|
"released": "Released",
|
||||||
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
|
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
|
||||||
"thanksForRating": "Thanks for rating!",
|
"thanksForRating": "Thanks for rating!",
|
||||||
"rateThisPuzzle": "Rate this puzzle:",
|
"rateThisPuzzle": "Rate this puzzle:",
|
||||||
|
"ratingTooltip": "Help our curators create good puzzles!",
|
||||||
"shared": "✓ Shared!",
|
"shared": "✓ Shared!",
|
||||||
"copied": "✓ Copied!",
|
"copied": "✓ Copied!",
|
||||||
"shareFailed": "✗ Failed",
|
"shareFailed": "✗ Failed",
|
||||||
@@ -55,6 +57,13 @@
|
|||||||
"points": "points",
|
"points": "points",
|
||||||
"skipBonus": "Skip Bonus",
|
"skipBonus": "Skip Bonus",
|
||||||
"notQuite": "Not quite!",
|
"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",
|
"youGuessed": "You guessed",
|
||||||
"actuallyReleasedIn": "Actually released in",
|
"actuallyReleasedIn": "Actually released in",
|
||||||
"skipped": "Skipped",
|
"skipped": "Skipped",
|
||||||
@@ -63,6 +72,12 @@
|
|||||||
"special": "Special",
|
"special": "Special",
|
||||||
"genre": "Genre"
|
"genre": "Genre"
|
||||||
},
|
},
|
||||||
|
"ExtraPuzzles": {
|
||||||
|
"title": "Still in the mood for puzzles?",
|
||||||
|
"message": "Hey, would you like to try some more puzzles? Then take a look at {name}!",
|
||||||
|
"cta": "Go to {name}",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
"Statistics": {
|
"Statistics": {
|
||||||
"yourStatistics": "Your Statistics",
|
"yourStatistics": "Your Statistics",
|
||||||
"totalPuzzles": "Total puzzles",
|
"totalPuzzles": "Total puzzles",
|
||||||
@@ -150,8 +165,197 @@
|
|||||||
"artist": "Artist",
|
"artist": "Artist",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"deletePuzzle": "Delete",
|
"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"
|
||||||
|
},
|
||||||
|
"CuratorHelp": {
|
||||||
|
"title": "Curator Help & Manual",
|
||||||
|
"backToDashboard": "Back to Dashboard",
|
||||||
|
"helpButton": "Help",
|
||||||
|
"modalTitle": "Help",
|
||||||
|
"introductionTitle": "Introduction",
|
||||||
|
"introductionText": "As a curator, you are responsible for managing songs within your assigned genres and specials. This dashboard allows you to upload, edit, and organize music for the Hördle game.",
|
||||||
|
"permissionsTitle": "Your Permissions",
|
||||||
|
"permission1": "Upload MP3 files and assign them to your genres",
|
||||||
|
"permission2": "Edit songs that are assigned to at least one of your genres or specials",
|
||||||
|
"permission3": "Delete songs that are exclusively assigned to your genres/specials",
|
||||||
|
"permission4": "View and manage comments from players about your puzzles",
|
||||||
|
"note": "Note",
|
||||||
|
"permissionNote": "You can only edit or delete songs that are assigned to your genres/specials. Songs assigned to other curators' genres cannot be modified by you.",
|
||||||
|
"uploadTitle": "Uploading Songs",
|
||||||
|
"uploadStepsTitle": "Step-by-Step Guide",
|
||||||
|
"uploadStep1": "Drag MP3 files into the upload area or click to select files",
|
||||||
|
"uploadStep2": "Select one or more genres to assign to the uploaded songs",
|
||||||
|
"uploadStep3": "Click 'Start upload' to begin the upload process",
|
||||||
|
"uploadStep4": "The system will automatically extract metadata (title, artist, release year) from the files",
|
||||||
|
"uploadBestPracticesTitle": "Best Practices",
|
||||||
|
"uploadBestPractice1": "Ensure MP3 files have correct ID3 tags (title, artist) for automatic metadata extraction",
|
||||||
|
"uploadBestPractice2": "Select appropriate genres before uploading to avoid manual assignment later",
|
||||||
|
"uploadBestPractice3": "Check for duplicates before uploading - the system will warn you if a song already exists",
|
||||||
|
"tip": "Tip",
|
||||||
|
"uploadTip": "All songs uploaded by curators are automatically excluded from the global playlist. Only admins can change this setting.",
|
||||||
|
"editingTitle": "Editing Songs",
|
||||||
|
"singleEditTitle": "Single Song Editing",
|
||||||
|
"singleEditText": "Click the edit button (✏️) next to a song to modify its title, artist, release year, genres, specials, or exclude-from-global flag. Only songs you can edit will have an active edit button.",
|
||||||
|
"batchEditTitle": "Batch Editing",
|
||||||
|
"batchEditText": "Select multiple songs using the checkboxes, then use the batch edit toolbar to apply changes to all selected songs at once:",
|
||||||
|
"batchEditFeature1": "Genre Toggle: Add or remove genres from all selected songs",
|
||||||
|
"batchEditFeature2": "Special Toggle: Add or remove specials from all selected songs",
|
||||||
|
"batchEditFeature3": "Artist Change: Set the same artist name for all selected songs",
|
||||||
|
"batchEditFeature4": "Exclude Global Flag: Set or remove the exclude-from-global flag (Global Curators only)",
|
||||||
|
"genreSpecialAssignmentTitle": "Genre & Special Assignment",
|
||||||
|
"genreSpecialAssignmentText": "You can only assign songs to genres and specials that you are responsible for. Songs can have multiple genres and specials. When editing, you can toggle assignments - if a genre/special is already assigned, it will be removed; if not, it will be added.",
|
||||||
|
"commentsTitle": "Managing Comments",
|
||||||
|
"commentsText": "Players can send you feedback about puzzles in your genres or specials. Comments appear in your dashboard with a badge showing unread messages.",
|
||||||
|
"commentsActionsTitle": "Available Actions",
|
||||||
|
"markAsRead": "Mark as Read",
|
||||||
|
"markAsReadText": "Click on a comment to mark it as read. Read comments are displayed with a gray border.",
|
||||||
|
"archive": "Archive",
|
||||||
|
"archiveText": "Archive comments you no longer need. Archived comments are removed from your view.",
|
||||||
|
"bestPracticesTitle": "Best Practices for Curators",
|
||||||
|
"bestPractice1": "Keep metadata accurate: Ensure song titles and artist names are correct and consistent",
|
||||||
|
"bestPractice2": "Use appropriate genres: Assign songs to the most relevant genres to help players discover music",
|
||||||
|
"bestPractice3": "Respond to comments: Check comments regularly and consider player feedback when curating",
|
||||||
|
"bestPractice4": "Maintain quality: Review uploaded songs for audio quality and metadata accuracy",
|
||||||
|
"bestPractice5": "Use batch editing efficiently: When making similar changes to multiple songs, use batch edit to save time",
|
||||||
|
"troubleshootingTitle": "Troubleshooting",
|
||||||
|
"troubleshootingQ1": "Why can't I edit a song?",
|
||||||
|
"troubleshootingA1": "You can only edit songs that are assigned to at least one of your genres or specials. If a song has no genres/specials assigned, you can edit it. If it's assigned to other curators' genres only, you cannot edit it.",
|
||||||
|
"troubleshootingQ2": "Why can't I delete a song?",
|
||||||
|
"troubleshootingA2": "You can only delete songs that are exclusively assigned to your genres/specials. If a song has any genres or specials assigned to other curators, you cannot delete it.",
|
||||||
|
"troubleshootingQ3": "Why can't I assign a genre/special?",
|
||||||
|
"troubleshootingA3": "You can only assign songs to genres and specials that you are responsible for. Contact the admin if you need access to additional genres or specials.",
|
||||||
|
"troubleshootingQ4": "Why is the exclude-from-global checkbox disabled?",
|
||||||
|
"troubleshootingA4": "Only Global Curators can change the exclude-from-global flag. If you need this permission, contact the admin.",
|
||||||
|
"tooltipDashboardShort": "Overview of your curator dashboard",
|
||||||
|
"tooltipDashboardLong": "This is your main dashboard where you can upload songs, manage your track list, and view comments from players. Use the help button (❓) to access the full manual.",
|
||||||
|
"tooltipUploadShort": "Upload MP3 files to your genres",
|
||||||
|
"tooltipUploadLong": "Drag and drop MP3 files or click to select. The system will automatically extract metadata (title, artist, release year) from ID3 tags. Select genres before uploading to automatically assign songs. All curator uploads are excluded from the global playlist by default.",
|
||||||
|
"tooltipGenreAssignmentShort": "Assign genres to uploaded songs",
|
||||||
|
"tooltipGenreAssignmentLong": "Select one or more genres before uploading. The selected genres will be assigned to all successfully uploaded songs. You can only assign genres that you are responsible for. If you don't select any genres, you can assign them later by editing the songs.",
|
||||||
|
"tooltipTracklistShort": "Manage your songs",
|
||||||
|
"tooltipTracklistLong": "This table shows all songs in your genres and specials. You can search, filter, sort, and edit songs. Use the checkboxes to select multiple songs for batch editing. Only songs you can edit will have an active checkbox.",
|
||||||
|
"tooltipSearchShort": "Search by title or artist",
|
||||||
|
"tooltipSearchLong": "Type in the search box to filter songs by title or artist name. The search is case-insensitive and matches partial text. Clear the search to show all songs again.",
|
||||||
|
"tooltipFilterShort": "Filter by genre, special, or global flag",
|
||||||
|
"tooltipFilterLong": "Use the filter dropdown to show only songs from a specific genre, special, or songs excluded from the global playlist. Combine with search for more precise filtering.",
|
||||||
|
"tooltipBatchEditShort": "Edit multiple songs at once",
|
||||||
|
"tooltipBatchEditLong": "Select multiple songs using checkboxes, then use the batch edit toolbar to apply changes to all selected songs simultaneously. You can toggle genres/specials, change artist names, or modify the exclude-from-global flag (Global Curators only).",
|
||||||
|
"tooltipBatchGenreToggleShort": "Add or remove genres",
|
||||||
|
"tooltipBatchGenreToggleLong": "Select genres to toggle. If a selected song already has the genre, it will be removed. If it doesn't have the genre, it will be added. This allows you to quickly add or remove genres from multiple songs at once.",
|
||||||
|
"tooltipBatchSpecialToggleShort": "Add or remove specials",
|
||||||
|
"tooltipBatchSpecialToggleLong": "Select specials to toggle. If a selected song already has the special, it will be removed. If it doesn't have the special, it will be added. You can only toggle specials you are responsible for.",
|
||||||
|
"tooltipBatchArtistShort": "Change artist for all selected songs",
|
||||||
|
"tooltipBatchArtistLong": "Enter a new artist name to set it for all selected songs. This is useful for correcting artist names or standardizing naming conventions across multiple songs.",
|
||||||
|
"tooltipCommentsShort": "Player feedback and comments",
|
||||||
|
"tooltipCommentsLong": "Players can send you messages about puzzles in your genres or specials. Unread comments are highlighted with a blue border and badge. Click on a comment to mark it as read, or archive it if you no longer need it."
|
||||||
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "About Hördle & Imprint",
|
"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.",
|
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",
|
||||||
@@ -164,12 +368,13 @@
|
|||||||
"imprintEmailLabel": "Email:",
|
"imprintEmailLabel": "Email:",
|
||||||
"costsTitle": "Ongoing costs of the project",
|
"costsTitle": "Ongoing costs of the project",
|
||||||
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
||||||
|
"costsDonationNote": "All income that exceeds the operating costs of the project will be donated at the end of the year to the campaign <link>Zentrum für politische Schönheit</link>.",
|
||||||
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
||||||
"costsServer": "Servers / vServers for the app and tracking",
|
"costsServer": "Servers / vServers for the app and tracking",
|
||||||
"costsEmail": "Email hosting",
|
"costsEmail": "Email hosting",
|
||||||
"costsLicenses": "Possible fees for copyrights or other licenses",
|
"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>.",
|
"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",
|
"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:",
|
"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)",
|
"supportSepaTitle": "SEPA Bank Transfer (preferred)",
|
||||||
@@ -179,6 +384,10 @@
|
|||||||
"supportPaypalLink": "paypal.me/MBusche",
|
"supportPaypalLink": "paypal.me/MBusche",
|
||||||
"supportSteadyTitle": "Steady",
|
"supportSteadyTitle": "Steady",
|
||||||
"supportSteadyDescription": "Regular support via Steady",
|
"supportSteadyDescription": "Regular support via Steady",
|
||||||
|
"supportCuratorTitle": "Apply as Curator",
|
||||||
|
"supportCuratorText": "Do you have good knowledge in a genre and would like to apply as a curator? We'd be happy to hear from you!",
|
||||||
|
"supportReportBugTitle": "Report Bugs",
|
||||||
|
"supportReportBugText": "Found a bug in the app? Please report it via email to <email>admin@hoerdle.de</email>.",
|
||||||
"privacyTitle": "Privacy",
|
"privacyTitle": "Privacy",
|
||||||
"privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.",
|
"privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.",
|
||||||
"privacyPlausibleTitle": "Self-hosted Plausible Analytics",
|
"privacyPlausibleTitle": "Self-hosted Plausible Analytics",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.4.2",
|
"version": "0.1.6.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PoliticalStatement" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"locale" TEXT NOT NULL,
|
||||||
|
"text" TEXT NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"source" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PoliticalStatement_locale_active_idx" ON "PoliticalStatement"("locale", "active");
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CuratorCommentRecipient" ADD COLUMN "archived" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@ model Genre {
|
|||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
songs Song[]
|
songs Song[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
|
curatorGenres CuratorGenre[]
|
||||||
|
comments CuratorComment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Special {
|
model Special {
|
||||||
@@ -48,6 +50,7 @@ model Special {
|
|||||||
songs SpecialSong[]
|
songs SpecialSong[]
|
||||||
puzzles DailyPuzzle[]
|
puzzles DailyPuzzle[]
|
||||||
news News[]
|
news News[]
|
||||||
|
curatorSpecials CuratorSpecial[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model SpecialSong {
|
model SpecialSong {
|
||||||
@@ -71,6 +74,7 @@ model DailyPuzzle {
|
|||||||
genre Genre? @relation(fields: [genreId], references: [id])
|
genre Genre? @relation(fields: [genreId], references: [id])
|
||||||
specialId Int?
|
specialId Int?
|
||||||
special Special? @relation(fields: [specialId], references: [id])
|
special Special? @relation(fields: [specialId], references: [id])
|
||||||
|
comments CuratorComment[]
|
||||||
|
|
||||||
@@unique([date, genreId, specialId])
|
@@unique([date, genreId, specialId])
|
||||||
}
|
}
|
||||||
@@ -101,3 +105,79 @@ model PlayerState {
|
|||||||
@@unique([identifier, genreKey])
|
@@unique([identifier, genreKey])
|
||||||
@@index([identifier])
|
@@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
|
||||||
|
text String
|
||||||
|
active Boolean @default(true)
|
||||||
|
source String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@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
77
scripts/backup-restic.sh
Executable 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
38
scripts/deploy-remote.sh
Executable 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."
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "🚀 Starting optimized deployment..."
|
echo "🚀 Starting optimized deployment with full rollback support..."
|
||||||
|
|
||||||
# Backup database
|
# Backup database (per Deployment, inkl. Metadaten für Rollback)
|
||||||
echo "💾 Creating database backup..."
|
echo "💾 Creating database backup for this deployment..."
|
||||||
|
|
||||||
# Try to find database path from docker-compose.yml or .env
|
# Try to find database path from docker-compose.yml or .env
|
||||||
DB_PATH=""
|
DB_PATH=""
|
||||||
@@ -32,10 +32,23 @@ if [ -n "$DB_PATH" ]; then
|
|||||||
mkdir -p ./backups
|
mkdir -p ./backups
|
||||||
|
|
||||||
# Create timestamped backup
|
# 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"
|
cp "$DB_PATH" "$BACKUP_FILE"
|
||||||
echo "✅ Database backed up to: $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
|
# Keep only last 10 backups
|
||||||
ls -t ./backups/*.db | tail -n +11 | xargs -r rm
|
ls -t ./backups/*.db | tail -n +11 | xargs -r rm
|
||||||
echo "🧹 Cleaned old backups (keeping last 10)"
|
echo "🧹 Cleaned old backups (keeping last 10)"
|
||||||
@@ -46,13 +59,13 @@ else
|
|||||||
echo "⚠️ Could not determine database path from config files"
|
echo "⚠️ Could not determine database path from config files"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pull latest changes
|
# Restic backup to remote repository
|
||||||
echo "📥 Pulling latest changes from git..."
|
./scripts/backup-restic.sh
|
||||||
git pull
|
|
||||||
|
|
||||||
# Fetch all tags
|
# Nur neueste Version holen (shallow fetch), vollständiges Repo ist im Deployment nicht nötig
|
||||||
echo "🏷️ Fetching git tags..."
|
echo "📥 Fetching latest commit (shallow clone) from git..."
|
||||||
git fetch --tags
|
git fetch --prune --tags --depth=1 origin master
|
||||||
|
git reset --hard origin/master
|
||||||
|
|
||||||
# Prüfe und erstelle/repariere Netzwerk falls nötig
|
# Prüfe und erstelle/repariere Netzwerk falls nötig
|
||||||
echo "🌐 Prüfe Docker-Netzwerk..."
|
echo "🌐 Prüfe Docker-Netzwerk..."
|
||||||
|
|||||||
93
scripts/restore.sh
Normal file
93
scripts/restore.sh
Normal 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."
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user