Compare commits

...

24 Commits

Author SHA1 Message Date
Hördle Bot
1613bf0dda chore: Bump version to 0.1.4.3 2025-12-02 01:28:34 +01:00
Hördle Bot
b872e87b50 feat: Add -5 points penalty for track extension on wrong guesses
- Add -5 points penalty for track extension (unlock steps) on wrong guesses
- Wrong guess now costs -8 points total (-3 for wrong + -5 for extension)
- Skip remains at -5 points (no additional penalty)
- Update documentation (README.md, SCORING_OPTIONS.md)
- Add SCORING_OPTIONS.md with detailed scoring system analysis
2025-12-02 01:28:27 +01:00
Hördle Bot
87c1ee63ec feat: Add tooltip to star rating and support section updates
- Add tooltip to star rating component encouraging users to help curators
- Add curator application information to support section
- Add bug report email link to support section
- All changes localized (de/en)
2025-12-02 01:00:08 +01:00
Hördle Bot
8c57e938e8 chore: Bump version to 0.1.4.2 2025-12-01 23:59:22 +01:00
Hördle Bot
9eb07ee8d5 refactor: Ensure genreKey is always recomputed before use
- Recompute genreKey inside useEffect and save functions to ensure current values
- Prevents potential closure issues with stale genreKey values
- Improves code quality and prevents future bugs
2025-12-01 23:58:11 +01:00
Hördle Bot
3eb6c7f5cf chore: Remove privacy legal advice disclaimer from about page
- Remove privacyNoLegalAdvice text from about page
- Remove privacyNoLegalAdvice from German and English translations
2025-12-01 22:12:49 +01:00
Hördle Bot
2846afb6f7 feat: Remove localStorage for game states and implement cross-domain player ID sync
- Remove localStorage for game states and statistics (backend only)
- Add API route to suggest player ID based on recently updated states
- Add async player ID lookup that finds existing IDs across domains
- When visiting a new domain, automatically find and use existing player ID
- Enables cross-domain synchronization between hoerdle.de and hördle.de
2025-12-01 20:37:47 +01:00
Hördle Bot
27fa689b18 fix: Prevent replaying already solved puzzles across domains
- Add checks in handleGuess, handleSkip, and handleGiveUp to prevent actions on solved/failed puzzles
- Add protection in addGuess to prevent adding guesses to solved puzzles
- Fix and simplify backend state loading logic
- Ensure solved puzzles cannot be replayed when switching domains
2025-12-01 20:22:28 +01:00
Hördle Bot
61846a6982 feat: Add backend storage for cross-domain player state synchronization
- Add PlayerState model to database schema for storing game states
- Create player identifier system (UUID-based) for cross-domain sync
- Implement API endpoints for loading/saving player states
- Refactor gameState hook to use backend storage with localStorage fallback
- Support synchronization between hoerdle.de and hördle.de
- Migration automatically runs on Docker container start
2025-12-01 20:09:54 +01:00
Hördle Bot
bba6b9ef31 fix: Use current domain in share URL instead of static config.domain
- Share URL now uses window.location.hostname to support both hoerdle.de and hördle.de
- Protocol is automatically detected from window.location.protocol
- Fixes issue where share URL always used hoerdle.de even when accessed via hördle.de
2025-12-01 19:53:26 +01:00
Hördle Bot
a8867ac42e feat: Add dynamic Open Graph image generation with correct aspect ratio
- Create /api/og-image endpoint that generates SVG with 1.91:1 ratio (1200x630px)
- Prevents logo cropping on Facebook and Twitter
- Uses safe padding (150px) to ensure content is never cut off
- Update default OG image to use dynamic endpoint
- Add SEO testing documentation
2025-12-01 19:44:48 +01:00
Hördle Bot
9006b208af chore: Bump version to v0.1.4.1 2025-12-01 19:31:00 +01:00
Hördle Bot
20c8ad7eaf feat: Add comprehensive SEO implementation
- Add robots.txt with admin/API blocking
- Add dynamic sitemap.xml with static pages and genre pages
- Implement full meta tags (Open Graph, Twitter Cards, Canonical, Alternates)
- Add SEO helper functions for domain detection and URL generation
- Add generateMetadata to all pages (homepage, about, genre, special)
- Support automatic domain detection for hoerdle.de and hördle.de
- Add SEO configuration to lib/config.ts
2025-12-01 19:28:43 +01:00
Hördle Bot
03129a5611 Remove imprint disclaimer from About page and localization files for German and English. 2025-12-01 19:13:33 +01:00
Hördle Bot
fd8f4adcc0 Bump version to 0.1.4 2025-12-01 18:51:31 +01:00
Hördle Bot
23997ccc3a Refactor: Plausible-Konfiguration - automatisches Domain-Tracking und NEXT_PUBLIC_PLAUSIBLE_DOMAIN entfernt
- Domain wird automatisch aus Request-Header erkannt (hoerdle.de / hördle.de)
- NEXT_PUBLIC_PLAUSIBLE_DOMAIN komplett entfernt (nicht mehr benötigt)
- CSP in proxy.ts konfigurierbar gemacht
- twitterHandle entfernt (wurde nicht verwendet)
- Dokumentation aktualisiert
2025-12-01 18:51:15 +01:00
Hördle Bot
85bdbf795c Refactor: Plausible-Konfiguration aktualisiert und twitterHandle entfernt
- Defaults auf neue Domains aktualisiert (hoerdle.de statt hoerdle.elpatron.me)
- CSP in proxy.ts konfigurierbar gemacht (liest Plausible-URL aus Umgebungsvariablen)
- twitterHandle entfernt (wurde nirgendwo verwendet)
- Dokumentation aktualisiert
2025-12-01 18:29:09 +01:00
Hördle Bot
ac0bb02ba0 Bump version to 0.1.3 2025-12-01 18:18:17 +01:00
Hördle Bot
63269c2600 Change: Standard-Sprache von Deutsch auf Englisch geändert
- defaultLocale in proxy.ts auf 'en' geändert
- Fallback in i18n/request.ts auf 'en' geändert
- Fallback-Reihenfolge in lib/i18n.ts angepasst (en vor de)
- Share-URL-Logik in Game.tsx angepasst
- Dokumentation aktualisiert
2025-12-01 18:18:11 +01:00
Hördle Bot
17a39d677d Update: package-lock.json aktualisiert für baseline-browser-mapping 2025-12-01 18:15:22 +01:00
Hördle Bot
1ff0787e4e Bump version to 0.1.2 2025-12-01 18:11:39 +01:00
Hördle Bot
ed5f02bdec Fix: 'Kurieren' zu 'Kuratieren' korrigiert im Admin-Dashboard 2025-12-01 18:11:24 +01:00
Hördle Bot
e3a09864a6 Refactor: Dokumentation nach docs/ verschoben
- Alle Markdown-Dateien (außer README.md) nach docs/ verschoben
- Referenzen in README.md aktualisiert
- /docs zu .dockerignore hinzugefügt
2025-12-01 17:58:32 +01:00
Hördle Bot
107739ade9 Fix: Update Dockerfile to optimize build process and reduce image size
- Refactored the Dockerfile to streamline the build process.
- Removed unnecessary layers and combined commands for efficiency.
- Improved caching strategy to enhance build performance.
2025-12-01 17:54:12 +01:00
46 changed files with 2386 additions and 129 deletions

