Compare commits

..

8 Commits

Author SHA1 Message Date
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
15 changed files with 952 additions and 75 deletions

View File

@@ -241,11 +241,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
</ul> </ul>
<p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p> <p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p>
<p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p> <p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p>
<p
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
>
{t("privacyNoLegalAdvice")}
</p>
</section> </section>
<section style={{ marginBottom: "2rem" }}> <section style={{ marginBottom: "2rem" }}>

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

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,7 +284,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : ''; const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : ''; const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
let shareUrl = `https://${config.domain}`; // Use current domain from window.location to support both hoerdle.de and hördle.de
const currentHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
let shareUrl = `${protocol}//${currentHost}`;
// Add locale prefix if not default (en) // Add locale prefix if not default (en)
if (locale !== 'en') { if (locale !== 'en') {
shareUrl += `/${locale}`; shareUrl += `/${locale}`;

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

@@ -14,7 +14,7 @@ export const config = {
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: { seo: {
ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/favicon.ico', ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/api/og-image',
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined, 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;

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

View File

@@ -189,7 +189,6 @@
"privacyPlausibleAggregated": "Auswertungen erfolgen ausschließlich in aggregierter Form (z. B. Seitenaufrufe, genutzte Browser).", "privacyPlausibleAggregated": "Auswertungen erfolgen ausschließlich in aggregierter Form (z. B. Seitenaufrufe, genutzte Browser).",
"privacyServerLogs": "Der Server kann technisch bedingt Logdateien mit IP-Adresse, Zeitpunkt des Zugriffs und abgerufenen Ressourcen führen. Diese Daten werden nur zur Sicherstellung des Betriebs und zur Fehleranalyse verwendet und regelmäßig gelöscht.", "privacyServerLogs": "Der Server kann technisch bedingt Logdateien mit IP-Adresse, Zeitpunkt des Zugriffs und abgerufenen Ressourcen führen. Diese Daten werden nur zur Sicherstellung des Betriebs und zur Fehleranalyse verwendet und regelmäßig gelöscht.",
"privacyContact": "Wenn du Fragen zu den verarbeiteten Daten hast oder Auskunft wünschst, kannst du dich über die im Impressum genannte E-Mail-Adresse melden.", "privacyContact": "Wenn du Fragen zu den verarbeiteten Daten hast oder Auskunft wünschst, kannst du dich über die im Impressum genannte E-Mail-Adresse melden.",
"privacyNoLegalAdvice": "Hinweis: Diese Datenschutzhinweise dienen nur als Beispiel und ersetzen keine rechtliche Beratung. Für eine rechtskonforme Datenschutzerklärung solltest du eine Fachperson konsultieren.",
"backTitle": "Zurück zum Spiel", "backTitle": "Zurück zum Spiel",
"backToGame": "Zurück zu Hördle", "backToGame": "Zurück zu Hördle",
"footerLinkLabel": "Über & Impressum" "footerLinkLabel": "Über & Impressum"

View File

@@ -189,7 +189,6 @@
"privacyPlausibleAggregated": "Analytics are only performed in aggregated form (e.g. page views, browsers used).", "privacyPlausibleAggregated": "Analytics are only performed in aggregated form (e.g. page views, browsers used).",
"privacyServerLogs": "For technical reasons, the server may log IP address, time of access and accessed resources. This data is only used to keep the service running and to debug issues and is deleted on a regular basis.", "privacyServerLogs": "For technical reasons, the server may log IP address, time of access and accessed resources. This data is only used to keep the service running and to debug issues and is deleted on a regular basis.",
"privacyContact": "If you have questions about the data processed or want to request information, please contact the email address given in the imprint.", "privacyContact": "If you have questions about the data processed or want to request information, please contact the email address given in the imprint.",
"privacyNoLegalAdvice": "Note: These privacy notes are only an example and do not replace legal advice. For a legally compliant privacy policy you should consult a professional.",
"backTitle": "Back to the game", "backTitle": "Back to the game",
"backToGame": "Back to Hördle", "backToGame": "Back to Hördle",
"footerLinkLabel": "About & Imprint" "footerLinkLabel": "About & Imprint"

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.4.1", "version": "0.1.4.2",
"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])
}