Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
||||
|
||||
@@ -49,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
|
||||
@@ -63,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
|
||||
|
||||
@@ -77,13 +77,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)
|
||||
|
||||
@@ -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
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,88 @@
|
||||
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.
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
if (s?.id != null) return s.id;
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.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);
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
if (s?.id != null) return s.id;
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.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 +96,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 +133,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 +148,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 +161,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 +366,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 +377,69 @@ 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: 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,29 +448,43 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
|
||||
if (excludeFromGlobal !== undefined) {
|
||||
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 }))
|
||||
};
|
||||
}
|
||||
|
||||
// Execute all database write operations in a transaction to ensure consistency
|
||||
const updatedSong = await prisma.$transaction(async (tx) => {
|
||||
// Handle SpecialSong relations separately
|
||||
if (specialIds !== undefined) {
|
||||
// First, get current special assignments
|
||||
const currentSpecials = await prisma.specialSong.findMany({
|
||||
if (effectiveSpecialIds !== undefined) {
|
||||
// First, get current special assignments (within transaction)
|
||||
const currentSpecials = await tx.specialSong.findMany({
|
||||
where: { songId: Number(id) }
|
||||
});
|
||||
|
||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||
const newSpecialIds = specialIds as number[];
|
||||
const newSpecialIds = effectiveSpecialIds as number[];
|
||||
|
||||
// Delete removed specials
|
||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||
if (toDelete.length > 0) {
|
||||
await prisma.specialSong.deleteMany({
|
||||
await tx.specialSong.deleteMany({
|
||||
where: {
|
||||
songId: Number(id),
|
||||
specialId: { in: toDelete }
|
||||
@@ -313,7 +495,7 @@ export async function PUT(request: Request) {
|
||||
// Add new specials
|
||||
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||
if (toAdd.length > 0) {
|
||||
await prisma.specialSong.createMany({
|
||||
await tx.specialSong.createMany({
|
||||
data: toAdd.map(specialId => ({
|
||||
songId: Number(id),
|
||||
specialId,
|
||||
@@ -323,7 +505,8 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSong = await prisma.song.update({
|
||||
// Update song (this also handles genre relations via Prisma's set operation)
|
||||
return await tx.song.update({
|
||||
where: { id: Number(id) },
|
||||
data,
|
||||
include: {
|
||||
@@ -335,6 +518,7 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json(updatedSong);
|
||||
} catch (error) {
|
||||
@@ -344,9 +528,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 +539,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,10 +582,12 @@ export async function DELETE(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database (will cascade delete related puzzles)
|
||||
await prisma.song.delete({
|
||||
// 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 });
|
||||
} catch (error) {
|
||||
|
||||
1333
app/curator/CuratorPageClient.tsx
Normal file
1333
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,7 +6,12 @@ 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';
|
||||
|
||||
// Plausible Analytics
|
||||
@@ -32,14 +37,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,6 +57,8 @@ 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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,6 +91,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]);
|
||||
@@ -108,6 +149,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 +204,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 +235,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
|
||||
@@ -274,7 +325,12 @@ 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}`;
|
||||
// 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}`;
|
||||
@@ -335,6 +391,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 +410,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,15 +519,21 @@ 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>
|
||||
|
||||
{statistics && <Statistics statistics={statistics} />}
|
||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||
<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>
|
||||
|
||||
{statistics && <Statistics statistics={statistics} />}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -475,6 +544,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
onSkip={handleYearSkip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showExtraPuzzlesPopover && extraPuzzle && (
|
||||
<ExtraPuzzlesPopover
|
||||
puzzle={extraPuzzle}
|
||||
onClose={() => setShowExtraPuzzlesPopover(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -674,7 +750,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}
|
||||
|
||||
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
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
163
lib/gameState.ts
163
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,52 +55,7 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
yearGuessed: false
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
setGameState(parsed as GameState);
|
||||
} else {
|
||||
// New day
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
}
|
||||
} else {
|
||||
// No state
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
const createNewStatistics = (): Statistics => ({
|
||||
solvedIn1: 0,
|
||||
solvedIn2: 0,
|
||||
solvedIn3: 0,
|
||||
@@ -106,18 +64,85 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
solvedIn6: 0,
|
||||
solvedIn7: 0,
|
||||
failed: 0,
|
||||
};
|
||||
setStatistics(newStats);
|
||||
localStorage.setItem(statsKey, JSON.stringify(newStats));
|
||||
}
|
||||
}, [genre]); // Re-run when genre changes
|
||||
});
|
||||
|
||||
const saveState = (newState: GameState) => {
|
||||
useEffect(() => {
|
||||
const today = getTodayISOString();
|
||||
|
||||
// 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);
|
||||
localStorage.setItem(getStorageKey(), JSON.stringify(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;
|
||||
}
|
||||
} 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatistics = (attempts: number, solved: boolean) => {
|
||||
loadFromBackend();
|
||||
}, [genre, isSpecial]); // Re-run when genre or isSpecial changes
|
||||
|
||||
const saveState = async (newState: GameState) => {
|
||||
setGameState(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 = 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' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -63,6 +65,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",
|
||||
@@ -150,7 +158,81 @@
|
||||
"artist": "Interpret",
|
||||
"actions": "Aktionen",
|
||||
"deletePuzzle": "Löschen",
|
||||
"wrongPassword": "Falsches Passwort"
|
||||
"wrongPassword": "Falsches Passwort",
|
||||
"manageCurators": "Kuratoren verwalten",
|
||||
"addCurator": "Kurator hinzufügen",
|
||||
"curatorUsername": "Benutzername",
|
||||
"curatorPassword": "Passwort (bei Leer lassen: nicht ändern)",
|
||||
"isGlobalCurator": "Globaler Kurator (darf Global-Flag ändern)",
|
||||
"assignedGenres": "Zugeordnete Genres",
|
||||
"assignedSpecials": "Zugeordnete Specials",
|
||||
"noCurators": "Noch keine Kuratoren angelegt."
|
||||
},
|
||||
"Curator": {
|
||||
"loginTitle": "Kuratoren-Login",
|
||||
"loginUsername": "Benutzername",
|
||||
"loginPassword": "Passwort",
|
||||
"loginButton": "Einloggen",
|
||||
"logout": "Abmelden",
|
||||
"loginFailed": "Login fehlgeschlagen.",
|
||||
"loginNetworkError": "Netzwerkfehler beim Login.",
|
||||
"loadCuratorError": "Fehler beim Laden der Kuratoren-Informationen.",
|
||||
"loadSongsError": "Fehler beim Laden der Songs.",
|
||||
"songUpdated": "Song erfolgreich aktualisiert.",
|
||||
"saveError": "Fehler beim Speichern: {error}",
|
||||
"saveNetworkError": "Netzwerkfehler beim Speichern.",
|
||||
"noDeletePermission": "Du darfst diesen Song nicht löschen.",
|
||||
"deleteConfirm": "Möchtest du \"{title}\" wirklich löschen?",
|
||||
"songDeleted": "Song gelöscht.",
|
||||
"deleteError": "Fehler beim Löschen: {error}",
|
||||
"deleteNetworkError": "Netzwerkfehler beim Löschen.",
|
||||
"uploadSectionTitle": "Titel hochladen",
|
||||
"uploadSectionDescription": "Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert (inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens eines deiner Genres aus, um die Titel zuzuordnen.",
|
||||
"dropzoneTitleEmpty": "MP3-Dateien hierher ziehen",
|
||||
"dropzoneTitleWithFiles": "{count} Datei(en) ausgewählt",
|
||||
"dropzoneSubtitle": "oder klicken, um Dateien auszuwählen",
|
||||
"selectedFilesTitle": "Ausgewählte Dateien:",
|
||||
"uploadProgress": "Upload: {current} / {total}",
|
||||
"assignGenresLabel": "Genres zuordnen",
|
||||
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
|
||||
"uploadButtonIdle": "Upload starten",
|
||||
"uploadButtonUploading": "Lade hoch...",
|
||||
"uploadSummary": "✅ {success}/{total} Uploads erfolgreich.",
|
||||
"uploadSummaryDuplicates": "⚠️ {count} Duplikat(e) übersprungen.",
|
||||
"uploadSummaryFailed": "❌ {count} fehlgeschlagen.",
|
||||
"uploadResultSuccess": "✅ erfolgreich",
|
||||
"uploadResultDuplicate": "⚠️ Duplikat: {error}",
|
||||
"uploadResultError": "❌ Fehler: {error}",
|
||||
"tracklistTitle": "Titel in deinen Genres & Specials ({count} Titel)",
|
||||
"tracklistDescription": "Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist. Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden.",
|
||||
"searchPlaceholder": "Nach Titel oder Artist suchen...",
|
||||
"filterAll": "Alle Inhalte",
|
||||
"filterNoGlobal": "🚫 Ohne Global",
|
||||
"filterReset": "Filter zurücksetzen",
|
||||
"noSongsInScope": "Keine passenden Songs in deinen Genres/Specials gefunden.",
|
||||
"columnId": "ID",
|
||||
"columnPlay": "Play",
|
||||
"columnTitle": "Titel",
|
||||
"columnArtist": "Artist",
|
||||
"columnYear": "Jahr",
|
||||
"columnGenresSpecials": "Genres / Specials",
|
||||
"columnAdded": "Hinzugefügt",
|
||||
"columnActivations": "Aktivierungen",
|
||||
"columnRating": "Rating",
|
||||
"columnExcludeGlobal": "Exclude Global",
|
||||
"columnActions": "Aktionen",
|
||||
"play": "Abspielen",
|
||||
"pause": "Pause",
|
||||
"excludeGlobalYes": "Ja",
|
||||
"excludeGlobalNo": "Nein",
|
||||
"excludeGlobalInfo": "Nur globale Kuratoren dürfen dieses Flag ändern.",
|
||||
"paginationPrev": "Zurück",
|
||||
"paginationNext": "Weiter",
|
||||
"paginationLabel": "Seite {page} von {total}",
|
||||
"loadingData": "Lade Daten...",
|
||||
"loggedInAs": "Eingeloggt als {username}",
|
||||
"globalCuratorSuffix": " (Globaler Kurator)",
|
||||
"pageSizeLabel": "Pro Seite:"
|
||||
},
|
||||
"About": {
|
||||
"title": "Über Hördle & Impressum",
|
||||
@@ -162,15 +244,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 +262,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 +276,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"
|
||||
|
||||
@@ -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",
|
||||
@@ -63,6 +65,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,7 +158,81 @@
|
||||
"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:"
|
||||
},
|
||||
"About": {
|
||||
"title": "About Hördle & Imprint",
|
||||
@@ -162,15 +244,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 +262,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 +276,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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5.2",
|
||||
"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");
|
||||
@@ -33,6 +33,7 @@ model Genre {
|
||||
active Boolean @default(true)
|
||||
songs Song[]
|
||||
dailyPuzzles DailyPuzzle[]
|
||||
curatorGenres CuratorGenre[]
|
||||
}
|
||||
|
||||
model Special {
|
||||
@@ -48,6 +49,7 @@ model Special {
|
||||
songs SpecialSong[]
|
||||
puzzles DailyPuzzle[]
|
||||
news News[]
|
||||
curatorSpecials CuratorSpecial[]
|
||||
}
|
||||
|
||||
model SpecialSong {
|
||||
@@ -88,3 +90,62 @@ 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[]
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
29
proxy.ts
29
proxy.ts
@@ -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;
|
||||
|
||||
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=""
|
||||
@@ -32,10 +32,23 @@ if [ -n "$DB_PATH" ]; then
|
||||
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,10 @@ else
|
||||
echo "⚠️ Could not determine database path from config files"
|
||||
fi
|
||||
|
||||
# Pull latest changes
|
||||
echo "📥 Pulling latest changes from git..."
|
||||
git pull
|
||||
|
||||
# Fetch all tags
|
||||
echo "🏷️ Fetching git tags..."
|
||||
git fetch --tags
|
||||
# Nur neueste Version holen (shallow fetch), vollständiges Repo ist im Deployment nicht nötig
|
||||
echo "📥 Fetching latest commit (shallow clone) from git..."
|
||||
git fetch --prune --tags --depth=1 origin master
|
||||
git reset --hard origin/master
|
||||
|
||||
# Prüfe und erstelle/repariere Netzwerk falls nötig
|
||||
echo "🌐 Prüfe Docker-Netzwerk..."
|
||||
|
||||
93
scripts/restore.sh
Normal file
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