View File

@@ -50,6 +50,7 @@ Dockerfile*
.dockerignore .dockerignore
# Documentation # Documentation
/docs
*.md *.md
!README.md !README.md

View File

@@ -38,6 +38,9 @@ RUN if [ -n "$APP_VERSION" ]; then \
# Uncomment the following line in case you want to disable telemetry during the build. # Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1
# Suppress baseline-browser-mapping warning about old data (informational only)
ENV NODE_ENV=production
# Generate Prisma Client # Generate Prisma Client
ENV DATABASE_URL="file:./dev.db" ENV DATABASE_URL="file:./dev.db"
RUN node_modules/.bin/prisma generate RUN node_modules/.bin/prisma generate
@@ -46,8 +49,6 @@ RUN node_modules/.bin/prisma generate
ARG NEXT_PUBLIC_APP_NAME ARG NEXT_PUBLIC_APP_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
@@ -60,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

View File

@@ -56,18 +56,18 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
Hördle unterstützt vollständige Mehrsprachigkeit für Deutsch und Englisch. Hördle unterstützt vollständige Mehrsprachigkeit für Deutsch und Englisch.
👉 **[Vollständige i18n-Dokumentation](I18N.md)** 👉 **[Vollständige i18n-Dokumentation](docs/I18N.md)**
**Schnellstart:** **Schnellstart:**
- Deutsche Version: `http://localhost:3000/de` - Deutsche Version: `http://localhost:3000/de`
- Englische Version: `http://localhost:3000/en` - Englische Version: `http://localhost:3000/en`
- Root (`/`) leitet automatisch zur Standardsprache (Deutsch) um - Root (`/`) leitet automatisch zur Standardsprache (Englisch) um
## White Labeling ## White Labeling
Hördle ist "White Label Ready". Das bedeutet, du kannst das Branding (Name, Farben, Logos) komplett anpassen, ohne den Code zu ändern. Hördle ist "White Label Ready". Das bedeutet, du kannst das Branding (Name, Farben, Logos) komplett anpassen, ohne den Code zu ändern.
👉 **[Anleitung zur Anpassung (White Label Guide)](WHITE_LABEL.md)** 👉 **[Anleitung zur Anpassung (White Label Guide)](docs/WHITE_LABEL.md)**
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen. Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
@@ -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 - **Start-Punktestand:** 90 Punkte
- **Richtige Antwort:** +20 Punkte - **Richtige Antwort:** +20 Punkte
- **Falsche Antwort:** -3 Punkte - **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
- **Überspringen (Skip):** -5 Punkte - **Überspringen (Skip):** -5 Punkte
- **Snippet erneut abspielen (Replay):** -1 Punkt - **Snippet erneut abspielen (Replay):** -1 Punkt
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort) - **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt. - **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
- **Minimum:** Der Punktestand kann nicht unter 0 fallen. - **Minimum:** Der Punktestand kann nicht unter 0 fallen.
**Hinweis:** Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die automatische Verlängerung des Audio-Snippets (unlockSteps) abgezogen, um die Verwendung dieses Hilfsmittels zu reflektieren.
## Tech Stack ## Tech Stack
- **Framework:** Next.js 16 (App Router) - **Framework:** Next.js 16 (App Router)
@@ -115,13 +117,13 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
```bash ```bash
npm run dev npm run dev
``` ```
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/de` um). Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/en` um).
## Deployment mit Docker ## Deployment mit Docker
Das Projekt ist für den Betrieb mit Docker optimiert. Das Projekt ist für den Betrieb mit Docker optimiert.
👉 **[White Labeling mit Docker? Hier klicken!](WHITE_LABEL.md#docker-deployment)** 👉 **[White Labeling mit Docker? Hier klicken!](docs/WHITE_LABEL.md#docker-deployment)**
1. **Vorbereitung:** 1. **Vorbereitung:**
Kopiere die Beispiel-Konfiguration: Kopiere die Beispiel-Konfiguration:

View File

@@ -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);

