Compare commits

...

21 Commits

Author SHA1 Message Date
Hördle Bot
68c074e9da feat(about): improve about page with real data and support section
- Replace example imprint data with real contact information
- Add support/donation section with SEPA, PayPal, and Steady options
- Add GEMA tariff tracking requirement note to privacy section
- Remove iframe embedding of Google Sheets, keep link only
- Remove empty lines from imprint section
- Add 'curated' to project description (DE/EN)
- Fix XML tag syntax for Google Sheets link
2025-12-01 14:53:38 +01:00
Hördle Bot
20910e5cbf feat: add multilingual about page with imprint and privacy info 2025-12-01 14:08:31 +01:00
Hördle Bot
ff6aff25e8 Merge branch 'migrate-middleware-to-proxy': Migrate middleware.ts to proxy.ts and fix dependencies 2025-11-29 18:08:44 +01:00
Hördle Bot
2f5c06fb52 fix: add unist-util-visit-parents as direct dependency to resolve module resolution error 2025-11-29 18:08:42 +01:00
Hördle Bot
6893158926 feat: migrate middleware.ts to proxy.ts
- Rename middleware.ts to proxy.ts using Next.js codemod
- Update function name from 'middleware' to 'proxy'
- Update documentation in I18N.md to reflect proxy.ts usage
- Maintain all existing functionality (i18n routing and security headers)
2025-11-29 17:56:28 +01:00
Hördle Bot
038797a5da chore: update baseline-browser-mapping and replace middlewareClientMaxBodySize with proxyClientMaxBodySize 2025-11-29 17:55:13 +01:00
Hördle Bot
25a79230a8 Admin-Seite lokalisiert: Übersetzungen hinzugefügt und URLs angepasst
- Admin-Namespace zu de.json und en.json hinzugefügt
- Alle UI-Texte in der Admin-Seite mit useTranslations lokalisiert
- Link-Komponente von next-intl verwendet für korrekte Locale-URLs
- Buttons, Labels, Formulare und Tabellen-Header übersetzt
2025-11-28 18:39:52 +01:00
Hördle Bot
0182db69b5 Füge Collapse-Funktionalität zu Admin-Management-Abschnitten hinzu 2025-11-28 18:07:09 +01:00
Hördle Bot
794e3fd74a Verbessere Docker-Migration: Entrypoint mit Baseline-Fallback und aktualisiere baseline-migrations.sh 2025-11-28 15:53:01 +01:00
Hördle Bot
d874682764 Dokumentiere i18n-Implementierung 2025-11-28 15:48:27 +01:00
Hördle Bot
771d0d06f3 Implementiere i18n für Frontend, Admin und Datenbank 2025-11-28 15:36:06 +01:00
Hördle Bot
9df9a808bf fix: share emoji fills remaining slots with black squares when game is lost 2025-11-27 13:06:01 +01:00
Hördle Bot
5da78c926d fix: share emoji grid shows black square for skipped last attempt 2025-11-27 12:31:52 +01:00
Hördle Bot
120ffaaf2c docs: update docker config and docs for white labeling 2025-11-27 11:26:27 +01:00
Hördle Bot
50511f11ac chore: bump version to 0.1.0.14 2025-11-27 11:20:15 +01:00
Hördle Bot
d69ac28bb3 feat: white label transformation and bugfix for audio stream 2025-11-27 11:19:32 +01:00
Hördle Bot
7a65c58214 feat: add healthcheck endpoint and bump version to 0.1.0.13 2025-11-26 23:39:06 +01:00
Hördle Bot
1a8177430d feat: add health check API endpoint 2025-11-26 23:37:40 +01:00
Hördle Bot
0ebb61515d docs: Add 'prototype' to footer disclaimer in AppFooter. 2025-11-26 20:42:55 +01:00
Hördle Bot
dede11d22b fix: correct plausible score calculation 2025-11-26 18:00:59 +01:00
Hördle Bot
4b96b95bff Feat: Add Plausible event tracking for puzzle completion 2025-11-26 11:29:30 +01:00
51 changed files with 5148 additions and 784 deletions

View File

@@ -40,6 +40,34 @@ ENV NEXT_TELEMETRY_DISABLED 1
ENV DATABASE_URL="file:./dev.db"
RUN node_modules/.bin/prisma generate
# White Label Build Arguments
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
ARG NEXT_PUBLIC_CREDITS_ENABLED
ARG NEXT_PUBLIC_CREDITS_TEXT
ARG NEXT_PUBLIC_CREDITS_LINK_TEXT
ARG NEXT_PUBLIC_CREDITS_LINK_URL
# Pass env vars to build
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
ENV NEXT_PUBLIC_CREDITS_ENABLED=$NEXT_PUBLIC_CREDITS_ENABLED
ENV NEXT_PUBLIC_CREDITS_TEXT=$NEXT_PUBLIC_CREDITS_TEXT
ENV NEXT_PUBLIC_CREDITS_LINK_TEXT=$NEXT_PUBLIC_CREDITS_LINK_TEXT
ENV NEXT_PUBLIC_CREDITS_LINK_URL=$NEXT_PUBLIC_CREDITS_LINK_URL
RUN npm run build
# Production image, copy all the files and run next

349
I18N.md Normal file
View File

@@ -0,0 +1,349 @@
# Internationalisierung (i18n) Dokumentation
Hördle unterstützt vollständige Mehrsprachigkeit (Internationalisierung) für Deutsch und Englisch.
## Übersicht
Die i18n-Implementierung basiert auf [next-intl](https://next-intl-docs.vercel.app/) und nutzt den Next.js App Router mit dynamischen `[locale]`-Segmenten.
## Unterstützte Sprachen
- **Deutsch (de)** - Standardsprache
- **Englisch (en)**
## URL-Struktur
Alle Routen sind lokalisiert:
- `http://localhost:3000/` → Redirect zu `/de` (Standard)
- `http://localhost:3000/de` → Deutsche Version
- `http://localhost:3000/en` → Englische Version
- `http://localhost:3000/de/admin` → Admin-Dashboard (Deutsch)
- `http://localhost:3000/de/Rock` → Rock Genre (Deutsch)
- `http://localhost:3000/de/special/Weihnachtslieder` → Special (Deutsch)
## Architektur
### Verzeichnisstruktur
```
app/
[locale]/ # Lokalisierte Routen
layout.tsx # Root Layout mit i18n Provider
page.tsx # Homepage
admin/
page.tsx # Admin Dashboard
[genre]/
page.tsx # Genre-spezifische Seite
special/
[name]/
page.tsx # Special-Seite
i18n/
request.ts # next-intl Konfiguration
messages/
de.json # Deutsche Übersetzungen
en.json # Englische Übersetzungen
lib/
i18n.ts # Helper-Funktionen für lokalisierte DB-Werte
navigation.ts # Lokalisierte Navigation (Link, useRouter, etc.)
```
### Übersetzungsdateien
Die Übersetzungen sind in JSON-Dateien unter `messages/` organisiert:
```json
{
"Common": {
"loading": "Laden...",
"error": "Ein Fehler ist aufgetreten"
},
"Game": {
"play": "Abspielen",
"pause": "Pause",
"won": "Gewonnen!"
},
"Home": {
"welcome": "Willkommen bei Hördle"
}
}
```
### Datenbank-Schema
Die folgenden Modelle unterstützen mehrsprachige Felder:
#### Genre
- `name`: JSON `{ "de": "Rock", "en": "Rock" }`
- `subtitle`: JSON `{ "de": "Klassischer Rock", "en": "Classic Rock" }`
#### Special
- `name`: JSON `{ "de": "Weihnachtslieder", "en": "Christmas Songs" }`
- `subtitle`: JSON `{ "de": "Festliche Musik", "en": "Festive Music" }`
#### News
- `title`: JSON `{ "de": "Neues Feature", "en": "New Feature" }`
- `content`: JSON `{ "de": "Markdown Inhalt...", "en": "Markdown content..." }`
### Helper-Funktionen
#### `getLocalizedValue(value, locale, fallback?)`
Extrahiert den lokalisierten Wert aus einem JSON-Objekt:
```typescript
import { getLocalizedValue } from '@/lib/i18n';
const genreName = getLocalizedValue(genre.name, 'de'); // "Rock"
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
4. Fallback zum ersten verfügbaren Schlüssel
5. Fallback zum übergebenen `fallback`-Parameter
#### `createLocalizedObject(de, en?)`
Erstellt ein lokalisiertes Objekt:
```typescript
import { createLocalizedObject } from '@/lib/i18n';
const name = createLocalizedObject('Rock', 'Rock');
// { de: "Rock", en: "Rock" }
```
## Verwendung in Komponenten
### Server Components
```typescript
import { getTranslations } from 'next-intl/server';
import { getLocalizedValue } from '@/lib/i18n';
export default async function Page({ params }: { params: { locale: string } }) {
const { locale } = await params;
const t = await getTranslations('Home');
const genreName = getLocalizedValue(genre.name, locale);
return <h1>{t('welcome')}</h1>;
}
```
### Client Components
```typescript
'use client';
import { useTranslations } from 'next-intl';
import { useLocale } from 'next-intl';
export default function Game() {
const t = useTranslations('Game');
const locale = useLocale();
return <button>{t('play')}</button>;
}
```
### Navigation
Verwende die lokalisierte Navigation aus `lib/navigation.ts`:
```typescript
import { Link } from '@/lib/navigation';
// Automatisch lokalisiert
<Link href="/admin">Admin</Link>
<Link href="/Rock">Rock</Link>
```
## Admin-Interface
Das Admin-Dashboard unterstützt mehrsprachige Eingaben:
1. **Sprach-Tabs:** Wechsle zwischen `DE` und `EN` Tabs
2. **Genre/Special/News:** Alle Felder können in beiden Sprachen bearbeitet werden
3. **Vorschau:** Sieh dir die lokalisierte Version direkt an
### Beispiel: Genre erstellen
1. Öffne `/de/admin`
2. Wähle den `DE` Tab
3. Gib Name und Subtitle ein
4. Wechsle zum `EN` Tab
5. Gib die englischen Übersetzungen ein
6. Speichere
## Migration bestehender Daten
Bestehende Daten werden automatisch migriert:
1. **Migration `20251128131405_add_i18n_columns`:** Fügt neue JSON-Spalten hinzu
2. **Migration `20251128132806_switch_to_json_columns`:** Konvertiert String-Spalten zu JSON
**Wichtig:** Alte String-Werte werden automatisch in beide Sprachen kopiert:
- `"Rock"``{ "de": "Rock", "en": "Rock" }`
## Proxy
Der Proxy (`proxy.ts`) leitet Anfragen automatisch um:
- `/``/de` (Standard)
- Ungültige Locales → 404
- Validiert Locale-Parameter
## Sprachumschalter
Die `LanguageSwitcher`-Komponente ermöglicht Nutzern, zwischen Sprachen zu wechseln:
```typescript
import LanguageSwitcher from '@/components/LanguageSwitcher';
<LanguageSwitcher />
```
Die aktuelle Route bleibt erhalten, nur die Locale ändert sich:
- `/de/admin``/en/admin`
- `/de/Rock``/en/Rock`
## API-Endpunkte
API-Routen unterstützen einen optionalen `locale`-Parameter:
```typescript
GET /api/genres?locale=de
GET /api/specials?locale=en
GET /api/news?locale=de
```
Falls kein `locale` angegeben wird, wird `de` als Standard verwendet.
## Best Practices
### 1. Immer `getLocalizedValue` verwenden
**Falsch:**
```typescript
<span>{genre.name}</span> // Rendert { de: "...", en: "..." }
```
**Richtig:**
```typescript
<span>{getLocalizedValue(genre.name, locale)}</span>
```
### 2. Übersetzungsschlüssel konsistent benennen
Verwende Namespaces für bessere Organisation:
- `Common.*` - Allgemeine UI-Elemente
- `Game.*` - Spiel-spezifische Texte
- `Home.*` - Homepage-Texte
- `Navigation.*` - Navigations-Elemente
### 3. Fallbacks definieren
Immer einen Fallback-Wert angeben:
```typescript
const name = getLocalizedValue(genre.name, locale, 'Unbekannt');
```
### 4. Neue Übersetzungen hinzufügen
1. Füge den Schlüssel zu `messages/de.json` hinzu
2. Füge den Schlüssel zu `messages/en.json` hinzu
3. Verwende `useTranslations('Namespace')` oder `getTranslations('Namespace')`
## Troubleshooting
### 404-Fehler auf `/de` oder `/en`
**Problem:** Route wird nicht gefunden.
**Lösung:**
1. Überprüfe, ob `proxy.ts` korrekt konfiguriert ist
2. Stelle sicher, dass `app/[locale]/layout.tsx` existiert
3. Prüfe die `i18n/request.ts` Konfiguration
### "Objects are not valid as a React child"
**Problem:** Ein JSON-Objekt wird direkt gerendert statt des lokalisierten Werts.
**Lösung:**
Verwende `getLocalizedValue()`:
```typescript
// ❌ Falsch
<span>{genre.name}</span>
// ✅ Richtig
<span>{getLocalizedValue(genre.name, locale)}</span>
```
### Übersetzungen werden nicht angezeigt
**Problem:** Texte erscheinen als Schlüssel (z.B. `"Game.play"`).
**Lösung:**
1. Überprüfe, ob der Übersetzungsschlüssel in `messages/de.json` und `messages/en.json` existiert
2. Stelle sicher, dass der Namespace korrekt ist: `useTranslations('Game')` für `Game.play`
3. Prüfe die JSON-Syntax auf Fehler
### Admin-Interface zeigt Objekte statt Text
**Problem:** In Dropdowns oder Listen werden `{ de: "...", en: "..." }` angezeigt.
**Lösung:**
Verwende `getLocalizedValue()` in allen Render-Funktionen:
```typescript
// ❌ Falsch
<option value={s.id}>{s.name}</option>
// ✅ Richtig
<option value={s.id}>{getLocalizedValue(s.name, activeTab)}</option>
```
## Erweiterung um weitere Sprachen
Um eine neue Sprache hinzuzufügen (z.B. Französisch):
1. **Übersetzungsdatei erstellen:**
```bash
cp messages/de.json messages/fr.json
```
2. **Übersetzungen hinzufügen:**
Bearbeite `messages/fr.json` mit französischen Übersetzungen
3. **Locale zur Konfiguration hinzufügen:**
- `i18n/request.ts`: `const locales = ['en', 'de', 'fr'];`
- `proxy.ts`: `locales: ['en', 'de', 'fr']`
- `lib/navigation.ts`: `export const locales = ['de', 'en', 'fr'] as const;`
4. **Layout aktualisieren:**
```typescript
// app/[locale]/layout.tsx
if (!['en', 'de', 'fr'].includes(locale)) {
notFound();
}
```
5. **LanguageSwitcher erweitern:**
Füge einen Button für `fr` hinzu
6. **Datenbank-Migration:**
Bestehende Daten behalten ihre Struktur, neue Einträge können optional `fr` enthalten
## Weitere Ressourcen
- [next-intl Dokumentation](https://next-intl-docs.vercel.app/)
- [Next.js App Router i18n](https://nextjs.org/docs/app/building-your-application/routing/internationalization)

View File

@@ -4,6 +4,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
## Features
- **🌍 Mehrsprachigkeit (i18n):** Vollständige Unterstützung für Deutsch und Englisch mit automatischer Sprachumleitung und lokalisierten Inhalten.
- **Tägliches Rätsel:** Jeden Tag ein neuer Song für alle Nutzer.
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
- **Admin Dashboard:**
@@ -48,9 +49,28 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- **Markdown Support:** Formatierung von Texten, Links und Listen.
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
- **Special-Verknüpfung:** Direkte Links zu Specials in News-Beiträgen.
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
- Verwaltung über das Admin-Dashboard.
## Internationalisierung (i18n)
Hördle unterstützt vollständige Mehrsprachigkeit für Deutsch und Englisch.
👉 **[Vollständige i18n-Dokumentation](I18N.md)**
**Schnellstart:**
- Deutsche Version: `http://localhost:3000/de`
- Englische Version: `http://localhost:3000/en`
- Root (`/`) leitet automatisch zur Standardsprache (Deutsch) um
## White Labeling
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)**
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
## Spielregeln & Punktesystem
Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und dabei einen möglichst hohen Highscore zu erzielen.
@@ -95,12 +115,14 @@ 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`.
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/de` um).
## Deployment mit Docker
Das Projekt ist für den Betrieb mit Docker optimiert.
👉 **[White Labeling mit Docker? Hier klicken!](WHITE_LABEL.md#docker-deployment)**
1. **Vorbereitung:**
Kopiere die Beispiel-Konfiguration:
```bash
@@ -129,7 +151,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
- Beim Start des Containers wird automatisch ein Migrations-Skript ausgeführt, das fehlende Cover-Bilder aus den MP3s extrahiert.
4. **Admin-Zugang:**
- URL: `/admin`
- URL: `/de/admin` oder `/en/admin`
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
5. **Special Curation & Scheduling verwenden:**
@@ -200,12 +222,12 @@ Hördle kann problemlos als iFrame in andere Webseiten eingebettet werden. Die A
### Genre-spezifische Einbindung
Einzelne Genres können direkt eingebunden werden:
Einzelne Genres können direkt eingebunden werden (mit Locale-Präfix):
```html
<!-- Rock Genre -->
<!-- Rock Genre (Deutsch) -->
<iframe
src="https://hoerdle.elpatron.me/Rock"
src="https://hoerdle.elpatron.me/de/Rock"
width="100%"
height="800"
frameborder="0"
@@ -213,9 +235,9 @@ Einzelne Genres können direkt eingebunden werden:
title="Hördle Rock Quiz">
</iframe>
<!-- Pop Genre -->
<!-- Pop Genre (Englisch) -->
<iframe
src="https://hoerdle.elpatron.me/Pop"
src="https://hoerdle.elpatron.me/en/Pop"
width="100%"
height="800"
frameborder="0"
@@ -229,8 +251,9 @@ Einzelne Genres können direkt eingebunden werden:
Auch thematische Specials können direkt eingebettet werden:
```html
<!-- Weihnachtslieder (Deutsch) -->
<iframe
src="https://hoerdle.elpatron.me/special/Weihnachtslieder"
src="https://hoerdle.elpatron.me/de/special/Weihnachtslieder"
width="100%"
height="800"
frameborder="0"

99
WHITE_LABEL.md Normal file
View File

@@ -0,0 +1,99 @@
# White Labeling Guide
This application is designed to be easily white-labeled. You can customize the branding, colors, and configuration without modifying the core code.
## Configuration
The application is configured via environment variables. You can set these in a `.env` or `.env.local` file.
### Branding
| Variable | Description | Default |
|----------|-------------|---------|
| `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` |
### 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` |
### Credits
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_CREDITS_ENABLED` | Enable/disable footer credits (`true`/`false`). | `true` |
| `NEXT_PUBLIC_CREDITS_TEXT` | Text before the link. | `Vibe coded with ☕ and 🍺 by` |
| `NEXT_PUBLIC_CREDITS_LINK_TEXT` | Text of the link. | `@elpatron@digitalcourage.social` |
| `NEXT_PUBLIC_CREDITS_LINK_URL` | URL of the link. | `https://digitalcourage.social/@elpatron` |
## Theming
The application uses CSS variables for theming. You can override these variables in your own CSS file or by modifying `app/globals.css`.
### Key Colors
| Variable | Description | Default |
|----------|-------------|---------|
| `--primary` | Main action color (buttons). | `#000000` |
| `--secondary` | Secondary actions. | `#4b5563` |
| `--accent` | Accent color. | `#667eea` |
| `--success` | Success state (correct guess). | `#22c55e` |
| `--danger` | Error state (wrong guess). | `#ef4444` |
| `--warning` | Warning state (stars). | `#ffc107` |
| `--muted` | Muted backgrounds. | `#f3f4f6` |
### Example: Red Theme
To create a red-themed version, add this to your CSS:
```css
:root {
--primary: #dc2626;
--accent: #ef4444;
--accent-gradient: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
}
```
## Assets
To replace the logo and icons:
1. Replace `public/favicon.ico`.
2. Replace `public/icon.png` (if it exists).
3. Update `app/manifest.ts` if you have custom icon paths.
3. Update `app/manifest.ts` if you have custom icon paths.
## Docker Deployment
When deploying with Docker, please note that **Next.js inlines `NEXT_PUBLIC_` environment variables at build time**.
This means you cannot simply change the environment variables in `docker-compose.yml` and restart the container to change the branding. You must **rebuild the image**.
### Using Docker Compose
1. Create a `.env` file with your custom configuration:
```bash
NEXT_PUBLIC_APP_NAME="My Music Game"
NEXT_PUBLIC_THEME_COLOR="#ff0000"
# ... other variables
```
2. Ensure your `docker-compose.yml` passes these variables as build arguments (already configured in `docker-compose.example.yml`):
```yaml
services:
hoerdle:
build:
context: .
args:
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
# ...
```
3. Build and start the container:
```bash
docker compose up --build -d
```

View File

@@ -1,110 +0,0 @@
import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
import { notFound } from 'next/navigation';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
interface PageProps {
params: Promise<{ genre: string }>;
}
export default async function GenrePage({ params }: PageProps) {
const { genre } = await params;
const decodedGenre = decodeURIComponent(genre);
// Check if genre exists and is active
const currentGenre = await prisma.genre.findUnique({
where: { name: decodedGenre }
});
if (!currentGenre || !currentGenre.active) {
notFound();
}
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const now = new Date();
const activeSpecials = specials.filter(s => {
const isStarted = !s.launchDate || s.launchDate <= now;
const isEnded = s.endDate && s.endDate < now;
return isStarted && !isEnded;
});
const upcomingSpecials = specials.filter(s => {
return s.launchDate && s.launchDate > now;
});
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
{/* Genres */}
{genres.map(g => (
<Link
key={g.id}
href={`/${g.name}`}
style={{
fontWeight: g.name === decodedGenre ? 'bold' : 'normal',
textDecoration: g.name === decodedGenre ? 'underline' : 'none',
color: g.name === decodedGenre ? 'black' : '#4b5563'
}}
>
{g.name}
</Link>
))}
{/* Separator if both exist */}
{genres.length > 0 && activeSpecials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{activeSpecials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
))}
</div>
{/* Upcoming Specials */}
{upcomingSpecials.length > 0 && (
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Coming soon: {upcomingSpecials.map(s => (
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
{s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: process.env.TZ
}) : ''})
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
</span>
))}
</div>
)}
</div>
<NewsSection />
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
</>
);
}

