Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c57e938e8 | ||
|
|
9eb07ee8d5 | ||
|
|
3eb6c7f5cf | ||
|
|
2846afb6f7 | ||
|
|
27fa689b18 | ||
|
|
61846a6982 | ||
|
|
bba6b9ef31 | ||
|
|
a8867ac42e | ||
|
|
9006b208af | ||
|
|
20c8ad7eaf | ||
|
|
03129a5611 | ||
|
|
fd8f4adcc0 | ||
|
|
23997ccc3a | ||
|
|
85bdbf795c |
@@ -49,8 +49,6 @@ RUN node_modules/.bin/prisma generate
|
|||||||
ARG NEXT_PUBLIC_APP_NAME
|
ARG NEXT_PUBLIC_APP_NAME
|
||||||
ARG NEXT_PUBLIC_APP_DESCRIPTION
|
ARG NEXT_PUBLIC_APP_DESCRIPTION
|
||||||
ARG NEXT_PUBLIC_DOMAIN
|
ARG NEXT_PUBLIC_DOMAIN
|
||||||
ARG NEXT_PUBLIC_TWITTER_HANDLE
|
|
||||||
ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN
|
|
||||||
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||||
ARG NEXT_PUBLIC_THEME_COLOR
|
ARG NEXT_PUBLIC_THEME_COLOR
|
||||||
ARG NEXT_PUBLIC_BACKGROUND_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_NAME=$NEXT_PUBLIC_APP_NAME
|
||||||
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
|
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
|
||||||
ENV NEXT_PUBLIC_DOMAIN=$NEXT_PUBLIC_DOMAIN
|
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_PLAUSIBLE_SCRIPT_SRC=$NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||||
ENV NEXT_PUBLIC_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
|
ENV NEXT_PUBLIC_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
|
||||||
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR
|
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { getLocalizedValue } from '@/lib/i18n';
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import { generateBaseMetadata } from '@/lib/metadata';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -15,6 +17,32 @@ interface PageProps {
|
|||||||
params: Promise<{ locale: string; genre: string }>;
|
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) {
|
export default async function GenrePage({ params }: PageProps) {
|
||||||
const { locale, genre } = await params;
|
const { locale, genre } = await params;
|
||||||
const decodedGenre = decodeURIComponent(genre);
|
const decodedGenre = decodeURIComponent(genre);
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Link } from "@/lib/navigation";
|
import { Link } from "@/lib/navigation";
|
||||||
|
import { generateBaseMetadata } from "@/lib/metadata";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
interface AboutPageProps {
|
interface AboutPageProps {
|
||||||
params: Promise<{ locale: string }>;
|
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) {
|
export default async function AboutPage({ params }: AboutPageProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: "About" });
|
const t = await getTranslations({ locale, namespace: "About" });
|
||||||
@@ -51,11 +63,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
{t("imprintEmailLabel")}{" "}
|
{t("imprintEmailLabel")}{" "}
|
||||||
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
|
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
|
||||||
</p>
|
</p>
|
||||||
<p
|
|
||||||
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
|
|
||||||
>
|
|
||||||
{t("imprintDisclaimer")}
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ marginBottom: "2rem" }}>
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
@@ -234,11 +241,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
</ul>
|
</ul>
|
||||||
<p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p>
|
<p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p>
|
||||||
<p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p>
|
<p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p>
|
||||||
<p
|
|
||||||
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
|
|
||||||
>
|
|
||||||
{t("privacyNoLegalAdvice")}
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ marginBottom: "2rem" }}>
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import "../globals.css"; // Adjusted path
|
|||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@/lib/config";
|
||||||
|
import { generateBaseMetadata } from "@/lib/metadata";
|
||||||
import InstallPrompt from "@/components/InstallPrompt";
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
import AppFooter from "@/components/AppFooter";
|
import AppFooter from "@/components/AppFooter";
|
||||||
|
|
||||||
@@ -20,10 +22,10 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
title: config.appName,
|
const { locale } = await params;
|
||||||
description: config.appDescription,
|
return await generateBaseMetadata(locale);
|
||||||
};
|
}
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: config.colors.themeColor,
|
themeColor: config.colors.themeColor,
|
||||||
@@ -52,12 +54,32 @@ export default async function LocaleLayout({
|
|||||||
// Providing all messages to the client
|
// Providing all messages to the client
|
||||||
const messages = await getMessages();
|
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 (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale}>
|
||||||
<head>
|
<head>
|
||||||
<Script
|
<Script
|
||||||
defer
|
defer
|
||||||
data-domain={config.plausibleDomain}
|
data-domain={plausibleDomain}
|
||||||
src={config.plausibleScriptSrc}
|
src={config.plausibleScriptSrc}
|
||||||
strategy="beforeInteractive"
|
strategy="beforeInteractive"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,15 +7,33 @@ import { Link } from '@/lib/navigation';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { getLocalizedValue } from '@/lib/i18n';
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
import { generateBaseMetadata } from '@/lib/metadata';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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({
|
export default async function Home({
|
||||||
params
|
params
|
||||||
}: {
|
}: {
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations('Home');
|
const t = await getTranslations('Home');
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Link } from '@/lib/navigation';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { getLocalizedValue } from '@/lib/i18n';
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import { generateBaseMetadata } from '@/lib/metadata';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -14,6 +16,30 @@ interface PageProps {
|
|||||||
params: Promise<{ locale: string; name: string }>;
|
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) {
|
export default async function SpecialPage({ params }: PageProps) {
|
||||||
const { locale, name } = await params;
|
const { locale, name } = await params;
|
||||||
const decodedName = decodeURIComponent(name);
|
const decodedName = decodeURIComponent(name);
|
||||||
|
|||||||
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
67
app/api/player-id/suggest/route.ts
Normal file
67
app/api/player-id/suggest/route.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/player-id/suggest
|
||||||
|
*
|
||||||
|
* Tries to find a player ID based on recently updated states for a genre.
|
||||||
|
* This helps synchronize player IDs across different domains (hoerdle.de and hördle.de).
|
||||||
|
*
|
||||||
|
* Request body:
|
||||||
|
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - playerId: Suggested player ID (UUID) if found, null otherwise
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { genreKey } = 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);
|
||||||
|
|
||||||
|
const recentState = await prisma.playerState.findFirst({
|
||||||
|
where: {
|
||||||
|
genreKey: genreKey,
|
||||||
|
lastPlayed: {
|
||||||
|
gte: cutoffDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
lastPlayed: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentState) {
|
||||||
|
// Return the player ID from the most recent state
|
||||||
|
return NextResponse.json({
|
||||||
|
playerId: recentState.identifier,
|
||||||
|
lastPlayed: recentState.lastPlayed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// No recent state found
|
||||||
|
return NextResponse.json({
|
||||||
|
playerId: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[player-id/suggest] Error finding player ID:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
172
app/api/player-state/route.ts
Normal file
172
app/api/player-state/route.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
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)
|
||||||
|
*/
|
||||||
|
function isValidUUID(uuid: string): boolean {
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
return uuidRegex.test(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 || !isValidUUID(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 || !isValidUUID(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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
128
app/sitemap.ts
Normal file
128
app/sitemap.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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 {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ 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 }: GameProps) {
|
||||||
const t = useTranslations('Game');
|
const t = useTranslations('Game');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||||
const [hasWon, setHasWon] = useState(false);
|
const [hasWon, setHasWon] = useState(false);
|
||||||
const [hasLost, setHasLost] = useState(false);
|
const [hasLost, setHasLost] = useState(false);
|
||||||
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
|
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
|
||||||
@@ -108,6 +108,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
const handleGuess = (song: any) => {
|
const handleGuess = (song: any) => {
|
||||||
if (isProcessingGuess) return;
|
if (isProcessingGuess) return;
|
||||||
|
// Prevent guessing if already solved or failed
|
||||||
|
if (gameState?.isSolved || gameState?.isFailed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsProcessingGuess(true);
|
setIsProcessingGuess(true);
|
||||||
setLastAction('GUESS');
|
setLastAction('GUESS');
|
||||||
@@ -159,6 +163,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSkip = () => {
|
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 user hasn't played audio yet on first attempt, start it instead of skipping
|
||||||
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
|
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
|
||||||
handleStartAudio();
|
handleStartAudio();
|
||||||
@@ -187,6 +194,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGiveUp = () => {
|
const handleGiveUp = () => {
|
||||||
|
// Prevent giving up if already solved or failed
|
||||||
|
if (gameState?.isSolved || gameState?.isFailed) return;
|
||||||
|
|
||||||
setLastAction('SKIP');
|
setLastAction('SKIP');
|
||||||
addGuess("SKIPPED", false);
|
addGuess("SKIPPED", false);
|
||||||
giveUp(); // Ensure game is marked as failed and score reset to 0
|
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||||
@@ -274,7 +284,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||||
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||||
|
|
||||||
let shareUrl = `https://${config.domain}`;
|
// Use current domain from window.location to support both hoerdle.de and hördle.de
|
||||||
|
const currentHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
||||||
|
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
||||||
|
let shareUrl = `${protocol}//${currentHost}`;
|
||||||
// Add locale prefix if not default (en)
|
// Add locale prefix if not default (en)
|
||||||
if (locale !== 'en') {
|
if (locale !== 'en') {
|
||||||
shareUrl += `/${locale}`;
|
shareUrl += `/${locale}`;
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ services:
|
|||||||
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
||||||
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
|
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
|
||||||
NEXT_PUBLIC_DOMAIN: ${NEXT_PUBLIC_DOMAIN}
|
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_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||||
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
|
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
|
||||||
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_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)
|
||||||
|
|
||||||
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_NAME` | The name of the application. | `Hördle` |
|
||||||
| `NEXT_PUBLIC_APP_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
|
| `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_DOMAIN` | The domain name (used for sharing). | `hoerdle.de` |
|
||||||
| `NEXT_PUBLIC_TWITTER_HANDLE` | Twitter handle for metadata. | `@elpatron` |
|
|
||||||
|
|
||||||
### Analytics (Plausible)
|
### Analytics (Plausible)
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| 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.example.com/js/script.js` |
|
||||||
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.elpatron.me/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
|
### Credits
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
export const config = {
|
export const config = {
|
||||||
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
|
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',
|
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',
|
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.de',
|
||||||
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
|
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js',
|
||||||
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',
|
|
||||||
colors: {
|
colors: {
|
||||||
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
|
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
|
||||||
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
|
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
|
||||||
@@ -14,5 +12,9 @@ export const config = {
|
|||||||
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
||||||
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||||
|
},
|
||||||
|
seo: {
|
||||||
|
ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/api/og-image',
|
||||||
|
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
158
lib/gameState.ts
158
lib/gameState.ts
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getTodayISOString } from './dateUtils';
|
import { getTodayISOString } from './dateUtils';
|
||||||
|
import { loadPlayerState, savePlayerState, getGenreKey } from './playerStorage';
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -27,17 +28,19 @@ export interface Statistics {
|
|||||||
failed: number;
|
failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
|
||||||
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
|
||||||
|
|
||||||
const INITIAL_SCORE = 90;
|
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 [gameState, setGameState] = useState<GameState | null>(null);
|
||||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||||
|
|
||||||
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
// Get genre key for backend storage
|
||||||
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
|
// For specials, genre contains the special name
|
||||||
|
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||||
|
|
||||||
const createNewState = (date: string): GameState => ({
|
const createNewState = (date: string): GameState => ({
|
||||||
date,
|
date,
|
||||||
@@ -52,72 +55,94 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
|||||||
yearGuessed: false
|
yearGuessed: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createNewStatistics = (): Statistics => ({
|
||||||
|
solvedIn1: 0,
|
||||||
|
solvedIn2: 0,
|
||||||
|
solvedIn3: 0,
|
||||||
|
solvedIn4: 0,
|
||||||
|
solvedIn5: 0,
|
||||||
|
solvedIn6: 0,
|
||||||
|
solvedIn7: 0,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load game state
|
|
||||||
const storageKey = getStorageKey();
|
|
||||||
const stored = localStorage.getItem(storageKey);
|
|
||||||
const today = getTodayISOString();
|
const today = getTodayISOString();
|
||||||
|
|
||||||
if (stored) {
|
// Always recompute genreKey to ensure it's current
|
||||||
const parsed = JSON.parse(stored);
|
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||||
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,
|
// Try to load from backend first
|
||||||
// but simpler to just start at 90 for active games to avoid confusion
|
const loadFromBackend = async () => {
|
||||||
|
try {
|
||||||
|
const backendState = await loadPlayerState(currentGenreKey);
|
||||||
|
|
||||||
|
if (backendState) {
|
||||||
|
const { gameState: loadedState, statistics: loadedStats } = backendState;
|
||||||
|
|
||||||
|
// Check if the loaded state is for today
|
||||||
|
if (loadedState.date === today) {
|
||||||
|
setGameState(loadedState);
|
||||||
|
setStatistics(loadedStats);
|
||||||
|
return; // Successfully loaded from backend
|
||||||
|
} else {
|
||||||
|
// State is for a different day - create new state
|
||||||
|
const newState = createNewState(today);
|
||||||
|
setGameState(newState);
|
||||||
|
setStatistics(loadedStats); // Keep statistics across days
|
||||||
|
// Save new state to backend
|
||||||
|
await savePlayerState(currentGenreKey, newState, loadedStats);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No backend state found - create new state
|
||||||
|
// This is the normal case for first-time players or new genre
|
||||||
|
const newState = createNewState(today);
|
||||||
|
setGameState(newState);
|
||||||
|
const newStats = createNewStatistics();
|
||||||
|
setStatistics(newStats);
|
||||||
|
// Save to backend for cross-domain sync
|
||||||
|
await savePlayerState(currentGenreKey, newState, newStats);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setGameState(parsed as GameState);
|
} catch (error) {
|
||||||
} else {
|
console.error('[gameState] Failed to load from backend:', error);
|
||||||
// New day
|
|
||||||
|
// On error, create new state and try to save to backend
|
||||||
|
// This handles network errors gracefully
|
||||||
const newState = createNewState(today);
|
const newState = createNewState(today);
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
const newStats = createNewStatistics();
|
||||||
|
setStatistics(newStats);
|
||||||
|
// Try to save to backend (may fail, but we try)
|
||||||
|
try {
|
||||||
|
await savePlayerState(currentGenreKey, newState, newStats);
|
||||||
|
} catch (saveError) {
|
||||||
|
console.error('[gameState] Failed to save new state to backend:', saveError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
};
|
||||||
// No state
|
|
||||||
const newState = createNewState(today);
|
|
||||||
setGameState(newState);
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load statistics
|
loadFromBackend();
|
||||||
const statsKey = getStatsKey();
|
}, [genre, isSpecial]); // Re-run when genre or isSpecial changes
|
||||||
const storedStats = localStorage.getItem(statsKey);
|
|
||||||
if (storedStats) {
|
|
||||||
const parsedStats = JSON.parse(storedStats);
|
|
||||||
// Migration for existing stats without solvedIn7
|
|
||||||
if (parsedStats.solvedIn7 === undefined) {
|
|
||||||
parsedStats.solvedIn7 = 0;
|
|
||||||
}
|
|
||||||
setStatistics(parsedStats);
|
|
||||||
} else {
|
|
||||||
const newStats: Statistics = {
|
|
||||||
solvedIn1: 0,
|
|
||||||
solvedIn2: 0,
|
|
||||||
solvedIn3: 0,
|
|
||||||
solvedIn4: 0,
|
|
||||||
solvedIn5: 0,
|
|
||||||
solvedIn6: 0,
|
|
||||||
solvedIn7: 0,
|
|
||||||
failed: 0,
|
|
||||||
};
|
|
||||||
setStatistics(newStats);
|
|
||||||
localStorage.setItem(statsKey, JSON.stringify(newStats));
|
|
||||||
}
|
|
||||||
}, [genre]); // Re-run when genre changes
|
|
||||||
|
|
||||||
const saveState = (newState: GameState) => {
|
const saveState = async (newState: GameState) => {
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
|
||||||
|
// Save to backend only
|
||||||
|
if (statistics) {
|
||||||
|
try {
|
||||||
|
// Always use the current genreKey (recompute it in case genre/isSpecial changed)
|
||||||
|
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||||
|
await savePlayerState(currentGenreKey, newState, statistics);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[gameState] Failed to save to backend:', error);
|
||||||
|
// No fallback - backend is required for cross-domain sync
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateStatistics = (attempts: number, solved: boolean) => {
|
const updateStatistics = async (attempts: number, solved: boolean) => {
|
||||||
if (!statistics) return;
|
if (!statistics) return;
|
||||||
|
|
||||||
const newStats = { ...statistics };
|
const newStats = { ...statistics };
|
||||||
@@ -139,11 +164,24 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
|||||||
}
|
}
|
||||||
|
|
||||||
setStatistics(newStats);
|
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) => {
|
const addGuess = (guess: string, correct: boolean) => {
|
||||||
if (!gameState) return;
|
if (!gameState) return;
|
||||||
|
// Prevent adding guesses if already solved or failed
|
||||||
|
if (gameState.isSolved || gameState.isFailed) return;
|
||||||
|
|
||||||
const newGuesses = [...gameState.guesses, guess];
|
const newGuesses = [...gameState.guesses, guess];
|
||||||
const isSolved = correct;
|
const isSolved = correct;
|
||||||
|
|||||||
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 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
120
lib/playerId.ts
Normal file
120
lib/playerId.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'hoerdle_player_id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a UUID v4
|
||||||
|
*/
|
||||||
|
function generatePlayerId(): 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to find an existing player ID from the backend
|
||||||
|
*
|
||||||
|
* @param genreKey - Genre key to search for
|
||||||
|
* @returns Player ID if found, null otherwise
|
||||||
|
*/
|
||||||
|
async function findExistingPlayerId(genreKey: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/player-id/suggest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ genreKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.playerId) {
|
||||||
|
return data.playerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[playerId] Failed to find existing player ID:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a player identifier
|
||||||
|
*
|
||||||
|
* If no identifier exists in localStorage, tries to find an existing one from the backend
|
||||||
|
* (based on recently updated states). If none found, generates a new UUID.
|
||||||
|
* This enables cross-domain synchronization between hoerdle.de and hördle.de.
|
||||||
|
*
|
||||||
|
* @param genreKey - Optional genre key to search for existing player ID
|
||||||
|
* @returns Player identifier (UUID v4)
|
||||||
|
*/
|
||||||
|
export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<string> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
// Server-side: return empty string (not used on server)
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let playerId = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!playerId) {
|
||||||
|
// Try to find an existing player ID from backend if genreKey is provided
|
||||||
|
if (genreKey) {
|
||||||
|
const existingId = await findExistingPlayerId(genreKey);
|
||||||
|
if (existingId) {
|
||||||
|
playerId = existingId;
|
||||||
|
localStorage.setItem(STORAGE_KEY, playerId);
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new UUID if no existing ID found
|
||||||
|
playerId = generatePlayerId();
|
||||||
|
localStorage.setItem(STORAGE_KEY, playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a player identifier (synchronous version)
|
||||||
|
*
|
||||||
|
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
|
||||||
|
*
|
||||||
|
* @returns Player identifier (UUID v4)
|
||||||
|
*/
|
||||||
|
export function getOrCreatePlayerId(): string {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
// Server-side: return empty string (not used on server)
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let playerId = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!playerId) {
|
||||||
|
playerId = generatePlayerId();
|
||||||
|
localStorage.setItem(STORAGE_KEY, playerId);
|
||||||
|
}
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current player identifier without creating a new one
|
||||||
|
*
|
||||||
|
* @returns Player identifier or null if not set
|
||||||
|
*/
|
||||||
|
export function getPlayerId(): string | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return localStorage.getItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
132
lib/playerStorage.ts
Normal file
132
lib/playerStorage.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
const playerId = getOrCreatePlayerId();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -162,7 +162,6 @@
|
|||||||
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
|
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
|
||||||
"imprintCountry": "Deutschland",
|
"imprintCountry": "Deutschland",
|
||||||
"imprintEmailLabel": "E-Mail:",
|
"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",
|
"costsTitle": "Laufende Kosten des Projekts",
|
||||||
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
||||||
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
||||||
@@ -190,7 +189,6 @@
|
|||||||
"privacyPlausibleAggregated": "Auswertungen erfolgen ausschließlich in aggregierter Form (z. B. Seitenaufrufe, genutzte Browser).",
|
"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.",
|
"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.",
|
"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",
|
"backTitle": "Zurück zum Spiel",
|
||||||
"backToGame": "Zurück zu Hördle",
|
"backToGame": "Zurück zu Hördle",
|
||||||
"footerLinkLabel": "Über & Impressum"
|
"footerLinkLabel": "Über & Impressum"
|
||||||
|
|||||||
@@ -162,7 +162,6 @@
|
|||||||
"imprintOperator": "Responsible for the content of this site (provider under German law):",
|
"imprintOperator": "Responsible for the content of this site (provider under German law):",
|
||||||
"imprintCountry": "Germany",
|
"imprintCountry": "Germany",
|
||||||
"imprintEmailLabel": "Email:",
|
"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",
|
"costsTitle": "Ongoing costs of the project",
|
||||||
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
||||||
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
||||||
@@ -190,7 +189,6 @@
|
|||||||
"privacyPlausibleAggregated": "Analytics are only performed in aggregated form (e.g. page views, browsers used).",
|
"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.",
|
"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.",
|
"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",
|
"backTitle": "Back to the game",
|
||||||
"backToGame": "Back to Hördle",
|
"backToGame": "Back to Hördle",
|
||||||
"footerLinkLabel": "About & Imprint"
|
"footerLinkLabel": "About & Imprint"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"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");
|
||||||
@@ -88,3 +88,16 @@ model News {
|
|||||||
|
|
||||||
@@index([publishedAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
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('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
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 = [
|
const csp = [
|
||||||
"default-src 'self'",
|
"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'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data: blob:",
|
"img-src 'self' data: blob:",
|
||||||
"font-src 'self' data:",
|
"font-src 'self' data:",
|
||||||
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
|
`connect-src ${connectSrcParts.join(' ')}`,
|
||||||
"media-src 'self' blob:",
|
"media-src 'self' blob:",
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|
||||||
headers.set('Content-Security-Policy', csp);
|
headers.set('Content-Security-Policy', csp);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
Reference in New Issue
Block a user