View File

@@ -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" }}>
@@ -199,6 +206,58 @@ export default async function AboutPage({ params }: AboutPageProps) {
</p> </p>
</div> </div>
</div> </div>
<div
style={{
padding: "1rem",
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
backgroundColor: "#f9fafb",
marginBottom: "0.5rem",
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: "600",
marginBottom: "0.5rem",
}}
>
{t("supportCuratorTitle")}
</h3>
<p style={{ marginBottom: 0 }}>
{t("supportCuratorText")}
</p>
</div>
<div
style={{
padding: "1rem",
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
backgroundColor: "#f9fafb",
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: "600",
marginBottom: "0.5rem",
}}
>
{t("supportReportBugTitle")}
</h3>
<p style={{ marginBottom: 0 }}>
{t.rich("supportReportBugText", {
email: (chunks) => (
<a
href="mailto:admin@hoerdle.de"
style={{ textDecoration: "underline" }}
>
{chunks}
</a>
),
})}
</p>
</div>
</section> </section>
<section style={{ marginBottom: "2rem" }}> <section style={{ marginBottom: "2rem" }}>
@@ -234,11 +293,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" }}>

View File

@@ -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"
/> />

View File

@@ -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');

View File

@@ -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
View 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',
},
});
}

View 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 }
);
}
}

View 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
View 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
View 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();
}
}

View File

