Compare commits

..

9 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
24 changed files with 595 additions and 50 deletions

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.
ENV NEXT_TELEMETRY_DISABLED 1
# Suppress baseline-browser-mapping warning about old data (informational only)
ENV NODE_ENV=production
# Generate Prisma Client
ENV DATABASE_URL="file:./dev.db"
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_DESCRIPTION
ARG NEXT_PUBLIC_DOMAIN
ARG NEXT_PUBLIC_TWITTER_HANDLE
ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
ARG NEXT_PUBLIC_THEME_COLOR
ARG NEXT_PUBLIC_BACKGROUND_COLOR
@@ -60,8 +61,6 @@ ARG NEXT_PUBLIC_CREDITS_LINK_URL
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
ENV NEXT_PUBLIC_DOMAIN=$NEXT_PUBLIC_DOMAIN
ENV NEXT_PUBLIC_TWITTER_HANDLE=$NEXT_PUBLIC_TWITTER_HANDLE
ENV NEXT_PUBLIC_PLAUSIBLE_DOMAIN=$NEXT_PUBLIC_PLAUSIBLE_DOMAIN
ENV NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=$NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
ENV NEXT_PUBLIC_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR

View File

@@ -61,7 +61,7 @@ Hördle unterstützt vollständige Mehrsprachigkeit für Deutsch und Englisch.
**Schnellstart:**
- Deutsche Version: `http://localhost:3000/de`
- Englische Version: `http://localhost:3000/en`
- Root (`/`) leitet automatisch zur Standardsprache (Deutsch) um
- Root (`/`) leitet automatisch zur Standardsprache (Englisch) um
## White Labeling
@@ -115,7 +115,7 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
```bash
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

View File

@@ -6,6 +6,8 @@ import { PrismaClient } from '@prisma/client';
import { notFound } from 'next/navigation';
import { getLocalizedValue } from '@/lib/i18n';
import { getTranslations } from 'next-intl/server';
import { generateBaseMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
export const dynamic = 'force-dynamic';
@@ -15,6 +17,32 @@ interface PageProps {
params: Promise<{ locale: string; genre: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, genre } = await params;
const decodedGenre = decodeURIComponent(genre);
// Fetch genre to get localized name
const allGenres = await prisma.genre.findMany();
const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre);
if (!currentGenre || !currentGenre.active) {
return await generateBaseMetadata(locale, genre);
}
const genreName = getLocalizedValue(currentGenre.name, locale);
const genreSubtitle = getLocalizedValue(currentGenre.subtitle, locale);
const title = locale === 'de'
? `${genreName} - Hördle`
: `${genreName} - Hördle`;
const description = genreSubtitle || (locale === 'de'
? `Spiele Hördle im Genre ${genreName} und errate Songs aus kurzen Audio-Clips!`
: `Play Hördle in the ${genreName} genre and guess songs from short audio clips!`);
return await generateBaseMetadata(locale, genre, title, description);
}
export default async function GenrePage({ params }: PageProps) {
const { locale, genre } = await params;
const decodedGenre = decodeURIComponent(genre);

View File

@@ -1,10 +1,22 @@
import { getTranslations } from "next-intl/server";
import { Link } from "@/lib/navigation";
import { generateBaseMetadata } from "@/lib/metadata";
import type { Metadata } from "next";
interface AboutPageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: AboutPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "About" });
const title = t("title");
const description = t("intro");
return await generateBaseMetadata(locale, "about", title, description);
}
export default async function AboutPage({ params }: AboutPageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "About" });
@@ -51,11 +63,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
{t("imprintEmailLabel")}{" "}
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
</p>
<p
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
>
{t("imprintDisclaimer")}
</p>
</section>
<section style={{ marginBottom: "2rem" }}>

View File

@@ -5,8 +5,10 @@ import "../globals.css"; // Adjusted path
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { headers } from 'next/headers';
import { config } from "@/lib/config";
import { generateBaseMetadata } from "@/lib/metadata";
import InstallPrompt from "@/components/InstallPrompt";
import AppFooter from "@/components/AppFooter";
@@ -20,10 +22,10 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: config.appName,
description: config.appDescription,
};
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
return await generateBaseMetadata(locale);
}
export const viewport: Viewport = {
themeColor: config.colors.themeColor,
@@ -52,12 +54,32 @@ export default async function LocaleLayout({
// Providing all messages to the client
const messages = await getMessages();
// Get current domain from request headers for dynamic Plausible tracking
// This automatically tracks the correct domain (hoerdle.de or hördle.de)
const headersList = await headers();
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
// Automatically detect which domain to track in Plausible based on the request
let plausibleDomain = 'hoerdle.de'; // Default fallback
if (host) {
// Extract domain from host (remove port if present)
const domain = host.split(':')[0].toLowerCase();
// Map domains: automatically track the current domain
if (domain === 'hoerdle.de') {
plausibleDomain = 'hoerdle.de';
} else if (domain === 'hördle.de' || domain === 'xn--hrdle-jua.de') {
plausibleDomain = 'hördle.de';
}
}
return (
<html lang={locale}>
<head>
<Script
defer
data-domain={config.plausibleDomain}
data-domain={plausibleDomain}
src={config.plausibleScriptSrc}
strategy="beforeInteractive"
/>

View File

@@ -7,15 +7,33 @@ import { Link } from '@/lib/navigation';
import { PrismaClient } from '@prisma/client';
import { getTranslations } from 'next-intl/server';
import { getLocalizedValue } from '@/lib/i18n';
import { generateBaseMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations('Home');
// Get localized title and description
const title = locale === 'de'
? 'Hördle - Tägliches Musik-Erraten'
: 'Hördle - Daily Music Guessing Game';
const description = locale === 'de'
? 'Spiele Hördle und errate Songs aus kurzen Audio-Clips! Täglich neue Rätsel aus verschiedenen Genres. Inspiriert von Wordle, aber für Musikfans.'
: 'Play Hördle and guess songs from short audio clips! Daily new puzzles from various genres. Inspired by Wordle, but for music lovers.';
return await generateBaseMetadata(locale, '', title, description);
}
export default async function Home({
params
}: {
params: { locale: string };
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations('Home');

View File

@@ -5,6 +5,8 @@ import { Link } from '@/lib/navigation';
import { PrismaClient } from '@prisma/client';
import { getLocalizedValue } from '@/lib/i18n';
import { getTranslations } from 'next-intl/server';
import { generateBaseMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
export const dynamic = 'force-dynamic';
@@ -14,6 +16,30 @@ interface PageProps {
params: Promise<{ locale: string; name: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, name } = await params;
const decodedName = decodeURIComponent(name);
// Fetch special to get localized name
const allSpecials = await prisma.special.findMany();
const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName);
if (!currentSpecial) {
return await generateBaseMetadata(locale, `special/${name}`);
}
const specialName = getLocalizedValue(currentSpecial.name, locale);
const specialSubtitle = getLocalizedValue(currentSpecial.subtitle, locale);
const title = `${specialName} - Hördle`;
const description = specialSubtitle || (locale === 'de'
? `Spiele das Hördle-Special "${specialName}" und errate Songs aus kurzen Audio-Clips!`
: `Play the Hördle special "${specialName}" and guess songs from short audio clips!`);
return await generateBaseMetadata(locale, `special/${name}`, title, description);
}
export default async function SpecialPage({ params }: PageProps) {
const { locale, name } = await params;
const decodedName = decodeURIComponent(name);

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` : '';
let shareUrl = `https://${config.domain}`;
// Add locale prefix if not default (de)
if (locale !== 'de') {
// Add locale prefix if not default (en)
if (locale !== 'en') {
shareUrl += `/${locale}`;
}
if (genre) {

View File

@@ -8,8 +8,6 @@ services:
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
NEXT_PUBLIC_DOMAIN: ${NEXT_PUBLIC_DOMAIN}
NEXT_PUBLIC_TWITTER_HANDLE: ${NEXT_PUBLIC_TWITTER_HANDLE}
NEXT_PUBLIC_PLAUSIBLE_DOMAIN: ${NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_COLOR}

View File

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

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_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.elpatron.me` |
| `NEXT_PUBLIC_TWITTER_HANDLE` | Twitter handle for metadata. | `@elpatron` |
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.de` |
### Analytics (Plausible)
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | The domain to track in Plausible. | `hoerdle.elpatron.me` |
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.elpatron.me/js/script.js` |
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.example.com/js/script.js` |
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt. Beide Domains (`hoerdle.de` und `hördle.de`) werden automatisch getrackt.
### Credits

View File

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

View File

@@ -1,10 +1,8 @@
export const config = {
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.elpatron.me',
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'hoerdle.elpatron.me',
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.elpatron.me/js/script.js',
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.de',
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js',
colors: {
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
@@ -14,5 +12,9 @@ export const config = {
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
},
seo: {
ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/favicon.ico',
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined,
}
};

View File

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

@@ -162,7 +162,6 @@
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
"imprintCountry": "Deutschland",
"imprintEmailLabel": "E-Mail:",
"imprintDisclaimer": "Hinweis: Diese Angaben entsprechen dem aktuellen Stand. Für rechtliche Fragen solltest du eine Fachperson konsultieren.",
"costsTitle": "Laufende Kosten des Projekts",
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
"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):",
"imprintCountry": "Germany",
"imprintEmailLabel": "Email:",
"imprintDisclaimer": "Note: This information is current as of the date indicated. For legal questions you should consult a legal professional.",
"costsTitle": "Ongoing costs of the project",
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",

4
package-lock.json generated
View File

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

View File

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

View File

@@ -2,9 +2,9 @@ import createMiddleware from 'next-intl/middleware';
import type { NextRequest } from 'next/server';
const i18nMiddleware = createMiddleware({
locales: ['de', 'en'],
defaultLocale: 'de',
// Wir nutzen überall Locale-Präfixe (`/de`, `/en`)
locales: ['en', 'de'],
defaultLocale: 'en',
// Wir nutzen überall Locale-Präfixe (`/en`, `/de`)
localePrefix: 'always'
});
@@ -21,16 +21,41 @@ export default function proxy(request: NextRequest) {
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// Extract Plausible domain from script URL for CSP
const plausibleScriptSrc = process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js';
let plausibleOrigin = 'https://plausible.example.com';
try {
const url = new URL(plausibleScriptSrc);
plausibleOrigin = url.origin;
} catch {
// If URL parsing fails, try to extract domain manually
const match = plausibleScriptSrc.match(/https?:\/\/([^/]+)/);
if (match) {
plausibleOrigin = `https://${match[1]}`;
}
}
// Get other service URLs from environment (only add to CSP if configured)
const gotifyUrl = process.env.GOTIFY_URL;
const openrouterUrl = process.env.NEXT_PUBLIC_OPENROUTER_URL || 'https://openrouter.ai';
// Build CSP dynamically based on environment variables
const connectSrcParts = ["'self'", openrouterUrl, plausibleOrigin];
if (gotifyUrl && !gotifyUrl.includes('example.com')) {
connectSrcParts.push(gotifyUrl);
}
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me",
`script-src 'self' 'unsafe-inline' 'unsafe-eval' ${plausibleOrigin}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
`connect-src ${connectSrcParts.join(' ')}`,
"media-src 'self' blob:",
"frame-ancestors 'self'",
].join('; ');
headers.set('Content-Security-Policy', csp);
return response;