Compare commits
82 Commits
v0.1.1
...
curator-he
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8c57e938e8 | ||
|
|
9eb07ee8d5 | ||
|
|
3eb6c7f5cf | ||
|
|
2846afb6f7 | ||
|
|
27fa689b18 | ||
|
|
61846a6982 | ||
|
|
bba6b9ef31 | ||
|
|
a8867ac42e | ||
|
|
9006b208af | ||
|
|
20c8ad7eaf | ||
|
|
03129a5611 | ||
|
|
fd8f4adcc0 | ||
|
|
23997ccc3a | ||
|
|
85bdbf795c | ||
|
|
ac0bb02ba0 | ||
|
|
63269c2600 | ||
|
|
17a39d677d | ||
|
|
1ff0787e4e | ||
|
|
ed5f02bdec | ||
|
|
e3a09864a6 | ||
|
|
107739ade9 |
@@ -50,6 +50,7 @@ Dockerfile*
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
/docs
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ next-env.d.ts
|
||||
.release-years-migrated
|
||||
.covers-migrated
|
||||
docker-compose.yml
|
||||
scripts/scrape-bahn-expert-statements.js
|
||||
docs/bahn-expert-statements.txt
|
||||
|
||||
@@ -38,6 +38,9 @@ RUN if [ -n "$APP_VERSION" ]; then \
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Suppress baseline-browser-mapping warning about old data (informational only)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Generate Prisma Client
|
||||
ENV DATABASE_URL="file:./dev.db"
|
||||
RUN node_modules/.bin/prisma generate
|
||||
@@ -46,8 +49,6 @@ RUN node_modules/.bin/prisma generate
|
||||
ARG NEXT_PUBLIC_APP_NAME
|
||||
ARG NEXT_PUBLIC_APP_DESCRIPTION
|
||||
ARG NEXT_PUBLIC_DOMAIN
|
||||
ARG NEXT_PUBLIC_TWITTER_HANDLE
|
||||
ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN
|
||||
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||
ARG NEXT_PUBLIC_THEME_COLOR
|
||||
ARG NEXT_PUBLIC_BACKGROUND_COLOR
|
||||
@@ -60,8 +61,6 @@ ARG NEXT_PUBLIC_CREDITS_LINK_URL
|
||||
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
|
||||
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
|
||||
ENV NEXT_PUBLIC_DOMAIN=$NEXT_PUBLIC_DOMAIN
|
||||
ENV NEXT_PUBLIC_TWITTER_HANDLE=$NEXT_PUBLIC_TWITTER_HANDLE
|
||||
ENV NEXT_PUBLIC_PLAUSIBLE_DOMAIN=$NEXT_PUBLIC_PLAUSIBLE_DOMAIN
|
||||
ENV NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=$NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||
ENV NEXT_PUBLIC_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
|
||||
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR
|
||||
|
||||
49
README.md
49
README.md
@@ -15,6 +15,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Bearbeitung von Metadaten.
|
||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
||||
- **Kuratoren-Verwaltung:** Erstellen und Verwalten von Kurator-Accounts mit Zuweisung zu Genres und Specials.
|
||||
- **Cover Art:**
|
||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||
@@ -42,7 +43,6 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Live-Vorschau beim Hovern über die Waveform.
|
||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Manuelle Speicherung mit visueller Bestätigung.
|
||||
- **News & Announcements:**
|
||||
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||
@@ -51,23 +51,42 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||
- Verwaltung über das Admin-Dashboard.
|
||||
- **Kurator-System:**
|
||||
- **Kurator-Accounts:** Separate Login-Accounts für Kuratoren (nicht Admins).
|
||||
- **Genre- & Special-Zuweisung:** Kuratoren können einzelnen Genres oder Specials zugewiesen werden.
|
||||
- **Global-Kuratoren:** Optionale globale Kuratoren, die für alle Rätsel zuständig sind.
|
||||
- **Kurator-Dashboard:** Eigene Dashboard-Seite (`/curator` oder `/de/curator`, `/en/curator`) für Kuratoren.
|
||||
- **Song-Verwaltung:** Kuratoren können Songs hochladen, bearbeiten und Genres/Specials zuweisen.
|
||||
- **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)
|
||||
|
||||
Hördle unterstützt vollständige Mehrsprachigkeit für Deutsch und Englisch.
|
||||
|
||||
👉 **[Vollständige i18n-Dokumentation](I18N.md)**
|
||||
👉 **[Vollständige i18n-Dokumentation](docs/I18N.md)**
|
||||
|
||||
**Schnellstart:**
|
||||
- Deutsche Version: `http://localhost:3000/de`
|
||||
- Englische Version: `http://localhost:3000/en`
|
||||
- Root (`/`) leitet automatisch zur Standardsprache (Deutsch) um
|
||||
- Root (`/`) leitet automatisch zur Standardsprache (Englisch) um
|
||||
|
||||
## White Labeling
|
||||
|
||||
Hördle ist "White Label Ready". Das bedeutet, du kannst das Branding (Name, Farben, Logos) komplett anpassen, ohne den Code zu ändern.
|
||||
|
||||
👉 **[Anleitung zur Anpassung (White Label Guide)](WHITE_LABEL.md)**
|
||||
👉 **[Anleitung zur Anpassung (White Label Guide)](docs/WHITE_LABEL.md)**
|
||||
|
||||
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
|
||||
|
||||
@@ -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
|
||||
- **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
|
||||
- **Snippet erneut abspielen (Replay):** -1 Punkt
|
||||
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
|
||||
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
|
||||
- **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
|
||||
|
||||
- **Framework:** Next.js 16 (App Router)
|
||||
@@ -115,13 +136,13 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/de` um).
|
||||
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/en` um).
|
||||
|
||||
## Deployment mit Docker
|
||||
|
||||
Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
|
||||
👉 **[White Labeling mit Docker? Hier klicken!](WHITE_LABEL.md#docker-deployment)**
|
||||
👉 **[White Labeling mit Docker? Hier klicken!](docs/WHITE_LABEL.md#docker-deployment)**
|
||||
|
||||
1. **Vorbereitung:**
|
||||
Kopiere die Beispiel-Konfiguration:
|
||||
@@ -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_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
||||
- `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`: URL zum Plausible Analytics Script (z.B. `https://plausible.example.com/js/script.js`, optional)
|
||||
|
||||
2. **Starten:**
|
||||
```bash
|
||||
@@ -154,7 +176,18 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
- URL: `/de/admin` oder `/en/admin`
|
||||
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
||||
|
||||
5. **Special Curation & Scheduling verwenden:**
|
||||
5. **Kurator-Zugang:**
|
||||
- URL: `/de/curator` oder `/en/curator`
|
||||
- Kurator-Accounts werden vom Admin erstellt und verwaltet.
|
||||
- Kuratoren können Songs hochladen und verwalten, sowie Kommentare von Spielern einsehen.
|
||||
- **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:
|
||||
- Gib Name, Max Attempts und Unlock Steps ein.
|
||||
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
||||
|
||||
@@ -6,6 +6,8 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -15,6 +17,32 @@ interface PageProps {
|
||||
params: Promise<{ locale: string; genre: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
|
||||
// Fetch genre to get localized name
|
||||
const allGenres = await prisma.genre.findMany();
|
||||
const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre);
|
||||
|
||||
if (!currentGenre || !currentGenre.active) {
|
||||
return await generateBaseMetadata(locale, genre);
|
||||
}
|
||||
|
||||
const genreName = getLocalizedValue(currentGenre.name, locale);
|
||||
const genreSubtitle = getLocalizedValue(currentGenre.subtitle, locale);
|
||||
|
||||
const title = locale === 'de'
|
||||
? `${genreName} - Hördle`
|
||||
: `${genreName} - Hördle`;
|
||||
|
||||
const description = genreSubtitle || (locale === 'de'
|
||||
? `Spiele Hördle im Genre ${genreName} und errate Songs aus kurzen Audio-Clips!`
|
||||
: `Play Hördle in the ${genreName} genre and guess songs from short audio clips!`);
|
||||
|
||||
return await generateBaseMetadata(locale, genre, title, description);
|
||||
}
|
||||
|
||||
export default async function GenrePage({ params }: PageProps) {
|
||||
const { locale, genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
@@ -59,6 +87,9 @@ export default async function GenrePage({ params }: PageProps) {
|
||||
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 (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
@@ -128,7 +159,7 @@ export default async function GenrePage({ params }: PageProps) {
|
||||
)}
|
||||
</div>
|
||||
<NewsSection locale={locale} />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} requiredDailyKeys={requiredDailyKeys} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/lib/navigation";
|
||||
import { generateBaseMetadata } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
interface AboutPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: AboutPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "About" });
|
||||
|
||||
const title = t("title");
|
||||
const description = t("intro");
|
||||
|
||||
return await generateBaseMetadata(locale, "about", title, description);
|
||||
}
|
||||
|
||||
export default async function AboutPage({ params }: AboutPageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "About" });
|
||||
@@ -51,11 +63,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
{t("imprintEmailLabel")}{" "}
|
||||
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
|
||||
</p>
|
||||
<p
|
||||
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
|
||||
>
|
||||
{t("imprintDisclaimer")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
@@ -91,13 +98,27 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
marginBottom: "0.75rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.9rem",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{t("costsSheetPrivacyNote")}
|
||||
</p>
|
||||
<p style={{ marginBottom: "0.75rem" }}>
|
||||
{t.rich("costsDonationNote", {
|
||||
link: (chunks) => (
|
||||
<a
|
||||
href="https://politicalbeauty.de/ueber-das-ZPS.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
@@ -199,6 +220,58 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
</p>
|
||||
</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 style={{ marginBottom: "2rem" }}>
|
||||
@@ -234,11 +307,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
</ul>
|
||||
<p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p>
|
||||
<p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p>
|
||||
<p
|
||||
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
|
||||
>
|
||||
{t("privacyNoLegalAdvice")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ import "../globals.css"; // Adjusted path
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { config } from "@/lib/config";
|
||||
import { generateBaseMetadata } from "@/lib/metadata";
|
||||
import InstallPrompt from "@/components/InstallPrompt";
|
||||
import AppFooter from "@/components/AppFooter";
|
||||
import PoliticalStatementBanner from "@/components/PoliticalStatementBanner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -20,10 +23,10 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: config.appName,
|
||||
description: config.appDescription,
|
||||
};
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
return await generateBaseMetadata(locale);
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: config.colors.themeColor,
|
||||
@@ -52,12 +55,32 @@ export default async function LocaleLayout({
|
||||
// Providing all messages to the client
|
||||
const messages = await getMessages();
|
||||
|
||||
// Get current domain from request headers for dynamic Plausible tracking
|
||||
// This automatically tracks the correct domain (hoerdle.de or hördle.de)
|
||||
const headersList = await headers();
|
||||
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
|
||||
|
||||
// Automatically detect which domain to track in Plausible based on the request
|
||||
let plausibleDomain = 'hoerdle.de'; // Default fallback
|
||||
|
||||
if (host) {
|
||||
// Extract domain from host (remove port if present)
|
||||
const domain = host.split(':')[0].toLowerCase();
|
||||
|
||||
// Map domains: automatically track the current domain
|
||||
if (domain === 'hoerdle.de') {
|
||||
plausibleDomain = 'hoerdle.de';
|
||||
} else if (domain === 'hördle.de' || domain === 'xn--hrdle-jua.de') {
|
||||
plausibleDomain = 'hördle.de';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<Script
|
||||
defer
|
||||
data-domain={config.plausibleDomain}
|
||||
data-domain={plausibleDomain}
|
||||
src={config.plausibleScriptSrc}
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
@@ -67,6 +90,7 @@ export default async function LocaleLayout({
|
||||
{children}
|
||||
<InstallPrompt />
|
||||
<AppFooter />
|
||||
<PoliticalStatementBanner />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,15 +7,33 @@ import { Link } from '@/lib/navigation';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('Home');
|
||||
|
||||
// Get localized title and description
|
||||
const title = locale === 'de'
|
||||
? 'Hördle - Tägliches Musik-Erraten'
|
||||
: 'Hördle - Daily Music Guessing Game';
|
||||
|
||||
const description = locale === 'de'
|
||||
? 'Spiele Hördle und errate Songs aus kurzen Audio-Clips! Täglich neue Rätsel aus verschiedenen Genres. Inspiriert von Wordle, aber für Musikfans.'
|
||||
: 'Play Hördle and guess songs from short audio clips! Daily new puzzles from various genres. Inspired by Wordle, but for music lovers.';
|
||||
|
||||
return await generateBaseMetadata(locale, '', title, description);
|
||||
}
|
||||
|
||||
export default async function Home({
|
||||
params
|
||||
}: {
|
||||
params: { locale: string };
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('Home');
|
||||
@@ -43,6 +61,9 @@ export default async function Home({
|
||||
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 (
|
||||
<>
|
||||
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
||||
@@ -131,7 +152,7 @@ export default async function Home({
|
||||
<NewsSection locale={locale} />
|
||||
</div>
|
||||
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} requiredDailyKeys={requiredDailyKeys} />
|
||||
<OnboardingTour />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Link } from '@/lib/navigation';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -14,6 +16,30 @@ interface PageProps {
|
||||
params: Promise<{ locale: string; name: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
// Fetch special to get localized name
|
||||
const allSpecials = await prisma.special.findMany();
|
||||
const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName);
|
||||
|
||||
if (!currentSpecial) {
|
||||
return await generateBaseMetadata(locale, `special/${name}`);
|
||||
}
|
||||
|
||||
const specialName = getLocalizedValue(currentSpecial.name, locale);
|
||||
const specialSubtitle = getLocalizedValue(currentSpecial.subtitle, locale);
|
||||
|
||||
const title = `★ ${specialName} - Hördle`;
|
||||
|
||||
const description = specialSubtitle || (locale === 'de'
|
||||
? `Spiele das Hördle-Special "${specialName}" und errate Songs aus kurzen Audio-Clips!`
|
||||
: `Play the Hördle special "${specialName}" and guess songs from short audio clips!`);
|
||||
|
||||
return await generateBaseMetadata(locale, `special/${name}`, title, description);
|
||||
}
|
||||
|
||||
export default async function SpecialPage({ params }: PageProps) {
|
||||
const { locale, name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
78
app/api/og-image/route.ts
Normal file
78
app/api/og-image/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { config } from '@/lib/config';
|
||||
import { getBaseUrl } from '@/lib/seo';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Generate Open Graph image as SVG with correct aspect ratio (1.91:1 = 1200x630)
|
||||
* This prevents cropping on Facebook and Twitter
|
||||
*/
|
||||
export async function GET() {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const appName = config.appName;
|
||||
const bgColor = config.colors.backgroundColor || '#ffffff';
|
||||
const primaryColor = config.colors.themeColor || '#000000';
|
||||
|
||||
// SVG with correct Open Graph dimensions: 1200x630 (1.91:1 ratio)
|
||||
// Safe area: 150px padding on all sides to prevent cropping
|
||||
// This ensures content is never cut off on Facebook/Twitter
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="${bgColor}"/>
|
||||
|
||||
<!-- Gradient definition -->
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Content container - centered with safe padding (150px on all sides) -->
|
||||
<g transform="translate(150, 150)">
|
||||
<!-- Main graphic area (centered horizontally) -->
|
||||
<g transform="translate(300, 0)">
|
||||
<!-- Musical note (left side, within safe area) -->
|
||||
<g fill="url(#gradient)" opacity="0.9">
|
||||
<!-- Note head -->
|
||||
<ellipse cx="0" cy="40" rx="40" ry="28"/>
|
||||
<!-- Note stem -->
|
||||
<rect x="30" y="-60" width="16" height="100" rx="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Waveform (center-right, within safe area) -->
|
||||
<g transform="translate(70, 15)" fill="none" stroke="url(#gradient)" stroke-width="8" stroke-linecap="round" opacity="0.8">
|
||||
<path d="M 0 25 Q 20 -15 40 25 T 80 25"/>
|
||||
<path d="M 0 40 Q 20 0 40 40 T 80 40"/>
|
||||
<path d="M 0 55 Q 20 15 40 55 T 80 55"/>
|
||||
</g>
|
||||
|
||||
<!-- Vertical bar (right side, within safe area) -->
|
||||
<rect x="170" y="0" width="10" height="120" fill="url(#gradient)" opacity="0.7" rx="5"/>
|
||||
</g>
|
||||
|
||||
<!-- App name (centered, within safe vertical area) -->
|
||||
<text x="450" y="180" font-family="system-ui, -apple-system, sans-serif" font-size="56" font-weight="bold" fill="${primaryColor}" text-anchor="middle" letter-spacing="-0.5">
|
||||
${appName}
|
||||
</text>
|
||||
|
||||
<!-- Domain/subtitle (centered, within safe vertical area) -->
|
||||
<text x="450" y="220" font-family="system-ui, -apple-system, sans-serif" font-size="28" fill="#666666" text-anchor="middle">
|
||||
${config.domain}
|
||||
</text>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
return new NextResponse(svg, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
114
app/api/player-id/suggest/route.ts
Normal file
114
app/api/player-id/suggest/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* POST /api/player-id/suggest
|
||||
*
|
||||
* 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)
|
||||
* on the same device.
|
||||
*
|
||||
* Request body:
|
||||
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
||||
* - deviceId: Device identifier (UUID)
|
||||
*
|
||||
* Returns:
|
||||
* - basePlayerId: Suggested base player ID (UUID) if found, null otherwise
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { genreKey, deviceId } = body;
|
||||
|
||||
if (!genreKey || typeof genreKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing or invalid genreKey' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the most recently updated player state for this genre
|
||||
// Look for states updated in the last 48 hours
|
||||
const cutoffDate = new Date();
|
||||
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({
|
||||
where: {
|
||||
genreKey: genreKey,
|
||||
lastPlayed: {
|
||||
gte: cutoffDate,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lastPlayed: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (recentState) {
|
||||
// Extract base player ID if format is basePlayerId:deviceId
|
||||
const colonIndex = recentState.identifier.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
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
|
||||
return NextResponse.json({
|
||||
basePlayerId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[player-id/suggest] Error finding player ID:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
192
app/api/player-state/route.ts
Normal file
192
app/api/player-state/route.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import type { GameState, Statistics } from '@/lib/gameState';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Validate UUID format (basic check)
|
||||
* Supports both legacy format (single UUID) and new format (basePlayerId:deviceId)
|
||||
*/
|
||||
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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/player-state
|
||||
*
|
||||
* Loads player state for a given identifier and genre/special.
|
||||
*
|
||||
* Query parameters:
|
||||
* - genre: Genre name (e.g., "Rock")
|
||||
* - special: Special name (e.g., "00725")
|
||||
*
|
||||
* Headers:
|
||||
* - X-Player-Id: Player identifier (UUID)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const genreName = searchParams.get('genre');
|
||||
const specialName = searchParams.get('special');
|
||||
|
||||
// Get player identifier from header
|
||||
const playerId = request.headers.get('X-Player-Id');
|
||||
if (!playerId || !isValidPlayerId(playerId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing player identifier' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine genre key
|
||||
let genreKey: string;
|
||||
if (specialName) {
|
||||
genreKey = `special:${specialName}`;
|
||||
} else if (genreName) {
|
||||
genreKey = genreName;
|
||||
} else {
|
||||
genreKey = 'global';
|
||||
}
|
||||
|
||||
// Load player state from database
|
||||
const playerState = await prisma.playerState.findUnique({
|
||||
where: {
|
||||
identifier_genreKey: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!playerState) {
|
||||
return NextResponse.json(null, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse JSON strings
|
||||
let gameState: GameState;
|
||||
let statistics: Statistics;
|
||||
|
||||
try {
|
||||
gameState = JSON.parse(playerState.gameState) as GameState;
|
||||
statistics = JSON.parse(playerState.statistics) as Statistics;
|
||||
} catch (parseError) {
|
||||
console.error('[player-state] Failed to parse stored state:', parseError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid stored state format' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
gameState,
|
||||
statistics,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[player-state] Error loading player state:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/player-state
|
||||
*
|
||||
* Saves player state for a given identifier and genre/special.
|
||||
*
|
||||
* Request body:
|
||||
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
||||
* - gameState: GameState object
|
||||
* - statistics: Statistics object
|
||||
*
|
||||
* Headers:
|
||||
* - X-Player-Id: Player identifier (UUID)
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Get player identifier from header
|
||||
const playerId = request.headers.get('X-Player-Id');
|
||||
if (!playerId || !isValidPlayerId(playerId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing player identifier' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const { genreKey, gameState, statistics } = body;
|
||||
|
||||
if (!genreKey || !gameState || !statistics) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: genreKey, gameState, statistics' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate genre key format
|
||||
if (typeof genreKey !== 'string' || genreKey.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid genreKey format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Serialize to JSON strings
|
||||
const gameStateJson = JSON.stringify(gameState);
|
||||
const statisticsJson = JSON.stringify(statistics);
|
||||
|
||||
// Upsert player state (update if exists, create if not)
|
||||
await prisma.playerState.upsert({
|
||||
where: {
|
||||
identifier_genreKey: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
gameState: gameStateJson,
|
||||
statistics: statisticsJson,
|
||||
lastPlayed: new Date(),
|
||||
},
|
||||
create: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
gameState: gameStateJson,
|
||||
statistics: statisticsJson,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[player-state] Error saving player state:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
265
app/api/songs/batch/route.ts
Normal file
265
app/api/songs/batch/route.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function getCuratorAssignments(curatorId: number) {
|
||||
const [genres, specials] = await Promise.all([
|
||||
prisma.curatorGenre.findMany({
|
||||
where: { curatorId },
|
||||
select: { genreId: true },
|
||||
}),
|
||||
prisma.curatorSpecial.findMany({
|
||||
where: { curatorId },
|
||||
select: { specialId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
genreIds: new Set(genres.map(g => g.genreId)),
|
||||
specialIds: new Set(specials.map(s => s.specialId)),
|
||||
};
|
||||
}
|
||||
|
||||
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||
if (context.role === 'admin') return true;
|
||||
|
||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.id;
|
||||
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||
return undefined;
|
||||
})
|
||||
.filter((id: any): id is number => typeof id === 'number');
|
||||
|
||||
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasGenre = songGenreIds.some((id: number) => assignments.genreIds.has(id));
|
||||
const hasSpecial = songSpecialIds.some((id: number) => assignments.specialIds.has(id));
|
||||
|
||||
return hasGenre || hasSpecial;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) return error!;
|
||||
|
||||
try {
|
||||
const { songIds, genreToggleIds, specialToggleIds, artist, excludeFromGlobal } = await request.json();
|
||||
|
||||
if (!songIds || !Array.isArray(songIds) || songIds.length === 0) {
|
||||
return NextResponse.json({ error: 'Missing or invalid songIds array' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate that at least one operation is requested
|
||||
const hasGenreToggle = genreToggleIds && Array.isArray(genreToggleIds) && genreToggleIds.length > 0;
|
||||
const hasSpecialToggle = specialToggleIds && Array.isArray(specialToggleIds) && specialToggleIds.length > 0;
|
||||
const hasArtistChange = artist !== undefined && artist !== null && artist.trim() !== '';
|
||||
const hasExcludeGlobalChange = excludeFromGlobal !== undefined;
|
||||
|
||||
if (!hasGenreToggle && !hasSpecialToggle && !hasArtistChange && !hasExcludeGlobalChange) {
|
||||
return NextResponse.json({ error: 'No update operations specified' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate artist if provided
|
||||
if (hasArtistChange && artist.trim() === '') {
|
||||
return NextResponse.json({ error: 'Artist cannot be empty' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate excludeFromGlobal permission
|
||||
if (hasExcludeGlobalChange && context.role === 'curator' && !context.curator.isGlobalCurator) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
let assignments: { genreIds: Set<number>; specialIds: Set<number> } | null = null;
|
||||
if (context.role === 'curator') {
|
||||
assignments = await getCuratorAssignments(context.curator.id);
|
||||
|
||||
// Validate genre/special toggles are within curator's assignments
|
||||
if (hasGenreToggle) {
|
||||
const invalidGenre = genreToggleIds.some((id: number) => !assignments.genreIds.has(id));
|
||||
if (invalidGenre) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Curators may only toggle their own genres' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSpecialToggle) {
|
||||
const invalidSpecial = specialToggleIds.some((id: number) => !assignments.specialIds.has(id));
|
||||
if (invalidSpecial) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Curators may only toggle their own specials' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load all songs with relations for permission checks
|
||||
const songs = await prisma.song.findMany({
|
||||
where: { id: { in: songIds.map((id: any) => Number(id)) } },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (songs.length === 0) {
|
||||
return NextResponse.json({ error: 'No songs found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Filter songs that can be edited
|
||||
const editableSongs = context.role === 'admin'
|
||||
? songs
|
||||
: songs.filter(song => curatorCanEditSong(context, song, assignments!));
|
||||
|
||||
if (editableSongs.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No songs can be edited with current permissions' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const results = {
|
||||
total: songIds.length,
|
||||
processed: editableSongs.length,
|
||||
skipped: songs.length - editableSongs.length,
|
||||
success: 0,
|
||||
errors: [] as Array<{ songId: number; error: string }>,
|
||||
};
|
||||
|
||||
// Process each song in a transaction
|
||||
for (const song of editableSongs) {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const updateData: any = {};
|
||||
|
||||
// Handle artist change
|
||||
if (hasArtistChange) {
|
||||
updateData.artist = artist.trim();
|
||||
}
|
||||
|
||||
// Handle excludeFromGlobal change
|
||||
if (hasExcludeGlobalChange) {
|
||||
updateData.excludeFromGlobal = excludeFromGlobal;
|
||||
}
|
||||
|
||||
// Handle genre toggles
|
||||
if (hasGenreToggle) {
|
||||
const currentGenreIds = song.genres.map(g => g.id);
|
||||
const genreIdsToToggle = genreToggleIds as number[];
|
||||
|
||||
// Determine which genres to add/remove
|
||||
const genresToAdd = genreIdsToToggle.filter(id => !currentGenreIds.includes(id));
|
||||
const genresToRemove = genreIdsToToggle.filter(id => currentGenreIds.includes(id));
|
||||
|
||||
// For curators, preserve genres they can't manage
|
||||
let finalGenreIds: number[];
|
||||
if (context.role === 'curator') {
|
||||
const fixedGenreIds = currentGenreIds.filter(gid => !assignments!.genreIds.has(gid));
|
||||
const managedGenreIds = currentGenreIds
|
||||
.filter(gid => assignments!.genreIds.has(gid) && !genresToRemove.includes(gid))
|
||||
.concat(genresToAdd);
|
||||
finalGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
|
||||
} else {
|
||||
const newGenreIds = currentGenreIds
|
||||
.filter(id => !genresToRemove.includes(id))
|
||||
.concat(genresToAdd);
|
||||
finalGenreIds = Array.from(new Set(newGenreIds));
|
||||
}
|
||||
|
||||
updateData.genres = {
|
||||
set: finalGenreIds.map(gId => ({ id: gId }))
|
||||
};
|
||||
}
|
||||
|
||||
// Update song basic data
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await tx.song.update({
|
||||
where: { id: song.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle special toggles
|
||||
if (hasSpecialToggle) {
|
||||
const currentSpecials = await tx.specialSong.findMany({
|
||||
where: { songId: song.id }
|
||||
});
|
||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||
const specialIdsToToggle = specialToggleIds as number[];
|
||||
|
||||
// Determine which specials to add/remove
|
||||
const specialsToAdd = specialIdsToToggle.filter(id => !currentSpecialIds.includes(id));
|
||||
const specialsToRemove = specialIdsToToggle.filter(id => currentSpecialIds.includes(id));
|
||||
|
||||
// For curators, preserve specials they can't manage
|
||||
let finalSpecialIds: number[];
|
||||
if (context.role === 'curator') {
|
||||
const fixedSpecialIds = currentSpecialIds.filter(sid => !assignments!.specialIds.has(sid));
|
||||
const managedSpecialIds = currentSpecialIds
|
||||
.filter(sid => assignments!.specialIds.has(sid) && !specialsToRemove.includes(sid))
|
||||
.concat(specialsToAdd);
|
||||
finalSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
|
||||
} else {
|
||||
const newSpecialIds = currentSpecialIds
|
||||
.filter(id => !specialsToRemove.includes(id))
|
||||
.concat(specialsToAdd);
|
||||
finalSpecialIds = Array.from(new Set(newSpecialIds));
|
||||
}
|
||||
|
||||
// Delete removed specials
|
||||
const toDelete = currentSpecialIds.filter(sid => !finalSpecialIds.includes(sid));
|
||||
if (toDelete.length > 0) {
|
||||
await tx.specialSong.deleteMany({
|
||||
where: {
|
||||
songId: song.id,
|
||||
specialId: { in: toDelete }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add new specials
|
||||
const toAdd = finalSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||
if (toAdd.length > 0) {
|
||||
await tx.specialSong.createMany({
|
||||
data: toAdd.map(specialId => ({
|
||||
songId: song.id,
|
||||
specialId,
|
||||
startTime: 0
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
results.success++;
|
||||
} catch (error: any) {
|
||||
results.errors.push({
|
||||
songId: song.id,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error in batch update:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,96 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { writeFile, unlink } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { parseBuffer } from 'music-metadata';
|
||||
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function getCuratorAssignments(curatorId: number) {
|
||||
const [genres, specials] = await Promise.all([
|
||||
prisma.curatorGenre.findMany({
|
||||
where: { curatorId },
|
||||
select: { genreId: true },
|
||||
}),
|
||||
prisma.curatorSpecial.findMany({
|
||||
where: { curatorId },
|
||||
select: { specialId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
genreIds: new Set(genres.map(g => g.genreId)),
|
||||
specialIds: new Set(specials.map(s => s.specialId)),
|
||||
};
|
||||
}
|
||||
|
||||
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||
if (context.role === 'admin') return true;
|
||||
|
||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||
// `song.specials` kann je nach Context entweder ein Array von
|
||||
// - `Special` (mit `id`)
|
||||
// - `SpecialSong` (mit `specialId`)
|
||||
// - `SpecialSong` (mit Relation `special.id`)
|
||||
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
|
||||
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
|
||||
// Daher zuerst specialId oder special.id prüfen.
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.id;
|
||||
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||
return undefined;
|
||||
})
|
||||
.filter((id: any): id is number => typeof id === 'number');
|
||||
|
||||
// Songs ohne Genres/Specials sind für Kuratoren generell editierbar
|
||||
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasGenre = songGenreIds.some((id: number) => assignments.genreIds.has(id));
|
||||
const hasSpecial = songSpecialIds.some((id: number) => assignments.specialIds.has(id));
|
||||
|
||||
return hasGenre || hasSpecial;
|
||||
}
|
||||
|
||||
function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||
if (context.role === 'admin') return true;
|
||||
|
||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
|
||||
// Daher zuerst specialId oder special.id prüfen.
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.id;
|
||||
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||
return undefined;
|
||||
})
|
||||
.filter((id: any): id is number => typeof id === 'number');
|
||||
|
||||
const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id));
|
||||
const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id));
|
||||
|
||||
return allGenresAllowed && allSpecialsAllowed;
|
||||
}
|
||||
|
||||
// Configure route to handle large file uploads
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
// Alle Zugriffe auf die Songliste erfordern Staff-Auth (Admin oder Kurator)
|
||||
const { error, context } = await requireStaffAuth(request);
|
||||
if (error || !context) return error!;
|
||||
|
||||
const songs = await prisma.song.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
@@ -26,8 +104,33 @@ export async function GET() {
|
||||
},
|
||||
});
|
||||
|
||||
let visibleSongs = songs;
|
||||
|
||||
if (context.role === 'curator') {
|
||||
const assignments = await getCuratorAssignments(context.curator.id);
|
||||
|
||||
visibleSongs = songs.filter(song => {
|
||||
const songGenreIds = song.genres.map(g => g.id);
|
||||
// `song.specials` ist hier ein Array von SpecialSong mit Relation `special`.
|
||||
// Es kann theoretisch verwaiste Einträge ohne `special` geben → defensiv optional chainen.
|
||||
const songSpecialIds = song.specials
|
||||
.map(ss => ss.special?.id)
|
||||
.filter((id): id is number => typeof id === 'number');
|
||||
|
||||
// Songs ohne Genres/Specials sind immer sichtbar
|
||||
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasGenre = songGenreIds.some(id => assignments.genreIds.has(id));
|
||||
const hasSpecial = songSpecialIds.some(id => assignments.specialIds.has(id));
|
||||
|
||||
return hasGenre || hasSpecial;
|
||||
});
|
||||
}
|
||||
|
||||
// Map to include activation count and flatten specials
|
||||
const songsWithActivations = songs.map(song => ({
|
||||
const songsWithActivations = visibleSongs.map(song => ({
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
@@ -38,7 +141,10 @@ export async function GET() {
|
||||
activations: song.puzzles.length,
|
||||
puzzles: song.puzzles,
|
||||
genres: song.genres,
|
||||
specials: song.specials.map(ss => ss.special),
|
||||
// Nur Specials mit existierender Relation durchreichen, um undefinierte Einträge zu vermeiden.
|
||||
specials: song.specials
|
||||
.map(ss => ss.special)
|
||||
.filter((s): s is any => !!s),
|
||||
averageRating: song.averageRating,
|
||||
ratingCount: song.ratingCount,
|
||||
excludeFromGlobal: song.excludeFromGlobal,
|
||||
@@ -50,11 +156,11 @@ export async function GET() {
|
||||
export async function POST(request: Request) {
|
||||
console.log('[UPLOAD] Starting song upload request');
|
||||
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) {
|
||||
console.log('[UPLOAD] Authentication failed');
|
||||
return authError;
|
||||
return error!;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -63,10 +169,17 @@ export async function POST(request: Request) {
|
||||
const file = formData.get('file') as File;
|
||||
let title = '';
|
||||
let artist = '';
|
||||
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||
let excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||
|
||||
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
||||
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
|
||||
console.log('[UPLOAD] excludeFromGlobal (raw):', excludeFromGlobal);
|
||||
|
||||
// Apply global playlist rules:
|
||||
// - Admin: may control the flag via form data
|
||||
// - Curator: uploads are always excluded from global by default
|
||||
if (context.role === 'curator') {
|
||||
excludeFromGlobal = true;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
console.error('[UPLOAD] No file provided');
|
||||
@@ -261,9 +374,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) return error!;
|
||||
|
||||
try {
|
||||
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||
@@ -272,6 +385,73 @@ export async function PUT(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Load current song with relations for permission checks
|
||||
const existingSong = await prisma.song.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSong) {
|
||||
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
let effectiveGenreIds = genreIds as number[] | undefined;
|
||||
let effectiveSpecialIds = specialIds as number[] | undefined;
|
||||
|
||||
if (context.role === 'curator') {
|
||||
const assignments = await getCuratorAssignments(context.curator.id);
|
||||
|
||||
if (!curatorCanEditSong(context, existingSong, assignments)) {
|
||||
return NextResponse.json({ error: 'Forbidden: You are not allowed to edit this song' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Curators may assign genres, but only within their own assignments.
|
||||
// Genres außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
|
||||
if (effectiveGenreIds !== undefined) {
|
||||
const invalidGenre = effectiveGenreIds.some(id => !assignments.genreIds.has(id));
|
||||
if (invalidGenre) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Curators may only assign their own genres' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const fixedGenreIds = existingSong.genres
|
||||
.filter(g => !assignments.genreIds.has(g.id))
|
||||
.map(g => g.id);
|
||||
const managedGenreIds = effectiveGenreIds.filter(id => assignments.genreIds.has(id));
|
||||
effectiveGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
|
||||
}
|
||||
|
||||
// Curators may assign specials, but only within their own assignments.
|
||||
// Specials außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
|
||||
if (effectiveSpecialIds !== undefined) {
|
||||
const invalidSpecial = effectiveSpecialIds.some(id => !assignments.specialIds.has(id));
|
||||
if (invalidSpecial) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Curators may only assign their own specials' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const currentSpecials = await prisma.specialSong.findMany({
|
||||
where: { songId: Number(id) }
|
||||
});
|
||||
const fixedSpecialIds = currentSpecials
|
||||
.map(ss => ss.specialId)
|
||||
.filter(sid => !assignments.specialIds.has(sid));
|
||||
const managedSpecialIds = effectiveSpecialIds.filter(id => assignments.specialIds.has(id));
|
||||
effectiveSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
|
||||
}
|
||||
}
|
||||
|
||||
const data: any = { title, artist };
|
||||
|
||||
// Update releaseYear if provided (can be null to clear it)
|
||||
@@ -280,60 +460,76 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
|
||||
if (excludeFromGlobal !== undefined) {
|
||||
data.excludeFromGlobal = excludeFromGlobal;
|
||||
if (context.role === 'admin') {
|
||||
data.excludeFromGlobal = excludeFromGlobal;
|
||||
} else {
|
||||
// Curators may only change the flag if they are global curators
|
||||
if (!context.curator.isGlobalCurator) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
data.excludeFromGlobal = excludeFromGlobal;
|
||||
}
|
||||
}
|
||||
|
||||
if (genreIds) {
|
||||
// Wenn effectiveGenreIds definiert ist, auch leere Arrays übernehmen (löscht alle Zuordnungen).
|
||||
if (effectiveGenreIds !== undefined) {
|
||||
data.genres = {
|
||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
||||
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
||||
};
|
||||
}
|
||||
|
||||
// Handle SpecialSong relations separately
|
||||
if (specialIds !== undefined) {
|
||||
// First, get current special assignments
|
||||
const currentSpecials = await prisma.specialSong.findMany({
|
||||
where: { songId: Number(id) }
|
||||
});
|
||||
|
||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||
const newSpecialIds = specialIds as number[];
|
||||
|
||||
// Delete removed specials
|
||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||
if (toDelete.length > 0) {
|
||||
await prisma.specialSong.deleteMany({
|
||||
where: {
|
||||
songId: Number(id),
|
||||
specialId: { in: toDelete }
|
||||
}
|
||||
// Execute all database write operations in a transaction to ensure consistency
|
||||
const updatedSong = await prisma.$transaction(async (tx) => {
|
||||
// Handle SpecialSong relations separately
|
||||
if (effectiveSpecialIds !== undefined) {
|
||||
// First, get current special assignments (within transaction)
|
||||
const currentSpecials = await tx.specialSong.findMany({
|
||||
where: { songId: Number(id) }
|
||||
});
|
||||
}
|
||||
|
||||
// Add new specials
|
||||
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||
if (toAdd.length > 0) {
|
||||
await prisma.specialSong.createMany({
|
||||
data: toAdd.map(specialId => ({
|
||||
songId: Number(id),
|
||||
specialId,
|
||||
startTime: 0
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||
const newSpecialIds = effectiveSpecialIds as number[];
|
||||
|
||||
const updatedSong = await prisma.song.update({
|
||||
where: { id: Number(id) },
|
||||
data,
|
||||
include: {
|
||||
genres: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
// Delete removed specials
|
||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||
if (toDelete.length > 0) {
|
||||
await tx.specialSong.deleteMany({
|
||||
where: {
|
||||
songId: Number(id),
|
||||
specialId: { in: toDelete }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add new specials
|
||||
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||
if (toAdd.length > 0) {
|
||||
await tx.specialSong.createMany({
|
||||
data: toAdd.map(specialId => ({
|
||||
songId: Number(id),
|
||||
specialId,
|
||||
startTime: 0
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update song (this also handles genre relations via Prisma's set operation)
|
||||
return await tx.song.update({
|
||||
where: { id: Number(id) },
|
||||
data,
|
||||
include: {
|
||||
genres: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json(updatedSong);
|
||||
@@ -344,9 +540,9 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) return error!;
|
||||
|
||||
try {
|
||||
const { id } = await request.json();
|
||||
@@ -355,16 +551,31 @@ export async function DELETE(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get song to find filename
|
||||
// Get song to find filename and relations for permission checks
|
||||
const song = await prisma.song.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!song) {
|
||||
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete file
|
||||
if (context.role === 'curator') {
|
||||
const assignments = await getCuratorAssignments(context.curator.id);
|
||||
|
||||
if (!curatorCanDeleteSong(context, song, assignments)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: You are not allowed to delete this song' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete files first (outside transaction, as file system operations can't be rolled back)
|
||||
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||
try {
|
||||
await unlink(filePath);
|
||||
@@ -383,9 +594,11 @@ export async function DELETE(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database (will cascade delete related puzzles)
|
||||
await prisma.song.delete({
|
||||
where: { id: Number(id) },
|
||||
// Delete from database in transaction (will cascade delete related puzzles, SpecialSong, etc.)
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.song.delete({
|
||||
where: { id: Number(id) },
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
1941
app/curator/CuratorPageClient.tsx
Normal file
1941
app/curator/CuratorPageClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
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 />;
|
||||
}
|
||||
|
||||
|
||||
20
app/robots.ts
Normal file
20
app/robots.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
const siteUrl = `${protocol}://${baseUrl}`;
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin/', '/api/'],
|
||||
},
|
||||
],
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
136
app/sitemap.ts
Normal file
136
app/sitemap.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
const siteUrl = `${protocol}://${baseUrl}`;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Static pages
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: `${siteUrl}/en`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 1.0,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de`,
|
||||
'en': `${siteUrl}/en`,
|
||||
'x-default': `${siteUrl}/en`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de`,
|
||||
'en': `${siteUrl}/en`,
|
||||
'x-default': `${siteUrl}/en`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/en/about`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/about`,
|
||||
'en': `${siteUrl}/en/about`,
|
||||
'x-default': `${siteUrl}/en/about`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de/about`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/about`,
|
||||
'en': `${siteUrl}/en/about`,
|
||||
'x-default': `${siteUrl}/en/about`,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Dynamic genre pages
|
||||
try {
|
||||
// Während des Docker-Builds wird häufig eine temporäre SQLite-DB (file:./dev.db)
|
||||
// ohne migrierte Tabellen verwendet. In diesem Fall überspringen wir die
|
||||
// Datenbankabfrage und liefern nur die statischen Seiten, um Build-Fehler zu vermeiden.
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (dbUrl && dbUrl.startsWith('file:./')) {
|
||||
return staticPages;
|
||||
}
|
||||
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
});
|
||||
|
||||
const genrePages: MetadataRoute.Sitemap = [];
|
||||
|
||||
for (const genre of genres) {
|
||||
const genreNameEn = getLocalizedValue(genre.name, 'en');
|
||||
const genreNameDe = getLocalizedValue(genre.name, 'de');
|
||||
|
||||
// Only add if genre name is valid
|
||||
if (genreNameEn && genreNameDe) {
|
||||
const encodedEn = encodeURIComponent(genreNameEn);
|
||||
const encodedDe = encodeURIComponent(genreNameDe);
|
||||
|
||||
genrePages.push(
|
||||
{
|
||||
url: `${siteUrl}/en/${encodedEn}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/${encodedDe}`,
|
||||
'en': `${siteUrl}/en/${encodedEn}`,
|
||||
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de/${encodedDe}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/${encodedDe}`,
|
||||
'en': `${siteUrl}/en/${encodedEn}`,
|
||||
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [...staticPages, ...genrePages];
|
||||
} catch (error) {
|
||||
console.error('Error generating sitemap:', error);
|
||||
// Return static pages only if database query fails
|
||||
return staticPages;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
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 GuessInput from './GuessInput';
|
||||
import Statistics from './Statistics';
|
||||
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
|
||||
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 { getOrCreatePlayerId } from '@/lib/playerId';
|
||||
|
||||
// Plausible Analytics
|
||||
declare global {
|
||||
@@ -32,14 +38,17 @@ interface GameProps {
|
||||
isSpecial?: boolean;
|
||||
maxAttempts?: 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];
|
||||
|
||||
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 locale = useLocale();
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||
const [hasWon, setHasWon] = useState(false);
|
||||
const [hasLost, setHasLost] = useState(false);
|
||||
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
|
||||
@@ -49,7 +58,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const [hasRated, setHasRated] = useState(false);
|
||||
const [showYearModal, setShowYearModal] = 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 [commentText, setCommentText] = useState('');
|
||||
const [commentSending, setCommentSending] = useState(false);
|
||||
const [commentSent, setCommentSent] = useState(false);
|
||||
const [commentError, setCommentError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCountdown = () => {
|
||||
@@ -81,6 +96,37 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
}, [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(() => {
|
||||
setLastAction(null);
|
||||
}, [dailyPuzzle?.id]);
|
||||
@@ -93,6 +139,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
} else {
|
||||
setHasRated(false);
|
||||
}
|
||||
|
||||
// Check if comment already sent for this puzzle
|
||||
const playerIdentifier = getOrCreatePlayerId();
|
||||
if (playerIdentifier) {
|
||||
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
|
||||
if (commentedPuzzles.includes(dailyPuzzle.id)) {
|
||||
setCommentSent(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [dailyPuzzle]);
|
||||
|
||||
@@ -108,6 +163,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
const handleGuess = (song: any) => {
|
||||
if (isProcessingGuess) return;
|
||||
// Prevent guessing if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessingGuess(true);
|
||||
setLastAction('GUESS');
|
||||
@@ -159,6 +218,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Prevent skipping if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) return;
|
||||
|
||||
// If user hasn't played audio yet on first attempt, start it instead of skipping
|
||||
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
|
||||
handleStartAudio();
|
||||
@@ -187,6 +249,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
};
|
||||
|
||||
const handleGiveUp = () => {
|
||||
// Prevent giving up if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) return;
|
||||
|
||||
setLastAction('SKIP');
|
||||
addGuess("SKIPPED", false);
|
||||
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||
@@ -249,6 +314,59 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
};
|
||||
|
||||
const handleCommentSubmit = async () => {
|
||||
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCommentSending(true);
|
||||
setCommentError(null);
|
||||
|
||||
try {
|
||||
const playerIdentifier = getOrCreatePlayerId();
|
||||
if (!playerIdentifier) {
|
||||
throw new Error('Could not get player identifier');
|
||||
}
|
||||
|
||||
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle
|
||||
const genreId = isSpecial ? null : null; // API will determine from puzzle
|
||||
|
||||
const response = await fetch('/api/curator-comment', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
puzzleId: dailyPuzzle.id,
|
||||
genreId: genreId,
|
||||
message: commentText.trim(),
|
||||
playerIdentifier: playerIdentifier
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to send comment');
|
||||
}
|
||||
|
||||
setCommentSent(true);
|
||||
setCommentText('');
|
||||
|
||||
// Store in localStorage that comment was sent
|
||||
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
|
||||
if (!commentedPuzzles.includes(dailyPuzzle.id)) {
|
||||
commentedPuzzles.push(dailyPuzzle.id);
|
||||
localStorage.setItem(`${config.appName.toLowerCase()}_commented_puzzles`, JSON.stringify(commentedPuzzles));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending comment:', error);
|
||||
setCommentError(error instanceof Error ? error.message : 'Failed to send comment');
|
||||
} finally {
|
||||
setCommentSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||||
|
||||
const handleShare = async () => {
|
||||
@@ -274,9 +392,14 @@ 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 genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||
|
||||
let shareUrl = `https://${config.domain}`;
|
||||
// Add locale prefix if not default (de)
|
||||
if (locale !== 'de') {
|
||||
// Use current domain from window.location to support both hoerdle.de and hördle.de,
|
||||
// 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:';
|
||||
let shareUrl = `${protocol}//${currentHost}`;
|
||||
// Add locale prefix if not default (en)
|
||||
if (locale !== 'en') {
|
||||
shareUrl += `/${locale}`;
|
||||
}
|
||||
if (genre) {
|
||||
@@ -335,6 +458,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
};
|
||||
|
||||
// Aktuelle Attempt-Anzeige:
|
||||
// - Während des Spiels: nächster Versuch = guesses.length + 1
|
||||
// - Nach Spielende (gelöst oder verloren): letzter Versuch = guesses.length
|
||||
const currentAttempt = (gameState.isSolved || gameState.isFailed)
|
||||
? gameState.guesses.length
|
||||
: gameState.guesses.length + 1;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="header">
|
||||
@@ -347,7 +477,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
<main className="game-board">
|
||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||
<div id="tour-status" className="status-bar">
|
||||
<span>{t('attempt')} {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||||
<span>{t('attempt')} {currentAttempt} / {maxAttempts}</span>
|
||||
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
||||
</div>
|
||||
|
||||
@@ -456,14 +586,80 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.25rem', textAlign: 'center' }}>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>
|
||||
{t('shareExplanation')}
|
||||
</p>
|
||||
<button onClick={handleShare} className="btn-primary">
|
||||
{shareText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comment Form */}
|
||||
{!commentSent && (
|
||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
{t('sendComment')}
|
||||
</h3>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
|
||||
{t('commentHelp')}
|
||||
</p>
|
||||
<textarea
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder={t('commentPlaceholder')}
|
||||
maxLength={2000}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '100px',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical',
|
||||
marginBottom: '0.5rem'
|
||||
}}
|
||||
disabled={commentSending}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
|
||||
{commentText.length}/2000
|
||||
</span>
|
||||
{commentError && (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
|
||||
{commentError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCommentSubmit}
|
||||
disabled={!commentText.trim() || commentSending || commentSent}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
width: '100%',
|
||||
opacity: (!commentText.trim() || commentSending || commentSent) ? 0.5 : 1,
|
||||
cursor: (!commentText.trim() || commentSending || commentSent) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{commentSending ? t('sending') : t('sendComment')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commentSent && (
|
||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center' }}>
|
||||
{t('commentSent')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statistics && <Statistics statistics={statistics} />}
|
||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||
{shareText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
@@ -475,6 +671,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
onSkip={handleYearSkip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showExtraPuzzlesPopover && extraPuzzle && (
|
||||
<ExtraPuzzlesPopover
|
||||
puzzle={extraPuzzle}
|
||||
onClose={() => setShowExtraPuzzlesPopover(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -674,7 +877,11 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
||||
}
|
||||
|
||||
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>
|
||||
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
||||
{[...Array(5)].map((_, index) => {
|
||||
|
||||
@@ -22,9 +22,25 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/songs')
|
||||
.then(res => res.json())
|
||||
.then(data => setSongs(data));
|
||||
fetch('/api/public-songs')
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load songs: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (Array.isArray(data)) {
|
||||
setSongs(data);
|
||||
} else {
|
||||
console.error('Unexpected songs payload in GuessInput:', data);
|
||||
setSongs([]);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading songs for GuessInput:', err);
|
||||
setSongs([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ services:
|
||||
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
||||
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
|
||||
NEXT_PUBLIC_DOMAIN: ${NEXT_PUBLIC_DOMAIN}
|
||||
NEXT_PUBLIC_TWITTER_HANDLE: ${NEXT_PUBLIC_TWITTER_HANDLE}
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN: ${NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
|
||||
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_COLOR}
|
||||
|
||||
183
docs/CADDY_TROUBLESHOOTING.md
Normal file
183
docs/CADDY_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Caddy Zertifikat-Troubleshooting
|
||||
|
||||
## Problem: Zertifikat für Punycode-Domain (hördle.de / xn--hrdle-jua.de) fehlt
|
||||
|
||||
Wenn die Domain `hördle.de` (xn--hrdle-jua.de) einen `ERR_SSL_PROTOCOL_ERROR` zeigt, bedeutet das, dass kein gültiges SSL-Zertifikat vorhanden ist.
|
||||
|
||||
### Schritt 1: Zertifikat-Status prüfen
|
||||
|
||||
Führe das Check-Script aus:
|
||||
|
||||
```bash
|
||||
./scripts/check-caddy-certificates.sh
|
||||
```
|
||||
|
||||
Dieses Script prüft:
|
||||
- Ob Caddy läuft
|
||||
- Welche Zertifikate vorhanden sind
|
||||
- Ob die DNS-Einträge korrekt sind
|
||||
- Ob die HTTPS-Verbindungen funktionieren
|
||||
|
||||
### Schritt 2: DNS-Einträge prüfen
|
||||
|
||||
**Wichtig**: Beide Domains müssen auf die gleiche Server-IP zeigen!
|
||||
|
||||
#### In GoDaddy prüfen:
|
||||
|
||||
1. Gehe zu [GoDaddy DNS-Verwaltung](https://dcc.godaddy.com/manage/hoerdle.de/dns)
|
||||
2. Prüfe die A-Records:
|
||||
|
||||
**Für hoerdle.de:**
|
||||
- Name: `@` oder `hoerdle.de`
|
||||
- Typ: `A`
|
||||
- Wert: `DEINE_SERVER_IP`
|
||||
|
||||
**Für hördle.de (Punycode):**
|
||||
- Name: `@` oder `xn--hrdle-jua.de` (oder der Unicode-Name, falls unterstützt)
|
||||
- Typ: `A`
|
||||
- Wert: **GLEICHE_SERVER_IP wie hoerdle.de**
|
||||
|
||||
#### DNS manuell testen:
|
||||
|
||||
```bash
|
||||
# Prüfe hoerdle.de
|
||||
dig +short hoerdle.de @8.8.8.8
|
||||
|
||||
# Prüfe xn--hrdle-jua.de (Punycode)
|
||||
dig +short xn--hrdle-jua.de @8.8.8.8
|
||||
|
||||
# Beide sollten die gleiche IP zurückgeben!
|
||||
```
|
||||
|
||||
### Schritt 3: Zertifikat neu erstellen
|
||||
|
||||
Wenn die DNS-Einträge korrekt sind, lösche das alte (fehlgeschlagene) Zertifikat und lass Caddy es neu erstellen:
|
||||
|
||||
```bash
|
||||
./scripts/renew-caddy-certificates.sh
|
||||
```
|
||||
|
||||
Wähle Option 2: "Nur Zertifikat für xn--hrdle-jua.de löschen"
|
||||
|
||||
### Schritt 4: Caddy-Logs überwachen
|
||||
|
||||
Während Caddy das Zertifikat erstellt, überwache die Logs:
|
||||
|
||||
```bash
|
||||
docker logs hoerdle-caddy -f
|
||||
```
|
||||
|
||||
Du solltest sehen:
|
||||
- `[INFO] attempting ACME challenge` - Caddy versucht die Challenge
|
||||
- `[INFO] successfully completed ACME challenge` - Challenge erfolgreich
|
||||
- `[INFO] certificate obtained successfully` - Zertifikat erstellt
|
||||
|
||||
Bei Fehlern siehst du:
|
||||
- `[ERROR] acme: error` - Challenge fehlgeschlagen
|
||||
- `[ERROR] unable to validate` - Validierung fehlgeschlagen
|
||||
|
||||
### Schritt 5: Häufige Probleme und Lösungen
|
||||
|
||||
#### Problem 1: DNS zeigt auf falsche IP
|
||||
|
||||
**Symptom**: `dig` zeigt eine andere IP als erwartet
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe DNS-Einträge in GoDaddy
|
||||
2. Warte auf DNS-Propagierung (kann 5-60 Minuten dauern)
|
||||
3. Verwende einen DNS-Checker: https://www.whatsmydns.net/
|
||||
|
||||
#### Problem 2: Port 80 nicht erreichbar
|
||||
|
||||
**Symptom**: Caddy-Logs zeigen "connection refused" oder Timeout
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe Firewall: `sudo ufw status`
|
||||
2. Prüfe ob Port 80 offen ist: `sudo netstat -tulpn | grep :80`
|
||||
3. Prüfe ob Caddy auf Port 80 lauscht: `docker exec hoerdle-caddy netstat -tulpn | grep :80`
|
||||
|
||||
#### Problem 3: Let's Encrypt Rate Limit
|
||||
|
||||
**Symptom**: Logs zeigen "too many certificates already issued"
|
||||
|
||||
**Lösung**:
|
||||
- Warte 1 Woche (Rate Limit von Let's Encrypt)
|
||||
- Oder verwende Staging-Environment zum Testen:
|
||||
```caddyfile
|
||||
tls {
|
||||
staging
|
||||
}
|
||||
```
|
||||
|
||||
#### Problem 4: Punycode-Domain wird nicht erkannt
|
||||
|
||||
**Symptom**: Caddy erstellt Zertifikat nur für hoerdle.de, nicht für xn--hrdle-jua.de
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe ob beide Domains in der Caddyfile stehen
|
||||
2. Prüfe DNS-Einträge (siehe Schritt 2)
|
||||
3. Erzwinge Zertifikat-Erstellung (siehe Schritt 3)
|
||||
|
||||
### Manuelle Zertifikat-Löschung
|
||||
|
||||
Falls das Script nicht funktioniert, kannst du Zertifikate manuell löschen:
|
||||
|
||||
```bash
|
||||
# Alle Zertifikate löschen
|
||||
docker exec hoerdle-caddy rm -rf /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/*
|
||||
|
||||
# Nur Punycode-Zertifikat löschen (manuell)
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--*" -delete
|
||||
|
||||
# Container neu starten
|
||||
docker compose -f docker-compose.caddy.yml --profile production restart caddy
|
||||
```
|
||||
|
||||
### DNS-Propagierung prüfen
|
||||
|
||||
Nach DNS-Änderungen kann es bis zu 60 Minuten dauern, bis alle DNS-Server aktualisiert sind:
|
||||
|
||||
```bash
|
||||
# Prüfe DNS-Propagierung weltweit
|
||||
curl "https://dnschecker.org/#A/hoerdle.de"
|
||||
curl "https://dnschecker.org/#A/xn--hrdle-jua.de"
|
||||
```
|
||||
|
||||
### Test-Zertifikat erstellen (Staging)
|
||||
|
||||
Zum Testen ohne Rate-Limits kannst du ein Staging-Zertifikat erstellen:
|
||||
|
||||
1. Temporär Caddyfile ändern (in beiden Domain-Blocks):
|
||||
```caddyfile
|
||||
tls {
|
||||
staging
|
||||
}
|
||||
```
|
||||
|
||||
2. Container neu starten
|
||||
3. Zertifikat erstellen lassen
|
||||
4. Zurück zu Produktion ändern (Staging-Block entfernen)
|
||||
5. Erneut Container neu starten
|
||||
|
||||
### Verifizieren, dass es funktioniert
|
||||
|
||||
Nach erfolgreicher Zertifikats-Erstellung:
|
||||
|
||||
```bash
|
||||
# Teste HTTPS-Verbindung
|
||||
curl -I https://hoerdle.de
|
||||
curl -I https://xn--hrdle-jua.de
|
||||
|
||||
# Prüfe Zertifikat-Details
|
||||
echo | openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -subject -dates
|
||||
echo | openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -subject -dates
|
||||
```
|
||||
|
||||
### Support
|
||||
|
||||
Falls das Problem weiterhin besteht:
|
||||
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy`
|
||||
2. Prüfe DNS: `dig +short xn--hrdle-jua.de @8.8.8.8`
|
||||
3. Prüfe Firewall: `sudo ufw status`
|
||||
4. Prüfe Port-Zugriff: `curl -I http://hoerdle.de`
|
||||
|
||||
@@ -8,14 +8,14 @@ Die i18n-Implementierung basiert auf [next-intl](https://next-intl-docs.vercel.a
|
||||
|
||||
## Unterstützte Sprachen
|
||||
|
||||
- **Deutsch (de)** - Standardsprache
|
||||
- **Englisch (en)**
|
||||
- **Englisch (en)** - Standardsprache
|
||||
- **Deutsch (de)**
|
||||
|
||||
## URL-Struktur
|
||||
|
||||
Alle Routen sind lokalisiert:
|
||||
|
||||
- `http://localhost:3000/` → Redirect zu `/de` (Standard)
|
||||
- `http://localhost:3000/` → Redirect zu `/en` (Standard)
|
||||
- `http://localhost:3000/de` → Deutsche Version
|
||||
- `http://localhost:3000/en` → Englische Version
|
||||
- `http://localhost:3000/de/admin` → Admin-Dashboard (Deutsch)
|
||||
@@ -103,8 +103,8 @@ const genreNameEn = getLocalizedValue(genre.name, 'en'); // "Rock"
|
||||
|
||||
**Fallback-Verhalten:**
|
||||
1. Versucht die angeforderte Locale (`de` oder `en`)
|
||||
2. Fallback zu `de` falls nicht vorhanden
|
||||
3. Fallback zu `en` falls nicht vorhanden
|
||||
2. Fallback zu `en` falls nicht vorhanden
|
||||
3. Fallback zu `de` falls nicht vorhanden
|
||||
4. Fallback zum ersten verfügbaren Schlüssel
|
||||
5. Fallback zum übergebenen `fallback`-Parameter
|
||||
|
||||
@@ -195,7 +195,7 @@ Bestehende Daten werden automatisch migriert:
|
||||
|
||||
Der Proxy (`proxy.ts`) leitet Anfragen automatisch um:
|
||||
|
||||
- `/` → `/de` (Standard)
|
||||
- `/` → `/en` (Standard)
|
||||
- Ungültige Locales → 404
|
||||
- Validiert Locale-Parameter
|
||||
|
||||
@@ -223,7 +223,7 @@ GET /api/specials?locale=en
|
||||
GET /api/news?locale=de
|
||||
```
|
||||
|
||||
Falls kein `locale` angegeben wird, wird `de` als Standard verwendet.
|
||||
Falls kein `locale` angegeben wird, wird `en` als Standard verwendet.
|
||||
|
||||
## Best Practices
|
||||
|
||||
167
docs/PLAUSIBLE_SETUP.md
Normal file
167
docs/PLAUSIBLE_SETUP.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Plausible Analytics Konfiguration
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die App verwendet Plausible Analytics für anonyme Nutzungsstatistiken. Die Konfiguration erfolgt über Umgebungsvariablen.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Erforderliche Variablen
|
||||
|
||||
**Nur eine Variable ist erforderlich:**
|
||||
|
||||
1. **`NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`** (erforderlich)
|
||||
- Die vollständige URL zum Plausible-Script
|
||||
- Beispiel (selbst gehostet): `https://plausible.elpatron.me/js/script.js`
|
||||
- Beispiel (extern): `https://plausible.io/js/script.js`
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt. Beide Domains (`hoerdle.de` und `hördle.de`) werden automatisch getrackt.
|
||||
|
||||
### Konfiguration für Docker
|
||||
|
||||
Da es sich um **Build-Time Variablen** handelt (NEXT_PUBLIC_*), muss die App neu gebaut werden, wenn diese geändert werden.
|
||||
|
||||
#### Schritt 1: Umgebungsvariablen setzen
|
||||
|
||||
Erstelle oder bearbeite eine `.env`-Datei im Projektverzeichnis:
|
||||
|
||||
```bash
|
||||
# Plausible Analytics (Script-URL ist erforderlich)
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.elpatron.me/js/script.js
|
||||
|
||||
# Die Domain wird automatisch erkannt - keine weitere Konfiguration nötig!
|
||||
```
|
||||
|
||||
#### Schritt 2: docker-compose.yml konfigurieren
|
||||
|
||||
Stelle sicher, dass die Variablen als Build-Args übergeben werden:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
hoerdle:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||
```
|
||||
|
||||
Die `docker-compose.example.yml` enthält bereits diese Konfiguration.
|
||||
|
||||
#### Schritt 3: App neu bauen
|
||||
|
||||
**WICHTIG:** Nach Änderung der Plausible-Variablen muss die App neu gebaut werden:
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Oder mit dem Deploy-Script:
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
### Konfiguration für beide Domains
|
||||
|
||||
Die App unterstützt **automatisches Tracking** für beide Domains (`hoerdle.de` und `hördle.de`). Die Domain wird automatisch aus dem Request-Header ausgelesen und entsprechend in Plausible getrackt.
|
||||
|
||||
#### Automatisches Domain-Tracking
|
||||
|
||||
**Standard-Verhalten:** Die App erkennt automatisch, welche Domain aufgerufen wurde, und setzt die entsprechende `data-domain` im Plausible-Script:
|
||||
- `https://hoerdle.de/*` → `data-domain="hoerdle.de"`
|
||||
- `https://hördle.de/*` → `data-domain="hördle.de"`
|
||||
|
||||
#### In Plausible konfigurieren
|
||||
|
||||
Du hast zwei Optionen:
|
||||
|
||||
##### Option 1: Beide Domains als separate Sites (separate Statistiken) - Empfohlen für getrenntes Tracking
|
||||
|
||||
1. Erstelle in Plausible zwei separate Sites:
|
||||
- `hoerdle.de`
|
||||
- `hördle.de`
|
||||
|
||||
2. Fertig! Die App trackt automatisch die richtige Domain.
|
||||
|
||||
**Vorteil:** Separate Statistiken für jede Domain.
|
||||
|
||||
##### Option 2: Beide Domains als Aliase für eine Site (gemeinsame Statistiken)
|
||||
|
||||
1. Erstelle in Plausible eine Site: `hoerdle.de`
|
||||
2. Füge `hördle.de` als Alias hinzu (in den Site-Einstellungen)
|
||||
|
||||
3. Fertig! Die App trackt automatisch die richtige Domain, und Plausible behandelt beide als Aliase für die gleiche Site.
|
||||
|
||||
**Hinweis:** Du musst nichts zusätzlich konfigurieren. Die App trackt automatisch `hoerdle.de` oder `hördle.de` basierend auf der Request-Domain, und Plausible erkennt beide als Aliase.
|
||||
|
||||
**Vorteil:** Gemeinsame Statistiken für beide Domains in einer Site.
|
||||
|
||||
#### Empfehlung
|
||||
|
||||
Für separate Statistiken: **Option 1** (automatisches Tracking)
|
||||
Für gemeinsame Statistiken: **Option 2** (Aliase in Plausible)
|
||||
|
||||
### Automatische CSP-Anpassung
|
||||
|
||||
Die Content Security Policy (CSP) in `proxy.ts` wird automatisch an die konfigurierte Plausible-URL angepasst. Die Domain wird automatisch aus der Script-URL extrahiert.
|
||||
|
||||
### Prüfen der Konfiguration
|
||||
|
||||
Nach dem Neubau kannst du prüfen, ob Plausible korrekt geladen wird:
|
||||
|
||||
1. **Browser-Entwicklertools öffnen**
|
||||
- Network-Tab: Suche nach dem Plausible-Script
|
||||
- Console: Prüfe auf Fehler
|
||||
|
||||
2. **Prüfe die Meta-Tags**
|
||||
```html
|
||||
<script defer data-domain="hoerdle.de" src="https://plausible.elpatron.me/js/script.js"></script>
|
||||
```
|
||||
|
||||
3. **Prüfe Plausible-Dashboard**
|
||||
- Öffne dein Plausible-Dashboard
|
||||
- Prüfe, ob Daten ankommen
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Plausible wird nicht geladen
|
||||
|
||||
- Prüfe, ob die Umgebungsvariablen korrekt gesetzt sind
|
||||
- Prüfe, ob die App neu gebaut wurde (Build-Time Variablen!)
|
||||
- Prüfe Browser-Console auf CSP-Fehler
|
||||
|
||||
#### CSP blockiert Plausible
|
||||
|
||||
Die CSP sollte automatisch angepasst werden. Falls Probleme auftreten:
|
||||
- Prüfe, ob `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` korrekt gesetzt ist
|
||||
- Prüfe die Logs des Containers
|
||||
|
||||
#### Daten werden nicht in Plausible angezeigt
|
||||
|
||||
- Prüfe, ob die Domain in Plausible als Site konfiguriert ist
|
||||
- Prüfe, ob `data-domain` Attribut mit der konfigurierten Domain übereinstimmt
|
||||
- Prüfe Browser-Console auf Fehler beim Laden des Scripts
|
||||
|
||||
### Beispiel-Konfiguration
|
||||
|
||||
#### Für selbst gehostetes Plausible:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.elpatron.me/js/script.js
|
||||
```
|
||||
|
||||
#### Für Plausible.io (extern):
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.io/js/script.js
|
||||
```
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt - keine weitere Konfiguration nötig!
|
||||
|
||||
### Weitere Informationen
|
||||
|
||||
- [Plausible Dokumentation](https://plausible.io/docs)
|
||||
- [Plausible Self-Hosting](https://plausible.io/docs/self-hosting)
|
||||
|
||||
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)
|
||||
|
||||
235
docs/SEO_TESTING.md
Normal file
235
docs/SEO_TESTING.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# SEO & Open Graph Testing Guide
|
||||
|
||||
## Übersicht
|
||||
|
||||
Diese Anleitung zeigt dir, wie du die SEO-Implementierung (Meta-Tags, Open Graph, Twitter Cards) testen kannst.
|
||||
|
||||
## Lokales Testen
|
||||
|
||||
### 1. Browser-Entwicklertools
|
||||
|
||||
1. **App starten:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Die App läuft unter `http://localhost:3000`
|
||||
|
||||
2. **Meta-Tags im HTML prüfen:**
|
||||
- Öffne eine Seite (z.B. `http://localhost:3000/en` oder `http://localhost:3000/de/about`)
|
||||
- Rechtsklick → "Seite untersuchen" (F12)
|
||||
- Tab "Elements" → `<head>` Bereich erweitern
|
||||
- Suche nach Meta-Tags:
|
||||
- `<meta property="og:title">`
|
||||
- `<meta property="og:description">`
|
||||
- `<meta property="og:image">`
|
||||
- `<meta name="twitter:card">`
|
||||
|
||||
3. **View Page Source:**
|
||||
- Rechtsklick → "Seitenquelltext anzeigen"
|
||||
- Suche nach "og:" oder "twitter:" um alle Open Graph und Twitter Meta-Tags zu sehen
|
||||
|
||||
### 2. cURL-Test (für schnelle Prüfung)
|
||||
|
||||
```bash
|
||||
# Prüfe Meta-Tags einer Seite
|
||||
curl -s http://localhost:3000/en | grep -i "og:\|twitter:"
|
||||
```
|
||||
|
||||
### 3. Node.js-Script zum Testen
|
||||
|
||||
Erstelle eine Test-Datei `test-og.js`:
|
||||
|
||||
```javascript
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
function fetchHTML(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
client.get(url, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => data += chunk);
|
||||
res.on('end', () => resolve(data));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function testOGTags(url) {
|
||||
try {
|
||||
const html = await fetchHTML(url);
|
||||
const ogTags = {
|
||||
title: html.match(/<meta property="og:title" content="([^"]*)"/)?.[1],
|
||||
description: html.match(/<meta property="og:description" content="([^"]*)"/)?.[1],
|
||||
image: html.match(/<meta property="og:image" content="([^"]*)"/)?.[1],
|
||||
url: html.match(/<meta property="og:url" content="([^"]*)"/)?.[1],
|
||||
};
|
||||
|
||||
console.log('Open Graph Tags:', ogTags);
|
||||
return ogTags;
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
testOGTags('http://localhost:3000/en');
|
||||
```
|
||||
|
||||
## Online-Tools (für Produktions-URLs)
|
||||
|
||||
### 1. Facebook Sharing Debugger (Empfohlen)
|
||||
|
||||
**URL:** https://developers.facebook.com/tools/debug/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine Produktions-URL ein (z.B. `https://hoerdle.de/en`)
|
||||
3. Klicke auf "Debuggen"
|
||||
4. Prüfe die Vorschau und alle Meta-Tags
|
||||
|
||||
**Wichtig:**
|
||||
- Facebook cached die Vorschau! Klicke auf "Scraping erneut ausführen" um den Cache zu leeren
|
||||
- Funktioniert nur mit öffentlich erreichbaren URLs (nicht localhost)
|
||||
|
||||
### 2. Twitter Card Validator
|
||||
|
||||
**URL:** https://cards-dev.twitter.com/validator
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine Produktions-URL ein
|
||||
3. Prüfe die Twitter Card Vorschau
|
||||
|
||||
**Hinweis:** Twitter hat den Validator eingestellt, aber die Cards funktionieren trotzdem. Du kannst auch einfach einen Tweet mit deiner URL erstellen, um zu sehen, ob die Card angezeigt wird.
|
||||
|
||||
### 3. LinkedIn Post Inspector
|
||||
|
||||
**URL:** https://www.linkedin.com/post-inspector/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL (Login erforderlich)
|
||||
2. Gib deine Produktions-URL ein
|
||||
3. Prüfe die LinkedIn Vorschau
|
||||
|
||||
### 4. OpenGraph.xyz (Universelles Tool)
|
||||
|
||||
**URL:** https://www.opengraph.xyz/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine URL ein
|
||||
3. Sieh dir alle Open Graph und Twitter Meta-Tags an
|
||||
4. Sieh dir die Vorschau für verschiedene Plattformen an
|
||||
|
||||
### 5. Metatags.io
|
||||
|
||||
**URL:** https://metatags.io/
|
||||
|
||||
**Verwendung:**
|
||||
- Gebe deine URL ein
|
||||
- Sieh dir alle Meta-Tags an
|
||||
- Vorschau für verschiedene Plattformen
|
||||
|
||||
## Produktions-Test (hoerdle.de / hördle.de)
|
||||
|
||||
Sobald die App deployed ist, kannst du alle oben genannten Tools mit deinen Produktions-URLs verwenden:
|
||||
|
||||
### Test-URLs:
|
||||
|
||||
- Homepage (EN): `https://hoerdle.de/en`
|
||||
- Homepage (DE): `https://hoerdle.de/de`
|
||||
- About (EN): `https://hoerdle.de/en/about`
|
||||
- About (DE): `https://hoerdle.de/de/about`
|
||||
- Genre-Seiten: `https://hoerdle.de/en/Rock` (Beispiel)
|
||||
- Special-Seiten: `https://hoerdle.de/en/special/Weihnachtslieder` (Beispiel)
|
||||
|
||||
### Schnelltest mit cURL:
|
||||
|
||||
```bash
|
||||
# Teste Homepage
|
||||
curl -s https://hoerdle.de/en | grep -E "og:|twitter:" | head -10
|
||||
|
||||
# Teste About-Seite
|
||||
curl -s https://hoerdle.de/de/about | grep -E "og:|twitter:" | head -10
|
||||
```
|
||||
|
||||
## Erwartete Meta-Tags
|
||||
|
||||
Die folgenden Meta-Tags sollten auf allen Seiten vorhanden sein:
|
||||
|
||||
### Open Graph Tags:
|
||||
- `og:title` - Seitentitel
|
||||
- `og:description` - Seitenbeschreibung
|
||||
- `og:image` - Bild für Social Media (Standard: `/favicon.ico`)
|
||||
- `og:url` - Canonical URL
|
||||
- `og:type` - Typ (sollte "website" sein)
|
||||
- `og:site_name` - Name der Site
|
||||
- `og:locale` - Sprache (de/en)
|
||||
|
||||
### Twitter Tags:
|
||||
- `twitter:card` - Card-Typ (sollte "summary_large_image" sein)
|
||||
- `twitter:title` - Titel
|
||||
- `twitter:description` - Beschreibung
|
||||
- `twitter:image` - Bild
|
||||
|
||||
### Canonical & Alternates:
|
||||
- `<link rel="canonical">` - Canonical URL
|
||||
- `<link rel="alternate" hreflang="de">` - Deutsche Version
|
||||
- `<link rel="alternate" hreflang="en">` - Englische Version
|
||||
- `<link rel="alternate" hreflang="x-default">` - Standard-Version
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: Meta-Tags werden nicht angezeigt
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob die App läuft: `npm run dev`
|
||||
2. Prüfe Browser-Console auf Fehler
|
||||
3. Stelle sicher, dass `generateMetadata` in der Seite exportiert ist
|
||||
4. Prüfe, ob `lib/metadata.ts` korrekt importiert wird
|
||||
|
||||
### Problem: Open Graph Image wird nicht angezeigt
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob das Bild unter `/favicon.ico` existiert (oder konfiguriertes OG-Image)
|
||||
2. Für bessere Ergebnisse: Erstelle ein dediziertes Open Graph Bild (1200x630px)
|
||||
3. Platziere es in `public/og-image.png`
|
||||
4. Setze in `.env`: `NEXT_PUBLIC_OG_IMAGE=/og-image.png`
|
||||
|
||||
### Problem: Facebook zeigt alte Vorschau
|
||||
|
||||
**Lösung:**
|
||||
1. Öffne Facebook Sharing Debugger
|
||||
2. Gib deine URL ein
|
||||
3. Klicke auf "Scraping erneut ausführen" (mehrfach, falls nötig)
|
||||
4. Facebook cached die Vorschau - Cache kann mehrere Stunden dauern
|
||||
|
||||
### Problem: Domain-Erkennung funktioniert nicht
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe `lib/seo.ts` - `getBaseUrl()` Funktion
|
||||
2. Stelle sicher, dass Request-Headers korrekt sind
|
||||
3. In Produktion: Prüfe, ob Proxy-Headers (`x-forwarded-host`) korrekt gesetzt sind
|
||||
|
||||
## Open Graph Bild optimieren
|
||||
|
||||
Für bessere Social Media Vorschauen solltest du ein dediziertes OG-Bild erstellen:
|
||||
|
||||
**Empfohlene Größe:** 1200x630px
|
||||
**Format:** PNG oder JPG
|
||||
**Pfad:** `public/og-image.png`
|
||||
|
||||
**Konfiguration:**
|
||||
```bash
|
||||
# In .env
|
||||
NEXT_PUBLIC_OG_IMAGE=/og-image.png
|
||||
```
|
||||
|
||||
Dann wird dieses Bild in allen Open Graph Meta-Tags verwendet.
|
||||
|
||||
## Nützliche Links
|
||||
|
||||
- [Open Graph Protocol Dokumentation](https://ogp.me/)
|
||||
- [Twitter Cards Dokumentation](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards)
|
||||
- [Facebook Sharing Best Practices](https://developers.facebook.com/docs/sharing/webmasters)
|
||||
|
||||
@@ -12,15 +12,15 @@ The application is configured via environment variables. You can set these in a
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_APP_NAME` | The name of the application. | `Hördle` |
|
||||
| `NEXT_PUBLIC_APP_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
|
||||
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.elpatron.me` |
|
||||
| `NEXT_PUBLIC_TWITTER_HANDLE` | Twitter handle for metadata. | `@elpatron` |
|
||||
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.de` |
|
||||
|
||||
### Analytics (Plausible)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | The domain to track in Plausible. | `hoerdle.elpatron.me` |
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.elpatron.me/js/script.js` |
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.example.com/js/script.js` |
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt. Beide Domains (`hoerdle.de` und `hördle.de`) werden automatisch getrackt.
|
||||
|
||||
### Credits
|
||||
|
||||
@@ -9,7 +9,7 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
console.log('[i18n/request] incoming requestLocale:', locale);
|
||||
|
||||
if (!locale || !locales.includes(locale as (typeof locales)[number])) {
|
||||
locale = 'de';
|
||||
locale = 'en';
|
||||
console.log('[i18n/request] falling back to default locale:', locale);
|
||||
}
|
||||
|
||||
|
||||
58
lib/auth.ts
58
lib/auth.ts
@@ -1,4 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient, Curator } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export type StaffContext =
|
||||
| { role: 'admin' }
|
||||
| { role: 'curator'; curator: Curator };
|
||||
|
||||
/**
|
||||
* Authentication middleware for admin API routes
|
||||
@@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise<NextRespon
|
||||
return null; // Auth successful
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve current staff (admin or curator) from headers.
|
||||
*
|
||||
* Admin:
|
||||
* - x-admin-auth: 'authenticated'
|
||||
*
|
||||
* Curator:
|
||||
* - x-curator-auth: 'authenticated'
|
||||
* - x-curator-username: <username>
|
||||
*/
|
||||
export async function getStaffContext(request: NextRequest): Promise<StaffContext | null> {
|
||||
const adminHeader = request.headers.get('x-admin-auth');
|
||||
if (adminHeader === 'authenticated') {
|
||||
return { role: 'admin' };
|
||||
}
|
||||
|
||||
const curatorAuth = request.headers.get('x-curator-auth');
|
||||
const curatorUsername = request.headers.get('x-curator-username');
|
||||
|
||||
if (curatorAuth === 'authenticated' && curatorUsername) {
|
||||
const curator = await prisma.curator.findUnique({
|
||||
where: { username: curatorUsername },
|
||||
});
|
||||
|
||||
if (curator) {
|
||||
return { role: 'curator', curator };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that the current request is authenticated as staff (admin or curator).
|
||||
* Returns either an error response or a resolved context.
|
||||
*/
|
||||
export async function requireStaffAuth(request: NextRequest): Promise<{ error?: NextResponse; context?: StaffContext }> {
|
||||
const context = await getStaffContext(request);
|
||||
|
||||
if (!context) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: 'Unauthorized - Staff authentication required' },
|
||||
{ status: 401 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { context };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to verify admin password
|
||||
*/
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
export const config = {
|
||||
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
|
||||
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
|
||||
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.elpatron.me',
|
||||
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
|
||||
plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'hoerdle.elpatron.me',
|
||||
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.elpatron.me/js/script.js',
|
||||
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.de',
|
||||
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js',
|
||||
colors: {
|
||||
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
|
||||
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
|
||||
},
|
||||
credits: {
|
||||
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
|
||||
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
||||
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Made with 💚, ☕ and 🍺 by',
|
||||
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||
},
|
||||
seo: {
|
||||
ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/api/og-image',
|
||||
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,13 +49,15 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
|
||||
// Calculate total weight
|
||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||
|
||||
// Pick a random song based on weights
|
||||
// Pick a random song based on weights using cumulative weights
|
||||
// This ensures proper distribution and handles edge cases
|
||||
let random = Math.random() * totalWeight;
|
||||
let selectedSong = weightedSongs[0].song;
|
||||
let selectedSong = weightedSongs[weightedSongs.length - 1].song; // Fallback to last song
|
||||
|
||||
let cumulativeWeight = 0;
|
||||
for (const item of weightedSongs) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
cumulativeWeight += item.weight;
|
||||
if (random <= cumulativeWeight) {
|
||||
selectedSong = item.song;
|
||||
break;
|
||||
}
|
||||
@@ -156,11 +158,13 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
|
||||
|
||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
let selectedSpecialSong = weightedSongs[0].specialSong;
|
||||
let selectedSpecialSong = weightedSongs[weightedSongs.length - 1].specialSong; // Fallback to last song
|
||||
|
||||
// Pick a random song based on weights using cumulative weights
|
||||
let cumulativeWeight = 0;
|
||||
for (const item of weightedSongs) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
cumulativeWeight += item.weight;
|
||||
if (random <= cumulativeWeight) {
|
||||
selectedSpecialSong = item.specialSong;
|
||||
break;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
169
lib/gameState.ts
169
lib/gameState.ts
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getTodayISOString } from './dateUtils';
|
||||
import { loadPlayerState, savePlayerState, getGenreKey } from './playerStorage';
|
||||
|
||||
export interface GameState {
|
||||
date: string;
|
||||
@@ -27,17 +28,19 @@ export interface Statistics {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
||||
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
||||
|
||||
const INITIAL_SCORE = 90;
|
||||
|
||||
export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
|
||||
export function useGameState(
|
||||
genre: string | null = null,
|
||||
maxAttempts: number = 7,
|
||||
isSpecial: boolean = false
|
||||
) {
|
||||
const [gameState, setGameState] = useState<GameState | null>(null);
|
||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||
|
||||
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
||||
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
|
||||
|
||||
// Get genre key for backend storage
|
||||
// For specials, genre contains the special name
|
||||
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
|
||||
const createNewState = (date: string): GameState => ({
|
||||
date,
|
||||
@@ -52,72 +55,94 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
yearGuessed: false
|
||||
});
|
||||
|
||||
const createNewStatistics = (): Statistics => ({
|
||||
solvedIn1: 0,
|
||||
solvedIn2: 0,
|
||||
solvedIn3: 0,
|
||||
solvedIn4: 0,
|
||||
solvedIn5: 0,
|
||||
solvedIn6: 0,
|
||||
solvedIn7: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Load game state
|
||||
const storageKey = getStorageKey();
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
const today = getTodayISOString();
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.date === today) {
|
||||
// Migration for existing states without score
|
||||
if (parsed.score === undefined) {
|
||||
parsed.score = INITIAL_SCORE;
|
||||
parsed.replayCount = 0;
|
||||
parsed.skipCount = 0;
|
||||
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
|
||||
parsed.yearGuessed = false;
|
||||
|
||||
// Retroactively deduct points for existing guesses if possible,
|
||||
// but simpler to just start at 90 for active games to avoid confusion
|
||||
|
||||
// Always recompute genreKey to ensure it's current
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
|
||||
// Try to load from backend first
|
||||
const loadFromBackend = async () => {
|
||||
try {
|
||||
const backendState = await loadPlayerState(currentGenreKey);
|
||||
|
||||
if (backendState) {
|
||||
const { gameState: loadedState, statistics: loadedStats } = backendState;
|
||||
|
||||
// Check if the loaded state is for today
|
||||
if (loadedState.date === today) {
|
||||
setGameState(loadedState);
|
||||
setStatistics(loadedStats);
|
||||
return; // Successfully loaded from backend
|
||||
} else {
|
||||
// State is for a different day - create new state
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
setStatistics(loadedStats); // Keep statistics across days
|
||||
// Save new state to backend
|
||||
await savePlayerState(currentGenreKey, newState, loadedStats);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No backend state found - create new state
|
||||
// This is the normal case for first-time players or new genre
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
const newStats = createNewStatistics();
|
||||
setStatistics(newStats);
|
||||
// Save to backend for cross-domain sync
|
||||
await savePlayerState(currentGenreKey, newState, newStats);
|
||||
return;
|
||||
}
|
||||
setGameState(parsed as GameState);
|
||||
} else {
|
||||
// New day
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to load from backend:', error);
|
||||
|
||||
// On error, create new state and try to save to backend
|
||||
// This handles network errors gracefully
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
const newStats = createNewStatistics();
|
||||
setStatistics(newStats);
|
||||
// Try to save to backend (may fail, but we try)
|
||||
try {
|
||||
await savePlayerState(currentGenreKey, newState, newStats);
|
||||
} catch (saveError) {
|
||||
console.error('[gameState] Failed to save new state to backend:', saveError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No state
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
}
|
||||
};
|
||||
|
||||
loadFromBackend();
|
||||
}, [genre, isSpecial]); // Re-run when genre or isSpecial changes
|
||||
|
||||
// Load statistics
|
||||
const statsKey = getStatsKey();
|
||||
const storedStats = localStorage.getItem(statsKey);
|
||||
if (storedStats) {
|
||||
const parsedStats = JSON.parse(storedStats);
|
||||
// Migration for existing stats without solvedIn7
|
||||
if (parsedStats.solvedIn7 === undefined) {
|
||||
parsedStats.solvedIn7 = 0;
|
||||
}
|
||||
setStatistics(parsedStats);
|
||||
} else {
|
||||
const newStats: Statistics = {
|
||||
solvedIn1: 0,
|
||||
solvedIn2: 0,
|
||||
solvedIn3: 0,
|
||||
solvedIn4: 0,
|
||||
solvedIn5: 0,
|
||||
solvedIn6: 0,
|
||||
solvedIn7: 0,
|
||||
failed: 0,
|
||||
};
|
||||
setStatistics(newStats);
|
||||
localStorage.setItem(statsKey, JSON.stringify(newStats));
|
||||
}
|
||||
}, [genre]); // Re-run when genre changes
|
||||
|
||||
const saveState = (newState: GameState) => {
|
||||
const saveState = async (newState: GameState) => {
|
||||
setGameState(newState);
|
||||
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
||||
|
||||
// Save to backend only
|
||||
if (statistics) {
|
||||
try {
|
||||
// Always use the current genreKey (recompute it in case genre/isSpecial changed)
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
await savePlayerState(currentGenreKey, newState, statistics);
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to save to backend:', error);
|
||||
// No fallback - backend is required for cross-domain sync
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatistics = (attempts: number, solved: boolean) => {
|
||||
const updateStatistics = async (attempts: number, solved: boolean) => {
|
||||
if (!statistics) return;
|
||||
|
||||
const newStats = { ...statistics };
|
||||
@@ -139,11 +164,24 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
}
|
||||
|
||||
setStatistics(newStats);
|
||||
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
||||
|
||||
// Save to backend only
|
||||
if (gameState) {
|
||||
try {
|
||||
// Always use the current genreKey (recompute it in case genre/isSpecial changed)
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
await savePlayerState(currentGenreKey, gameState, newStats);
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to save statistics to backend:', error);
|
||||
// No fallback - backend is required for cross-domain sync
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addGuess = (guess: string, correct: boolean) => {
|
||||
if (!gameState) return;
|
||||
// Prevent adding guesses if already solved or failed
|
||||
if (gameState.isSolved || gameState.isFailed) return;
|
||||
|
||||
const newGuesses = [...gameState.guesses, guess];
|
||||
const isSolved = correct;
|
||||
@@ -162,6 +200,9 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
} else {
|
||||
newScore -= 3;
|
||||
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
|
||||
// Additional penalty for track extension (unlock steps)
|
||||
newScore -= 5;
|
||||
newBreakdown.push({ value: -5, reason: 'Track extension' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,12 @@ export function getLocalizedValue(
|
||||
if (typeof value === 'object') {
|
||||
if (value[locale]) return value[locale];
|
||||
|
||||
// Fallback to 'de'
|
||||
if (value['de']) return value['de'];
|
||||
|
||||
// Fallback to 'en'
|
||||
if (value['en']) return value['en'];
|
||||
|
||||
// Fallback to 'de'
|
||||
if (value['de']) return value['de'];
|
||||
|
||||
// Fallback to first key
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > 0) return value[keys[0]];
|
||||
|
||||
64
lib/metadata.ts
Normal file
64
lib/metadata.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { config } from './config';
|
||||
import { getBaseUrl } from './seo';
|
||||
|
||||
/**
|
||||
* Generate base metadata with Open Graph, Twitter Cards, and canonical URLs
|
||||
*/
|
||||
export async function generateBaseMetadata(
|
||||
locale: string,
|
||||
path: string = '',
|
||||
title?: string,
|
||||
description?: string,
|
||||
image?: string
|
||||
): Promise<Metadata> {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const pathSegment = path ? `/${path}` : '';
|
||||
const fullUrl = `${baseUrl}/${locale}${pathSegment}`;
|
||||
|
||||
// Determine alternate URLs for both locales (same path for both)
|
||||
const alternateLocale = locale === 'de' ? 'en' : 'de';
|
||||
const alternateUrl = `${baseUrl}/${alternateLocale}${pathSegment}`;
|
||||
|
||||
// Default values
|
||||
const metaTitle = title || config.appName;
|
||||
const metaDescription = description || config.appDescription;
|
||||
const ogImage = image || `${baseUrl}${config.seo.ogImage}`;
|
||||
|
||||
return {
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
alternates: {
|
||||
canonical: fullUrl,
|
||||
languages: {
|
||||
[locale]: fullUrl,
|
||||
[alternateLocale]: alternateUrl,
|
||||
'x-default': `${baseUrl}/en${pathSegment}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
url: fullUrl,
|
||||
siteName: config.appName,
|
||||
images: [
|
||||
{
|
||||
url: ogImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: metaTitle,
|
||||
},
|
||||
],
|
||||
locale: locale,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
images: [ogImage],
|
||||
...(config.seo.twitterHandle && { creator: config.seo.twitterHandle }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
223
lib/playerId.ts
Normal file
223
lib/playerId.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Player Identifier Management
|
||||
*
|
||||
* Generates and manages a unique player identifier (UUID) that is stored
|
||||
* in localStorage. This identifier is used to sync game states across
|
||||
* 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_PLAYER = 'hoerdle_player_id';
|
||||
const STORAGE_KEY_DEVICE = 'hoerdle_device_id';
|
||||
|
||||
/**
|
||||
* Generate a UUID v4
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @returns Base player ID if found, null otherwise
|
||||
*/
|
||||
async function findExistingBasePlayerId(genreKey: string): Promise<string | null> {
|
||||
try {
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
const response = await fetch('/api/player-id/suggest', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ genreKey, deviceId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.basePlayerId) {
|
||||
return data.basePlayerId;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[playerId] Failed to find existing base player ID:', error);
|
||||
}
|
||||
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
|
||||
*
|
||||
* Player ID format: {basePlayerId}:{deviceId}
|
||||
*
|
||||
* If no identifier exists in localStorage, tries to find an existing base player ID
|
||||
* 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> {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Always get/create device ID (device-specific)
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
|
||||
// Try to get base player ID from localStorage
|
||||
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) {
|
||||
const existingBaseId = await findExistingBasePlayerId(genreKey);
|
||||
if (existingBaseId) {
|
||||
basePlayerId = existingBaseId;
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new base player ID if no existing one found
|
||||
if (!basePlayerId) {
|
||||
basePlayerId = generateBasePlayerId();
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine base player ID with device ID
|
||||
return combinePlayerId(basePlayerId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a player identifier (synchronous version)
|
||||
*
|
||||
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
|
||||
*
|
||||
* @returns Full player identifier ({basePlayerId}:{deviceId})
|
||||
*/
|
||||
export function getOrCreatePlayerId(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
|
||||
if (!basePlayerId) {
|
||||
basePlayerId = generateBasePlayerId();
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
|
||||
return combinePlayerId(basePlayerId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current player identifier without creating a new one
|
||||
*
|
||||
* @returns Full player identifier ({basePlayerId}:{deviceId}) or null if not set
|
||||
*/
|
||||
export function getPlayerId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
134
lib/playerStorage.ts
Normal file
134
lib/playerStorage.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Player Storage API
|
||||
*
|
||||
* Handles loading and saving player game states from/to the backend.
|
||||
* This enables cross-domain synchronization between hoerdle.de and hördle.de.
|
||||
*/
|
||||
|
||||
import { getOrCreatePlayerId } from './playerId';
|
||||
import type { GameState, Statistics } from './gameState';
|
||||
|
||||
/**
|
||||
* Get genre key for storage
|
||||
*
|
||||
* Formats the genre/special into a consistent key format:
|
||||
* - Global: "global"
|
||||
* - Genre: "Rock" (localized name)
|
||||
* - Special: "special:00725" (special name)
|
||||
*/
|
||||
export function getGenreKey(
|
||||
genre: string | null,
|
||||
isSpecial: boolean,
|
||||
specialName?: string
|
||||
): string {
|
||||
if (isSpecial && specialName) {
|
||||
return `special:${specialName}`;
|
||||
}
|
||||
return genre || 'global';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load player state from backend
|
||||
*
|
||||
* @param genreKey - Genre key (from getGenreKey)
|
||||
* @returns GameState and Statistics, or null if not found
|
||||
*/
|
||||
export async function loadPlayerState(
|
||||
genreKey: string
|
||||
): Promise<{ gameState: GameState; statistics: Statistics } | null> {
|
||||
try {
|
||||
// Use async version to enable cross-domain player ID sync
|
||||
const { getOrCreatePlayerIdAsync } = await import('./playerId');
|
||||
const playerId = await getOrCreatePlayerIdAsync(genreKey);
|
||||
if (!playerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine if it's a special or genre
|
||||
const isSpecial = genreKey.startsWith('special:');
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (isSpecial) {
|
||||
const specialName = genreKey.replace('special:', '');
|
||||
params.append('special', specialName);
|
||||
} else if (genreKey !== 'global') {
|
||||
params.append('genre', genreKey);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/player-state?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Player-Id': playerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
// No state found - this is normal for new players
|
||||
return null;
|
||||
}
|
||||
// Other errors: log and return null (will fallback to localStorage)
|
||||
console.warn('[playerStorage] Failed to load player state:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || !data.gameState || !data.statistics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
gameState: data.gameState as GameState,
|
||||
statistics: data.statistics as Statistics,
|
||||
};
|
||||
} catch (error) {
|
||||
// Network errors or other issues: fallback to localStorage
|
||||
console.warn('[playerStorage] Error loading player state:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save player state to backend
|
||||
*
|
||||
* @param genreKey - Genre key (from getGenreKey)
|
||||
* @param gameState - Current game state
|
||||
* @param statistics - Current statistics
|
||||
*/
|
||||
export async function savePlayerState(
|
||||
genreKey: string,
|
||||
gameState: GameState,
|
||||
statistics: Statistics
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Use async version to ensure device ID is included
|
||||
const { getOrCreatePlayerIdAsync } = await import('./playerId');
|
||||
const playerId = await getOrCreatePlayerIdAsync();
|
||||
if (!playerId) {
|
||||
console.warn('[playerStorage] No player ID available, cannot save state');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/player-state', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Player-Id': playerId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
genreKey,
|
||||
gameState,
|
||||
statistics,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[playerStorage] Failed to save player state:', response.status);
|
||||
// Don't throw - allow fallback to localStorage
|
||||
}
|
||||
} catch (error) {
|
||||
// Network errors: log but don't throw (will fallback to localStorage)
|
||||
console.warn('[playerStorage] Error saving player state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
43
lib/seo.ts
Normal file
43
lib/seo.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { config } from './config';
|
||||
|
||||
/**
|
||||
* Get the current base URL from request headers
|
||||
* Automatically detects hoerdle.de or hördle.de (xn--hrdle-jua.de)
|
||||
*/
|
||||
export async function getBaseUrl(): Promise<string> {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
|
||||
|
||||
let domain = config.domain; // Default fallback
|
||||
|
||||
if (host) {
|
||||
// Extract domain from host (remove port if present)
|
||||
const detectedDomain = host.split(':')[0].toLowerCase();
|
||||
|
||||
// Map domains
|
||||
if (detectedDomain === 'hoerdle.de') {
|
||||
domain = 'hoerdle.de';
|
||||
} else if (detectedDomain === 'hördle.de' || detectedDomain === 'xn--hrdle-jua.de') {
|
||||
domain = 'hördle.de';
|
||||
} else {
|
||||
// Use detected domain if it's different from default
|
||||
domain = detectedDomain;
|
||||
}
|
||||
}
|
||||
|
||||
// Always use HTTPS in production
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
return `${protocol}://${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base URL synchronously (for use in non-async contexts)
|
||||
* Uses environment variable or config as fallback
|
||||
*/
|
||||
export function getBaseUrlSync(): string {
|
||||
const domain = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
return `${protocol}://${domain}`;
|
||||
}
|
||||
|
||||
137
messages/de.json
137
messages/de.json
@@ -41,12 +41,14 @@
|
||||
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
|
||||
"theSongWas": "Das Lied war:",
|
||||
"score": "Punkte",
|
||||
"shareExplanation": "Teile dein Ergebnis mit Freund:innen – so hilfst du, Hördle bekannter zu machen.",
|
||||
"scoreBreakdown": "Punkteaufschlüsselung",
|
||||
"albumCover": "Album-Cover",
|
||||
"released": "Veröffentlicht",
|
||||
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
|
||||
"thanksForRating": "Danke für die Bewertung!",
|
||||
"rateThisPuzzle": "Bewerte dieses Rätsel:",
|
||||
"ratingTooltip": "Hilf unseren Kuratoren, gute Rätsel zu machen!",
|
||||
"shared": "✓ Geteilt!",
|
||||
"copied": "✓ Kopiert!",
|
||||
"shareFailed": "✗ Fehlgeschlagen",
|
||||
@@ -55,6 +57,13 @@
|
||||
"points": "Punkte",
|
||||
"skipBonus": "Bonus überspringen",
|
||||
"notQuite": "Nicht ganz!",
|
||||
"sendComment": "Nachricht an Kurator senden",
|
||||
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres...",
|
||||
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
|
||||
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
|
||||
"commentError": "Fehler beim Senden der Nachricht",
|
||||
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
|
||||
"sending": "Wird gesendet...",
|
||||
"youGuessed": "Du hast geraten",
|
||||
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
||||
"skipped": "Übersprungen",
|
||||
@@ -63,6 +72,12 @@
|
||||
"special": "Special",
|
||||
"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": {
|
||||
"yourStatistics": "Deine Statistiken",
|
||||
"totalPuzzles": "Gesamte Rätsel",
|
||||
@@ -125,7 +140,7 @@
|
||||
"delete": "Löschen",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"curate": "Kurieren",
|
||||
"curate": "Kuratieren",
|
||||
"name": "Name",
|
||||
"subtitle": "Untertitel",
|
||||
"maxAttempts": "Max. Versuche",
|
||||
@@ -150,9 +165,116 @@
|
||||
"artist": "Interpret",
|
||||
"actions": "Aktionen",
|
||||
"deletePuzzle": "Löschen",
|
||||
"wrongPassword": "Falsches Passwort"
|
||||
"wrongPassword": "Falsches Passwort",
|
||||
"manageCurators": "Kuratoren verwalten",
|
||||
"addCurator": "Kurator hinzufügen",
|
||||
"curatorUsername": "Benutzername",
|
||||
"curatorPassword": "Passwort (bei Leer lassen: nicht ändern)",
|
||||
"isGlobalCurator": "Globaler Kurator (darf Global-Flag ändern)",
|
||||
"assignedGenres": "Zugeordnete Genres",
|
||||
"assignedSpecials": "Zugeordnete Specials",
|
||||
"noCurators": "Noch keine Kuratoren angelegt."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"About": {
|
||||
"title": "Über Hördle & Impressum",
|
||||
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
|
||||
"projectTitle": "Über dieses Projekt",
|
||||
@@ -162,15 +284,15 @@
|
||||
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
|
||||
"imprintCountry": "Deutschland",
|
||||
"imprintEmailLabel": "E-Mail:",
|
||||
"imprintDisclaimer": "Hinweis: Diese Angaben entsprechen dem aktuellen Stand. Für rechtliche Fragen solltest du eine Fachperson konsultieren.",
|
||||
"costsTitle": "Laufende Kosten des Projekts",
|
||||
"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)",
|
||||
"costsServer": "Server / vServer für App und Tracking",
|
||||
"costsEmail": "E-Mail-Hosting",
|
||||
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
|
||||
"costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.",
|
||||
"costsSheetPrivacyNote": "Beim Aufruf oder Einbetten der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
|
||||
"costsSheetPrivacyNote": "Beim Aufruf der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
|
||||
"supportTitle": "Hördle unterstützen",
|
||||
"supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:",
|
||||
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",
|
||||
@@ -180,6 +302,10 @@
|
||||
"supportPaypalLink": "paypal.me/MBusche",
|
||||
"supportSteadyTitle": "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",
|
||||
"privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.",
|
||||
"privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics",
|
||||
@@ -190,7 +316,6 @@
|
||||
"privacyPlausibleAggregated": "Auswertungen erfolgen ausschließlich in aggregierter Form (z. B. Seitenaufrufe, genutzte Browser).",
|
||||
"privacyServerLogs": "Der Server kann technisch bedingt Logdateien mit IP-Adresse, Zeitpunkt des Zugriffs und abgerufenen Ressourcen führen. Diese Daten werden nur zur Sicherstellung des Betriebs und zur Fehleranalyse verwendet und regelmäßig gelöscht.",
|
||||
"privacyContact": "Wenn du Fragen zu den verarbeiteten Daten hast oder Auskunft wünschst, kannst du dich über die im Impressum genannte E-Mail-Adresse melden.",
|
||||
"privacyNoLegalAdvice": "Hinweis: Diese Datenschutzhinweise dienen nur als Beispiel und ersetzen keine rechtliche Beratung. Für eine rechtskonforme Datenschutzerklärung solltest du eine Fachperson konsultieren.",
|
||||
"backTitle": "Zurück zum Spiel",
|
||||
"backToGame": "Zurück zu Hördle",
|
||||
"footerLinkLabel": "Über & Impressum"
|
||||
|
||||
133
messages/en.json
133
messages/en.json
@@ -41,12 +41,14 @@
|
||||
"comeBackTomorrow": "Come back tomorrow for a new song.",
|
||||
"theSongWas": "The song was:",
|
||||
"score": "Score",
|
||||
"shareExplanation": "Share your result with friends – your support helps Hördle grow.",
|
||||
"scoreBreakdown": "Score Breakdown",
|
||||
"albumCover": "Album Cover",
|
||||
"released": "Released",
|
||||
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
|
||||
"thanksForRating": "Thanks for rating!",
|
||||
"rateThisPuzzle": "Rate this puzzle:",
|
||||
"ratingTooltip": "Help our curators create good puzzles!",
|
||||
"shared": "✓ Shared!",
|
||||
"copied": "✓ Copied!",
|
||||
"shareFailed": "✗ Failed",
|
||||
@@ -55,6 +57,13 @@
|
||||
"points": "points",
|
||||
"skipBonus": "Skip Bonus",
|
||||
"notQuite": "Not quite!",
|
||||
"sendComment": "Send message to curator",
|
||||
"commentPlaceholder": "Write a message to the curators of this genre...",
|
||||
"commentHelp": "Share your thoughts about the puzzle with the curators. Your message will be displayed to them.",
|
||||
"commentSent": "✓ Message sent! Thank you for your feedback.",
|
||||
"commentError": "Error sending message",
|
||||
"commentRateLimited": "You have already sent a message for this puzzle.",
|
||||
"sending": "Sending...",
|
||||
"youGuessed": "You guessed",
|
||||
"actuallyReleasedIn": "Actually released in",
|
||||
"skipped": "Skipped",
|
||||
@@ -63,6 +72,12 @@
|
||||
"special": "Special",
|
||||
"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": {
|
||||
"yourStatistics": "Your Statistics",
|
||||
"totalPuzzles": "Total puzzles",
|
||||
@@ -150,8 +165,115 @@
|
||||
"artist": "Artist",
|
||||
"actions": "Actions",
|
||||
"deletePuzzle": "Delete",
|
||||
"wrongPassword": "Wrong password"
|
||||
"wrongPassword": "Wrong password",
|
||||
"manageCurators": "Manage Curators",
|
||||
"addCurator": "Add Curator",
|
||||
"curatorUsername": "Username",
|
||||
"curatorPassword": "Password (leave empty to keep)",
|
||||
"isGlobalCurator": "Global curator (may change global flag)",
|
||||
"assignedGenres": "Assigned genres",
|
||||
"assignedSpecials": "Assigned specials",
|
||||
"noCurators": "No curators created yet."
|
||||
},
|
||||
"Curator": {
|
||||
"loginTitle": "Curator Login",
|
||||
"loginUsername": "Username",
|
||||
"loginPassword": "Password",
|
||||
"loginButton": "Log in",
|
||||
"logout": "Logout",
|
||||
"loginFailed": "Login failed.",
|
||||
"loginNetworkError": "Network error during login.",
|
||||
"loadCuratorError": "Failed to load curator information.",
|
||||
"loadSongsError": "Failed to load songs.",
|
||||
"songUpdated": "Song updated successfully.",
|
||||
"saveError": "Error while saving: {error}",
|
||||
"saveNetworkError": "Network error while saving.",
|
||||
"noDeletePermission": "You are not allowed to delete this song.",
|
||||
"deleteConfirm": "Do you really want to delete \"{title}\"?",
|
||||
"songDeleted": "Song deleted.",
|
||||
"deleteError": "Error while deleting: {error}",
|
||||
"deleteNetworkError": "Network error while deleting.",
|
||||
"uploadSectionTitle": "Upload titles",
|
||||
"uploadSectionDescription": "Drag one or more MP3 files here or select them. The titles will be analysed automatically (including detection of the release year) and excluded from the global playlist. Select at least one of your genres to assign the titles.",
|
||||
"dropzoneTitleEmpty": "Drag MP3 files here",
|
||||
"dropzoneTitleWithFiles": "{count} file(s) selected",
|
||||
"dropzoneSubtitle": "or click to select files",
|
||||
"selectedFilesTitle": "Selected files:",
|
||||
"uploadProgress": "Upload: {current} / {total}",
|
||||
"assignGenresLabel": "Assign genres",
|
||||
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
|
||||
"uploadButtonIdle": "Start upload",
|
||||
"uploadButtonUploading": "Uploading...",
|
||||
"uploadSummary": "✅ {success}/{total} uploads successful.",
|
||||
"uploadSummaryDuplicates": "⚠️ {count} duplicate(s) skipped.",
|
||||
"uploadSummaryFailed": "❌ {count} failed.",
|
||||
"uploadResultSuccess": "✅ successful",
|
||||
"uploadResultDuplicate": "⚠️ Duplicate: {error}",
|
||||
"uploadResultError": "❌ Error: {error}",
|
||||
"tracklistTitle": "Titles in your genres & specials ({count} titles)",
|
||||
"tracklistDescription": "You can edit songs that are assigned to at least one of your genres or specials. Deletion is only allowed if a song is assigned exclusively to your genres/specials. Genres, specials, news and political statements can only be managed by the admin.",
|
||||
"searchPlaceholder": "Search by title or artist...",
|
||||
"filterAll": "All content",
|
||||
"filterNoGlobal": "🚫 No global",
|
||||
"filterReset": "Reset filters",
|
||||
"noSongsInScope": "No matching songs in your genres/specials.",
|
||||
"columnId": "ID",
|
||||
"columnPlay": "Play",
|
||||
"columnTitle": "Title",
|
||||
"columnArtist": "Artist",
|
||||
"columnYear": "Year",
|
||||
"columnGenresSpecials": "Genres / Specials",
|
||||
"columnAdded": "Added",
|
||||
"columnActivations": "Activations",
|
||||
"columnRating": "Rating",
|
||||
"columnExcludeGlobal": "Exclude global",
|
||||
"columnActions": "Actions",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"excludeGlobalYes": "Yes",
|
||||
"excludeGlobalNo": "No",
|
||||
"excludeGlobalInfo": "Only global curators may change this flag.",
|
||||
"paginationPrev": "Previous",
|
||||
"paginationNext": "Next",
|
||||
"paginationLabel": "Page {page} of {total}",
|
||||
"loadingData": "Loading data...",
|
||||
"loggedInAs": "Logged in as {username}",
|
||||
"globalCuratorSuffix": " (Global curator)",
|
||||
"pageSizeLabel": "Per page:",
|
||||
"commentsTitle": "Comments",
|
||||
"showComments": "Show comments",
|
||||
"hideComments": "Hide comments",
|
||||
"loadingComments": "Loading comments...",
|
||||
"noComments": "No comments available.",
|
||||
"loadCommentsError": "Error loading comments.",
|
||||
"commentFromPuzzle": "Comment from puzzle",
|
||||
"commentGenre": "Genre",
|
||||
"unreadComment": "Unread",
|
||||
"archiveComment": "Archive",
|
||||
"archiveCommentConfirm": "Do you really want to archive this comment?",
|
||||
"archiveCommentError": "Error archiving comment.",
|
||||
"newComments": "new",
|
||||
"batchEditTitle": "Batch Edit",
|
||||
"clearSelection": "Clear Selection",
|
||||
"batchToggleGenres": "Toggle Genres",
|
||||
"batchToggleSpecials": "Toggle Specials",
|
||||
"batchChangeArtist": "Change Artist",
|
||||
"batchArtistPlaceholder": "Enter new artist name",
|
||||
"batchExcludeGlobal": "Exclude from Global",
|
||||
"batchNoChange": "No change",
|
||||
"batchExclude": "Exclude",
|
||||
"batchInclude": "Include",
|
||||
"batchUpdating": "Updating...",
|
||||
"batchApply": "Apply Changes",
|
||||
"selectAll": "Select all",
|
||||
"selectSong": "Select song",
|
||||
"cannotEditSong": "Cannot edit this song",
|
||||
"noSongsSelected": "No songs selected",
|
||||
"noBatchOperations": "No batch operations specified",
|
||||
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
|
||||
"batchUpdateError": "Error: {error}",
|
||||
"batchUpdateNetworkError": "Network error during batch update"
|
||||
},
|
||||
"About": {
|
||||
"title": "About Hördle & Imprint",
|
||||
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",
|
||||
@@ -162,15 +284,15 @@
|
||||
"imprintOperator": "Responsible for the content of this site (provider under German law):",
|
||||
"imprintCountry": "Germany",
|
||||
"imprintEmailLabel": "Email:",
|
||||
"imprintDisclaimer": "Note: This information is current as of the date indicated. For legal questions you should consult a legal professional.",
|
||||
"costsTitle": "Ongoing costs of the project",
|
||||
"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)",
|
||||
"costsServer": "Servers / vServers for the app and tracking",
|
||||
"costsEmail": "Email hosting",
|
||||
"costsLicenses": "Possible fees for copyrights or other licenses",
|
||||
"costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.",
|
||||
"costsSheetPrivacyNote": "When accessing or embedding the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
|
||||
"costsSheetPrivacyNote": "When accessing the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
|
||||
"supportTitle": "Support Hördle",
|
||||
"supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:",
|
||||
"supportSepaTitle": "SEPA Bank Transfer (preferred)",
|
||||
@@ -180,6 +302,10 @@
|
||||
"supportPaypalLink": "paypal.me/MBusche",
|
||||
"supportSteadyTitle": "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",
|
||||
"privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.",
|
||||
"privacyPlausibleTitle": "Self-hosted Plausible Analytics",
|
||||
@@ -190,7 +316,6 @@
|
||||
"privacyPlausibleAggregated": "Analytics are only performed in aggregated form (e.g. page views, browsers used).",
|
||||
"privacyServerLogs": "For technical reasons, the server may log IP address, time of access and accessed resources. This data is only used to keep the service running and to debug issues and is deleted on a regular basis.",
|
||||
"privacyContact": "If you have questions about the data processed or want to request information, please contact the email address given in the imprint.",
|
||||
"privacyNoLegalAdvice": "Note: These privacy notes are only an example and do not replace legal advice. For a legally compliant privacy policy you should consult a professional.",
|
||||
"backTitle": "Back to the game",
|
||||
"backToGame": "Back to Hördle",
|
||||
"footerLinkLabel": "About & Imprint"
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.0.15",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.0.15",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.6.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PlayerState" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"genreKey" TEXT NOT NULL,
|
||||
"gameState" TEXT NOT NULL,
|
||||
"statistics" TEXT NOT NULL,
|
||||
"lastPlayed" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PlayerState_identifier_idx" ON "PlayerState"("identifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PlayerState_identifier_genreKey_key" ON "PlayerState"("identifier", "genreKey");
|
||||
@@ -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)
|
||||
songs Song[]
|
||||
dailyPuzzles DailyPuzzle[]
|
||||
curatorGenres CuratorGenre[]
|
||||
comments CuratorComment[]
|
||||
}
|
||||
|
||||
model Special {
|
||||
@@ -48,6 +50,7 @@ model Special {
|
||||
songs SpecialSong[]
|
||||
puzzles DailyPuzzle[]
|
||||
news News[]
|
||||
curatorSpecials CuratorSpecial[]
|
||||
}
|
||||
|
||||
model SpecialSong {
|
||||
@@ -71,6 +74,7 @@ model DailyPuzzle {
|
||||
genre Genre? @relation(fields: [genreId], references: [id])
|
||||
specialId Int?
|
||||
special Special? @relation(fields: [specialId], references: [id])
|
||||
comments CuratorComment[]
|
||||
|
||||
@@unique([date, genreId, specialId])
|
||||
}
|
||||
@@ -88,3 +92,92 @@ model News {
|
||||
|
||||
@@index([publishedAt])
|
||||
}
|
||||
|
||||
model PlayerState {
|
||||
id Int @id @default(autoincrement())
|
||||
identifier String // UUID des Spielers (für Cross-Domain-Sync)
|
||||
genreKey String // Genre-Name oder "global" oder "special:<name>"
|
||||
gameState String // JSON-String des GameState
|
||||
statistics String // JSON-String der Statistics
|
||||
lastPlayed DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([identifier, genreKey])
|
||||
@@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])
|
||||
}
|
||||
|
||||
35
proxy.ts
35
proxy.ts
@@ -2,9 +2,9 @@ import createMiddleware from 'next-intl/middleware';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
const i18nMiddleware = createMiddleware({
|
||||
locales: ['de', 'en'],
|
||||
defaultLocale: 'de',
|
||||
// Wir nutzen überall Locale-Präfixe (`/de`, `/en`)
|
||||
locales: ['en', 'de'],
|
||||
defaultLocale: 'en',
|
||||
// Wir nutzen überall Locale-Präfixe (`/en`, `/de`)
|
||||
localePrefix: 'always'
|
||||
});
|
||||
|
||||
@@ -21,16 +21,41 @@ export default function proxy(request: NextRequest) {
|
||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
|
||||
// Extract Plausible domain from script URL for CSP
|
||||
const plausibleScriptSrc = process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js';
|
||||
let plausibleOrigin = 'https://plausible.example.com';
|
||||
try {
|
||||
const url = new URL(plausibleScriptSrc);
|
||||
plausibleOrigin = url.origin;
|
||||
} catch {
|
||||
// If URL parsing fails, try to extract domain manually
|
||||
const match = plausibleScriptSrc.match(/https?:\/\/([^/]+)/);
|
||||
if (match) {
|
||||
plausibleOrigin = `https://${match[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get other service URLs from environment (only add to CSP if configured)
|
||||
const gotifyUrl = process.env.GOTIFY_URL;
|
||||
const openrouterUrl = process.env.NEXT_PUBLIC_OPENROUTER_URL || 'https://openrouter.ai';
|
||||
|
||||
// Build CSP dynamically based on environment variables
|
||||
const connectSrcParts = ["'self'", openrouterUrl, plausibleOrigin];
|
||||
if (gotifyUrl && !gotifyUrl.includes('example.com')) {
|
||||
connectSrcParts.push(gotifyUrl);
|
||||
}
|
||||
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me",
|
||||
`script-src 'self' 'unsafe-inline' 'unsafe-eval' ${plausibleOrigin}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
|
||||
`connect-src ${connectSrcParts.join(' ')}`,
|
||||
"media-src 'self' blob:",
|
||||
"frame-ancestors 'self'",
|
||||
].join('; ');
|
||||
|
||||
headers.set('Content-Security-Policy', csp);
|
||||
|
||||
return response;
|
||||
|
||||
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
|
||||
|
||||
94
scripts/check-caddy-certificates.sh
Executable file
94
scripts/check-caddy-certificates.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# Script zum Prüfen der Caddy-Zertifikate
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Prüfe Caddy-Zertifikat-Status..."
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Caddy-Container läuft
|
||||
if ! docker ps | grep -q hoerdle-caddy; then
|
||||
echo "❌ Caddy-Container läuft nicht!"
|
||||
echo " Starte ihn mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Caddy-Container läuft"
|
||||
echo ""
|
||||
|
||||
# Prüfe Zertifikate im Volume
|
||||
echo "📜 Gespeicherte Zertifikate im Volume:"
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*.crt" -o -name "*.key" 2>/dev/null | head -20 || echo " (Keine Zertifikate gefunden oder Fehler beim Zugriff)"
|
||||
echo ""
|
||||
|
||||
# Prüfe Caddy-Logs für Zertifikats-Fehler
|
||||
echo "📋 Letzte Caddy-Logs (Zertifikat-bezogen):"
|
||||
docker logs hoerdle-caddy 2>&1 | grep -i -E "(certificate|tls|acme|challenge|error)" | tail -20 || echo " (Keine relevanten Log-Einträge gefunden)"
|
||||
echo ""
|
||||
|
||||
# Prüfe DNS-Einträge
|
||||
echo "🌐 DNS-Einträge prüfen:"
|
||||
echo ""
|
||||
|
||||
# Prüfe hoerdle.de
|
||||
echo "1️⃣ hoerdle.de:"
|
||||
HOERDLE_IP=$(dig +short hoerdle.de @8.8.8.8 | head -1 || echo "DNS-Fehler")
|
||||
if [ -n "$HOERDLE_IP" ] && [ "$HOERDLE_IP" != "DNS-Fehler" ]; then
|
||||
echo " ✅ DNS-A-Record: $HOERDLE_IP"
|
||||
else
|
||||
echo " ❌ DNS-A-Record: nicht aufgelöst"
|
||||
fi
|
||||
|
||||
# Prüfe xn--hrdle-jua.de (Punycode)
|
||||
echo "2️⃣ xn--hrdle-jua.de (hördle.de):"
|
||||
PUNYCODE_IP=$(dig +short xn--hrdle-jua.de @8.8.8.8 | head -1 || echo "DNS-Fehler")
|
||||
if [ -n "$PUNYCODE_IP" ] && [ "$PUNYCODE_IP" != "DNS-Fehler" ]; then
|
||||
echo " ✅ DNS-A-Record: $PUNYCODE_IP"
|
||||
else
|
||||
echo " ❌ DNS-A-Record: nicht aufgelöst"
|
||||
fi
|
||||
|
||||
# Prüfe auch die Unicode-Domain direkt (falls unterstützt)
|
||||
echo "3️⃣ hördle.de (Unicode):"
|
||||
UNICODE_IP=$(dig +short hördle.de @8.8.8.8 2>/dev/null | head -1 || echo "Nicht unterstützt")
|
||||
if [ -n "$UNICODE_IP" ] && [ "$UNICODE_IP" != "Nicht unterstützt" ]; then
|
||||
echo " ✅ DNS-A-Record: $UNICODE_IP"
|
||||
else
|
||||
echo " ⚠️ Unicode-Domain-Abfrage nicht unterstützt (normal)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🔒 Zertifikat-Test:"
|
||||
echo ""
|
||||
|
||||
# Teste HTTPS-Verbindung zu hoerdle.de
|
||||
echo "1️⃣ hoerdle.de HTTPS:"
|
||||
if echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
echo " ✅ Zertifikat gültig"
|
||||
CERT_INFO=$(echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -subject -dates 2>/dev/null || echo "")
|
||||
echo " $CERT_INFO" | sed 's/^/ /'
|
||||
else
|
||||
echo " ❌ Zertifikat fehlt oder ungültig"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "2️⃣ hördle.de (xn--hrdle-jua.de) HTTPS:"
|
||||
if echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
echo " ✅ Zertifikat gültig"
|
||||
CERT_INFO=$(echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -subject -dates 2>/dev/null || echo "")
|
||||
echo " $CERT_INFO" | sed 's/^/ /'
|
||||
else
|
||||
echo " ❌ Zertifikat fehlt oder ungültig"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📝 Zertifikat-Details aus Caddy Volume:"
|
||||
echo ""
|
||||
docker exec hoerdle-caddy ls -la /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/ 2>/dev/null | head -10 || echo " (Verzeichnis nicht gefunden oder leer)"
|
||||
|
||||
echo ""
|
||||
echo "💡 Tipps:"
|
||||
echo " - Wenn kein Zertifikat vorhanden ist, führe aus: ./scripts/renew-caddy-certificates.sh"
|
||||
echo " - Prüfe die Caddy-Logs: docker logs hoerdle-caddy"
|
||||
echo " - Prüfe DNS-Einträge in GoDaddy: https://dcc.godaddy.com/manage/hoerdle.de/dns"
|
||||
|
||||
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
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting optimized deployment..."
|
||||
echo "🚀 Starting optimized deployment with full rollback support..."
|
||||
|
||||
# Backup database
|
||||
echo "💾 Creating database backup..."
|
||||
# Backup database (per Deployment, inkl. Metadaten für Rollback)
|
||||
echo "💾 Creating database backup for this deployment..."
|
||||
|
||||
# Try to find database path from docker-compose.yml or .env
|
||||
DB_PATH=""
|
||||
@@ -26,16 +26,29 @@ if [ -n "$DB_PATH" ]; then
|
||||
# Convert container path to host path if needed
|
||||
# /app/data/prod.db -> ./data/prod.db
|
||||
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
|
||||
|
||||
|
||||
if [ -f "$DB_PATH" ]; then
|
||||
# Create backups directory
|
||||
mkdir -p ./backups
|
||||
|
||||
|
||||
# Create timestamped backup
|
||||
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_$(date +%Y%m%d_%H%M%S).db"
|
||||
DEPLOY_TS="$(date +%Y%m%d_%H%M%S)"
|
||||
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_${DEPLOY_TS}.db"
|
||||
cp "$DB_PATH" "$BACKUP_FILE"
|
||||
echo "✅ Database backed up to: $BACKUP_FILE"
|
||||
|
||||
|
||||
# Store metadata for restore (Timestamp, DB-Path, Git-Commit)
|
||||
CURRENT_COMMIT="$(git rev-parse HEAD || echo 'unknown')"
|
||||
{
|
||||
echo "timestamp=${DEPLOY_TS}"
|
||||
echo "db_path=${DB_PATH}"
|
||||
echo "backup_file=${BACKUP_FILE}"
|
||||
echo "git_commit=${CURRENT_COMMIT}"
|
||||
} > "./backups/last_deploy.meta"
|
||||
|
||||
# Append to history manifest (eine Zeile pro Deployment)
|
||||
echo "${DEPLOY_TS}|${DB_PATH}|${BACKUP_FILE}|${CURRENT_COMMIT}" >> "./backups/deploy_history.log"
|
||||
|
||||
# Keep only last 10 backups
|
||||
ls -t ./backups/*.db | tail -n +11 | xargs -r rm
|
||||
echo "🧹 Cleaned old backups (keeping last 10)"
|
||||
@@ -46,13 +59,13 @@ else
|
||||
echo "⚠️ Could not determine database path from config files"
|
||||
fi
|
||||
|
||||
# Pull latest changes
|
||||
echo "📥 Pulling latest changes from git..."
|
||||
git pull
|
||||
# Restic backup to remote repository
|
||||
./scripts/backup-restic.sh
|
||||
|
||||
# Fetch all tags
|
||||
echo "🏷️ Fetching git tags..."
|
||||
git fetch --tags
|
||||
# Nur neueste Version holen (shallow fetch), vollständiges Repo ist im Deployment nicht nötig
|
||||
echo "📥 Fetching latest commit (shallow clone) from git..."
|
||||
git fetch --prune --tags --depth=1 origin master
|
||||
git reset --hard origin/master
|
||||
|
||||
# Prüfe und erstelle/repariere Netzwerk falls nötig
|
||||
echo "🌐 Prüfe Docker-Netzwerk..."
|
||||
|
||||
110
scripts/quick-check-punycode-dns.sh
Executable file
110
scripts/quick-check-punycode-dns.sh
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
# Quick-Check für Punycode-Domain DNS und Zertifikat
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Quick-Check für hördle.de (xn--hrdle-jua.de)"
|
||||
echo ""
|
||||
|
||||
# Prüfe DNS
|
||||
echo "1️⃣ DNS-Auflösung:"
|
||||
echo ""
|
||||
echo " hoerdle.de:"
|
||||
HOERDLE_IP=$(dig +short hoerdle.de @8.8.8.8 | head -1 || echo "")
|
||||
if [ -z "$HOERDLE_IP" ]; then
|
||||
echo " ❌ Konnte nicht aufgelöst werden"
|
||||
else
|
||||
echo " ✅ IP: $HOERDLE_IP"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " xn--hrdle-jua.de:"
|
||||
PUNYCODE_IP=$(dig +short xn--hrdle-jua.de @8.8.8.8 | head -1 || echo "")
|
||||
if [ -z "$PUNYCODE_IP" ]; then
|
||||
echo " ❌ Konnte nicht aufgelöst werden"
|
||||
else
|
||||
echo " ✅ IP: $PUNYCODE_IP"
|
||||
fi
|
||||
|
||||
if [ -n "$HOERDLE_IP" ] && [ -n "$PUNYCODE_IP" ]; then
|
||||
if [ "$HOERDLE_IP" = "$PUNYCODE_IP" ]; then
|
||||
echo ""
|
||||
echo " ✅ Beide Domains zeigen auf die gleiche IP ($HOERDLE_IP)"
|
||||
else
|
||||
echo ""
|
||||
echo " ⚠️ WARNUNG: Domains zeigen auf unterschiedliche IPs!"
|
||||
echo " hoerdle.de: $HOERDLE_IP"
|
||||
echo " xn--hrdle-jua.de: $PUNYCODE_IP"
|
||||
echo " → Beide sollten auf die gleiche IP zeigen!"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "2️⃣ HTTPS-Verbindungstest:"
|
||||
echo ""
|
||||
|
||||
# Test hoerdle.de
|
||||
echo " hoerdle.de:"
|
||||
if timeout 5 bash -c "echo > /dev/tcp/hoerdle.de/443" 2>/dev/null; then
|
||||
echo " ✅ Port 443 ist erreichbar"
|
||||
if echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
CERT_VALID_UNTIL=$(echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")
|
||||
echo " ✅ Zertifikat gültig bis: $CERT_VALID_UNTIL"
|
||||
else
|
||||
echo " ❌ Zertifikat ungültig oder fehlt"
|
||||
fi
|
||||
else
|
||||
echo " ❌ Port 443 nicht erreichbar"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " xn--hrdle-jua.de:"
|
||||
if timeout 5 bash -c "echo > /dev/tcp/xn--hrdle-jua.de/443" 2>/dev/null; then
|
||||
echo " ✅ Port 443 ist erreichbar"
|
||||
if echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
CERT_VALID_UNTIL=$(echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")
|
||||
echo " ✅ Zertifikat gültig bis: $CERT_VALID_UNTIL"
|
||||
else
|
||||
echo " ❌ Zertifikat ungültig oder fehlt (ERR_SSL_PROTOCOL_ERROR)"
|
||||
echo ""
|
||||
echo " 💡 Lösung: Führe aus: ./scripts/renew-caddy-certificates.sh"
|
||||
fi
|
||||
else
|
||||
echo " ❌ Port 443 nicht erreichbar"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "3️⃣ Caddy-Container Status:"
|
||||
echo ""
|
||||
if docker ps | grep -q hoerdle-caddy; then
|
||||
echo " ✅ Caddy-Container läuft"
|
||||
echo ""
|
||||
echo " 📋 Letzte Caddy-Logs (Zertifikat-bezogen):"
|
||||
docker logs hoerdle-caddy 2>&1 | grep -i -E "(xn--hrdle|punycode|certificate|tls|acme|challenge)" | tail -5 || echo " (Keine relevanten Einträge gefunden)"
|
||||
else
|
||||
echo " ❌ Caddy-Container läuft nicht"
|
||||
echo " Starte mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "💡 Nächste Schritte:"
|
||||
echo ""
|
||||
if [ -n "$HOERDLE_IP" ] && [ -n "$PUNYCODE_IP" ] && [ "$HOERDLE_IP" != "$PUNYCODE_IP" ]; then
|
||||
echo " 1. ❗ DNS-Konfiguration prüfen!"
|
||||
echo " Beide Domains müssen auf die gleiche IP zeigen."
|
||||
echo " Prüfe in GoDaddy: https://dcc.godaddy.com/manage/hoerdle.de/dns"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo " 2. Wenn DNS korrekt ist, Zertifikat neu erstellen:"
|
||||
echo " ./scripts/renew-caddy-certificates.sh"
|
||||
echo ""
|
||||
echo " 3. Caddy-Logs überwachen:"
|
||||
echo " docker logs hoerdle-caddy -f"
|
||||
echo ""
|
||||
echo " 4. Detaillierte Diagnose:"
|
||||
echo " ./scripts/check-caddy-certificates.sh"
|
||||
echo ""
|
||||
|
||||
83
scripts/renew-caddy-certificates.sh
Executable file
83
scripts/renew-caddy-certificates.sh
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# Script zum Erneuern/Löschen und Neu-Erstellen von Caddy-Zertifikaten
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔄 Caddy-Zertifikat-Erneuerung"
|
||||
echo ""
|
||||
echo "⚠️ WICHTIG: Dieses Script wird die Zertifikate löschen und Caddy zwingen, sie neu zu erstellen."
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Caddy-Container läuft
|
||||
if ! docker ps | grep -q hoerdle-caddy; then
|
||||
echo "❌ Caddy-Container läuft nicht!"
|
||||
echo " Starte ihn mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📋 Verfügbare Optionen:"
|
||||
echo " 1) Alle Zertifikate löschen und neu erstellen (empfohlen bei Problemen)"
|
||||
echo " 2) Nur Zertifikat für xn--hrdle-jua.de löschen (Punycode-Domain)"
|
||||
echo " 3) Nur Zertifikat für hoerdle.de löschen"
|
||||
echo " 4) Abbrechen"
|
||||
echo ""
|
||||
read -p "Wähle eine Option (1-4): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
echo ""
|
||||
echo "🗑️ Lösche ALLE Zertifikate..."
|
||||
docker exec hoerdle-caddy rm -rf /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/* 2>/dev/null || true
|
||||
echo "✅ Alle Zertifikate gelöscht"
|
||||
;;
|
||||
2)
|
||||
echo ""
|
||||
echo "🗑️ Lösche Zertifikat für xn--hrdle-jua.de..."
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--hrdle-jua.de*" -delete 2>/dev/null || true
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--*" -delete 2>/dev/null || true
|
||||
echo "✅ Zertifikat für Punycode-Domain gelöscht"
|
||||
;;
|
||||
3)
|
||||
echo ""
|
||||
echo "🗑️ Lösche Zertifikat für hoerdle.de..."
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*hoerdle.de*" ! -name "*xn--*" -delete 2>/dev/null || true
|
||||
echo "✅ Zertifikat für hoerdle.de gelöscht"
|
||||
;;
|
||||
4)
|
||||
echo "❌ Abgebrochen."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ Ungültige Option. Abgebrochen."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "🔄 Starte Caddy-Container neu, um Zertifikate neu zu erstellen..."
|
||||
docker compose -f docker-compose.caddy.yml --profile production restart caddy
|
||||
|
||||
echo ""
|
||||
echo "⏳ Warte 5 Sekunden, damit Caddy startet..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "📋 Prüfe Caddy-Logs auf Zertifikats-Erstellung:"
|
||||
echo ""
|
||||
docker logs hoerdle-caddy --tail=30 2>&1 | grep -i -E "(certificate|tls|acme|challenge|error|success)" || echo " (Keine relevanten Log-Einträge in den letzten 30 Zeilen)"
|
||||
|
||||
echo ""
|
||||
echo "✅ Container wurde neu gestartet."
|
||||
echo ""
|
||||
echo "💡 Nächste Schritte:"
|
||||
echo " 1. Warte 1-2 Minuten, damit Caddy die Zertifikate erstellt"
|
||||
echo " 2. Prüfe die Logs: docker logs hoerdle-caddy -f"
|
||||
echo " 3. Prüfe den Status: ./scripts/check-caddy-certificates.sh"
|
||||
echo " 4. Teste die Domain: curl -I https://xn--hrdle-jua.de"
|
||||
echo ""
|
||||
echo "⚠️ Falls das Zertifikat nicht erstellt wird:"
|
||||
echo " - Prüfe DNS-Einträge: Beide Domains müssen auf die Server-IP zeigen"
|
||||
echo " - Prüfe Port 80: Muss von außen erreichbar sein (für HTTP-01 Challenge)"
|
||||
echo " - Prüfe Firewall: Ports 80 und 443 müssen offen sein"
|
||||
echo " - Prüfe Caddy-Logs: docker logs hoerdle-caddy"
|
||||
|
||||
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