View File

@@ -0,0 +1,134 @@
import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import { Link } from '@/lib/navigation';
import { PrismaClient } from '@prisma/client';
import { notFound } from 'next/navigation';
import { getLocalizedValue } from '@/lib/i18n';
import { getTranslations } from 'next-intl/server';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
interface PageProps {
params: Promise<{ locale: string; genre: string }>;
}
export default async function GenrePage({ params }: PageProps) {
const { locale, genre } = await params;
const decodedGenre = decodeURIComponent(genre);
const tNav = await getTranslations('Navigation');
// Fetch all genres to find the matching one by localized name
const allGenres = await prisma.genre.findMany();
const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre);
if (!currentGenre || !currentGenre.active) {
notFound();
}
const dailyPuzzle = await getOrCreateDailyPuzzle(currentGenre);
// getOrCreateDailyPuzzle likely expects string or needs update.
// Actually, getOrCreateDailyPuzzle takes `genreName: string | null`.
// If I pass the JSON object, it might fail.
// But wait, the DB schema for DailyPuzzle stores `genreId`.
// `getOrCreateDailyPuzzle` probably looks up genre by name.
// I should check `lib/dailyPuzzle.ts`.
// For now, I'll pass the localized name, but that might be risky if it tries to create a genre (unlikely).
// Let's assume for now I should pass the localized name if that's what it uses to find/create.
// But if `getOrCreateDailyPuzzle` uses `findUnique({ where: { name: genreName } })`, it will fail because name is JSON.
// I need to update `lib/dailyPuzzle.ts` too!
// I'll mark that as a todo. For now, let's proceed with page creation.
const genres = allGenres.filter(g => g.active);
// Sort
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
const specials = await prisma.special.findMany();
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
const now = new Date();
const activeSpecials = specials.filter(s => {
const isStarted = !s.launchDate || s.launchDate <= now;
const isEnded = s.endDate && s.endDate < now;
return isStarted && !isEnded;
});
const upcomingSpecials = specials.filter(s => {
return s.launchDate && s.launchDate > now;
});
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>{tNav('global')}</Link>
{/* Genres */}
{genres.map(g => {
const name = getLocalizedValue(g.name, locale);
return (
<Link
key={g.id}
href={`/${name}`}
style={{
fontWeight: name === decodedGenre ? 'bold' : 'normal',
textDecoration: name === decodedGenre ? 'underline' : 'none',
color: name === decodedGenre ? 'black' : '#4b5563'
}}
>
{name}
</Link>
);
})}
{/* Separator if both exist */}
{genres.length > 0 && activeSpecials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{activeSpecials.map(s => {
const name = getLocalizedValue(s.name, locale);
return (
<Link
key={s.id}
href={`/special/${name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{name}
</Link>
);
})}
</div>
{/* Upcoming Specials */}
{upcomingSpecials.length > 0 && (
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Coming soon: {upcomingSpecials.map(s => {
const name = getLocalizedValue(s.name, locale);
return (
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
{name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: process.env.TZ
}) : ''})
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
</span>
);
})}
</div>
)}
</div>
<NewsSection locale={locale} />
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
</>
);
}

256
app/[locale]/about/page.tsx Normal file
View File

@@ -0,0 +1,256 @@
import { getTranslations } from "next-intl/server";
import { Link } from "@/lib/navigation";
interface AboutPageProps {
params: Promise<{ locale: string }>;
}
export default async function AboutPage({ params }: AboutPageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "About" });
const sheetUrl =
"https://docs.google.com/spreadsheets/d/1LuMkDsnidlvMtzzSqwrz-GACnqMaqzs-VBa-ZK0nZeI/edit?usp=sharing";
return (
<main
style={{
maxWidth: "960px",
margin: "0 auto",
padding: "2rem 1rem",
lineHeight: 1.6,
}}
>
<h1 style={{ fontSize: "2rem", marginBottom: "1rem" }}>{t("title")}</h1>
<p style={{ marginBottom: "2rem", color: "#4b5563" }}>{t("intro")}</p>
<section style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
{t("projectTitle")}
</h2>
<p style={{ marginBottom: "0.5rem" }}>{t("projectPrivateNote")}</p>
<p style={{ marginBottom: "0.5rem" }}>{t("projectIdea")}</p>
</section>
<section style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
{t("imprintTitle")}
</h2>
<p style={{ marginBottom: "0.5rem" }}>
<strong>{t("imprintOperator")}</strong>
</p>
<p style={{ marginBottom: 0, lineHeight: "1.5" }}>
Markus Busche
<br />
Knorrstr. 16
<br />
24106 Kiel
<br />
{t("imprintCountry")}
<br />
{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" }}>
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
{t("costsTitle")}
</h2>
<p style={{ marginBottom: "0.5rem" }}>{t("costsIntro")}</p>
<ul
style={{
marginLeft: "1.25rem",
marginBottom: "0.75rem",
listStyleType: "disc",
}}
>
<li>{t("costsDomain")}</li>
<li>{t("costsServer")}</li>
<li>{t("costsEmail")}</li>
<li>{t("costsLicenses")}</li>
</ul>
<p style={{ marginBottom: "0.5rem" }}>
{t.rich("costsSheetLinkText", {
link: (chunks) => (
<a
href={sheetUrl}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "underline" }}
>
{chunks}
</a>
),
})}
</p>
<p
style={{
marginBottom: "0.75rem",
fontSize: "0.9rem",
color: "#6b7280",
}}
>
{t("costsSheetPrivacyNote")}
</p>
</section>
<section style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
{t("supportTitle")}
</h2>
<p style={{ marginBottom: "1rem" }}>{t("supportIntro")}</p>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
marginBottom: "1rem",
}}
>
<div
style={{
padding: "1rem",
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
backgroundColor: "#f9fafb",
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: "600",
marginBottom: "0.5rem",
}}
>
{t("supportSepaTitle")}
</h3>
<p style={{ marginBottom: "0.25rem" }}>
<strong>{t("supportSepaName")}</strong>
</p>
<p style={{ marginBottom: 0, fontFamily: "monospace" }}>
{t("supportSepaIban")}
</p>
</div>
<div
style={{
padding: "1rem",
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
backgroundColor: "#f9fafb",
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: "600",
marginBottom: "0.5rem",
}}
>
{t("supportPaypalTitle")}
</h3>
<p style={{ marginBottom: 0 }}>
<a
href="https://paypal.me/MBusche"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "underline" }}
>
{t("supportPaypalLink")}
</a>
</p>
</div>
<div
style={{
padding: "1rem",
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
backgroundColor: "#f9fafb",
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: "600",
marginBottom: "0.5rem",
}}
>
{t("supportSteadyTitle")}
</h3>
<p style={{ marginBottom: "0.5rem" }}>
{t("supportSteadyDescription")}
</p>
<p style={{ marginBottom: 0 }}>
<a
href="https://steady.page/de/hoerdle"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "underline" }}
>
https://steady.page/de/hoerdle
</a>
</p>
</div>
</div>
</section>
<section style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
{t("privacyTitle")}
</h2>
<p style={{ marginBottom: "0.5rem" }}>{t("privacyIntro")}</p>
<h3
style={{
fontSize: "1.25rem",
marginTop: "1rem",
marginBottom: "0.5rem",
}}
>
{t("privacyPlausibleTitle")}
</h3>
<p style={{ marginBottom: "0.5rem" }}>
{t("privacyPlausibleSelfHosted")}
</p>
<p style={{ marginBottom: "0.5rem" }}>
{t("privacyPlausibleGemaTariff")}
</p>
<ul
style={{
marginLeft: "1.25rem",
marginBottom: "0.75rem",
listStyleType: "disc",
}}
>
<li>{t("privacyPlausibleNoCookies")}</li>
<li>{t("privacyPlausibleNoTrackingAcrossSites")}</li>
<li>{t("privacyPlausibleAggregated")}</li>
</ul>
<p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p>
<p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p>
<p
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
>
{t("privacyNoLegalAdvice")}
</p>
</section>
<section style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
{t("backTitle")}
</h2>
<p>
<Link href="/" style={{ textDecoration: "underline" }}>
{t("backToGame")}
</Link>
</p>
</section>
</main>
);
}

2242
app/[locale]/admin/page.tsx Normal file

File diff suppressed because it is too large Load Diff

74
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,74 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import "../globals.css"; // Adjusted path
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { config } from "@/lib/config";
import InstallPrompt from "@/components/InstallPrompt";
import AppFooter from "@/components/AppFooter";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: config.appName,
description: config.appDescription,
};
export const viewport: Viewport = {
themeColor: config.colors.themeColor,
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
console.log('[app/[locale]/layout] params locale:', locale);
// Ensure that the incoming `locale` is valid
if (!['en', 'de'].includes(locale)) {
console.log('[app/[locale]/layout] invalid locale, triggering notFound()');
notFound();
}
// Providing all messages to the client
const messages = await getMessages();
return (
<html lang={locale}>
<head>
<Script
defer
data-domain={config.plausibleDomain}
src={config.plausibleScriptSrc}
strategy="beforeInteractive"
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<NextIntlClientProvider messages={messages}>
{children}
<InstallPrompt />
<AppFooter />
</NextIntlClientProvider>
</body>
</html>
);
}

138
app/[locale]/page.tsx Normal file
View File

@@ -0,0 +1,138 @@
import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import OnboardingTour from '@/components/OnboardingTour';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import { Link } from '@/lib/navigation';
import { PrismaClient } from '@prisma/client';
import { getTranslations } from 'next-intl/server';
import { getLocalizedValue } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
export default async function Home({
params
}: {
params: { locale: string };
}) {
const { locale } = await params;
const t = await getTranslations('Home');
const tNav = await getTranslations('Navigation');
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
const genres = await prisma.genre.findMany({
where: { active: true },
});
const specials = await prisma.special.findMany();
// Sort in memory
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
const now = new Date();
const activeSpecials = specials.filter(s => {
const isStarted = !s.launchDate || s.launchDate <= now;
const isEnded = s.endDate && s.endDate < now;
return isStarted && !isEnded;
});
const upcomingSpecials = specials.filter(s => {
return s.launchDate && s.launchDate > now;
});
return (
<>
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem', maxWidth: '1200px', margin: '0 auto', padding: '0 1rem' }}>
<div style={{ flex: 1 }} />
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center', flex: 2 }}>
<div className="tooltip">
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>{tNav('global')}</Link>
<span className="tooltip-text">{t('globalTooltip')}</span>
</div>
{/* Genres */}
{genres.map(g => {
const name = getLocalizedValue(g.name, locale);
const subtitle = getLocalizedValue(g.subtitle, locale);
return (
<div key={g.id} className="tooltip">
<Link href={`/${name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
{name}
</Link>
{subtitle && <span className="tooltip-text">{subtitle}</span>}
</div>
);
})}
{/* Separator if both exist */}
{genres.length > 0 && activeSpecials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Active Specials */}
{activeSpecials.map(s => {
const name = getLocalizedValue(s.name, locale);
const subtitle = getLocalizedValue(s.subtitle, locale);
return (
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div className="tooltip">
<Link
href={`/special/${name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{name}
</Link>
{subtitle && <span className="tooltip-text">{subtitle}</span>}
</div>
{s.curator && (
<span style={{ fontSize: '0.75rem', color: '#666' }}>
{t('curatedBy')} {s.curator}
</span>
)}
</div>
);
})}
</div>
{/* Upcoming Specials */}
{upcomingSpecials.length > 0 && (
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
{t('comingSoon')}: {upcomingSpecials.map(s => {
const name = getLocalizedValue(s.name, locale);
return (
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
{name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: process.env.TZ
}) : ''})
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>{t('curatedBy')} {s.curator}</span>}
</span>
);
})}
</div>
)}
</div>
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
<LanguageSwitcher />
</div>
</div>
<div id="tour-news">
<NewsSection locale={locale} />
</div>
<Game dailyPuzzle={dailyPuzzle} genre={null} />
<OnboardingTour />
</>
);
}

View File

@@ -0,0 +1,125 @@
import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
import { Link } from '@/lib/navigation';
import { PrismaClient } from '@prisma/client';
import { getLocalizedValue } from '@/lib/i18n';
import { getTranslations } from 'next-intl/server';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
interface PageProps {
params: Promise<{ locale: string; name: string }>;
}
export default async function SpecialPage({ params }: PageProps) {
const { locale, name } = await params;
const decodedName = decodeURIComponent(name);
const tNav = await getTranslations('Navigation');
const allSpecials = await prisma.special.findMany();
const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName);
const now = new Date();
const isStarted = currentSpecial && (!currentSpecial.launchDate || currentSpecial.launchDate <= now);
const isEnded = currentSpecial && (currentSpecial.endDate && currentSpecial.endDate < now);
if (!currentSpecial || !isStarted) {
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h1>Special Not Available</h1>
<p>This special has not launched yet or does not exist.</p>
<Link href="/">{tNav('home')}</Link>
</div>
);
}
if (isEnded) {
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h1>Special Ended</h1>
<p>This special event has ended.</p>
<Link href="/">{tNav('home')}</Link>
</div>
);
}
// Need to handle getOrCreateSpecialPuzzle with localized name or ID
// Ideally pass ID or full object, but existing function takes name string.
// I'll need to update lib/dailyPuzzle.ts to handle this.
const dailyPuzzle = await getOrCreateSpecialPuzzle(currentSpecial);
const genres = await prisma.genre.findMany({
where: { active: true },
});
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
const specials = allSpecials; // Already fetched
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
const activeSpecials = specials.filter(s => {
const sStarted = !s.launchDate || s.launchDate <= now;
const sEnded = s.endDate && s.endDate < now;
return sStarted && !sEnded;
});
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>{tNav('global')}</Link>
{/* Genres */}
{genres.map(g => {
const gName = getLocalizedValue(g.name, locale);
return (
<Link
key={g.id}
href={`/${gName}`}
style={{
color: '#4b5563',
textDecoration: 'none'
}}
>
{gName}
</Link>
);
})}
{/* Separator if both exist */}
{genres.length > 0 && activeSpecials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{activeSpecials.map(s => {
const sName = getLocalizedValue(s.name, locale);
return (
<Link
key={s.id}
href={`/special/${sName}`}
style={{
fontWeight: sName === decodedName ? 'bold' : 'normal',
textDecoration: sName === decodedName ? 'underline' : 'none',
color: sName === decodedName ? '#9d174d' : '#be185d'
}}
>
{sName}
</Link>
);
})}
</div>
</div>
<NewsSection locale={locale} />
<Game
dailyPuzzle={dailyPuzzle}
genre={decodedName}
isSpecial={true}
maxAttempts={dailyPuzzle?.maxAttempts}
unlockSteps={dailyPuzzle?.unlockSteps}
/>
</>
);
}

View File

@@ -98,14 +98,14 @@ export async function DELETE(request: Request) {
where: { id: puzzle.specialId }
});
if (special) {
newPuzzle = await getOrCreateSpecialPuzzle(special.name);
newPuzzle = await getOrCreateSpecialPuzzle(special);
}
} else if (puzzle.genreId) {
const genre = await prisma.genre.findUnique({
where: { id: puzzle.genreId }
});
if (genre) {
newPuzzle = await getOrCreateDailyPuzzle(genre.name);
newPuzzle = await getOrCreateDailyPuzzle(genre);
}
} else {
newPuzzle = await getOrCreateDailyPuzzle(null);

View File

@@ -44,11 +44,35 @@ export async function GET(
const stream = createReadStream(filePath, { start, end });
// Convert Node stream to Web stream
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: any) => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', (err: any) => controller.error(err));
let isClosed = false;
stream.on('data', (chunk: any) => {
if (isClosed) return;
try {
controller.enqueue(chunk);
} catch (e) {
isClosed = true;
stream.destroy();
}
});
stream.on('end', () => {
if (isClosed) return;
isClosed = true;
controller.close();
});
stream.on('error', (err: any) => {
if (isClosed) return;
isClosed = true;
controller.error(err);
});
},
cancel() {
stream.destroy();
}
});
@@ -68,9 +92,32 @@ export async function GET(
// Convert Node stream to Web stream
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: any) => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', (err: any) => controller.error(err));
let isClosed = false;
stream.on('data', (chunk: any) => {
if (isClosed) return;
try {
controller.enqueue(chunk);
} catch (e) {
isClosed = true;
stream.destroy();
}
});
stream.on('end', () => {
if (isClosed) return;
isClosed = true;
controller.close();
});
stream.on('error', (err: any) => {
if (isClosed) return;
isClosed = true;
controller.error(err);
});
},
cancel() {
stream.destroy();
}
});

View File

@@ -2,6 +2,7 @@
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
import { getLocalizedValue } from '@/lib/i18n';
const prisma = new PrismaClient();
@@ -83,7 +84,8 @@ export async function POST(request: Request) {
// Process each song in this batch
for (const song of uncategorizedSongs) {
try {
const genreNames = allGenres.map(g => g.name);
// Use German names for AI categorization (primary language)
const genreNames = allGenres.map(g => getLocalizedValue(g.name, 'de'));
const prompt = `You are a music genre categorization assistant. Given a song title and artist, categorize it into 0-3 of the available genres.
@@ -140,7 +142,7 @@ Your response:`;
// Filter to only valid genres and get their IDs
const genreIds = allGenres
.filter(g => suggestedGenreNames.includes(g.name))
.filter(g => suggestedGenreNames.includes(getLocalizedValue(g.name, 'de')))
.map(g => g.id)
.slice(0, 3); // Max 3 genres
@@ -160,7 +162,7 @@ Your response:`;
title: song.title,
artist: song.artist,
assignedGenres: suggestedGenreNames.filter(name =>
allGenres.some(g => g.name === name)
allGenres.some(g => getLocalizedValue(g.name, 'de') === name)
)
});
}

View File

@@ -1,12 +1,26 @@
import { NextResponse } from 'next/server';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import { PrismaClient } from '@prisma/client';
import { getLocalizedValue } from '@/lib/i18n';
const prisma = new PrismaClient();
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const genreName = searchParams.get('genre');
const puzzle = await getOrCreateDailyPuzzle(genreName);
let genre = null;
if (genreName) {
// Find genre by localized name (try both locales)
const allGenres = await prisma.genre.findMany();
genre = allGenres.find(g =>
getLocalizedValue(g.name, 'de') === genreName ||
getLocalizedValue(g.name, 'en') === genreName
) || null;
}
const puzzle = await getOrCreateDailyPuzzle(genre);
if (!puzzle) {
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });

View File

@@ -1,19 +1,35 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
import { getLocalizedValue } from '@/lib/i18n';
const prisma = new PrismaClient();
export async function GET() {
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale');
const genres = await prisma.genre.findMany({
orderBy: { name: 'asc' },
// orderBy: { name: 'asc' }, // Cannot sort by JSON field easily in SQLite
include: {
_count: {
select: { songs: true }
}
}
});
// Sort in memory if needed, or just return
// If locale is provided, map to localized values
if (locale) {
const localizedGenres = genres.map(g => ({
...g,
name: getLocalizedValue(g.name, locale),
subtitle: getLocalizedValue(g.subtitle, locale)
})).sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json(localizedGenres);
}
return NextResponse.json(genres);
} catch (error) {
console.error('Error fetching genres:', error);
@@ -29,14 +45,18 @@ export async function POST(request: Request) {
try {
const { name, subtitle, active } = await request.json();
if (!name || typeof name !== 'string') {
if (!name) {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
}
// Ensure name is stored as JSON
const nameData = typeof name === 'string' ? { de: name, en: name } : name;
const subtitleData = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
const genre = await prisma.genre.create({
data: {
name: name.trim(),
subtitle: subtitle ? subtitle.trim() : null,
name: nameData,
subtitle: subtitleData,
active: active !== undefined ? active : true
},
});
@@ -83,13 +103,14 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
const updateData: any = {};
if (name) updateData.name = typeof name === 'string' ? { de: name, en: name } : name;
if (subtitle !== undefined) updateData.subtitle = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
if (active !== undefined) updateData.active = active;
const genre = await prisma.genre.update({
where: { id: Number(id) },
data: {
...(name && { name: name.trim() }),
subtitle: subtitle ? subtitle.trim() : null,
...(active !== undefined && { active })
},
data: updateData,
});
return NextResponse.json(genre);

5
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'ok' }, { status: 200 });
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
import { getLocalizedValue } from '@/lib/i18n';
const prisma = new PrismaClient();
@@ -10,6 +11,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get('limit') || '10');
const featuredOnly = searchParams.get('featured') === 'true';
const locale = searchParams.get('locale');
const where = featuredOnly ? { featured: true } : {};
@@ -27,6 +29,19 @@ export async function GET(request: Request) {
}
});
if (locale) {
const localizedNews = news.map(item => ({
...item,
title: getLocalizedValue(item.title, locale),
content: getLocalizedValue(item.content, locale),
special: item.special ? {
...item.special,
name: getLocalizedValue(item.special.name, locale)
} : null
}));
return NextResponse.json(localizedNews);
}
return NextResponse.json(news);
} catch (error) {
console.error('Error fetching news:', error);
@@ -52,10 +67,14 @@ export async function POST(request: Request) {
);
}
// Ensure title and content are stored as JSON
const titleData = typeof title === 'string' ? { de: title, en: title } : title;
const contentData = typeof content === 'string' ? { de: content, en: content } : content;
const news = await prisma.news.create({
data: {
title,
content,
title: titleData,
content: contentData,
author: author || null,
featured: featured || false,
specialId: specialId || null
@@ -93,8 +112,8 @@ export async function PUT(request: Request) {
}
const updateData: any = {};
if (title !== undefined) updateData.title = title;
if (content !== undefined) updateData.content = content;
if (title !== undefined) updateData.title = typeof title === 'string' ? { de: title, en: title } : title;
if (content !== undefined) updateData.content = typeof content === 'string' ? { de: content, en: content } : content;
if (author !== undefined) updateData.author = author || null;
if (featured !== undefined) updateData.featured = featured;
if (specialId !== undefined) updateData.specialId = specialId || null;

View File

@@ -1,18 +1,32 @@
import { PrismaClient, Special } from '@prisma/client';
import { NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/auth';
import { getLocalizedValue } from '@/lib/i18n';
const prisma = new PrismaClient();
export async function GET() {
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale');
const specials = await prisma.special.findMany({
orderBy: { name: 'asc' },
// orderBy: { name: 'asc' },
include: {
_count: {
select: { songs: true }
}
}
});
if (locale) {
const localizedSpecials = specials.map(s => ({
...s,
name: getLocalizedValue(s.name, locale),
subtitle: getLocalizedValue(s.subtitle, locale)
})).sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json(localizedSpecials);
}
return NextResponse.json(specials);
}
@@ -25,10 +39,15 @@ export async function POST(request: Request) {
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
// Ensure name is stored as JSON
const nameData = typeof name === 'string' ? { de: name, en: name } : name;
const subtitleData = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
const special = await prisma.special.create({
data: {
name,
subtitle: subtitle || null,
name: nameData,
subtitle: subtitleData,
maxAttempts: Number(maxAttempts),
unlockSteps,
launchDate: launchDate ? new Date(launchDate) : null,
@@ -61,17 +80,19 @@ export async function PUT(request: Request) {
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
}
const updateData: any = {};
if (name) updateData.name = typeof name === 'string' ? { de: name, en: name } : name;
if (subtitle !== undefined) updateData.subtitle = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
if (maxAttempts) updateData.maxAttempts = Number(maxAttempts);
if (unlockSteps) updateData.unlockSteps = unlockSteps;
if (launchDate !== undefined) updateData.launchDate = launchDate ? new Date(launchDate) : null;
if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null;
if (curator !== undefined) updateData.curator = curator || null;
const updated = await prisma.special.update({
where: { id: Number(id) },
data: {
...(name && { name }),
subtitle: subtitle || null, // Allow clearing or setting
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
...(unlockSteps && { unlockSteps }),
launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
},
data: updateData,
});
return NextResponse.json(updated);
}

View File

@@ -2,6 +2,24 @@
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
/* Theme Colors */
--primary: #000000;
--primary-foreground: #ffffff;
--secondary: #4b5563;
--secondary-foreground: #ffffff;
--accent: #667eea;
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--success: #22c55e;
--success-foreground: #ffffff;
--danger: #ef4444;
--danger-foreground: #ffffff;
--warning: #ffc107;
--muted: #f3f4f6;
--muted-foreground: #6b7280;
--border: #e5e7eb;
--input: #d1d5db;
--ring: #000000;
}
body {
@@ -51,13 +69,13 @@ body {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #666;
color: var(--muted-foreground);
margin-bottom: 0.5rem;
}
/* Audio Player */
.audio-player {
background: #f3f4f6;
background: var(--muted);
padding: 1rem;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
@@ -73,8 +91,8 @@ body {
width: 3rem;
height: 3rem;
border-radius: 50%;
background: #000;
color: #fff;
background: var(--primary);
color: var(--primary-foreground);
border: none;
display: flex;
align-items: center;
@@ -85,19 +103,20 @@ body {
.play-button:hover {
background: #333;
/* Keep for now or add --primary-hover */
}
.progress-bar-container {
flex: 1;
height: 0.5rem;
background: #d1d5db;
background: var(--input);
border-radius: 999px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: #22c55e;
background: var(--success);
transition: width 0.1s linear;
}
@@ -114,7 +133,7 @@ body {
gap: 0.5rem;
padding: 0.5rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border: 1px solid var(--border);
border-radius: 0.25rem;
font-size: 0.875rem;
}
@@ -125,7 +144,7 @@ body {
}
.guess-text {
color: #ef4444;
color: var(--danger);
/* Red for wrong */
}
@@ -135,7 +154,7 @@ body {
}
.guess-text.correct {
color: #22c55e;
color: var(--success);
}
/* Input */
@@ -148,14 +167,14 @@ body {
.guess-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border: 1px solid var(--input);
border-radius: 0.25rem;
font-size: 1rem;
box-sizing: border-box;
}
.guess-input:focus {
outline: 2px solid #000;
outline: 2px solid var(--ring);
border-color: transparent;
}
@@ -163,7 +182,7 @@ body {
position: absolute;
width: 100%;
background: #fff;
border: 1px solid #d1d5db;
border: 1px solid var(--input);
border-radius: 0.25rem;
margin-top: 0.25rem;
max-height: 15rem;
@@ -177,11 +196,11 @@ body {
.suggestion-item {
padding: 0.75rem;
cursor: pointer;
border-bottom: 1px solid #f3f4f6;
border-bottom: 1px solid var(--muted);
}
.suggestion-item:hover {
background: #f3f4f6;
background: var(--muted);
}
.suggestion-title {
@@ -190,14 +209,14 @@ body {
.suggestion-artist {
font-size: 0.875rem;
color: #666;
color: var(--muted-foreground);
}
.skip-button {
width: 100%;
padding: 1rem 1.5rem;
margin-top: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--accent-gradient);
color: white;
border: none;
border-radius: 0.5rem;
@@ -246,7 +265,7 @@ body {
}
.admin-card {
background: #f3f4f6;
background: var(--muted);
padding: 2rem;
border-radius: 0.5rem;
}
@@ -265,14 +284,14 @@ body {
.form-input {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border: 1px solid var(--input);
border-radius: 0.25rem;
box-sizing: border-box;
}
.btn-primary {
background: #000;
color: #fff;
background: var(--primary);
color: var(--primary-foreground);
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
@@ -292,8 +311,8 @@ body {
}
.btn-secondary {
background: #4b5563;
color: #fff;
background: var(--secondary);
color: var(--secondary-foreground);
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
@@ -312,8 +331,8 @@ body {
}
.btn-danger {
background: #ef4444;
color: #fff;
background: var(--danger);
color: var(--danger-foreground);
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
@@ -337,8 +356,8 @@ body {
padding: 2rem 1rem 1rem;
text-align: center;
font-size: 0.875rem;
color: #666;
border-top: 1px solid #e5e7eb;
color: var(--muted-foreground);
border-top: 1px solid var(--border);
width: 100%;
}
@@ -347,7 +366,7 @@ body {
}
.app-footer a {
color: #000;
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
@@ -375,7 +394,7 @@ body {
font-size: 0.875rem;
text-align: center;
margin: 0 0 1rem 0;
color: #666;
color: var(--muted-foreground);
}
.statistics-grid {
@@ -391,7 +410,7 @@ body {
padding: 0.75rem 0.5rem;
background: rgba(255, 255, 255, 0.8);
border-radius: 0.375rem;
border: 1px solid #e5e7eb;
border: 1px solid var(--border);
}
.stat-badge {
@@ -401,7 +420,7 @@ body {
.stat-label {
font-size: 0.75rem;
color: #666;
color: var(--muted-foreground);
margin-bottom: 0.25rem;
text-align: center;
}
@@ -409,7 +428,7 @@ body {
.stat-count {
font-size: 1.25rem;
font-weight: bold;
color: #000;
color: var(--primary);
}
/* Tooltip */

View File

@@ -1,53 +0,0 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Hördle",
description: "Daily music guessing game - Guess the song from short audio clips",
};
export const viewport: Viewport = {
themeColor: "#000000",
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
import InstallPrompt from "@/components/InstallPrompt";
import AppFooter from "@/components/AppFooter";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<Script
defer
data-domain="hoerdle.elpatron.me"
src="https://plausible.elpatron.me/js/script.js"
strategy="beforeInteractive"
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
<InstallPrompt />
<AppFooter />
</body>
</html>
);
}

View File

@@ -1,14 +1,15 @@
import type { MetadataRoute } from 'next'
import { config } from '@/lib/config'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Hördle',
short_name: 'Hördle',
description: 'Daily music guessing game - Guess the song from short audio clips',
name: config.appName,
short_name: config.appName,
description: config.appDescription,
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
background_color: config.colors.backgroundColor,
theme_color: config.colors.themeColor,
icons: [
{
src: '/favicon.ico',

View File

@@ -1,107 +0,0 @@
import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import OnboardingTour from '@/components/OnboardingTour';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
export default async function Home() {
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const now = new Date();
const activeSpecials = specials.filter(s => {
const isStarted = !s.launchDate || s.launchDate <= now;
const isEnded = s.endDate && s.endDate < now;
return isStarted && !isEnded;
});
const upcomingSpecials = specials.filter(s => {
return s.launchDate && s.launchDate > now;
});
return (
<>
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<div className="tooltip">
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
<span className="tooltip-text">A random song from the entire collection</span>
</div>
{/* Genres */}
{genres.map(g => (
<div key={g.id} className="tooltip">
<Link href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
{g.name}
</Link>
{g.subtitle && <span className="tooltip-text">{g.subtitle}</span>}
</div>
))}
{/* Separator if both exist */}
{genres.length > 0 && activeSpecials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Active Specials */}
{activeSpecials.map(s => (
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div className="tooltip">
<Link
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
{s.subtitle && <span className="tooltip-text">{s.subtitle}</span>}
</div>
{s.curator && (
<span style={{ fontSize: '0.75rem', color: '#666' }}>
Curated by {s.curator}
</span>
)}
</div>
))}
</div>
{/* Upcoming Specials */}
{upcomingSpecials.length > 0 && (
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Coming soon: {upcomingSpecials.map(s => (
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
{s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: process.env.TZ
}) : ''})
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
</span>
))}
</div>
)}
</div>
<div id="tour-news">
<NewsSection />
</div>
<Game dailyPuzzle={dailyPuzzle} genre={null} />
<OnboardingTour />
</>
);
}

View File

@@ -1,111 +0,0 @@
import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
interface PageProps {
params: Promise<{ name: string }>;
}
export default async function SpecialPage({ params }: PageProps) {
const { name } = await params;
const decodedName = decodeURIComponent(name);
const currentSpecial = await prisma.special.findUnique({
where: { name: decodedName }
});
const now = new Date();
const isStarted = currentSpecial && (!currentSpecial.launchDate || currentSpecial.launchDate <= now);
const isEnded = currentSpecial && (currentSpecial.endDate && currentSpecial.endDate < now);
if (!currentSpecial || !isStarted) {
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h1>Special Not Available</h1>
<p>This special has not launched yet or does not exist.</p>
<Link href="/">Go Home</Link>
</div>
);
}
if (isEnded) {
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h1>Special Ended</h1>
<p>This special event has ended.</p>
<Link href="/">Go Home</Link>
</div>
);
}
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const activeSpecials = specials.filter(s => {
const sStarted = !s.launchDate || s.launchDate <= now;
const sEnded = s.endDate && s.endDate < now;
return sStarted && !sEnded;
});
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
{/* Genres */}
{genres.map(g => (
<Link
key={g.id}
href={`/${g.name}`}
style={{
color: '#4b5563',
textDecoration: 'none'
}}
>
{g.name}
</Link>
))}
{/* Separator if both exist */}
{genres.length > 0 && activeSpecials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{activeSpecials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}
style={{
fontWeight: s.name === decodedName ? 'bold' : 'normal',
textDecoration: s.name === decodedName ? 'underline' : 'none',
color: s.name === decodedName ? '#9d174d' : '#be185d'
}}
>
{s.name}
</Link>
))}
</div>
</div>
<NewsSection />
<Game
dailyPuzzle={dailyPuzzle}
genre={decodedName}
isSpecial={true}
maxAttempts={dailyPuzzle?.maxAttempts}
unlockSteps={dailyPuzzle?.unlockSteps}
/>
</>
);
}

View File

@@ -1,34 +1,47 @@
'use client';
"use client";
import { useEffect, useState } from 'react';
import { config } from "@/lib/config";
import { Link } from "@/lib/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
export default function AppFooter() {
const [version, setVersion] = useState<string>('');
const [version, setVersion] = useState<string>("");
const t = useTranslations("About");
useEffect(() => {
fetch('/api/version')
.then(res => res.json())
.then(data => setVersion(data.version))
.catch(() => setVersion(''));
}, []);
useEffect(() => {
fetch("/api/version")
.then((res) => res.json())
.then((data) => setVersion(data.version))
.catch(() => setVersion(""));
}, []);
return (
<footer className="app-footer">
<p>
Vibe coded with and 🍺 by{' '}
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
@elpatron@digitalcourage.social
</a>
{' '}- for personal use among friends only!
{version && (
<>
{' '}·{' '}
<span style={{ fontSize: '0.85em', opacity: 0.7 }}>
{version}
</span>
</>
)}
</p>
</footer>
);
if (!config.credits.enabled) return null;
return (
<footer className="app-footer">
<p>
{config.credits.text}{" "}
<a
href={config.credits.linkUrl}
target="_blank"
rel="noopener noreferrer"
>
{config.credits.linkText}
</a>
{version && (
<>
{" "}
·{" "}
<span style={{ fontSize: "0.85em", opacity: 0.7 }}>{version}</span>
</>
)}
</p>
<p style={{ marginTop: "0.5rem", fontSize: "0.85rem" }}>
<Link href="/about" style={{ textDecoration: "underline" }}>
{t("footerLinkLabel")}
</Link>
</p>
</footer>
);
}

View File

@@ -1,12 +1,21 @@
'use client';
import { config } from '@/lib/config';
import { useEffect, useState, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
import GuessInput from './GuessInput';
import Statistics from './Statistics';
import { useGameState } from '../lib/gameState';
import { sendGotifyNotification, submitRating } from '../app/actions';
// Plausible Analytics
declare global {
interface Window {
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
}
}
interface GameProps {
dailyPuzzle: {
id: number;
@@ -28,10 +37,12 @@ interface GameProps {
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) {
const t = useTranslations('Game');
const locale = useLocale();
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('🔗 Share');
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
const [timeUntilNext, setTimeUntilNext] = useState('');
@@ -76,7 +87,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
useEffect(() => {
if (dailyPuzzle) {
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
if (ratedPuzzles.includes(dailyPuzzle.id)) {
setHasRated(true);
} else {
@@ -87,13 +98,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (!dailyPuzzle) return (
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
<h2>No Puzzle Available</h2>
<p>Could not generate a daily puzzle.</p>
<p>Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.</p>
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
<h2>{t('noPuzzleAvailable')}</h2>
<p>{t('noPuzzleDescription')}</p>
<p>{t('noPuzzleGenre')}{genre ? ` für Genre "${genre}"` : ''}.</p>
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>{t('goToAdmin')}</a>
</div>
);
if (!gameState) return <div>Loading state...</div>;
if (!gameState) return <div>{t('loadingState')}</div>;
const handleGuess = (song: any) => {
if (isProcessingGuess) return;
@@ -103,6 +114,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true);
setHasWon(true);
// Track puzzle solved event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length + 1,
score: gameState.score + 20, // Include the win bonus
outcome: 'won'
}
});
}
// Notification sent after year guess or skip
if (!dailyPuzzle.releaseYear) {
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
@@ -112,6 +134,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true);
setHasWon(false);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: maxAttempts,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
}
}
@@ -138,6 +171,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true);
setHasWon(false);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: maxAttempts,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
}
};
@@ -148,6 +192,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
giveUp(); // Ensure game is marked as failed and score reset to 0
setHasLost(true);
setHasWon(false);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length + 1,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
};
@@ -156,6 +211,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
addYearBonus(correct);
setShowYearModal(false);
// Update the puzzle_solved event with year bonus result
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length,
score: gameState.score + (correct ? 10 : 0), // Include year bonus if correct
outcome: 'won',
year_bonus: correct ? 'correct' : 'incorrect'
}
});
}
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
};
@@ -163,6 +231,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const handleYearSkip = () => {
skipYearBonus();
setShowYearModal(false);
// Update the puzzle_solved event with year bonus result
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length,
score: gameState.score, // Score already includes win bonus
outcome: 'won',
year_bonus: 'skipped'
}
});
}
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
};
@@ -175,23 +257,28 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
for (let i = 0; i < totalGuesses; i++) {
if (i < gameState.guesses.length) {
if (hasWon && i === gameState.guesses.length - 1) {
emojiGrid += '🟩';
} else if (gameState.guesses[i] === 'SKIPPED') {
if (gameState.guesses[i] === 'SKIPPED') {
emojiGrid += '⬛';
} else if (hasWon && i === gameState.guesses.length - 1) {
emojiGrid += '🟩';
} else {
emojiGrid += '🟥';
}
} else {
emojiGrid += '⬜';
// If game is lost, fill remaining slots with black squares
emojiGrid += hasLost ? '⬛' : '⬜';
}
}
const speaker = hasWon ? '🔉' : '🔇';
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
let shareUrl = 'https://hoerdle.elpatron.me';
let shareUrl = `https://${config.domain}`;
// Add locale prefix if not default (de)
if (locale !== 'de') {
shareUrl += `/${locale}`;
}
if (genre) {
if (isSpecial) {
shareUrl += `/special/${encodeURIComponent(genre)}`;
@@ -200,7 +287,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
}
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
const text = `${config.appName} #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\n${t('score')}: ${gameState.score}\n\n#${config.appName} #Music\n\n${shareUrl}`;
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
@@ -210,8 +297,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
text: text,
});
setShareText('✓ Shared!');
setTimeout(() => setShareText('🔗 Share'), 2000);
setShareText(t('shared'));
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
return;
} catch (err) {
if ((err as Error).name !== 'AbortError') {
@@ -222,12 +309,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
try {
await navigator.clipboard.writeText(text);
setShareText('✓ Copied!');
setTimeout(() => setShareText('🔗 Share'), 2000);
setShareText(t('copied'));
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
} catch (err) {
console.error('Clipboard failed:', err);
setShareText('✗ Failed');
setTimeout(() => setShareText('🔗 Share'), 2000);
setShareText(t('shareFailed'));
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
}
};
@@ -238,10 +325,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
setHasRated(true);
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
ratedPuzzles.push(dailyPuzzle.id);
localStorage.setItem('hoerdle_rated_puzzles', JSON.stringify(ratedPuzzles));
localStorage.setItem(`${config.appName.toLowerCase()}_rated_puzzles`, JSON.stringify(ratedPuzzles));
}
} catch (error) {
console.error('Failed to submit rating', error);
@@ -251,17 +338,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
return (
<div className="container">
<header className="header">
<h1 id="tour-title" className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '0.5rem', marginBottom: '1rem' }}>
Next puzzle in: {timeUntilNext}
<h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
<div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
{t('nextPuzzle')}: {timeUntilNext}
</div>
</header>
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div id="tour-status" className="status-bar">
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s unlocked</span>
<span>{t('attempt')} {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s {t('unlocked')}</span>
</div>
<div id="tour-score">
@@ -288,7 +375,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
<div key={i} className="guess-item">
<span className="guess-number">#{i + 1}</span>
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
{isCorrect ? 'Correct!' : guess}
{isCorrect ? t('correct') : guess}
</span>
</div>
);
@@ -307,8 +394,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
className="skip-button"
>
{gameState.guesses.length === 0 && !hasPlayedAudio
? 'Start'
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
? t('start')
: t('skipWithBonus', { seconds: unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds })
}
</button>
) : (
@@ -320,7 +407,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
boxShadow: '0 4px 15px rgba(245, 87, 108, 0.4)'
}}
>
Solve (Give Up)
{t('solveGiveUp')}
</button>
)}
</>
@@ -329,15 +416,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{(hasWon || hasLost) && (
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{hasWon ? 'You won!' : 'Game Over'}
{hasWon ? t('won') : t('lost')}
</h2>
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
Score: {gameState.score}
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? 'var(--success)' : 'var(--danger)' }}>
{t('score')}: {gameState.score}
</div>
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
<summary>Score Breakdown</summary>
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: 'var(--muted-foreground)' }}>
<summary>{t('scoreBreakdown')}</summary>
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
{gameState.scoreBreakdown.map((item, i) => (
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
@@ -350,22 +437,22 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</ul>
</details>
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
<p>{hasWon ? t('comeBackTomorrow') : t('theSongWas')}</p>
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<img
src={dailyPuzzle.coverImage || '/favicon.ico'}
alt="Album Cover"
alt={t('albumCover')}
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 1rem 0' }}>{t('released')}: {dailyPuzzle.releaseYear}</p>
)}
<audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
{t('yourBrowserDoesNotSupport')}
</audio>
</div>
@@ -412,19 +499,20 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
textAlign: 'center',
margin: '0.5rem 0',
padding: '0.5rem',
background: '#f3f4f6',
background: 'var(--muted)',
borderRadius: '0.5rem',
fontSize: '0.9rem',
fontFamily: 'monospace',
cursor: 'help'
}}>
<span style={{ color: '#666' }}>{expression} = </span>
<span style={{ color: 'var(--muted-foreground)' }}>{expression} = </span>
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
</div>
);
}
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
const t = useTranslations('Game');
const [options, setOptions] = useState<number[]>([]);
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
@@ -498,8 +586,8 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
}}>
{!feedback.show ? (
<>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: 'var(--primary)' }}>{t('bonusRound')}</h3>
<p style={{ marginBottom: '1.5rem', color: 'var(--secondary)' }}>{t('guessReleaseYear')} <strong style={{ color: 'var(--success)' }}>+10 {t('points')}</strong>!</p>
<div style={{
display: 'grid',
@@ -513,17 +601,17 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
onClick={() => handleGuess(year)}
style={{
padding: '0.75rem',
background: '#f3f4f6',
border: '2px solid #e5e7eb',
background: 'var(--muted)',
border: '2px solid var(--border)',
borderRadius: '0.5rem',
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#374151',
color: 'var(--secondary)',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
onMouseOver={e => e.currentTarget.style.borderColor = 'var(--success)'}
onMouseOut={e => e.currentTarget.style.borderColor = 'var(--border)'}
>
{year}
</button>
@@ -535,13 +623,13 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
style={{
background: 'none',
border: 'none',
color: '#6b7280',
color: 'var(--muted-foreground)',
textDecoration: 'underline',
cursor: 'pointer',
fontSize: '0.9rem'
}}
>
Skip Bonus
{t('skipBonus')}
</button>
</>
) : (
@@ -550,23 +638,23 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
feedback.correct ? (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#10b981', marginBottom: '0.5rem' }}>Correct!</h3>
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#10b981', marginTop: '1rem' }}>+10 Points!</p>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>{t('correct')}</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 {t('points')}!</p>
</>
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ef4444', marginBottom: '0.5rem' }}>Not quite!</h3>
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>You guessed {feedback.guessedYear}</p>
<p style={{ fontSize: '1.2rem', color: '#4b5563', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></p>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>{t('notQuite')}</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('youGuessed')} {feedback.guessedYear}</p>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)', marginTop: '0.5rem' }}>{t('actuallyReleasedIn')} <strong>{correctYear}</strong></p>
</>
)
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}></div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#6b7280', marginBottom: '0.5rem' }}>Skipped</h3>
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>{t('skipped')}</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
</>
)}
</div>
@@ -577,16 +665,17 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
}
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
const t = useTranslations('Game');
const [hover, setHover] = useState(0);
const [rating, setRating] = useState(0);
if (hasRated) {
return <div style={{ color: '#666', fontStyle: 'italic' }}>Thanks for rating!</div>;
return <div style={{ color: 'var(--muted-foreground)', fontStyle: 'italic' }}>{t('thanksForRating')}</div>;
}
return (
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', color: '#666', fontWeight: '500' }}>Rate this puzzle:</span>
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
{[...Array(5)].map((_, index) => {
const ratingValue = index + 1;
@@ -599,7 +688,7 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
border: 'none',
cursor: 'pointer',
fontSize: '2rem',
color: ratingValue <= (hover || rating) ? '#ffc107' : '#9ca3af',
color: ratingValue <= (hover || rating) ? 'var(--warning)' : 'var(--muted-foreground)',
transition: 'color 0.2s',
padding: '0 0.25rem'
}}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
interface Song {
id: number;
@@ -14,6 +15,7 @@ interface GuessInputProps {
}
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
const t = useTranslations('Game');
const [query, setQuery] = useState('');
const [songs, setSongs] = useState<Song[]>([]);
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
@@ -53,7 +55,7 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
value={query}
onChange={(e) => setQuery(e.target.value)}
disabled={disabled}
placeholder={disabled ? "Game Over" : "Know it? Search for the artist / title"}
placeholder={disabled ? t('gameOverPlaceholder') : t('knowItSearch')}
className="guess-input"
/>

View File

@@ -1,8 +1,10 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
export default function InstallPrompt() {
const t = useTranslations('InstallPrompt');
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
const [showPrompt, setShowPrompt] = useState(false);
@@ -80,9 +82,9 @@ export default function InstallPrompt() {
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div>
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>Install Hördle App</h3>
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>{t('installApp')}</h3>
<p style={{ fontSize: '0.875rem', color: '#666' }}>
Install the app for a better experience and quick access!
{t('installDescription')}
</p>
</div>
<button
@@ -102,7 +104,7 @@ export default function InstallPrompt() {
{isIOS ? (
<div style={{ fontSize: '0.875rem', background: '#f3f4f6', padding: '0.75rem', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
Tap <span style={{ fontSize: '1.2rem' }}>share</span> then "Add to Home Screen" <span style={{ fontSize: '1.2rem' }}>+</span>
{t('iosInstructions')} <span style={{ fontSize: '1.2rem' }}>{t('iosShare')}</span> {t('iosThen')} <span style={{ fontSize: '1.2rem' }}>+</span>
</div>
) : (
<button
@@ -118,7 +120,7 @@ export default function InstallPrompt() {
marginTop: '0.5rem'
}}
>
Install App
{t('installButton')}
</button>
)}
<style jsx>{`

View File

@@ -0,0 +1,59 @@
'use client';
import { usePathname, useRouter } from '@/lib/navigation';
import { useLocale } from 'next-intl';
export default function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const switchLocale = (newLocale: 'de' | 'en') => {
router.replace(pathname, { locale: newLocale });
};
return (
<div style={{
display: 'flex',
background: '#f3f4f6',
borderRadius: '0.375rem',
padding: '0.25rem',
gap: '0.25rem'
}}>
<button
onClick={() => switchLocale('de')}
style={{
padding: '0.375rem 0.75rem',
background: locale === 'de' ? 'white' : 'transparent',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontWeight: locale === 'de' ? '600' : '400',
fontSize: '0.875rem',
color: locale === 'de' ? '#111827' : '#6b7280',
boxShadow: locale === 'de' ? '0 1px 2px rgba(0,0,0,0.05)' : 'none',
transition: 'all 0.2s'
}}
>
DE
</button>
<button
onClick={() => switchLocale('en')}
style={{
padding: '0.375rem 0.75rem',
background: locale === 'en' ? 'white' : 'transparent',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontWeight: locale === 'en' ? '600' : '400',
fontSize: '0.875rem',
color: locale === 'en' ? '#111827' : '#6b7280',
boxShadow: locale === 'en' ? '0 1px 2px rgba(0,0,0,0.05)' : 'none',
transition: 'all 0.2s'
}}
>
EN
</button>
</div>
);
}

View File

@@ -2,33 +2,38 @@
import { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import Link from 'next/link';
import { Link } from '@/lib/navigation';
import { getLocalizedValue } from '@/lib/i18n';
interface NewsItem {
id: number;
title: string;
content: string;
title: any;
content: any;
author: string | null;
publishedAt: string;
featured: boolean;
special: {
id: number;
name: string;
name: any;
} | null;
}
export default function NewsSection() {
interface NewsSectionProps {
locale: string;
}
export default function NewsSection({ locale }: NewsSectionProps) {
const [news, setNews] = useState<NewsItem[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchNews();
}, []);
}, [locale]);
const fetchNews = async () => {
try {
const res = await fetch('/api/news?limit=3');
const res = await fetch(`/api/news?limit=3&locale=${locale}`);
if (res.ok) {
const data = await res.json();
setNews(data);
@@ -115,7 +120,7 @@ export default function NewsSection() {
fontWeight: '600',
color: '#111827'
}}>
{item.title}
{getLocalizedValue(item.title, locale)}
</h3>
</div>
@@ -145,14 +150,14 @@ export default function NewsSection() {
<>
<span></span>
<Link
href={`/special/${item.special.name}`}
href={`/special/${getLocalizedValue(item.special.name, locale)}`}
style={{
color: '#be185d',
textDecoration: 'none',
fontWeight: '500'
}}
>
{item.special.name}
{getLocalizedValue(item.special.name, locale)}
</Link>
</>
)}
@@ -187,7 +192,7 @@ export default function NewsSection() {
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
}}
>
{item.content}
{getLocalizedValue(item.content, locale)}
</ReactMarkdown>
</div>
</div>

View File

@@ -1,10 +1,13 @@
'use client';
import { useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
export default function OnboardingTour() {
const t = useTranslations('OnboardingTour');
useEffect(() => {
const hasCompletedOnboarding = localStorage.getItem('hoerdle_onboarding_completed');
@@ -16,9 +19,9 @@ export default function OnboardingTour() {
showProgress: true,
animate: true,
allowClose: true,
doneBtnText: 'Done',
nextBtnText: 'Next',
prevBtnText: 'Previous',
doneBtnText: t('done'),
nextBtnText: t('next'),
prevBtnText: t('previous'),
onDestroyed: () => {
localStorage.setItem('hoerdle_onboarding_completed', 'true');
},
@@ -26,8 +29,8 @@ export default function OnboardingTour() {
{
element: '#tour-genres',
popover: {
title: 'Genres & Specials',
description: 'Choose a specific genre or a curated special event here.',
title: t('genresSpecials'),
description: t('genresSpecialsDescription'),
side: 'bottom',
align: 'start'
}
@@ -35,8 +38,8 @@ export default function OnboardingTour() {
{
element: '#tour-news',
popover: {
title: 'News',
description: 'Stay updated with the latest news and announcements.',
title: t('news'),
description: t('newsDescription'),
side: 'top',
align: 'start'
}
@@ -44,8 +47,8 @@ export default function OnboardingTour() {
{
element: '#tour-title',
popover: {
title: 'Hördle',
description: 'This is the daily puzzle. One new song every day per genre.',
title: t('hoerdle'),
description: t('hoerdleDescription'),
side: 'bottom',
align: 'start'
}
@@ -53,8 +56,8 @@ export default function OnboardingTour() {
{
element: '#tour-status',
popover: {
title: 'Attempts',
description: 'You have a limited number of attempts to guess the song.',
title: t('attempts'),
description: t('attemptsDescription'),
side: 'bottom',
align: 'start'
}
@@ -62,8 +65,8 @@ export default function OnboardingTour() {
{
element: '#tour-score',
popover: {
title: 'Score',
description: 'Your current score. Try to keep it high!',
title: t('score'),
description: t('scoreDescription'),
side: 'bottom',
align: 'start'
}
@@ -71,8 +74,8 @@ export default function OnboardingTour() {
{
element: '#tour-player',
popover: {
title: 'Player',
description: 'Listen to the snippet. Each additional play reduces your potential score.',
title: t('player'),
description: t('playerDescription'),
side: 'top',
align: 'start'
}
@@ -80,8 +83,8 @@ export default function OnboardingTour() {
{
element: '#tour-input',
popover: {
title: 'Input',
description: 'Type your guess here. Search for artist or title.',
title: t('input'),
description: t('inputDescription'),
side: 'top',
align: 'start'
}
@@ -89,8 +92,8 @@ export default function OnboardingTour() {
{
element: '#tour-controls',
popover: {
title: 'Controls',
description: 'Start the music or skip to the next snippet if you\'re stuck.',
title: t('controls'),
description: t('controlsDescription'),
side: 'top',
align: 'start'
}
@@ -103,7 +106,7 @@ export default function OnboardingTour() {
driverObj.drive();
}, 1000);
}, []);
}, [t]);
return null;
}

View File

@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { Statistics as StatsType } from '../lib/gameState';
interface StatisticsProps {
@@ -18,6 +19,7 @@ const BADGES = {
};
export default function Statistics({ statistics }: StatisticsProps) {
const t = useTranslations('Statistics');
const total =
statistics.solvedIn1 +
statistics.solvedIn2 +
@@ -36,19 +38,19 @@ export default function Statistics({ statistics }: StatisticsProps) {
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
{ attempts: 7, count: statistics.solvedIn7, badge: BADGES[7] },
{ attempts: 'Failed', count: statistics.failed, badge: BADGES.failed },
{ attempts: t('failed'), count: statistics.failed, badge: BADGES.failed },
];
return (
<div className="statistics-container">
<h3 className="statistics-title">Your Statistics</h3>
<p className="statistics-total">Total puzzles: {total}</p>
<h3 className="statistics-title">{t('yourStatistics')}</h3>
<p className="statistics-total">{t('totalPuzzles')}: {total}</p>
<div className="statistics-grid">
{stats.map((stat, index) => (
<div key={index} className="stat-item">
<div className="stat-badge">{stat.badge}</div>
<div className="stat-label">
{typeof stat.attempts === 'number' ? `${stat.attempts} try` : stat.attempts}
{typeof stat.attempts === 'number' ? `${stat.attempts} ${t('try')}` : stat.attempts}
</div>
<div className="stat-count">{stat.count}</div>
</div>

View File

@@ -4,6 +4,19 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
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}
NEXT_PUBLIC_CREDITS_ENABLED: ${NEXT_PUBLIC_CREDITS_ENABLED}
NEXT_PUBLIC_CREDITS_TEXT: ${NEXT_PUBLIC_CREDITS_TEXT}
NEXT_PUBLIC_CREDITS_LINK_TEXT: ${NEXT_PUBLIC_CREDITS_LINK_TEXT}
NEXT_PUBLIC_CREDITS_LINK_URL: ${NEXT_PUBLIC_CREDITS_LINK_URL}
user: root
restart: always
ports:
@@ -24,6 +37,4 @@ services:
timeout: 10s
retries: 3
start_period: 40s
# Run migrations and start server (auto-baseline on first run if needed)
command: >
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node server.js"
# docker-entrypoint.sh handles migrations and server startup (with baseline fallback)

20
i18n/request.ts Normal file
View File

@@ -0,0 +1,20 @@
import { getRequestConfig } from 'next-intl/server';
const locales = ['en', 'de'] as const;
export default getRequestConfig(async ({ requestLocale }) => {
// `requestLocale` kommt von next-intl (z.B. aus dem [locale]-Segment oder Fallback)
let locale = await requestLocale;
console.log('[i18n/request] incoming requestLocale:', locale);
if (!locale || !locales.includes(locale as (typeof locales)[number])) {
locale = 'de';
console.log('[i18n/request] falling back to default locale:', locale);
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default
};
});

18
lib/config.ts Normal file
View File

@@ -0,0 +1,18 @@
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',
colors: {
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
},
credits: {
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
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',
}
};

View File

@@ -1,22 +1,15 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient, Genre, Special } from '@prisma/client';
import { getTodayISOString } from './dateUtils';
const prisma = new PrismaClient();
export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
try {
const today = getTodayISOString();
let genreId: number | null = null;
if (genreName) {
const genre = await prisma.genre.findUnique({
where: { name: genreName }
});
if (genre) {
genreId = genre.id;
} else {
return null; // Genre not found
}
if (genre) {
genreId = genre.id;
}
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
@@ -27,8 +20,6 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
include: { song: true },
});
if (!dailyPuzzle) {
// Get songs available for this genre
const whereClause = genreId
@@ -45,7 +36,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
});
if (allSongs.length === 0) {
console.log(`[Daily Puzzle] No songs available for genre: ${genreName || 'Global'}`);
console.log(`[Daily Puzzle] No songs available for genre: ${genre ? JSON.stringify(genre.name) : 'Global'}`);
return null;
}
@@ -80,7 +71,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
},
include: { song: true },
});
console.log(`[Daily Puzzle] Created new puzzle for ${today} (Genre: ${genreName || 'Global'}) with song: ${selectedSong.title}`);
console.log(`[Daily Puzzle] Created new puzzle for ${today} (Genre: ${genre ? JSON.stringify(genre.name) : 'Global'}) with song: ${selectedSong.title}`);
} catch (e) {
// Handle race condition
console.log('[Daily Puzzle] Creation failed, trying to fetch again (likely race condition)');
@@ -119,7 +110,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
releaseYear: dailyPuzzle.song.releaseYear,
genre: genreName
genre: genre ? genre.name : null
};
} catch (error) {
@@ -128,16 +119,10 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
}
}
export async function getOrCreateSpecialPuzzle(specialName: string) {
export async function getOrCreateSpecialPuzzle(special: Special) {
try {
const today = getTodayISOString();
const special = await prisma.special.findUnique({
where: { name: specialName }
});
if (!special) return null;
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
where: {
date: today,
@@ -232,7 +217,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
releaseYear: dailyPuzzle.song.releaseYear,
special: specialName,
special: special.name,
maxAttempts: special.maxAttempts,
unlockSteps: JSON.parse(special.unlockSteps),
startTime: specialSong?.startTime || 0

41
lib/i18n.ts Normal file
View File

@@ -0,0 +1,41 @@
export type LocalizedString = {
[key: string]: string;
};
export function getLocalizedValue(
value: any,
locale: string,
fallback: string = ''
): string {
if (!value) return fallback;
// If it's already a string, return it (backward compatibility or simple values)
if (typeof value === 'string') return value;
// If it's an object, try to get the requested locale
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 first key
const keys = Object.keys(value);
if (keys.length > 0) return value[keys[0]];
}
return fallback;
}
export function createLocalizedObject(
de: string,
en?: string
): LocalizedString {
return {
de: de.trim(),
en: (en || de).trim()
};
}

9
lib/navigation.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createNavigation } from 'next-intl/navigation';
export const locales = ['de', 'en'] as const;
export const localePrefix = 'always'; // Default
export const { Link, redirect, usePathname, useRouter } = createNavigation({
locales,
localePrefix
});

198
messages/de.json Normal file
View File

@@ -0,0 +1,198 @@
{
"Common": {
"loading": "Laden...",
"error": "Ein Fehler ist aufgetreten",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"back": "Zurück"
},
"Navigation": {
"home": "Startseite",
"admin": "Admin",
"global": "Global",
"news": "Neuigkeiten"
},
"Game": {
"play": "Abspielen",
"pause": "Pause",
"skip": "Überspringen",
"submit": "Raten",
"next": "Nächstes",
"won": "Gewonnen!",
"lost": "Verloren",
"correct": "Richtig!",
"wrong": "Falsch",
"guessPlaceholder": "Lied oder Interpret eingeben...",
"attempts": "Versuche",
"share": "Teilen",
"nextPuzzle": "Nächstes Rätsel in",
"noPuzzleAvailable": "Kein Rätsel verfügbar",
"noPuzzleDescription": "Tägliches Rätsel konnte nicht generiert werden.",
"noPuzzleGenre": "Bitte stelle sicher, dass Songs in der Datenbank vorhanden sind",
"goToAdmin": "Zum Admin-Dashboard gehen",
"loadingState": "Lade Status...",
"attempt": "Versuch",
"unlocked": "freigeschaltet",
"start": "Start",
"skipWithBonus": "Überspringen (+{seconds}s)",
"solveGiveUp": "Lösen (Aufgeben)",
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
"theSongWas": "Das Lied war:",
"score": "Punkte",
"scoreBreakdown": "Punkteaufschlüsselung",
"albumCover": "Album-Cover",
"released": "Veröffentlicht",
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
"thanksForRating": "Danke für die Bewertung!",
"rateThisPuzzle": "Bewerte dieses Rätsel:",
"shared": "✓ Geteilt!",
"copied": "✓ Kopiert!",
"shareFailed": "✗ Fehlgeschlagen",
"bonusRound": "Bonus-Runde!",
"guessReleaseYear": "Errate das Veröffentlichungsjahr für",
"points": "Punkte",
"skipBonus": "Bonus überspringen",
"notQuite": "Nicht ganz!",
"youGuessed": "Du hast geraten",
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
"skipped": "Übersprungen",
"gameOverPlaceholder": "Spiel beendet",
"knowItSearch": "Weißt du es? Suche nach Interpret / Titel",
"special": "Special",
"genre": "Genre"
},
"Statistics": {
"yourStatistics": "Deine Statistiken",
"totalPuzzles": "Gesamte Rätsel",
"try": "Versuch",
"failed": "Verloren"
},
"OnboardingTour": {
"done": "Fertig",
"next": "Weiter",
"previous": "Zurück",
"genresSpecials": "Genres & Specials",
"genresSpecialsDescription": "Wähle hier ein bestimmtes Genre oder ein kuratiertes Special-Event.",
"news": "Neuigkeiten",
"newsDescription": "Bleibe auf dem Laufenden mit den neuesten Nachrichten und Ankündigungen.",
"hoerdle": "Hördle",
"hoerdleDescription": "Das ist das tägliche Rätsel. Ein neues Lied jeden Tag pro Genre.",
"attempts": "Versuche",
"attemptsDescription": "Du hast eine begrenzte Anzahl von Versuchen, um das Lied zu erraten.",
"score": "Punkte",
"scoreDescription": "Deine aktuelle Punktzahl. Versuche sie hoch zu halten!",
"player": "Player",
"playerDescription": "Höre dir den Ausschnitt an. Jedes zusätzliche Abspielen reduziert deine mögliche Punktzahl.",
"input": "Eingabe",
"inputDescription": "Gib hier deine Vermutung ein. Suche nach Interpret oder Titel.",
"controls": "Steuerung",
"controlsDescription": "Starte die Musik oder überspringe zum nächsten Ausschnitt, wenn du feststeckst."
},
"InstallPrompt": {
"installApp": "Hördle App installieren",
"installDescription": "Installiere die App für eine bessere Erfahrung und schnellen Zugriff!",
"iosInstructions": "Tippe auf",
"iosShare": "Teilen",
"iosThen": "dann \"Zum Home-Bildschirm hinzufügen\"",
"installButton": "App installieren"
},
"Home": {
"welcome": "Willkommen bei Hördle",
"subtitle": "Errate den Song anhand kurzer Ausschnitte",
"globalTooltip": "Ein zufälliger Song aus der gesamten Sammlung",
"comingSoon": "Demnächst",
"curatedBy": "Kuratiert von"
},
"Admin": {
"title": "Hördle Admin Dashboard",
"login": "Admin Login",
"password": "Passwort",
"loginButton": "Login",
"logout": "Abmelden",
"manageSpecials": "Specials verwalten",
"manageGenres": "Genres verwalten",
"manageNews": "News & Ankündigungen verwalten",
"uploadSongs": "Songs hochladen",
"todaysPuzzles": "Heutige tägliche Rätsel",
"show": "▶ Anzeigen",
"hide": "▼ Ausblenden",
"addSpecial": "Special hinzufügen",
"addGenre": "Genre hinzufügen",
"addNews": "News hinzufügen",
"edit": "Bearbeiten",
"delete": "Löschen",
"save": "Speichern",
"cancel": "Abbrechen",
"curate": "Kurieren",
"name": "Name",
"subtitle": "Untertitel",
"maxAttempts": "Max. Versuche",
"unlockSteps": "Freischalt-Schritte",
"launchDate": "Startdatum",
"endDate": "Enddatum",
"curator": "Kurator",
"active": "Aktiv",
"newGenreName": "Neuer Genre-Name",
"editSpecial": "Special bearbeiten",
"editGenre": "Genre bearbeiten",
"editNews": "News bearbeiten",
"newsTitle": "News-Titel",
"content": "Inhalt (Markdown unterstützt)",
"author": "Autor (optional)",
"featured": "Hervorgehoben",
"noSpecialLink": "Kein Special-Link",
"noNewsItems": "Noch keine News-Einträge. Erstelle einen oben!",
"noPuzzlesToday": "Keine täglichen Rätsel für heute gefunden.",
"category": "Kategorie",
"song": "Song",
"artist": "Interpret",
"actions": "Aktionen",
"deletePuzzle": "Löschen",
"wrongPassword": "Falsches Passwort"
},
"About": {
"title": "Über Hördle & Impressum",
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
"projectTitle": "Über dieses Projekt",
"projectPrivateNote": "Hördle wird privat in der Freizeit entwickelt, betrieben, kuratiert und finanziert. Es besteht kein Anspruch auf permanente Verfügbarkeit oder Vollständigkeit.",
"projectIdea": "Die Idee hinter Hördle ist, Musik spielerisch neu zu entdecken und Lieblingssongs wiederzuentdecken inspiriert von Wordle, aber für Musikfans.",
"imprintTitle": "Impressum",
"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)",
"costsServer": "Server / vServer für App und Tracking",
"costsEmail": "E-Mail-Hosting",
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
"costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.",
"costsSheetPrivacyNote": "Beim Aufruf oder Einbetten der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
"supportTitle": "Hördle unterstützen",
"supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:",
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",
"supportSepaName": "Markus Busche",
"supportSepaIban": "IBAN: DE28500310001071584000",
"supportPaypalTitle": "PayPal Spende",
"supportPaypalLink": "paypal.me/MBusche",
"supportSteadyTitle": "Steady",
"supportSteadyDescription": "Regelmäßige Unterstützung über Steady",
"privacyTitle": "Datenschutz",
"privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.",
"privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics",
"privacyPlausibleSelfHosted": "Für anonyme Nutzungsstatistiken wird Plausible Analytics auf einem selbst gehosteten Server verwendet. Es werden keine personalisierten Profile erstellt.",
"privacyPlausibleGemaTariff": "Das Tracking ist erforderlich, um den passenden GEMA Tarif zu bestimmen.",
"privacyPlausibleNoCookies": "Es werden keine Cookies für das Tracking gesetzt.",
"privacyPlausibleNoTrackingAcrossSites": "Es findet kein Tracking über mehrere Webseiten oder Geräte hinweg statt.",
"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.",
"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",
"backToGame": "Zurück zu Hördle",
"footerLinkLabel": "Über & Impressum"
}
}

198
messages/en.json Normal file
View File

@@ -0,0 +1,198 @@
{
"Common": {
"loading": "Loading...",
"error": "An error occurred",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"back": "Back"
},
"Navigation": {
"home": "Home",
"admin": "Admin",
"global": "Global",
"news": "News"
},
"Game": {
"play": "Play",
"pause": "Pause",
"skip": "Skip",
"submit": "Guess",
"next": "Next",
"won": "You won!",
"lost": "Game Over",
"correct": "Correct!",
"wrong": "Wrong",
"guessPlaceholder": "Type song or artist...",
"attempts": "Attempts",
"share": "Share",
"nextPuzzle": "Next puzzle in",
"noPuzzleAvailable": "No Puzzle Available",
"noPuzzleDescription": "Could not generate a daily puzzle.",
"noPuzzleGenre": "Please ensure there are songs in the database",
"goToAdmin": "Go to Admin Dashboard",
"loadingState": "Loading state...",
"attempt": "Attempt",
"unlocked": "unlocked",
"start": "Start",
"skipWithBonus": "Skip (+{seconds}s)",
"solveGiveUp": "Solve (Give Up)",
"comeBackTomorrow": "Come back tomorrow for a new song.",
"theSongWas": "The song was:",
"score": "Score",
"scoreBreakdown": "Score Breakdown",
"albumCover": "Album Cover",
"released": "Released",
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
"thanksForRating": "Thanks for rating!",
"rateThisPuzzle": "Rate this puzzle:",
"shared": "✓ Shared!",
"copied": "✓ Copied!",
"shareFailed": "✗ Failed",
"bonusRound": "Bonus Round!",
"guessReleaseYear": "Guess the release year for",
"points": "points",
"skipBonus": "Skip Bonus",
"notQuite": "Not quite!",
"youGuessed": "You guessed",
"actuallyReleasedIn": "Actually released in",
"skipped": "Skipped",
"gameOverPlaceholder": "Game Over",
"knowItSearch": "Know it? Search for the artist / title",
"special": "Special",
"genre": "Genre"
},
"Statistics": {
"yourStatistics": "Your Statistics",
"totalPuzzles": "Total puzzles",
"try": "try",
"failed": "Failed"
},
"OnboardingTour": {
"done": "Done",
"next": "Next",
"previous": "Previous",
"genresSpecials": "Genres & Specials",
"genresSpecialsDescription": "Choose a specific genre or a curated special event here.",
"news": "News",
"newsDescription": "Stay updated with the latest news and announcements.",
"hoerdle": "Hördle",
"hoerdleDescription": "This is the daily puzzle. One new song every day per genre.",
"attempts": "Attempts",
"attemptsDescription": "You have a limited number of attempts to guess the song.",
"score": "Score",
"scoreDescription": "Your current score. Try to keep it high!",
"player": "Player",
"playerDescription": "Listen to the snippet. Each additional play reduces your potential score.",
"input": "Input",
"inputDescription": "Type your guess here. Search for artist or title.",
"controls": "Controls",
"controlsDescription": "Start the music or skip to the next snippet if you're stuck."
},
"InstallPrompt": {
"installApp": "Install Hördle App",
"installDescription": "Install the app for a better experience and quick access!",
"iosInstructions": "Tap",
"iosShare": "share",
"iosThen": "then \"Add to Home Screen\"",
"installButton": "Install App"
},
"Home": {
"welcome": "Welcome to Hördle",
"subtitle": "Guess the song from short snippets",
"globalTooltip": "A random song from the entire collection",
"comingSoon": "Coming soon",
"curatedBy": "Curated by"
},
"Admin": {
"title": "Hördle Admin Dashboard",
"login": "Admin Login",
"password": "Password",
"loginButton": "Login",
"logout": "Logout",
"manageSpecials": "Manage Specials",
"manageGenres": "Manage Genres",
"manageNews": "Manage News & Announcements",
"uploadSongs": "Upload Songs",
"todaysPuzzles": "Today's Daily Puzzles",
"show": "▶ Show",
"hide": "▼ Hide",
"addSpecial": "Add Special",
"addGenre": "Add Genre",
"addNews": "Add News",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"curate": "Curate",
"name": "Name",
"subtitle": "Subtitle",
"maxAttempts": "Max Attempts",
"unlockSteps": "Unlock Steps",
"launchDate": "Launch Date",
"endDate": "End Date",
"curator": "Curator",
"active": "Active",
"newGenreName": "New Genre Name",
"editSpecial": "Edit Special",
"editGenre": "Edit Genre",
"editNews": "Edit News",
"newsTitle": "News Title",
"content": "Content (Markdown supported)",
"author": "Author (optional)",
"featured": "Featured",
"noSpecialLink": "No Special Link",
"noNewsItems": "No news items yet. Create one above!",
"noPuzzlesToday": "No daily puzzles found for today.",
"category": "Category",
"song": "Song",
"artist": "Artist",
"actions": "Actions",
"deletePuzzle": "Delete",
"wrongPassword": "Wrong password"
},
"About": {
"title": "About Hördle & Imprint",
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",
"projectTitle": "About this project",
"projectPrivateNote": "Hördle is developed, operated, curated and financed privately in the creator's spare time. There is no guarantee for permanent availability or completeness.",
"projectIdea": "The idea behind Hördle is to (re)discover music in a playful way inspired by Wordle, but for music lovers.",
"imprintTitle": "Imprint",
"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)",
"costsServer": "Servers / vServers for the app and tracking",
"costsEmail": "Email hosting",
"costsLicenses": "Possible fees for copyrights or other licenses",
"costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.",
"costsSheetPrivacyNote": "When accessing or embedding the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
"supportTitle": "Support Hördle",
"supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:",
"supportSepaTitle": "SEPA Bank Transfer (preferred)",
"supportSepaName": "Markus Busche",
"supportSepaIban": "IBAN: DE28500310001071584000",
"supportPaypalTitle": "PayPal Donation",
"supportPaypalLink": "paypal.me/MBusche",
"supportSteadyTitle": "Steady",
"supportSteadyDescription": "Regular support via Steady",
"privacyTitle": "Privacy",
"privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.",
"privacyPlausibleTitle": "Self-hosted Plausible Analytics",
"privacyPlausibleSelfHosted": "For anonymous usage statistics, Plausible Analytics is used on a self-hosted server. No personal profiles are created.",
"privacyPlausibleGemaTariff": "Tracking is required to determine the appropriate GEMA tariff.",
"privacyPlausibleNoCookies": "No cookies are set for analytics purposes.",
"privacyPlausibleNoTrackingAcrossSites": "There is no tracking across multiple websites or devices.",
"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.",
"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",
"backToGame": "Back to Hördle",
"footerLinkLabel": "About & Imprint"
}
}

View File

@@ -1,5 +1,8 @@
import createNextIntlPlugin from 'next-intl/plugin';
import type { NextConfig } from "next";
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
@@ -8,7 +11,7 @@ const nextConfig: NextConfig = {
serverActions: {
bodySizeLimit: '50mb',
},
middlewareClientMaxBodySize: '50mb',
proxyClientMaxBodySize: '50mb',
},
env: {
TZ: process.env.TZ || 'Europe/Berlin',
@@ -36,4 +39,4 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
export default withNextIntl(nextConfig);

581
package-lock.json generated
View File

@@ -1,22 +1,24 @@
{
"name": "hoerdle",
"version": "0.1.0",
"version": "0.1.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoerdle",
"version": "0.1.0",
"version": "0.1.0.15",
"dependencies": {
"@prisma/client": "^6.19.0",
"bcryptjs": "^3.0.3",
"driver.js": "^1.4.0",
"music-metadata": "^11.10.2",
"next": "16.0.3",
"next-intl": "^4.5.6",
"prisma": "^6.19.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0"
"react-markdown": "^10.1.0",
"unist-util-visit-parents": "^6.0.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
@@ -24,6 +26,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.8.32",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"typescript": "^5"
@@ -396,9 +399,9 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -408,7 +411,7 @@
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1"
},
@@ -456,6 +459,66 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@formatjs/ecma402-abstract": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "2.2.7",
"@formatjs/intl-localematcher": "0.6.2",
"decimal.js": "^10.4.3",
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/fast-memoize": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/icu-messageformat-parser": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
"integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
"license": "MIT",
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"@formatjs/icu-skeleton-parser": "1.8.16",
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/icu-skeleton-parser": {
"version": "1.8.16",
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
"integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
"license": "MIT",
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz",
"integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==",
"license": "MIT",
"dependencies": {
"tslib": "2"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1315,12 +1378,184 @@
"dev": true,
"license": "MIT"
},
"node_modules/@schummar/icu-type-parser": {
"version": "1.21.5",
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.3.tgz",
"integrity": "sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.3.tgz",
"integrity": "sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.3.tgz",
"integrity": "sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.3.tgz",
"integrity": "sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.3.tgz",
"integrity": "sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.3.tgz",
"integrity": "sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.3.tgz",
"integrity": "sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.3.tgz",
"integrity": "sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.3.tgz",
"integrity": "sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.3.tgz",
"integrity": "sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1330,6 +1565,15 @@
"tslib": "^2.8.0"
}
},
"node_modules/@swc/types": {
"version": "0.1.25",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
"integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
@@ -1444,9 +1688,9 @@
}
},
"node_modules/@types/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1469,17 +1713,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz",
"integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==",
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz",
"integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/type-utils": "8.47.0",
"@typescript-eslint/utils": "8.47.0",
"@typescript-eslint/visitor-keys": "8.47.0",
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/type-utils": "8.48.0",
"@typescript-eslint/utils": "8.48.0",
"@typescript-eslint/visitor-keys": "8.48.0",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@@ -1493,7 +1737,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.47.0",
"@typescript-eslint/parser": "^8.48.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
@@ -1509,16 +1753,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz",
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz",
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0",
"@typescript-eslint/typescript-estree": "8.47.0",
"@typescript-eslint/visitor-keys": "8.47.0",
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
"@typescript-eslint/typescript-estree": "8.48.0",
"@typescript-eslint/visitor-keys": "8.48.0",
"debug": "^4.3.4"
},
"engines": {
@@ -1534,14 +1778,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz",
"integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==",
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz",
"integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.47.0",
"@typescript-eslint/types": "^8.47.0",
"@typescript-eslint/tsconfig-utils": "^8.48.0",
"@typescript-eslint/types": "^8.48.0",
"debug": "^4.3.4"
},
"engines": {
@@ -1556,14 +1800,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
"integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz",
"integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.47.0",
"@typescript-eslint/visitor-keys": "8.47.0"
"@typescript-eslint/types": "8.48.0",
"@typescript-eslint/visitor-keys": "8.48.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1574,9 +1818,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz",
"integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==",
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz",
"integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1591,15 +1835,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz",
"integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==",
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz",
"integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.47.0",
"@typescript-eslint/typescript-estree": "8.47.0",
"@typescript-eslint/utils": "8.47.0",
"@typescript-eslint/types": "8.48.0",
"@typescript-eslint/typescript-estree": "8.48.0",
"@typescript-eslint/utils": "8.48.0",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@@ -1616,9 +1860,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
"integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz",
"integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1630,21 +1874,20 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz",
"integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==",
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz",
"integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.47.0",
"@typescript-eslint/tsconfig-utils": "8.47.0",
"@typescript-eslint/types": "8.47.0",
"@typescript-eslint/visitor-keys": "8.47.0",
"@typescript-eslint/project-service": "8.48.0",
"@typescript-eslint/tsconfig-utils": "8.48.0",
"@typescript-eslint/types": "8.48.0",
"@typescript-eslint/visitor-keys": "8.48.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.1.0"
},
"engines": {
@@ -1668,36 +1911,6 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -1728,16 +1941,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz",
"integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==",
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz",
"integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0",
"@typescript-eslint/typescript-estree": "8.47.0"
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
"@typescript-eslint/typescript-estree": "8.48.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1752,13 +1965,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
"integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz",
"integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.47.0",
"@typescript-eslint/types": "8.48.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -2358,9 +2571,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.30",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz",
"integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==",
"version": "2.8.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2523,9 +2736,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001756",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz",
"integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==",
"version": "1.0.30001757",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
"funding": [
{
"type": "opencollective",
@@ -2806,6 +3019,12 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -2972,9 +3191,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.259",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz",
"integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==",
"version": "1.5.262",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz",
"integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==",
"dev": true,
"license": "ISC"
},
@@ -4271,6 +4490,18 @@
"node": ">= 0.4"
}
},
"node_modules/intl-messageformat": {
"version": "10.7.18",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
"integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",
"license": "BSD-3-Clause",
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"@formatjs/fast-memoize": "2.2.7",
"@formatjs/icu-messageformat-parser": "2.11.4",
"tslib": "^2.8.0"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -5634,9 +5865,9 @@
"license": "MIT"
},
"node_modules/music-metadata": {
"version": "11.10.2",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.10.2.tgz",
"integrity": "sha512-sGbF+Si+GqYJGO6qQDyfvesfxb1M49m0QjLLkGR5zoRlPaZtHRvo8DDI5R/vySJVtUzTQ6Lwfd7nspYpmmInsA==",
"version": "11.10.3",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.10.3.tgz",
"integrity": "sha512-j0g/x4cNNZW6I5gdcPAY+GFkJY9WHTpkFDMBJKQLxJQyvSfQbXm57fTE3haGFFuOzCgtsTd4Plwc49Sn9RacDQ==",
"funding": [
{
"type": "github",
@@ -5704,6 +5935,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
@@ -5756,6 +5996,91 @@
}
}
},
"node_modules/next-intl": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.5.6.tgz",
"integrity": "sha512-LD1mM9HL44NGqDus3cpIE8wqRU87GWf7rdy1g7UHceT9KJvvjER/jlmIRt3GHaoOiln16K4IbHpO2ZI6jiqiDQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@swc/core": "^1.15.2",
"negotiator": "^1.0.0",
"next-intl-swc-plugin-extractor": "^4.5.6",
"po-parser": "^1.0.2",
"use-intl": "^4.5.6"
},
"peerDependencies": {
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0",
"typescript": "^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/next-intl-swc-plugin-extractor": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.5.6.tgz",
"integrity": "sha512-ApB3wGYqni8lks90UuaslnCK4a+q8I6ajEafSpknN6RDrs2hUwNuWVrjKhOuhLqNLn4kBKl+Zi5c0WKpL968ag==",
"license": "MIT"
},
"node_modules/next-intl/node_modules/@swc/core": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.3.tgz",
"integrity": "sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.25"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.3",
"@swc/core-darwin-x64": "1.15.3",
"@swc/core-linux-arm-gnueabihf": "1.15.3",
"@swc/core-linux-arm64-gnu": "1.15.3",
"@swc/core-linux-arm64-musl": "1.15.3",
"@swc/core-linux-x64-gnu": "1.15.3",
"@swc/core-linux-x64-musl": "1.15.3",
"@swc/core-win32-arm64-msvc": "1.15.3",
"@swc/core-win32-ia32-msvc": "1.15.3",
"@swc/core-win32-x64-msvc": "1.15.3"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
@@ -6092,6 +6417,12 @@
"pathe": "^2.0.3"
}
},
"node_modules/po-parser": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-1.0.2.tgz",
"integrity": "sha512-yTIQL8PZy7V8c0psPoJUx7fayez+Mo/53MZgX9MPuPHx+Dt+sRPNuRbI+6Oqxnddhkd68x4Nlgon/zizL1Xg+w==",
"license": "MIT"
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -7284,16 +7615,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.47.0.tgz",
"integrity": "sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==",
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz",
"integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"@typescript-eslint/typescript-estree": "8.47.0",
"@typescript-eslint/utils": "8.47.0"
"@typescript-eslint/eslint-plugin": "8.48.0",
"@typescript-eslint/parser": "8.48.0",
"@typescript-eslint/typescript-estree": "8.48.0",
"@typescript-eslint/utils": "8.48.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7508,6 +7839,20 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-intl": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.5.6.tgz",
"integrity": "sha512-SzxrUH/X3LatVcgWVqz8ifoBK01LC3fzc8Y29Vj0QfrjLIXfGwxvJ3aapyWumBIIHsZmCR0Rx5FpKDWCc9JiOg==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "^2.2.0",
"@schummar/icu-type-parser": "1.21.5",
"intl-messageformat": "^10.5.14"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -7672,9 +8017,9 @@
}
},
"node_modules/zod": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
"funding": {

View File

@@ -1,6 +1,6 @@
{
"name": "hoerdle",
"version": "0.1.0",
"version": "0.1.0.15",
"private": true,
"scripts": {
"dev": "next dev",
@@ -14,10 +14,12 @@
"driver.js": "^1.4.0",
"music-metadata": "^11.10.2",
"next": "16.0.3",
"next-intl": "^4.5.6",
"prisma": "^6.19.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0"
"react-markdown": "^10.1.0",
"unist-util-visit-parents": "^6.0.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
@@ -25,6 +27,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.8.32",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"typescript": "^5"

View File

@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "Genre" ADD COLUMN "nameI18n" JSONB;
ALTER TABLE "Genre" ADD COLUMN "subtitleI18n" JSONB;
-- AlterTable
ALTER TABLE "News" ADD COLUMN "contentI18n" JSONB;
ALTER TABLE "News" ADD COLUMN "titleI18n" JSONB;
-- AlterTable
ALTER TABLE "Special" ADD COLUMN "nameI18n" JSONB;
ALTER TABLE "Special" ADD COLUMN "subtitleI18n" JSONB;

View File

@@ -0,0 +1,60 @@
/*
Warnings:
- You are about to drop the column `nameI18n` on the `Genre` table. All the data in the column will be lost.
- You are about to drop the column `subtitleI18n` on the `Genre` table. All the data in the column will be lost.
- You are about to alter the column `name` on the `Genre` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
- You are about to alter the column `subtitle` on the `Genre` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
- You are about to drop the column `contentI18n` on the `News` table. All the data in the column will be lost.
- You are about to drop the column `titleI18n` on the `News` table. All the data in the column will be lost.
- You are about to alter the column `content` on the `News` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
- You are about to alter the column `title` on the `News` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
- You are about to drop the column `nameI18n` on the `Special` table. All the data in the column will be lost.
- You are about to drop the column `subtitleI18n` on the `Special` table. All the data in the column will be lost.
- You are about to alter the column `name` on the `Special` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
- You are about to alter the column `subtitle` on the `Special` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Genre" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" JSONB NOT NULL,
"subtitle" JSONB,
"active" BOOLEAN NOT NULL DEFAULT true
);
INSERT INTO "new_Genre" ("active", "id", "name", "subtitle") SELECT "active", "id", "nameI18n", "subtitleI18n" FROM "Genre";
DROP TABLE "Genre";
ALTER TABLE "new_Genre" RENAME TO "Genre";
CREATE TABLE "new_News" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" JSONB NOT NULL,
"content" JSONB NOT NULL,
"author" TEXT,
"publishedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"featured" BOOLEAN NOT NULL DEFAULT false,
"specialId" INTEGER,
CONSTRAINT "News_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_News" ("author", "content", "featured", "id", "publishedAt", "specialId", "title", "updatedAt") SELECT "author", "contentI18n", "featured", "id", "publishedAt", "specialId", "titleI18n", "updatedAt" FROM "News";
DROP TABLE "News";
ALTER TABLE "new_News" RENAME TO "News";
CREATE INDEX "News_publishedAt_idx" ON "News"("publishedAt");
CREATE TABLE "new_Special" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" JSONB NOT NULL,
"subtitle" JSONB,
"maxAttempts" INTEGER NOT NULL DEFAULT 7,
"unlockSteps" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"launchDate" DATETIME,
"endDate" DATETIME,
"curator" TEXT
);
INSERT INTO "new_Special" ("createdAt", "curator", "endDate", "id", "launchDate", "maxAttempts", "name", "subtitle", "unlockSteps") SELECT "createdAt", "curator", "endDate", "id", "launchDate", "maxAttempts", "nameI18n", "subtitleI18n", "unlockSteps" FROM "Special";
DROP TABLE "Special";
ALTER TABLE "new_Special" RENAME TO "Special";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -28,8 +28,8 @@ model Song {
model Genre {
id Int @id @default(autoincrement())
name String @unique
subtitle String?
name Json // Multilingual: { "de": "Rock", "en": "Rock" }
subtitle Json? // Multilingual
active Boolean @default(true)
songs Song[]
dailyPuzzles DailyPuzzle[]
@@ -37,8 +37,8 @@ model Genre {
model Special {
id Int @id @default(autoincrement())
name String @unique
subtitle String?
name Json // Multilingual
subtitle Json? // Multilingual
maxAttempts Int @default(7)
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
createdAt DateTime @default(now())
@@ -77,8 +77,8 @@ model DailyPuzzle {
model News {
id Int @id @default(autoincrement())
title String
content String // Markdown format
title Json // Multilingual
content Json // Multilingual
author String? // Optional: curator/admin name
publishedAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -1,32 +1,30 @@
import { NextResponse } from 'next/server';
import createMiddleware from 'next-intl/middleware';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const i18nMiddleware = createMiddleware({
locales: ['de', 'en'],
defaultLocale: 'de',
// Wir nutzen überall Locale-Präfixe (`/de`, `/en`)
localePrefix: 'always'
});
// Security Headers
export default function proxy(request: NextRequest) {
// 1. i18n-Routing
const response = i18nMiddleware(request);
// 2. Security-Header ergänzen
const headers = response.headers;
// Prevent clickjacking
headers.set('X-Frame-Options', 'SAMEORIGIN');
// XSS Protection (legacy but still useful)
headers.set('X-XSS-Protection', '1; mode=block');
// Prevent MIME type sniffing
headers.set('X-Content-Type-Options', 'nosniff');
// Referrer Policy
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions Policy (restrict features)
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// Content Security Policy
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me", // Next.js requires unsafe-inline/eval
"style-src 'self' 'unsafe-inline'", // Allow inline styles
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me",
"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",
@@ -38,15 +36,8 @@ export function middleware(request: NextRequest) {
return response;
}
// Apply middleware to all routes
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!_next/static|_next/image|favicon.ico).*)',
],
// Empfohlener Matcher aus der next-intl Doku:
// alle Routen außer _next, API und statischen Dateien
matcher: ['/((?!api|_next|.*\\..*).*)']
};

View File

@@ -14,5 +14,10 @@ npx prisma migrate resolve --applied "20251123083856_add_rating_system"
npx prisma migrate resolve --applied "20251123140527_add_subtitles"
npx prisma migrate resolve --applied "20251123181922_add_release_year"
npx prisma migrate resolve --applied "20251123204000_fix_cascade_delete"
npx prisma migrate resolve --applied "20251124182259_add_exclude_from_global"
npx prisma migrate resolve --applied "20251124231438_add_genre_active_field"
npx prisma migrate resolve --applied "20251125101602_add_news_model"
npx prisma migrate resolve --applied "20251128131405_add_i18n_columns"
npx prisma migrate resolve --applied "20251128132806_switch_to_json_columns"
echo "✅ Baseline complete! Restart the container to apply migrations normally."

View File

@@ -9,11 +9,23 @@ fi
echo "Starting deployment..."
# Run migrations
# Run migrations with fallback to baseline if needed
echo "Running database migrations..."
npx prisma migrate deploy
if ! npx prisma migrate deploy; then
echo "⚠️ Migration failed, attempting to baseline existing database..."
if [ -f /app/scripts/baseline-migrations.sh ]; then
sh /app/scripts/baseline-migrations.sh
echo "Retrying migrations after baseline..."
npx prisma migrate deploy || {
echo "❌ Migration failed even after baseline. Please check your database."
exit 1
}
else
echo "❌ ERROR: Migration failed and baseline script not found at /app/scripts/baseline-migrations.sh"
exit 1
fi
fi
echo "✅ Migrations completed successfully"
# Start the application
echo "Starting application..."

View File

@@ -43,10 +43,22 @@ async function restoreSongs() {
// Simple normalization
const normalizedGenre = genreName.trim();
// Upsert genre (we can't use upsert easily with connect, so find or create first)
let genre = await prisma.genre.findUnique({ where: { name: normalizedGenre } });
// Find genre by checking all genres (name is now JSON)
const allGenres = await prisma.genre.findMany();
let genre = allGenres.find(g => {
const name = g.name as any;
return (typeof name === 'string' && name === normalizedGenre) ||
(typeof name === 'object' && (name.de === normalizedGenre || name.en === normalizedGenre));
});
if (!genre) {
genre = await prisma.genre.create({ data: { name: normalizedGenre } });
// Create with JSON structure
genre = await prisma.genre.create({
data: {
name: { de: normalizedGenre, en: normalizedGenre },
active: true
}
});
console.log(`Created genre: ${normalizedGenre}`);
}
genreConnect.push({ id: genre.id });

View File

@@ -0,0 +1,31 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Verifying i18n migration...');
const genre = await prisma.genre.findFirst();
if (!genre) {
console.log('No genres found to verify.');
return;
}
console.log('First genre:', JSON.stringify(genre, null, 2));
if (typeof genre.name === 'object' && genre.name !== null && 'de' in (genre.name as any)) {
console.log('SUCCESS: Genre name is a JSON object with "de" key.');
} else {
console.error('FAILURE: Genre name is NOT in expected format:', genre.name);
process.exit(1);
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});