Compare commits

...

12 Commits

Author SHA1 Message Date
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
33 changed files with 600 additions and 54 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.
@@ -115,13 +115,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" }}>

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

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

@@ -275,8 +275,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
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}`; let shareUrl = `https://${config.domain}`;
// Add locale prefix if not default (de) // Add locale prefix if not default (en)
if (locale !== 'de') { if (locale !== 'en') {
shareUrl += `/${locale}`; shareUrl += `/${locale}`;
} }
if (genre) { if (genre) {

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

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

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 || '/favicon.ico',
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined,
} }
}; };

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

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

@@ -125,7 +125,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 +162,6 @@
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):", "imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
"imprintCountry": "Deutschland", "imprintCountry": "Deutschland",
"imprintEmailLabel": "E-Mail:", "imprintEmailLabel": "E-Mail:",
"imprintDisclaimer": "Hinweis: Diese Angaben entsprechen dem aktuellen Stand. Für rechtliche Fragen solltest du eine Fachperson konsultieren.",
"costsTitle": "Laufende Kosten des Projekts", "costsTitle": "Laufende Kosten des Projekts",
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:", "costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)", "costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",

View File

@@ -162,7 +162,6 @@
"imprintOperator": "Responsible for the content of this site (provider under German law):", "imprintOperator": "Responsible for the content of this site (provider under German law):",
"imprintCountry": "Germany", "imprintCountry": "Germany",
"imprintEmailLabel": "Email:", "imprintEmailLabel": "Email:",
"imprintDisclaimer": "Note: This information is current as of the date indicated. For legal questions you should consult a legal professional.",
"costsTitle": "Ongoing costs of the project", "costsTitle": "Ongoing costs of the project",
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:", "costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)", "costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",

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.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

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;