Compare commits
20 Commits
0877842107
...
i18n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25a79230a8 | ||
|
|
0182db69b5 | ||
|
|
794e3fd74a | ||
|
|
d874682764 | ||
|
|
771d0d06f3 | ||
|
|
9df9a808bf | ||
|
|
5da78c926d | ||
|
|
120ffaaf2c | ||
|
|
50511f11ac | ||
|
|
d69ac28bb3 | ||
|
|
7a65c58214 | ||
|
|
1a8177430d | ||
|
|
0ebb61515d | ||
|
|
dede11d22b | ||
|
|
4b96b95bff | ||
|
|
89fb296564 | ||
|
|
301dce4c97 | ||
|
|
b66bab48bd | ||
|
|
fea8384e60 | ||
|
|
de8813da3e |
28
Dockerfile
28
Dockerfile
@@ -40,6 +40,34 @@ ENV NEXT_TELEMETRY_DISABLED 1
|
|||||||
ENV DATABASE_URL="file:./dev.db"
|
ENV DATABASE_URL="file:./dev.db"
|
||||||
RUN node_modules/.bin/prisma generate
|
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
|
RUN npm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
|
|||||||
349
I18N.md
Normal file
349
I18N.md
Normal 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" }`
|
||||||
|
|
||||||
|
## Middleware
|
||||||
|
|
||||||
|
Die Middleware (`middleware.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 `middleware.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'];`
|
||||||
|
- `middleware.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)
|
||||||
|
|
||||||
41
README.md
41
README.md
@@ -4,6 +4,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
|
|
||||||
## Features
|
## 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.
|
- **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).
|
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
|
||||||
- **Admin Dashboard:**
|
- **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.
|
- **Markdown Support:** Formatierung von Texten, Links und Listen.
|
||||||
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
|
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
|
||||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
- **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.
|
- 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
|
## 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.
|
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
|
```bash
|
||||||
npm run dev
|
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
|
## Deployment mit Docker
|
||||||
|
|
||||||
Das Projekt ist für den Betrieb mit Docker optimiert.
|
Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||||
|
|
||||||
|
👉 **[White Labeling mit Docker? Hier klicken!](WHITE_LABEL.md#docker-deployment)**
|
||||||
|
|
||||||
1. **Vorbereitung:**
|
1. **Vorbereitung:**
|
||||||
Kopiere die Beispiel-Konfiguration:
|
Kopiere die Beispiel-Konfiguration:
|
||||||
```bash
|
```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.
|
- Beim Start des Containers wird automatisch ein Migrations-Skript ausgeführt, das fehlende Cover-Bilder aus den MP3s extrahiert.
|
||||||
|
|
||||||
4. **Admin-Zugang:**
|
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.)
|
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
||||||
|
|
||||||
5. **Special Curation & Scheduling verwenden:**
|
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
|
### Genre-spezifische Einbindung
|
||||||
|
|
||||||
Einzelne Genres können direkt eingebunden werden:
|
Einzelne Genres können direkt eingebunden werden (mit Locale-Präfix):
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- Rock Genre -->
|
<!-- Rock Genre (Deutsch) -->
|
||||||
<iframe
|
<iframe
|
||||||
src="https://hoerdle.elpatron.me/Rock"
|
src="https://hoerdle.elpatron.me/de/Rock"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="800"
|
height="800"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
@@ -213,9 +235,9 @@ Einzelne Genres können direkt eingebunden werden:
|
|||||||
title="Hördle Rock Quiz">
|
title="Hördle Rock Quiz">
|
||||||
</iframe>
|
</iframe>
|
||||||
|
|
||||||
<!-- Pop Genre -->
|
<!-- Pop Genre (Englisch) -->
|
||||||
<iframe
|
<iframe
|
||||||
src="https://hoerdle.elpatron.me/Pop"
|
src="https://hoerdle.elpatron.me/en/Pop"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="800"
|
height="800"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
@@ -229,8 +251,9 @@ Einzelne Genres können direkt eingebunden werden:
|
|||||||
Auch thematische Specials können direkt eingebettet werden:
|
Auch thematische Specials können direkt eingebettet werden:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
|
<!-- Weihnachtslieder (Deutsch) -->
|
||||||
<iframe
|
<iframe
|
||||||
src="https://hoerdle.elpatron.me/special/Weihnachtslieder"
|
src="https://hoerdle.elpatron.me/de/special/Weihnachtslieder"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="800"
|
height="800"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
|
|||||||
99
WHITE_LABEL.md
Normal file
99
WHITE_LABEL.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
134
app/[locale]/[genre]/page.tsx
Normal file
134
app/[locale]/[genre]/page.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
2242
app/[locale]/admin/page.tsx
Normal file
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
74
app/[locale]/layout.tsx
Normal 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
138
app/[locale]/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
app/[locale]/special/[name]/page.tsx
Normal file
125
app/[locale]/special/[name]/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,14 +98,14 @@ export async function DELETE(request: Request) {
|
|||||||
where: { id: puzzle.specialId }
|
where: { id: puzzle.specialId }
|
||||||
});
|
});
|
||||||
if (special) {
|
if (special) {
|
||||||
newPuzzle = await getOrCreateSpecialPuzzle(special.name);
|
newPuzzle = await getOrCreateSpecialPuzzle(special);
|
||||||
}
|
}
|
||||||
} else if (puzzle.genreId) {
|
} else if (puzzle.genreId) {
|
||||||
const genre = await prisma.genre.findUnique({
|
const genre = await prisma.genre.findUnique({
|
||||||
where: { id: puzzle.genreId }
|
where: { id: puzzle.genreId }
|
||||||
});
|
});
|
||||||
if (genre) {
|
if (genre) {
|
||||||
newPuzzle = await getOrCreateDailyPuzzle(genre.name);
|
newPuzzle = await getOrCreateDailyPuzzle(genre);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newPuzzle = await getOrCreateDailyPuzzle(null);
|
newPuzzle = await getOrCreateDailyPuzzle(null);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { readFile, stat } from 'fs/promises';
|
import { stat } from 'fs/promises';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -30,24 +31,106 @@ export async function GET(
|
|||||||
return new NextResponse('Forbidden', { status: 403 });
|
return new NextResponse('Forbidden', { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
const stats = await stat(filePath);
|
||||||
|
const fileSize = stats.size;
|
||||||
|
const range = request.headers.get('range');
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
const parts = range.replace(/bytes=/, "").split("-");
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||||
|
const chunksize = (end - start) + 1;
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath, { start, end });
|
||||||
|
|
||||||
|
// Convert Node stream to Web stream
|
||||||
|
|
||||||
|
const readable = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
let isClosed = false;
|
||||||
|
|
||||||
|
stream.on('data', (chunk: any) => {
|
||||||
|
if (isClosed) return;
|
||||||
try {
|
try {
|
||||||
await stat(filePath);
|
controller.enqueue(chunk);
|
||||||
} catch {
|
} catch (e) {
|
||||||
return new NextResponse('File not found', { status: 404 });
|
isClosed = true;
|
||||||
|
stream.destroy();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Read file
|
stream.on('end', () => {
|
||||||
const fileBuffer = await readFile(filePath);
|
if (isClosed) return;
|
||||||
|
isClosed = true;
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
// Return with proper headers
|
stream.on('error', (err: any) => {
|
||||||
return new NextResponse(fileBuffer, {
|
if (isClosed) return;
|
||||||
|
isClosed = true;
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(readable, {
|
||||||
|
status: 206,
|
||||||
headers: {
|
headers: {
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': chunksize.toString(),
|
||||||
|
'Content-Type': 'audio/mpeg',
|
||||||
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
|
||||||
|
// Convert Node stream to Web stream
|
||||||
|
const readable = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(readable, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Length': fileSize.toString(),
|
||||||
'Content-Type': 'audio/mpeg',
|
'Content-Type': 'audio/mpeg',
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error serving audio file:', error);
|
console.error('Error serving audio file:', error);
|
||||||
return new NextResponse('Internal Server Error', { status: 500 });
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -83,7 +84,8 @@ export async function POST(request: Request) {
|
|||||||
// Process each song in this batch
|
// Process each song in this batch
|
||||||
for (const song of uncategorizedSongs) {
|
for (const song of uncategorizedSongs) {
|
||||||
try {
|
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.
|
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
|
// Filter to only valid genres and get their IDs
|
||||||
const genreIds = allGenres
|
const genreIds = allGenres
|
||||||
.filter(g => suggestedGenreNames.includes(g.name))
|
.filter(g => suggestedGenreNames.includes(getLocalizedValue(g.name, 'de')))
|
||||||
.map(g => g.id)
|
.map(g => g.id)
|
||||||
.slice(0, 3); // Max 3 genres
|
.slice(0, 3); // Max 3 genres
|
||||||
|
|
||||||
@@ -160,7 +162,7 @@ Your response:`;
|
|||||||
title: song.title,
|
title: song.title,
|
||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
assignedGenres: suggestedGenreNames.filter(name =>
|
assignedGenres: suggestedGenreNames.filter(name =>
|
||||||
allGenres.some(g => g.name === name)
|
allGenres.some(g => getLocalizedValue(g.name, 'de') === name)
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
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) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const genreName = searchParams.get('genre');
|
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) {
|
if (!puzzle) {
|
||||||
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });
|
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const locale = searchParams.get('locale');
|
||||||
|
|
||||||
const genres = await prisma.genre.findMany({
|
const genres = await prisma.genre.findMany({
|
||||||
orderBy: { name: 'asc' },
|
// orderBy: { name: 'asc' }, // Cannot sort by JSON field easily in SQLite
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { songs: true }
|
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);
|
return NextResponse.json(genres);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching genres:', error);
|
console.error('Error fetching genres:', error);
|
||||||
@@ -29,14 +45,18 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const { name, subtitle, active } = await request.json();
|
const { name, subtitle, active } = await request.json();
|
||||||
|
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name) {
|
||||||
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
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({
|
const genre = await prisma.genre.create({
|
||||||
data: {
|
data: {
|
||||||
name: name.trim(),
|
name: nameData,
|
||||||
subtitle: subtitle ? subtitle.trim() : null,
|
subtitle: subtitleData,
|
||||||
active: active !== undefined ? active : true
|
active: active !== undefined ? active : true
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -83,13 +103,14 @@ export async function PUT(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
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({
|
const genre = await prisma.genre.update({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: {
|
data: updateData,
|
||||||
...(name && { name: name.trim() }),
|
|
||||||
subtitle: subtitle ? subtitle.trim() : null,
|
|
||||||
...(active !== undefined && { active })
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(genre);
|
return NextResponse.json(genre);
|
||||||
|
|||||||
5
app/api/health/route.ts
Normal file
5
app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok' }, { status: 200 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export async function GET(request: Request) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = parseInt(searchParams.get('limit') || '10');
|
const limit = parseInt(searchParams.get('limit') || '10');
|
||||||
const featuredOnly = searchParams.get('featured') === 'true';
|
const featuredOnly = searchParams.get('featured') === 'true';
|
||||||
|
const locale = searchParams.get('locale');
|
||||||
|
|
||||||
const where = featuredOnly ? { featured: true } : {};
|
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);
|
return NextResponse.json(news);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching news:', 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({
|
const news = await prisma.news.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title: titleData,
|
||||||
content,
|
content: contentData,
|
||||||
author: author || null,
|
author: author || null,
|
||||||
featured: featured || false,
|
featured: featured || false,
|
||||||
specialId: specialId || null
|
specialId: specialId || null
|
||||||
@@ -93,8 +112,8 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData: any = {};
|
const updateData: any = {};
|
||||||
if (title !== undefined) updateData.title = title;
|
if (title !== undefined) updateData.title = typeof title === 'string' ? { de: title, en: title } : title;
|
||||||
if (content !== undefined) updateData.content = content;
|
if (content !== undefined) updateData.content = typeof content === 'string' ? { de: content, en: content } : content;
|
||||||
if (author !== undefined) updateData.author = author || null;
|
if (author !== undefined) updateData.author = author || null;
|
||||||
if (featured !== undefined) updateData.featured = featured;
|
if (featured !== undefined) updateData.featured = featured;
|
||||||
if (specialId !== undefined) updateData.specialId = specialId || null;
|
if (specialId !== undefined) updateData.specialId = specialId || null;
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
import { PrismaClient, Special } from '@prisma/client';
|
import { PrismaClient, Special } from '@prisma/client';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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({
|
const specials = await prisma.special.findMany({
|
||||||
orderBy: { name: 'asc' },
|
// orderBy: { name: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { songs: true }
|
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);
|
return NextResponse.json(specials);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,10 +39,15 @@ export async function POST(request: Request) {
|
|||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
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({
|
const special = await prisma.special.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name: nameData,
|
||||||
subtitle: subtitle || null,
|
subtitle: subtitleData,
|
||||||
maxAttempts: Number(maxAttempts),
|
maxAttempts: Number(maxAttempts),
|
||||||
unlockSteps,
|
unlockSteps,
|
||||||
launchDate: launchDate ? new Date(launchDate) : null,
|
launchDate: launchDate ? new Date(launchDate) : null,
|
||||||
@@ -61,17 +80,19 @@ export async function PUT(request: Request) {
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
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({
|
const updated = await prisma.special.update({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: {
|
data: updateData,
|
||||||
...(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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
--foreground-rgb: 0, 0, 0;
|
--foreground-rgb: 0, 0, 0;
|
||||||
--background-start-rgb: 214, 219, 220;
|
--background-start-rgb: 214, 219, 220;
|
||||||
--background-end-rgb: 255, 255, 255;
|
--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 {
|
body {
|
||||||
@@ -51,13 +69,13 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Audio Player */
|
/* Audio Player */
|
||||||
.audio-player {
|
.audio-player {
|
||||||
background: #f3f4f6;
|
background: var(--muted);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
@@ -73,8 +91,8 @@ body {
|
|||||||
width: 3rem;
|
width: 3rem;
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #000;
|
background: var(--primary);
|
||||||
color: #fff;
|
color: var(--primary-foreground);
|
||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -85,19 +103,20 @@ body {
|
|||||||
|
|
||||||
.play-button:hover {
|
.play-button:hover {
|
||||||
background: #333;
|
background: #333;
|
||||||
|
/* Keep for now or add --primary-hover */
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar-container {
|
.progress-bar-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
background: #d1d5db;
|
background: var(--input);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #22c55e;
|
background: var(--success);
|
||||||
transition: width 0.1s linear;
|
transition: width 0.1s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +133,7 @@ body {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
@@ -125,7 +144,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.guess-text {
|
.guess-text {
|
||||||
color: #ef4444;
|
color: var(--danger);
|
||||||
/* Red for wrong */
|
/* Red for wrong */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +154,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.guess-text.correct {
|
.guess-text.correct {
|
||||||
color: #22c55e;
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Input */
|
/* Input */
|
||||||
@@ -148,14 +167,14 @@ body {
|
|||||||
.guess-input {
|
.guess-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--input);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.guess-input:focus {
|
.guess-input:focus {
|
||||||
outline: 2px solid #000;
|
outline: 2px solid var(--ring);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +182,7 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--input);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
max-height: 15rem;
|
max-height: 15rem;
|
||||||
@@ -177,11 +196,11 @@ body {
|
|||||||
.suggestion-item {
|
.suggestion-item {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-item:hover {
|
.suggestion-item:hover {
|
||||||
background: #f3f4f6;
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-title {
|
.suggestion-title {
|
||||||
@@ -190,14 +209,14 @@ body {
|
|||||||
|
|
||||||
.suggestion-artist {
|
.suggestion-artist {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skip-button {
|
.skip-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: var(--accent-gradient);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
@@ -246,7 +265,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-card {
|
.admin-card {
|
||||||
background: #f3f4f6;
|
background: var(--muted);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -265,14 +284,14 @@ body {
|
|||||||
.form-input {
|
.form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--input);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #000;
|
background: var(--primary);
|
||||||
color: #fff;
|
color: var(--primary-foreground);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -292,8 +311,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: #4b5563;
|
background: var(--secondary);
|
||||||
color: #fff;
|
color: var(--secondary-foreground);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -312,8 +331,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: #ef4444;
|
background: var(--danger);
|
||||||
color: #fff;
|
color: var(--danger-foreground);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -337,8 +356,8 @@ body {
|
|||||||
padding: 2rem 1rem 1rem;
|
padding: 2rem 1rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid var(--border);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +366,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-footer a {
|
.app-footer a {
|
||||||
color: #000;
|
color: var(--primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -375,7 +394,7 @@ body {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-grid {
|
.statistics-grid {
|
||||||
@@ -391,7 +410,7 @@ body {
|
|||||||
padding: 0.75rem 0.5rem;
|
padding: 0.75rem 0.5rem;
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: rgba(255, 255, 255, 0.8);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-badge {
|
.stat-badge {
|
||||||
@@ -401,7 +420,7 @@ body {
|
|||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -409,7 +428,7 @@ body {
|
|||||||
.stat-count {
|
.stat-count {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #000;
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tooltip */
|
/* Tooltip */
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { MetadataRoute } from 'next'
|
import type { MetadataRoute } from 'next'
|
||||||
|
import { config } from '@/lib/config'
|
||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: 'Hördle',
|
name: config.appName,
|
||||||
short_name: 'Hördle',
|
short_name: config.appName,
|
||||||
description: 'Daily music guessing game - Guess the song from short audio clips',
|
description: config.appDescription,
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
background_color: '#ffffff',
|
background_color: config.colors.backgroundColor,
|
||||||
theme_color: '#000000',
|
theme_color: config.colors.themeColor,
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: '/favicon.ico',
|
src: '/favicon.ico',
|
||||||
|
|||||||
103
app/page.tsx
103
app/page.tsx
@@ -1,103 +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';
|
|
||||||
|
|
||||||
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 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>
|
|
||||||
|
|
||||||
<NewsSection />
|
|
||||||
|
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,108 +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({ 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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { config } from '@/lib/config';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
export default function AppFooter() {
|
export default function AppFooter() {
|
||||||
@@ -12,14 +13,15 @@ export default function AppFooter() {
|
|||||||
.catch(() => setVersion(''));
|
.catch(() => setVersion(''));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!config.credits.enabled) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
<p>
|
<p>
|
||||||
Vibe coded with ☕ and 🍺 by{' '}
|
{config.credits.text}{' '}
|
||||||
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
<a href={config.credits.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||||
@elpatron@digitalcourage.social
|
{config.credits.linkText}
|
||||||
</a>
|
</a>
|
||||||
{' '}- for personal use among friends only!
|
|
||||||
{version && (
|
{version && (
|
||||||
<>
|
<>
|
||||||
{' '}·{' '}
|
{' '}·{' '}
|
||||||
|
|||||||
@@ -22,17 +22,57 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
|||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
|
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
|
||||||
|
|
||||||
|
const [processedSrc, setProcessedSrc] = useState(src);
|
||||||
|
const [processedUnlockedSeconds, setProcessedUnlockedSeconds] = useState(unlockedSeconds);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[AudioPlayer] MOUNTED');
|
||||||
|
return () => console.log('[AudioPlayer] UNMOUNTED');
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
|
// Check if props changed compared to what we last processed
|
||||||
|
const hasChanged = src !== processedSrc || unlockedSeconds !== processedUnlockedSeconds;
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
audioRef.current.currentTime = startTime;
|
|
||||||
|
let startPos = startTime;
|
||||||
|
|
||||||
|
// If same song but more time unlocked, start from where previous segment ended
|
||||||
|
if (src === processedSrc && unlockedSeconds > processedUnlockedSeconds) {
|
||||||
|
startPos = startTime + processedUnlockedSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPos = startPos;
|
||||||
|
audioRef.current.currentTime = targetPos;
|
||||||
|
|
||||||
|
// Ensure position is set correctly even if browser resets it
|
||||||
|
setTimeout(() => {
|
||||||
|
if (audioRef.current && Math.abs(audioRef.current.currentTime - targetPos) > 0.5) {
|
||||||
|
audioRef.current.currentTime = targetPos;
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setProgress(0);
|
|
||||||
|
// Calculate initial progress
|
||||||
|
const initialElapsed = startPos - startTime;
|
||||||
|
const initialPercent = unlockedSeconds > 0 ? (initialElapsed / unlockedSeconds) * 100 : 0;
|
||||||
|
setProgress(Math.min(initialPercent, 100));
|
||||||
|
|
||||||
setHasPlayedOnce(false); // Reset for new segment
|
setHasPlayedOnce(false); // Reset for new segment
|
||||||
onHasPlayedChange?.(false); // Notify parent
|
onHasPlayedChange?.(false); // Notify parent
|
||||||
|
|
||||||
|
// Update processed state
|
||||||
|
setProcessedSrc(src);
|
||||||
|
setProcessedUnlockedSeconds(unlockedSeconds);
|
||||||
|
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
const playPromise = audioRef.current.play();
|
// Delay play slightly to ensure currentTime sticks
|
||||||
|
setTimeout(() => {
|
||||||
|
const playPromise = audioRef.current?.play();
|
||||||
if (playPromise !== undefined) {
|
if (playPromise !== undefined) {
|
||||||
playPromise
|
playPromise
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -46,9 +86,11 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [src, unlockedSeconds, startTime, autoPlay]);
|
}
|
||||||
|
}, [src, unlockedSeconds, startTime, autoPlay, processedSrc, processedUnlockedSeconds]);
|
||||||
|
|
||||||
// Expose play method to parent component
|
// Expose play method to parent component
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
@@ -148,4 +190,6 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
|||||||
|
|
||||||
AudioPlayer.displayName = 'AudioPlayer';
|
AudioPlayer.displayName = 'AudioPlayer';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default AudioPlayer;
|
export default AudioPlayer;
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { config } from '@/lib/config';
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||||
import GuessInput from './GuessInput';
|
import GuessInput from './GuessInput';
|
||||||
import Statistics from './Statistics';
|
import Statistics from './Statistics';
|
||||||
import { useGameState } from '../lib/gameState';
|
import { useGameState } from '../lib/gameState';
|
||||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
|
||||||
|
// Plausible Analytics
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface GameProps {
|
interface GameProps {
|
||||||
dailyPuzzle: {
|
dailyPuzzle: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -28,10 +37,12 @@ interface GameProps {
|
|||||||
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||||
|
|
||||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
||||||
|
const t = useTranslations('Game');
|
||||||
|
const locale = useLocale();
|
||||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
||||||
const [hasWon, setHasWon] = useState(false);
|
const [hasWon, setHasWon] = useState(false);
|
||||||
const [hasLost, setHasLost] = 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 [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
|
||||||
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
||||||
const [timeUntilNext, setTimeUntilNext] = useState('');
|
const [timeUntilNext, setTimeUntilNext] = useState('');
|
||||||
@@ -76,7 +87,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dailyPuzzle) {
|
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)) {
|
if (ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||||
setHasRated(true);
|
setHasRated(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -87,13 +98,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
if (!dailyPuzzle) return (
|
if (!dailyPuzzle) return (
|
||||||
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||||
<h2>No Puzzle Available</h2>
|
<h2>{t('noPuzzleAvailable')}</h2>
|
||||||
<p>Could not generate a daily puzzle.</p>
|
<p>{t('noPuzzleDescription')}</p>
|
||||||
<p>Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.</p>
|
<p>{t('noPuzzleGenre')}{genre ? ` für Genre "${genre}"` : ''}.</p>
|
||||||
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
|
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>{t('goToAdmin')}</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!gameState) return <div>Loading state...</div>;
|
if (!gameState) return <div>{t('loadingState')}</div>;
|
||||||
|
|
||||||
const handleGuess = (song: any) => {
|
const handleGuess = (song: any) => {
|
||||||
if (isProcessingGuess) return;
|
if (isProcessingGuess) return;
|
||||||
@@ -103,6 +114,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
if (song.id === dailyPuzzle.songId) {
|
if (song.id === dailyPuzzle.songId) {
|
||||||
addGuess(song.title, true);
|
addGuess(song.title, true);
|
||||||
setHasWon(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
|
// Notification sent after year guess or skip
|
||||||
if (!dailyPuzzle.releaseYear) {
|
if (!dailyPuzzle.releaseYear) {
|
||||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
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) {
|
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
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
|
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) {
|
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
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
|
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
|
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
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);
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,6 +211,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
addYearBonus(correct);
|
addYearBonus(correct);
|
||||||
setShowYearModal(false);
|
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
|
// Send notification now that game is fully complete
|
||||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
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 = () => {
|
const handleYearSkip = () => {
|
||||||
skipYearBonus();
|
skipYearBonus();
|
||||||
setShowYearModal(false);
|
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
|
// Send notification now that game is fully complete
|
||||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
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++) {
|
for (let i = 0; i < totalGuesses; i++) {
|
||||||
if (i < gameState.guesses.length) {
|
if (i < gameState.guesses.length) {
|
||||||
if (hasWon && i === gameState.guesses.length - 1) {
|
if (gameState.guesses[i] === 'SKIPPED') {
|
||||||
emojiGrid += '🟩';
|
|
||||||
} else if (gameState.guesses[i] === 'SKIPPED') {
|
|
||||||
emojiGrid += '⬛';
|
emojiGrid += '⬛';
|
||||||
|
} else if (hasWon && i === gameState.guesses.length - 1) {
|
||||||
|
emojiGrid += '🟩';
|
||||||
} else {
|
} else {
|
||||||
emojiGrid += '🟥';
|
emojiGrid += '🟥';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
emojiGrid += '⬜';
|
// If game is lost, fill remaining slots with black squares
|
||||||
|
emojiGrid += hasLost ? '⬛' : '⬜';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const speaker = hasWon ? '🔉' : '🔇';
|
const speaker = hasWon ? '🔉' : '🔇';
|
||||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||||
const genreText = genre ? `${isSpecial ? '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 (genre) {
|
||||||
if (isSpecial) {
|
if (isSpecial) {
|
||||||
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
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);
|
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}`,
|
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
|
||||||
text: text,
|
text: text,
|
||||||
});
|
});
|
||||||
setShareText('✓ Shared!');
|
setShareText(t('shared'));
|
||||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name !== 'AbortError') {
|
if ((err as Error).name !== 'AbortError') {
|
||||||
@@ -222,12 +309,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setShareText('✓ Copied!');
|
setShareText(t('copied'));
|
||||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Clipboard failed:', err);
|
console.error('Clipboard failed:', err);
|
||||||
setShareText('✗ Failed');
|
setShareText(t('shareFailed'));
|
||||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
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);
|
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
||||||
setHasRated(true);
|
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)) {
|
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||||
ratedPuzzles.push(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) {
|
} catch (error) {
|
||||||
console.error('Failed to submit rating', error);
|
console.error('Failed to submit rating', error);
|
||||||
@@ -251,21 +338,24 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
<h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||||
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '-0.5rem', marginBottom: '1rem' }}>
|
<div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
||||||
Next puzzle in: {timeUntilNext}
|
{t('nextPuzzle')}: {timeUntilNext}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="game-board">
|
<main className="game-board">
|
||||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||||
<div className="status-bar">
|
<div id="tour-status" className="status-bar">
|
||||||
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
<span>{t('attempt')} {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||||||
<span>{unlockedSeconds}s unlocked</span>
|
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="tour-score">
|
||||||
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tour-player">
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
ref={audioPlayerRef}
|
ref={audioPlayerRef}
|
||||||
src={dailyPuzzle.audioUrl}
|
src={dailyPuzzle.audioUrl}
|
||||||
@@ -276,6 +366,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
onHasPlayedChange={setHasPlayedAudio}
|
onHasPlayedChange={setHasPlayedAudio}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="guess-list">
|
<div className="guess-list">
|
||||||
{gameState.guesses.map((guess, i) => {
|
{gameState.guesses.map((guess, i) => {
|
||||||
@@ -284,7 +375,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
<div key={i} className="guess-item">
|
<div key={i} className="guess-item">
|
||||||
<span className="guess-number">#{i + 1}</span>
|
<span className="guess-number">#{i + 1}</span>
|
||||||
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
|
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
|
||||||
{isCorrect ? 'Correct!' : guess}
|
{isCorrect ? t('correct') : guess}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -293,15 +384,18 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
{!hasWon && !hasLost && (
|
{!hasWon && !hasLost && (
|
||||||
<>
|
<>
|
||||||
|
<div id="tour-input">
|
||||||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||||||
|
</div>
|
||||||
{gameState.guesses.length < maxAttempts - 1 ? (
|
{gameState.guesses.length < maxAttempts - 1 ? (
|
||||||
<button
|
<button
|
||||||
|
id="tour-controls"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="skip-button"
|
className="skip-button"
|
||||||
>
|
>
|
||||||
{gameState.guesses.length === 0 && !hasPlayedAudio
|
{gameState.guesses.length === 0 && !hasPlayedAudio
|
||||||
? 'Start'
|
? t('start')
|
||||||
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
|
: t('skipWithBonus', { seconds: unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds })
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -313,7 +407,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
boxShadow: '0 4px 15px rgba(245, 87, 108, 0.4)'
|
boxShadow: '0 4px 15px rgba(245, 87, 108, 0.4)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Solve (Give Up)
|
{t('solveGiveUp')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -322,15 +416,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
{(hasWon || hasLost) && (
|
{(hasWon || hasLost) && (
|
||||||
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
||||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||||
{hasWon ? 'You won!' : 'Game Over'}
|
{hasWon ? t('won') : t('lost')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
|
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? 'var(--success)' : 'var(--danger)' }}>
|
||||||
Score: {gameState.score}
|
{t('score')}: {gameState.score}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
|
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: 'var(--muted-foreground)' }}>
|
||||||
<summary>Score Breakdown</summary>
|
<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' }}>
|
<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) => (
|
{gameState.scoreBreakdown.map((item, i) => (
|
||||||
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
||||||
@@ -343,22 +437,22 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</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' }}>
|
<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
|
<img
|
||||||
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
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)' }}
|
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>
|
<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 && (
|
{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%' }}>
|
<audio controls style={{ width: '100%' }}>
|
||||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||||
Your browser does not support the audio element.
|
{t('yourBrowserDoesNotSupport')}
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,20 +499,22 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
margin: '0.5rem 0',
|
margin: '0.5rem 0',
|
||||||
padding: '0.5rem',
|
padding: '0.5rem',
|
||||||
background: '#f3f4f6',
|
background: 'var(--muted)',
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
cursor: 'help'
|
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>
|
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
||||||
|
const t = useTranslations('Game');
|
||||||
const [options, setOptions] = useState<number[]>([]);
|
const [options, setOptions] = useState<number[]>([]);
|
||||||
|
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@@ -447,6 +543,24 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
|||||||
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
||||||
}, [correctYear]);
|
}, [correctYear]);
|
||||||
|
|
||||||
|
const handleGuess = (year: number) => {
|
||||||
|
const correct = year === correctYear;
|
||||||
|
setFeedback({ show: true, correct, guessedYear: year });
|
||||||
|
|
||||||
|
// Close modal after showing feedback
|
||||||
|
setTimeout(() => {
|
||||||
|
onGuess(year);
|
||||||
|
}, 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
setFeedback({ show: true, correct: false });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
onSkip();
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
@@ -470,8 +584,10 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||||||
}}>
|
}}>
|
||||||
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
|
{!feedback.show ? (
|
||||||
<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={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -482,20 +598,20 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
|||||||
{options.map(year => (
|
{options.map(year => (
|
||||||
<button
|
<button
|
||||||
key={year}
|
key={year}
|
||||||
onClick={() => onGuess(year)}
|
onClick={() => handleGuess(year)}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
background: '#f3f4f6',
|
background: 'var(--muted)',
|
||||||
border: '2px solid #e5e7eb',
|
border: '2px solid var(--border)',
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
fontSize: '1.1rem',
|
fontSize: '1.1rem',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: '#374151',
|
color: 'var(--secondary)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s'
|
transition: 'all 0.2s'
|
||||||
}}
|
}}
|
||||||
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
|
onMouseOver={e => e.currentTarget.style.borderColor = 'var(--success)'}
|
||||||
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
onMouseOut={e => e.currentTarget.style.borderColor = 'var(--border)'}
|
||||||
>
|
>
|
||||||
{year}
|
{year}
|
||||||
</button>
|
</button>
|
||||||
@@ -503,34 +619,63 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onSkip}
|
onClick={handleSkip}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: '#6b7280',
|
color: 'var(--muted-foreground)',
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '0.9rem'
|
fontSize: '0.9rem'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Skip Bonus
|
{t('skipBonus')}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '2rem 0' }}>
|
||||||
|
{feedback.guessedYear ? (
|
||||||
|
feedback.correct ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
|
||||||
|
<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: '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: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>{t('skipped')}</h3>
|
||||||
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
|
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
|
||||||
|
const t = useTranslations('Game');
|
||||||
const [hover, setHover] = useState(0);
|
const [hover, setHover] = useState(0);
|
||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
|
|
||||||
if (hasRated) {
|
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 (
|
return (
|
||||||
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
<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' }}>
|
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
||||||
{[...Array(5)].map((_, index) => {
|
{[...Array(5)].map((_, index) => {
|
||||||
const ratingValue = index + 1;
|
const ratingValue = index + 1;
|
||||||
@@ -543,7 +688,7 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '2rem',
|
fontSize: '2rem',
|
||||||
color: ratingValue <= (hover || rating) ? '#ffc107' : '#9ca3af',
|
color: ratingValue <= (hover || rating) ? 'var(--warning)' : 'var(--muted-foreground)',
|
||||||
transition: 'color 0.2s',
|
transition: 'color 0.2s',
|
||||||
padding: '0 0.25rem'
|
padding: '0 0.25rem'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface Song {
|
interface Song {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,6 +15,7 @@ interface GuessInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
||||||
|
const t = useTranslations('Game');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [songs, setSongs] = useState<Song[]>([]);
|
const [songs, setSongs] = useState<Song[]>([]);
|
||||||
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
|
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
|
||||||
@@ -53,7 +55,7 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={disabled ? "Game Over" : "Know it? Search for the artist / title"}
|
placeholder={disabled ? t('gameOverPlaceholder') : t('knowItSearch')}
|
||||||
className="guess-input"
|
className="guess-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function InstallPrompt() {
|
export default function InstallPrompt() {
|
||||||
|
const t = useTranslations('InstallPrompt');
|
||||||
const [isIOS, setIsIOS] = useState(false);
|
const [isIOS, setIsIOS] = useState(false);
|
||||||
const [isStandalone, setIsStandalone] = useState(false);
|
const [isStandalone, setIsStandalone] = useState(false);
|
||||||
const [showPrompt, setShowPrompt] = 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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||||
<div>
|
<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' }}>
|
<p style={{ fontSize: '0.875rem', color: '#666' }}>
|
||||||
Install the app for a better experience and quick access!
|
{t('installDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -102,7 +104,7 @@ export default function InstallPrompt() {
|
|||||||
|
|
||||||
{isIOS ? (
|
{isIOS ? (
|
||||||
<div style={{ fontSize: '0.875rem', background: '#f3f4f6', padding: '0.75rem', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -118,7 +120,7 @@ export default function InstallPrompt() {
|
|||||||
marginTop: '0.5rem'
|
marginTop: '0.5rem'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Install App
|
{t('installButton')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
|
|||||||
59
components/LanguageSwitcher.tsx
Normal file
59
components/LanguageSwitcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,33 +2,38 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/navigation';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
interface NewsItem {
|
interface NewsItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: any;
|
||||||
content: string;
|
content: any;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
featured: boolean;
|
featured: boolean;
|
||||||
special: {
|
special: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: any;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewsSection() {
|
interface NewsSectionProps {
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsSection({ locale }: NewsSectionProps) {
|
||||||
const [news, setNews] = useState<NewsItem[]>([]);
|
const [news, setNews] = useState<NewsItem[]>([]);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchNews();
|
fetchNews();
|
||||||
}, []);
|
}, [locale]);
|
||||||
|
|
||||||
const fetchNews = async () => {
|
const fetchNews = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/news?limit=3');
|
const res = await fetch(`/api/news?limit=3&locale=${locale}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setNews(data);
|
setNews(data);
|
||||||
@@ -115,7 +120,7 @@ export default function NewsSection() {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#111827'
|
color: '#111827'
|
||||||
}}>
|
}}>
|
||||||
{item.title}
|
{getLocalizedValue(item.title, locale)}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -145,14 +150,14 @@ export default function NewsSection() {
|
|||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/special/${item.special.name}`}
|
href={`/special/${getLocalizedValue(item.special.name, locale)}`}
|
||||||
style={{
|
style={{
|
||||||
color: '#be185d',
|
color: '#be185d',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
★ {item.special.name}
|
★ {getLocalizedValue(item.special.name, locale)}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -187,7 +192,7 @@ export default function NewsSection() {
|
|||||||
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
|
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.content}
|
{getLocalizedValue(item.content, locale)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
112
components/OnboardingTour.tsx
Normal file
112
components/OnboardingTour.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'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');
|
||||||
|
|
||||||
|
if (hasCompletedOnboarding) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const driverObj = driver({
|
||||||
|
showProgress: true,
|
||||||
|
animate: true,
|
||||||
|
allowClose: true,
|
||||||
|
doneBtnText: t('done'),
|
||||||
|
nextBtnText: t('next'),
|
||||||
|
prevBtnText: t('previous'),
|
||||||
|
onDestroyed: () => {
|
||||||
|
localStorage.setItem('hoerdle_onboarding_completed', 'true');
|
||||||
|
},
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
element: '#tour-genres',
|
||||||
|
popover: {
|
||||||
|
title: t('genresSpecials'),
|
||||||
|
description: t('genresSpecialsDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-news',
|
||||||
|
popover: {
|
||||||
|
title: t('news'),
|
||||||
|
description: t('newsDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-title',
|
||||||
|
popover: {
|
||||||
|
title: t('hoerdle'),
|
||||||
|
description: t('hoerdleDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-status',
|
||||||
|
popover: {
|
||||||
|
title: t('attempts'),
|
||||||
|
description: t('attemptsDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-score',
|
||||||
|
popover: {
|
||||||
|
title: t('score'),
|
||||||
|
description: t('scoreDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-player',
|
||||||
|
popover: {
|
||||||
|
title: t('player'),
|
||||||
|
description: t('playerDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-input',
|
||||||
|
popover: {
|
||||||
|
title: t('input'),
|
||||||
|
description: t('inputDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-controls',
|
||||||
|
popover: {
|
||||||
|
title: t('controls'),
|
||||||
|
description: t('controlsDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
driverObj.drive();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { Statistics as StatsType } from '../lib/gameState';
|
import { Statistics as StatsType } from '../lib/gameState';
|
||||||
|
|
||||||
interface StatisticsProps {
|
interface StatisticsProps {
|
||||||
@@ -18,6 +19,7 @@ const BADGES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Statistics({ statistics }: StatisticsProps) {
|
export default function Statistics({ statistics }: StatisticsProps) {
|
||||||
|
const t = useTranslations('Statistics');
|
||||||
const total =
|
const total =
|
||||||
statistics.solvedIn1 +
|
statistics.solvedIn1 +
|
||||||
statistics.solvedIn2 +
|
statistics.solvedIn2 +
|
||||||
@@ -36,19 +38,19 @@ export default function Statistics({ statistics }: StatisticsProps) {
|
|||||||
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
|
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
|
||||||
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
|
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
|
||||||
{ attempts: 7, count: statistics.solvedIn7, badge: BADGES[7] },
|
{ 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 (
|
return (
|
||||||
<div className="statistics-container">
|
<div className="statistics-container">
|
||||||
<h3 className="statistics-title">Your Statistics</h3>
|
<h3 className="statistics-title">{t('yourStatistics')}</h3>
|
||||||
<p className="statistics-total">Total puzzles: {total}</p>
|
<p className="statistics-total">{t('totalPuzzles')}: {total}</p>
|
||||||
<div className="statistics-grid">
|
<div className="statistics-grid">
|
||||||
{stats.map((stat, index) => (
|
{stats.map((stat, index) => (
|
||||||
<div key={index} className="stat-item">
|
<div key={index} className="stat-item">
|
||||||
<div className="stat-badge">{stat.badge}</div>
|
<div className="stat-badge">{stat.badge}</div>
|
||||||
<div className="stat-label">
|
<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>
|
||||||
<div className="stat-count">{stat.count}</div>
|
<div className="stat-count">{stat.count}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,19 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
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
|
user: root
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
@@ -24,6 +37,4 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
# Run migrations and start server (auto-baseline on first run if needed)
|
# docker-entrypoint.sh handles migrations and server startup (with baseline fallback)
|
||||||
command: >
|
|
||||||
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node server.js"
|
|
||||||
|
|||||||
20
i18n/request.ts
Normal file
20
i18n/request.ts
Normal 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
18
lib/config.ts
Normal 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',
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,22 +1,15 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient, Genre, Special } from '@prisma/client';
|
||||||
import { getTodayISOString } from './dateUtils';
|
import { getTodayISOString } from './dateUtils';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
|
||||||
try {
|
try {
|
||||||
const today = getTodayISOString();
|
const today = getTodayISOString();
|
||||||
let genreId: number | null = null;
|
let genreId: number | null = null;
|
||||||
|
|
||||||
if (genreName) {
|
|
||||||
const genre = await prisma.genre.findUnique({
|
|
||||||
where: { name: genreName }
|
|
||||||
});
|
|
||||||
if (genre) {
|
if (genre) {
|
||||||
genreId = genre.id;
|
genreId = genre.id;
|
||||||
} else {
|
|
||||||
return null; // Genre not found
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||||
@@ -27,8 +20,6 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
include: { song: true },
|
include: { song: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!dailyPuzzle) {
|
if (!dailyPuzzle) {
|
||||||
// Get songs available for this genre
|
// Get songs available for this genre
|
||||||
const whereClause = genreId
|
const whereClause = genreId
|
||||||
@@ -45,7 +36,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (allSongs.length === 0) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +71,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
},
|
},
|
||||||
include: { song: true },
|
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) {
|
} catch (e) {
|
||||||
// Handle race condition
|
// Handle race condition
|
||||||
console.log('[Daily Puzzle] Creation failed, trying to fetch again (likely 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,
|
artist: dailyPuzzle.song.artist,
|
||||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||||
releaseYear: dailyPuzzle.song.releaseYear,
|
releaseYear: dailyPuzzle.song.releaseYear,
|
||||||
genre: genreName
|
genre: genre ? genre.name : null
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
const today = getTodayISOString();
|
const today = getTodayISOString();
|
||||||
|
|
||||||
const special = await prisma.special.findUnique({
|
|
||||||
where: { name: specialName }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!special) return null;
|
|
||||||
|
|
||||||
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||||
where: {
|
where: {
|
||||||
date: today,
|
date: today,
|
||||||
@@ -232,7 +217,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
|||||||
artist: dailyPuzzle.song.artist,
|
artist: dailyPuzzle.song.artist,
|
||||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||||
releaseYear: dailyPuzzle.song.releaseYear,
|
releaseYear: dailyPuzzle.song.releaseYear,
|
||||||
special: specialName,
|
special: special.name,
|
||||||
maxAttempts: special.maxAttempts,
|
maxAttempts: special.maxAttempts,
|
||||||
unlockSteps: JSON.parse(special.unlockSteps),
|
unlockSteps: JSON.parse(special.unlockSteps),
|
||||||
startTime: specialSong?.startTime || 0
|
startTime: specialSong?.startTime || 0
|
||||||
|
|||||||
41
lib/i18n.ts
Normal file
41
lib/i18n.ts
Normal 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
9
lib/navigation.ts
Normal 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
|
||||||
|
});
|
||||||
155
messages/de.json
Normal file
155
messages/de.json
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
155
messages/en.json
Normal file
155
messages/en.json
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import createMiddleware from 'next-intl/middleware';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
const i18nMiddleware = createMiddleware({
|
||||||
const response = NextResponse.next();
|
locales: ['de', 'en'],
|
||||||
|
defaultLocale: 'de',
|
||||||
|
// Wir nutzen überall Locale-Präfixe (`/de`, `/en`)
|
||||||
|
localePrefix: 'always'
|
||||||
|
});
|
||||||
|
|
||||||
// Security Headers
|
export default function middleware(request: NextRequest) {
|
||||||
|
// 1. i18n-Routing
|
||||||
|
const response = i18nMiddleware(request);
|
||||||
|
|
||||||
|
// 2. Security-Header ergänzen
|
||||||
const headers = response.headers;
|
const headers = response.headers;
|
||||||
|
|
||||||
// Prevent clickjacking
|
|
||||||
headers.set('X-Frame-Options', 'SAMEORIGIN');
|
headers.set('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
|
||||||
// XSS Protection (legacy but still useful)
|
|
||||||
headers.set('X-XSS-Protection', '1; mode=block');
|
headers.set('X-XSS-Protection', '1; mode=block');
|
||||||
|
|
||||||
// Prevent MIME type sniffing
|
|
||||||
headers.set('X-Content-Type-Options', 'nosniff');
|
headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
|
||||||
// Referrer Policy
|
|
||||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
|
||||||
// Permissions Policy (restrict features)
|
|
||||||
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
|
||||||
// Content Security Policy
|
|
||||||
const csp = [
|
const csp = [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me", // Next.js requires unsafe-inline/eval
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me",
|
||||||
"style-src 'self' 'unsafe-inline'", // Allow inline styles
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data: blob:",
|
"img-src 'self' data: blob:",
|
||||||
"font-src 'self' data:",
|
"font-src 'self' data:",
|
||||||
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
|
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
|
||||||
@@ -38,15 +36,8 @@ export function middleware(request: NextRequest) {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply middleware to all routes
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
// Empfohlener Matcher aus der next-intl Doku:
|
||||||
/*
|
// alle Routen außer _next, API und statischen Dateien
|
||||||
* Match all request paths except for the ones starting with:
|
matcher: ['/((?!api|_next|.*\\..*).*)']
|
||||||
* - _next/static (static files)
|
|
||||||
* - _next/image (image optimization files)
|
|
||||||
* - favicon.ico (favicon file)
|
|
||||||
*/
|
|
||||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
@@ -36,4 +39,4 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
385
package-lock.json
generated
385
package-lock.json
generated
@@ -1,17 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"driver.js": "^1.4.0",
|
||||||
"music-metadata": "^11.10.2",
|
"music-metadata": "^11.10.2",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
|
"next-intl": "^4.5.6",
|
||||||
"prisma": "^6.19.0",
|
"prisma": "^6.19.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
@@ -455,6 +457,66 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -1314,12 +1376,184 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -1329,6 +1563,15 @@
|
|||||||
"tslib": "^2.8.0"
|
"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": {
|
"node_modules/@tokenizer/inflate": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||||
@@ -2805,6 +3048,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": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
||||||
@@ -2939,6 +3188,12 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/driver.js": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -4264,6 +4519,18 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@@ -5697,6 +5964,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/next": {
|
||||||
"version": "16.0.3",
|
"version": "16.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
|
||||||
@@ -5749,6 +6025,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": {
|
"node_modules/node-fetch-native": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
@@ -6085,6 +6446,12 @@
|
|||||||
"pathe": "^2.0.3"
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -7501,6 +7868,20 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/vfile": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -11,8 +11,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"driver.js": "^1.4.0",
|
||||||
"music-metadata": "^11.10.2",
|
"music-metadata": "^11.10.2",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
|
"next-intl": "^4.5.6",
|
||||||
"prisma": "^6.19.0",
|
"prisma": "^6.19.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -28,8 +28,8 @@ model Song {
|
|||||||
|
|
||||||
model Genre {
|
model Genre {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name Json // Multilingual: { "de": "Rock", "en": "Rock" }
|
||||||
subtitle String?
|
subtitle Json? // Multilingual
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
songs Song[]
|
songs Song[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
@@ -37,8 +37,8 @@ model Genre {
|
|||||||
|
|
||||||
model Special {
|
model Special {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name Json // Multilingual
|
||||||
subtitle String?
|
subtitle Json? // Multilingual
|
||||||
maxAttempts Int @default(7)
|
maxAttempts Int @default(7)
|
||||||
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
|
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -77,8 +77,8 @@ model DailyPuzzle {
|
|||||||
|
|
||||||
model News {
|
model News {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
title String
|
title Json // Multilingual
|
||||||
content String // Markdown format
|
content Json // Multilingual
|
||||||
author String? // Optional: curator/admin name
|
author String? // Optional: curator/admin name
|
||||||
publishedAt DateTime @default(now())
|
publishedAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -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 "20251123140527_add_subtitles"
|
||||||
npx prisma migrate resolve --applied "20251123181922_add_release_year"
|
npx prisma migrate resolve --applied "20251123181922_add_release_year"
|
||||||
npx prisma migrate resolve --applied "20251123204000_fix_cascade_delete"
|
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."
|
echo "✅ Baseline complete! Restart the container to apply migrations normally."
|
||||||
|
|||||||
@@ -9,11 +9,23 @@ fi
|
|||||||
|
|
||||||
echo "Starting deployment..."
|
echo "Starting deployment..."
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations with fallback to baseline if needed
|
||||||
echo "Running database migrations..."
|
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
|
# Start the application
|
||||||
echo "Starting application..."
|
echo "Starting application..."
|
||||||
|
|||||||
@@ -43,10 +43,22 @@ async function restoreSongs() {
|
|||||||
// Simple normalization
|
// Simple normalization
|
||||||
const normalizedGenre = genreName.trim();
|
const normalizedGenre = genreName.trim();
|
||||||
|
|
||||||
// Upsert genre (we can't use upsert easily with connect, so find or create first)
|
// Find genre by checking all genres (name is now JSON)
|
||||||
let genre = await prisma.genre.findUnique({ where: { name: normalizedGenre } });
|
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) {
|
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}`);
|
console.log(`Created genre: ${normalizedGenre}`);
|
||||||
}
|
}
|
||||||
genreConnect.push({ id: genre.id });
|
genreConnect.push({ id: genre.id });
|
||||||
|
|||||||
31
scripts/verify-i18n-migration.ts
Normal file
31
scripts/verify-i18n-migration.ts
Normal 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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user