@@ -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,9 +284,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 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
// Add locale prefix if not default (de) const currentHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
if (locale !== 'de') { 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}`; shareUrl += `/${locale}`;
} }
if (genre) { if (genre) {
@@ -674,7 +687,11 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
} }
return ( return (
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}> <div
className="star-rating"
title={t('ratingTooltip')}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span> <span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}> <div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
{[...Array(5)].map((_, index) => { {[...Array(5)].map((_, index) => {

View File

@@ -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}

View File

@@ -0,0 +1,183 @@
# Caddy Zertifikat-Troubleshooting
## Problem: Zertifikat für Punycode-Domain (hördle.de / xn--hrdle-jua.de) fehlt
Wenn die Domain `hördle.de` (xn--hrdle-jua.de) einen `ERR_SSL_PROTOCOL_ERROR` zeigt, bedeutet das, dass kein gültiges SSL-Zertifikat vorhanden ist.
### Schritt 1: Zertifikat-Status prüfen
Führe das Check-Script aus:
```bash
./scripts/check-caddy-certificates.sh
```
Dieses Script prüft:
- Ob Caddy läuft
- Welche Zertifikate vorhanden sind
- Ob die DNS-Einträge korrekt sind
- Ob die HTTPS-Verbindungen funktionieren
### Schritt 2: DNS-Einträge prüfen
**Wichtig**: Beide Domains müssen auf die gleiche Server-IP zeigen!
#### In GoDaddy prüfen:
1. Gehe zu [GoDaddy DNS-Verwaltung](https://dcc.godaddy.com/manage/hoerdle.de/dns)
2. Prüfe die A-Records:
**Für hoerdle.de:**
- Name: `@` oder `hoerdle.de`
- Typ: `A`
- Wert: `DEINE_SERVER_IP`
**Für hördle.de (Punycode):**
- Name: `@` oder `xn--hrdle-jua.de` (oder der Unicode-Name, falls unterstützt)
- Typ: `A`
- Wert: **GLEICHE_SERVER_IP wie hoerdle.de**
#### DNS manuell testen:
```bash
# Prüfe hoerdle.de
dig +short hoerdle.de @8.8.8.8
# Prüfe xn--hrdle-jua.de (Punycode)
dig +short xn--hrdle-jua.de @8.8.8.8
# Beide sollten die gleiche IP zurückgeben!
```
### Schritt 3: Zertifikat neu erstellen
Wenn die DNS-Einträge korrekt sind, lösche das alte (fehlgeschlagene) Zertifikat und lass Caddy es neu erstellen:
```bash
./scripts/renew-caddy-certificates.sh
```
Wähle Option 2: "Nur Zertifikat für xn--hrdle-jua.de löschen"
### Schritt 4: Caddy-Logs überwachen
Während Caddy das Zertifikat erstellt, überwache die Logs:
```bash
docker logs hoerdle-caddy -f
```
Du solltest sehen:
- `[INFO] attempting ACME challenge` - Caddy versucht die Challenge
- `[INFO] successfully completed ACME challenge` - Challenge erfolgreich
- `[INFO] certificate obtained successfully` - Zertifikat erstellt
Bei Fehlern siehst du:
- `[ERROR] acme: error` - Challenge fehlgeschlagen
- `[ERROR] unable to validate` - Validierung fehlgeschlagen
### Schritt 5: Häufige Probleme und Lösungen
#### Problem 1: DNS zeigt auf falsche IP
**Symptom**: `dig` zeigt eine andere IP als erwartet
**Lösung**:
1. Prüfe DNS-Einträge in GoDaddy
2. Warte auf DNS-Propagierung (kann 5-60 Minuten dauern)
3. Verwende einen DNS-Checker: https://www.whatsmydns.net/
#### Problem 2: Port 80 nicht erreichbar
**Symptom**: Caddy-Logs zeigen "connection refused" oder Timeout
**Lösung**:
1. Prüfe Firewall: `sudo ufw status`
2. Prüfe ob Port 80 offen ist: `sudo netstat -tulpn | grep :80`
3. Prüfe ob Caddy auf Port 80 lauscht: `docker exec hoerdle-caddy netstat -tulpn | grep :80`
#### Problem 3: Let's Encrypt Rate Limit
**Symptom**: Logs zeigen "too many certificates already issued"
**Lösung**:
- Warte 1 Woche (Rate Limit von Let's Encrypt)
- Oder verwende Staging-Environment zum Testen:
```caddyfile
tls {
staging
}
```
#### Problem 4: Punycode-Domain wird nicht erkannt
**Symptom**: Caddy erstellt Zertifikat nur für hoerdle.de, nicht für xn--hrdle-jua.de
**Lösung**:
1. Prüfe ob beide Domains in der Caddyfile stehen
2. Prüfe DNS-Einträge (siehe Schritt 2)
3. Erzwinge Zertifikat-Erstellung (siehe Schritt 3)
### Manuelle Zertifikat-Löschung
Falls das Script nicht funktioniert, kannst du Zertifikate manuell löschen:
```bash
# Alle Zertifikate löschen
docker exec hoerdle-caddy rm -rf /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/*
# Nur Punycode-Zertifikat löschen (manuell)
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--*" -delete
# Container neu starten
docker compose -f docker-compose.caddy.yml --profile production restart caddy
```
### DNS-Propagierung prüfen
Nach DNS-Änderungen kann es bis zu 60 Minuten dauern, bis alle DNS-Server aktualisiert sind:
```bash
# Prüfe DNS-Propagierung weltweit
curl "https://dnschecker.org/#A/hoerdle.de"
curl "https://dnschecker.org/#A/xn--hrdle-jua.de"
```
### Test-Zertifikat erstellen (Staging)
Zum Testen ohne Rate-Limits kannst du ein Staging-Zertifikat erstellen:
1. Temporär Caddyfile ändern (in beiden Domain-Blocks):
```caddyfile
tls {
staging
}
```
2. Container neu starten
3. Zertifikat erstellen lassen
4. Zurück zu Produktion ändern (Staging-Block entfernen)
5. Erneut Container neu starten
### Verifizieren, dass es funktioniert
Nach erfolgreicher Zertifikats-Erstellung:
```bash
# Teste HTTPS-Verbindung
curl -I https://hoerdle.de
curl -I https://xn--hrdle-jua.de
# Prüfe Zertifikat-Details
echo | openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -subject -dates
echo | openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -subject -dates
```
### Support
Falls das Problem weiterhin besteht:
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy`
2. Prüfe DNS: `dig +short xn--hrdle-jua.de @8.8.8.8`
3. Prüfe Firewall: `sudo ufw status`
4. Prüfe Port-Zugriff: `curl -I http://hoerdle.de`

View File

@@ -8,14 +8,14 @@ Die i18n-Implementierung basiert auf [next-intl](https://next-intl-docs.vercel.a
## Unterstützte Sprachen ## Unterstützte Sprachen
- **Deutsch (de)** - Standardsprache - **Englisch (en)** - Standardsprache
- **Englisch (en)** - **Deutsch (de)**
## URL-Struktur ## URL-Struktur
Alle Routen sind lokalisiert: Alle Routen sind lokalisiert:
- `http://localhost:3000/` → Redirect zu `/de` (Standard) - `http://localhost:3000/` → Redirect zu `/en` (Standard)
- `http://localhost:3000/de` → Deutsche Version - `http://localhost:3000/de` → Deutsche Version
- `http://localhost:3000/en` → Englische Version - `http://localhost:3000/en` → Englische Version
- `http://localhost:3000/de/admin` → Admin-Dashboard (Deutsch) - `http://localhost:3000/de/admin` → Admin-Dashboard (Deutsch)
@@ -103,8 +103,8 @@ const genreNameEn = getLocalizedValue(genre.name, 'en'); // "Rock"
**Fallback-Verhalten:** **Fallback-Verhalten:**
1. Versucht die angeforderte Locale (`de` oder `en`) 1. Versucht die angeforderte Locale (`de` oder `en`)
2. Fallback zu `de` falls nicht vorhanden 2. Fallback zu `en` falls nicht vorhanden
3. Fallback zu `en` falls nicht vorhanden 3. Fallback zu `de` falls nicht vorhanden
4. Fallback zum ersten verfügbaren Schlüssel 4. Fallback zum ersten verfügbaren Schlüssel
5. Fallback zum übergebenen `fallback`-Parameter 5. Fallback zum übergebenen `fallback`-Parameter
@@ -195,7 +195,7 @@ Bestehende Daten werden automatisch migriert:
Der Proxy (`proxy.ts`) leitet Anfragen automatisch um: Der Proxy (`proxy.ts`) leitet Anfragen automatisch um:
- `/``/de` (Standard) - `/``/en` (Standard)
- Ungültige Locales → 404 - Ungültige Locales → 404
- Validiert Locale-Parameter - Validiert Locale-Parameter
@@ -223,7 +223,7 @@ GET /api/specials?locale=en
GET /api/news?locale=de GET /api/news?locale=de
``` ```
Falls kein `locale` angegeben wird, wird `de` als Standard verwendet. Falls kein `locale` angegeben wird, wird `en` als Standard verwendet.
## Best Practices ## Best Practices

167
docs/PLAUSIBLE_SETUP.md Normal file
View 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
View 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
View 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)

View File

@@ -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

View File

@@ -9,7 +9,7 @@ export default getRequestConfig(async ({ requestLocale }) => {
console.log('[i18n/request] incoming requestLocale:', locale); console.log('[i18n/request] incoming requestLocale:', locale);
if (!locale || !locales.includes(locale as (typeof locales)[number])) { if (!locale || !locales.includes(locale as (typeof locales)[number])) {
locale = 'de'; locale = 'en';
console.log('[i18n/request] falling back to default locale:', locale); console.log('[i18n/request] falling back to default locale:', locale);
} }

View File

@@ -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,
} }
}; };

View File

@@ -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 // Try to load from backend first
if (parsed.score === undefined) { const loadFromBackend = async () => {
parsed.score = INITIAL_SCORE; try {
parsed.replayCount = 0; const backendState = await loadPlayerState(currentGenreKey);
parsed.skipCount = 0;
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }]; if (backendState) {
parsed.yearGuessed = false; const { gameState: loadedState, statistics: loadedStats } = backendState;
// Retroactively deduct points for existing guesses if possible, // Check if the loaded state is for today
// but simpler to just start at 90 for active games to avoid confusion 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); loadFromBackend();
setGameState(newState); }, [genre, isSpecial]); // Re-run when genre or isSpecial changes
localStorage.setItem(storageKey, JSON.stringify(newState));
}
// Load statistics const saveState = async (newState: GameState) => {
const statsKey = getStatsKey();
const storedStats = localStorage.getItem(statsKey);
if (storedStats) {
const parsedStats = JSON.parse(storedStats);
// Migration for existing stats without solvedIn7
if (parsedStats.solvedIn7 === undefined) {
parsedStats.solvedIn7 = 0;
}
setStatistics(parsedStats);
} else {
const newStats: Statistics = {
solvedIn1: 0,
solvedIn2: 0,
solvedIn3: 0,
solvedIn4: 0,
solvedIn5: 0,
solvedIn6: 0,
solvedIn7: 0,
failed: 0,
};
setStatistics(newStats);
localStorage.setItem(statsKey, JSON.stringify(newStats));
}
}, [genre]); // Re-run when genre changes
const saveState = (newState: GameState) => {
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;
@@ -162,6 +200,9 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
} else { } else {
newScore -= 3; newScore -= 3;
newBreakdown.push({ value: -3, reason: 'Wrong guess' }); newBreakdown.push({ value: -3, reason: 'Wrong guess' });
// Additional penalty for track extension (unlock steps)
newScore -= 5;
newBreakdown.push({ value: -5, reason: 'Track extension' });
} }
} }

View File

@@ -16,12 +16,12 @@ export function getLocalizedValue(
if (typeof value === 'object') { if (typeof value === 'object') {
if (value[locale]) return value[locale]; if (value[locale]) return value[locale];
// Fallback to 'de'
if (value['de']) return value['de'];
// Fallback to 'en' // Fallback to 'en'
if (value['en']) return value['en']; if (value['en']) return value['en'];
// Fallback to 'de'
if (value['de']) return value['de'];
// Fallback to first key // Fallback to first key
const keys = Object.keys(value); const keys = Object.keys(value);
if (keys.length > 0) return value[keys[0]]; if (keys.length > 0) return value[keys[0]];

64
lib/metadata.ts Normal file
View 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
View 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
View 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
View 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}`;
}

View File

@@ -47,6 +47,7 @@
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.", "yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
"thanksForRating": "Danke für die Bewertung!", "thanksForRating": "Danke für die Bewertung!",
"rateThisPuzzle": "Bewerte dieses Rätsel:", "rateThisPuzzle": "Bewerte dieses Rätsel:",
"ratingTooltip": "Hilf unseren Kuratoren, gute Rätsel zu machen!",
"shared": "✓ Geteilt!", "shared": "✓ Geteilt!",
"copied": "✓ Kopiert!", "copied": "✓ Kopiert!",
"shareFailed": "✗ Fehlgeschlagen", "shareFailed": "✗ Fehlgeschlagen",
@@ -125,7 +126,7 @@
"delete": "Löschen", "delete": "Löschen",
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"curate": "Kurieren", "curate": "Kuratieren",
"name": "Name", "name": "Name",
"subtitle": "Untertitel", "subtitle": "Untertitel",
"maxAttempts": "Max. Versuche", "maxAttempts": "Max. Versuche",
@@ -162,7 +163,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)",
@@ -180,6 +180,10 @@
"supportPaypalLink": "paypal.me/MBusche", "supportPaypalLink": "paypal.me/MBusche",
"supportSteadyTitle": "Steady", "supportSteadyTitle": "Steady",
"supportSteadyDescription": "Regelmäßige Unterstützung über Steady", "supportSteadyDescription": "Regelmäßige Unterstützung über Steady",
"supportCuratorTitle": "Als Kurator bewerben",
"supportCuratorText": "Du hast gute Kenntnisse in einem Genre und möchtest dich als Kurator bewerben? Wir freuen uns über deine Nachricht!",
"supportReportBugTitle": "Fehler melden",
"supportReportBugText": "Fehler in der App gefunden? Bitte melde sie per E-Mail an <email>admin@hoerdle.de</email>.",
"privacyTitle": "Datenschutz", "privacyTitle": "Datenschutz",
"privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.", "privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.",
"privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics", "privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics",
@@ -190,7 +194,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"

View File

@@ -47,6 +47,7 @@
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.", "yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
"thanksForRating": "Thanks for rating!", "thanksForRating": "Thanks for rating!",
"rateThisPuzzle": "Rate this puzzle:", "rateThisPuzzle": "Rate this puzzle:",
"ratingTooltip": "Help our curators create good puzzles!",
"shared": "✓ Shared!", "shared": "✓ Shared!",
"copied": "✓ Copied!", "copied": "✓ Copied!",
"shareFailed": "✗ Failed", "shareFailed": "✗ Failed",
@@ -162,7 +163,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)",
@@ -180,6 +180,10 @@
"supportPaypalLink": "paypal.me/MBusche", "supportPaypalLink": "paypal.me/MBusche",
"supportSteadyTitle": "Steady", "supportSteadyTitle": "Steady",
"supportSteadyDescription": "Regular support via Steady", "supportSteadyDescription": "Regular support via Steady",
"supportCuratorTitle": "Apply as Curator",
"supportCuratorText": "Do you have good knowledge in a genre and would like to apply as a curator? We'd be happy to hear from you!",
"supportReportBugTitle": "Report Bugs",
"supportReportBugText": "Found a bug in the app? Please report it via email to <email>admin@hoerdle.de</email>.",
"privacyTitle": "Privacy", "privacyTitle": "Privacy",
"privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.", "privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.",
"privacyPlausibleTitle": "Self-hosted Plausible Analytics", "privacyPlausibleTitle": "Self-hosted Plausible Analytics",
@@ -190,7 +194,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"

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.0.15", "version": "0.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.0.15", "version": "0.1.2",
"dependencies": { "dependencies": {
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",

View File

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

View File

@@ -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");

View File

@@ -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])
}

View File

@@ -2,9 +2,9 @@ import createMiddleware from 'next-intl/middleware';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
const i18nMiddleware = createMiddleware({ const i18nMiddleware = createMiddleware({
locales: ['de', 'en'], locales: ['en', 'de'],
defaultLocale: 'de', defaultLocale: 'en',
// Wir nutzen überall Locale-Präfixe (`/de`, `/en`) // Wir nutzen überall Locale-Präfixe (`/en`, `/de`)
localePrefix: 'always' localePrefix: 'always'
}); });
@@ -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;

View File

@@ -0,0 +1,94 @@
#!/bin/bash
# Script zum Prüfen der Caddy-Zertifikate
set -e
echo "🔍 Prüfe Caddy-Zertifikat-Status..."
echo ""
# Prüfe ob Caddy-Container läuft
if ! docker ps | grep -q hoerdle-caddy; then
echo "❌ Caddy-Container läuft nicht!"
echo " Starte ihn mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
exit 1
fi
echo "✅ Caddy-Container läuft"
echo ""
# Prüfe Zertifikate im Volume
echo "📜 Gespeicherte Zertifikate im Volume:"
docker exec hoerdle-caddy find /data/caddy/certificates -name "*.crt" -o -name "*.key" 2>/dev/null | head -20 || echo " (Keine Zertifikate gefunden oder Fehler beim Zugriff)"
echo ""
# Prüfe Caddy-Logs für Zertifikats-Fehler
echo "📋 Letzte Caddy-Logs (Zertifikat-bezogen):"
docker logs hoerdle-caddy 2>&1 | grep -i -E "(certificate|tls|acme|challenge|error)" | tail -20 || echo " (Keine relevanten Log-Einträge gefunden)"
echo ""
# Prüfe DNS-Einträge
echo "🌐 DNS-Einträge prüfen:"
echo ""
# Prüfe hoerdle.de
echo "1⃣ hoerdle.de:"
HOERDLE_IP=$(dig +short hoerdle.de @8.8.8.8 | head -1 || echo "DNS-Fehler")
if [ -n "$HOERDLE_IP" ] && [ "$HOERDLE_IP" != "DNS-Fehler" ]; then
echo " ✅ DNS-A-Record: $HOERDLE_IP"
else
echo " ❌ DNS-A-Record: nicht aufgelöst"
fi
# Prüfe xn--hrdle-jua.de (Punycode)
echo "2⃣ xn--hrdle-jua.de (hördle.de):"
PUNYCODE_IP=$(dig +short xn--hrdle-jua.de @8.8.8.8 | head -1 || echo "DNS-Fehler")
if [ -n "$PUNYCODE_IP" ] && [ "$PUNYCODE_IP" != "DNS-Fehler" ]; then
echo " ✅ DNS-A-Record: $PUNYCODE_IP"
else
echo " ❌ DNS-A-Record: nicht aufgelöst"
fi
# Prüfe auch die Unicode-Domain direkt (falls unterstützt)
echo "3⃣ hördle.de (Unicode):"
UNICODE_IP=$(dig +short hördle.de @8.8.8.8 2>/dev/null | head -1 || echo "Nicht unterstützt")
if [ -n "$UNICODE_IP" ] && [ "$UNICODE_IP" != "Nicht unterstützt" ]; then
echo " ✅ DNS-A-Record: $UNICODE_IP"
else
echo " ⚠️ Unicode-Domain-Abfrage nicht unterstützt (normal)"
fi
echo ""
echo "🔒 Zertifikat-Test:"
echo ""
# Teste HTTPS-Verbindung zu hoerdle.de
echo "1⃣ hoerdle.de HTTPS:"
if echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | grep -q "Verify return code: 0"; then
echo " ✅ Zertifikat gültig"
CERT_INFO=$(echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -subject -dates 2>/dev/null || echo "")
echo " $CERT_INFO" | sed 's/^/ /'
else
echo " ❌ Zertifikat fehlt oder ungültig"
fi
echo ""
echo "2⃣ hördle.de (xn--hrdle-jua.de) HTTPS:"
if echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | grep -q "Verify return code: 0"; then
echo " ✅ Zertifikat gültig"
CERT_INFO=$(echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -subject -dates 2>/dev/null || echo "")
echo " $CERT_INFO" | sed 's/^/ /'
else
echo " ❌ Zertifikat fehlt oder ungültig"
fi
echo ""
echo "📝 Zertifikat-Details aus Caddy Volume:"
echo ""
docker exec hoerdle-caddy ls -la /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/ 2>/dev/null | head -10 || echo " (Verzeichnis nicht gefunden oder leer)"
echo ""
echo "💡 Tipps:"
echo " - Wenn kein Zertifikat vorhanden ist, führe aus: ./scripts/renew-caddy-certificates.sh"
echo " - Prüfe die Caddy-Logs: docker logs hoerdle-caddy"
echo " - Prüfe DNS-Einträge in GoDaddy: https://dcc.godaddy.com/manage/hoerdle.de/dns"

View File

@@ -0,0 +1,110 @@
#!/bin/bash
# Quick-Check für Punycode-Domain DNS und Zertifikat
set -e
echo "🔍 Quick-Check für hördle.de (xn--hrdle-jua.de)"
echo ""
# Prüfe DNS
echo "1⃣ DNS-Auflösung:"
echo ""
echo " hoerdle.de:"
HOERDLE_IP=$(dig +short hoerdle.de @8.8.8.8 | head -1 || echo "")
if [ -z "$HOERDLE_IP" ]; then
echo " ❌ Konnte nicht aufgelöst werden"
else
echo " ✅ IP: $HOERDLE_IP"
fi
echo ""
echo " xn--hrdle-jua.de:"
PUNYCODE_IP=$(dig +short xn--hrdle-jua.de @8.8.8.8 | head -1 || echo "")
if [ -z "$PUNYCODE_IP" ]; then
echo " ❌ Konnte nicht aufgelöst werden"
else
echo " ✅ IP: $PUNYCODE_IP"
fi
if [ -n "$HOERDLE_IP" ] && [ -n "$PUNYCODE_IP" ]; then
if [ "$HOERDLE_IP" = "$PUNYCODE_IP" ]; then
echo ""
echo " ✅ Beide Domains zeigen auf die gleiche IP ($HOERDLE_IP)"
else
echo ""
echo " ⚠️ WARNUNG: Domains zeigen auf unterschiedliche IPs!"
echo " hoerdle.de: $HOERDLE_IP"
echo " xn--hrdle-jua.de: $PUNYCODE_IP"
echo " → Beide sollten auf die gleiche IP zeigen!"
fi
fi
echo ""
echo "2⃣ HTTPS-Verbindungstest:"
echo ""
# Test hoerdle.de
echo " hoerdle.de:"
if timeout 5 bash -c "echo > /dev/tcp/hoerdle.de/443" 2>/dev/null; then
echo " ✅ Port 443 ist erreichbar"
if echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | grep -q "Verify return code: 0"; then
CERT_VALID_UNTIL=$(echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")
echo " ✅ Zertifikat gültig bis: $CERT_VALID_UNTIL"
else
echo " ❌ Zertifikat ungültig oder fehlt"
fi
else
echo " ❌ Port 443 nicht erreichbar"
fi
echo ""
echo " xn--hrdle-jua.de:"
if timeout 5 bash -c "echo > /dev/tcp/xn--hrdle-jua.de/443" 2>/dev/null; then
echo " ✅ Port 443 ist erreichbar"
if echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | grep -q "Verify return code: 0"; then
CERT_VALID_UNTIL=$(echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")
echo " ✅ Zertifikat gültig bis: $CERT_VALID_UNTIL"
else
echo " ❌ Zertifikat ungültig oder fehlt (ERR_SSL_PROTOCOL_ERROR)"
echo ""
echo " 💡 Lösung: Führe aus: ./scripts/renew-caddy-certificates.sh"
fi
else
echo " ❌ Port 443 nicht erreichbar"
fi
echo ""
echo "3⃣ Caddy-Container Status:"
echo ""
if docker ps | grep -q hoerdle-caddy; then
echo " ✅ Caddy-Container läuft"
echo ""
echo " 📋 Letzte Caddy-Logs (Zertifikat-bezogen):"
docker logs hoerdle-caddy 2>&1 | grep -i -E "(xn--hrdle|punycode|certificate|tls|acme|challenge)" | tail -5 || echo " (Keine relevanten Einträge gefunden)"
else
echo " ❌ Caddy-Container läuft nicht"
echo " Starte mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "💡 Nächste Schritte:"
echo ""
if [ -n "$HOERDLE_IP" ] && [ -n "$PUNYCODE_IP" ] && [ "$HOERDLE_IP" != "$PUNYCODE_IP" ]; then
echo " 1. ❗ DNS-Konfiguration prüfen!"
echo " Beide Domains müssen auf die gleiche IP zeigen."
echo " Prüfe in GoDaddy: https://dcc.godaddy.com/manage/hoerdle.de/dns"
echo ""
fi
echo " 2. Wenn DNS korrekt ist, Zertifikat neu erstellen:"
echo " ./scripts/renew-caddy-certificates.sh"
echo ""
echo " 3. Caddy-Logs überwachen:"
echo " docker logs hoerdle-caddy -f"
echo ""
echo " 4. Detaillierte Diagnose:"
echo " ./scripts/check-caddy-certificates.sh"
echo ""

View File

@@ -0,0 +1,83 @@
#!/bin/bash
# Script zum Erneuern/Löschen und Neu-Erstellen von Caddy-Zertifikaten
set -e
echo "🔄 Caddy-Zertifikat-Erneuerung"
echo ""
echo "⚠️ WICHTIG: Dieses Script wird die Zertifikate löschen und Caddy zwingen, sie neu zu erstellen."
echo ""
# Prüfe ob Caddy-Container läuft
if ! docker ps | grep -q hoerdle-caddy; then
echo "❌ Caddy-Container läuft nicht!"
echo " Starte ihn mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
exit 1
fi
echo "📋 Verfügbare Optionen:"
echo " 1) Alle Zertifikate löschen und neu erstellen (empfohlen bei Problemen)"
echo " 2) Nur Zertifikat für xn--hrdle-jua.de löschen (Punycode-Domain)"
echo " 3) Nur Zertifikat für hoerdle.de löschen"
echo " 4) Abbrechen"
echo ""
read -p "Wähle eine Option (1-4): " choice
case $choice in
1)
echo ""
echo "🗑️ Lösche ALLE Zertifikate..."
docker exec hoerdle-caddy rm -rf /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/* 2>/dev/null || true
echo "✅ Alle Zertifikate gelöscht"
;;
2)
echo ""
echo "🗑️ Lösche Zertifikat für xn--hrdle-jua.de..."
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--hrdle-jua.de*" -delete 2>/dev/null || true
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--*" -delete 2>/dev/null || true
echo "✅ Zertifikat für Punycode-Domain gelöscht"
;;
3)
echo ""
echo "🗑️ Lösche Zertifikat für hoerdle.de..."
docker exec hoerdle-caddy find /data/caddy/certificates -name "*hoerdle.de*" ! -name "*xn--*" -delete 2>/dev/null || true
echo "✅ Zertifikat für hoerdle.de gelöscht"
;;
4)
echo "❌ Abgebrochen."
exit 0
;;
*)
echo "❌ Ungültige Option. Abgebrochen."
exit 1
;;
esac
echo ""
echo "🔄 Starte Caddy-Container neu, um Zertifikate neu zu erstellen..."
docker compose -f docker-compose.caddy.yml --profile production restart caddy
echo ""
echo "⏳ Warte 5 Sekunden, damit Caddy startet..."
sleep 5
echo ""
echo "📋 Prüfe Caddy-Logs auf Zertifikats-Erstellung:"
echo ""
docker logs hoerdle-caddy --tail=30 2>&1 | grep -i -E "(certificate|tls|acme|challenge|error|success)" || echo " (Keine relevanten Log-Einträge in den letzten 30 Zeilen)"
echo ""
echo "✅ Container wurde neu gestartet."
echo ""
echo "💡 Nächste Schritte:"
echo " 1. Warte 1-2 Minuten, damit Caddy die Zertifikate erstellt"
echo " 2. Prüfe die Logs: docker logs hoerdle-caddy -f"
echo " 3. Prüfe den Status: ./scripts/check-caddy-certificates.sh"
echo " 4. Teste die Domain: curl -I https://xn--hrdle-jua.de"
echo ""
echo "⚠️ Falls das Zertifikat nicht erstellt wird:"
echo " - Prüfe DNS-Einträge: Beide Domains müssen auf die Server-IP zeigen"
echo " - Prüfe Port 80: Muss von außen erreichbar sein (für HTTP-01 Challenge)"
echo " - Prüfe Firewall: Ports 80 und 443 müssen offen sein"
echo " - Prüfe Caddy-Logs: docker logs hoerdle-caddy"