Compare commits
24 Commits
v0.1.0.2
...
50511f11ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50511f11ac | ||
|
|
d69ac28bb3 | ||
|
|
7a65c58214 | ||
|
|
1a8177430d | ||
|
|
0ebb61515d | ||
|
|
dede11d22b | ||
|
|
4b96b95bff | ||
|
|
89fb296564 | ||
|
|
301dce4c97 | ||
|
|
b66bab48bd | ||
|
|
fea8384e60 | ||
|
|
de8813da3e | ||
|
|
0877842107 | ||
|
|
a5cbbffc20 | ||
|
|
ffb7be602f | ||
|
|
1d62aca2fb | ||
|
|
9bf7e72a6c | ||
|
|
f8b5dcf300 | ||
|
|
072158f4ed | ||
|
|
898d2f5959 | ||
|
|
a7aec80f39 | ||
|
|
0e313db2e3 | ||
|
|
3e647cd44b | ||
|
|
54af256e91 |
185
DEBUG_VERSION.md
Normal file
185
DEBUG_VERSION.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Debug Version Display - Remote Server Checklist
|
||||||
|
|
||||||
|
## 1. Überprüfe Git-Tags auf dem Remote-Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Im Projekt-Verzeichnis auf dem Remote-Server
|
||||||
|
cd /path/to/hoerdle
|
||||||
|
|
||||||
|
# Zeige alle Tags
|
||||||
|
git tag -l
|
||||||
|
|
||||||
|
# Zeige aktuellen Tag/Version
|
||||||
|
git describe --tags --always
|
||||||
|
|
||||||
|
# Wenn keine Tags angezeigt werden:
|
||||||
|
git fetch --tags
|
||||||
|
git describe --tags --always
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartetes Ergebnis:** Sollte `v0.1.0.2` oder ähnlich zeigen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Überprüfe die version.txt im Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Zeige den Inhalt der Version-Datei im laufenden Container
|
||||||
|
docker exec hoerdle cat /app/version.txt
|
||||||
|
|
||||||
|
# Sollte die Version zeigen, z.B. "v0.1.0.2"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartetes Ergebnis:** Die aktuelle Version, nicht "unknown" oder "dev"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Überprüfe die Umgebungsvariable im Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Zeige alle Umgebungsvariablen
|
||||||
|
docker exec hoerdle env | grep APP_VERSION
|
||||||
|
|
||||||
|
# Sollte APP_VERSION=v0.1.0.2 oder ähnlich zeigen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartetes Ergebnis:** `APP_VERSION=v0.1.0.2`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Überprüfe die Container-Logs beim Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Zeige die letzten Logs beim Container-Start
|
||||||
|
docker logs hoerdle | head -20
|
||||||
|
|
||||||
|
# Suche speziell nach Version-Ausgaben
|
||||||
|
docker logs hoerdle | grep -i version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartetes Ergebnis:** Eine Zeile wie "App version: v0.1.0.2"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Teste die API direkt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rufe die Version-API auf
|
||||||
|
curl http://localhost:3010/api/version
|
||||||
|
|
||||||
|
# Sollte JSON zurückgeben: {"version":"v0.1.0.2"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartetes Ergebnis:** `{"version":"v0.1.0.2"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Überprüfe wann der Container gebaut wurde
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Zeige Image-Informationen
|
||||||
|
docker images | grep hoerdle
|
||||||
|
|
||||||
|
# Zeige detaillierte Container-Informationen
|
||||||
|
docker inspect hoerdle | grep -i created
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:** Wenn das Image vor deinem letzten Deployment erstellt wurde, wurde es noch nicht neu gebaut!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Überprüfe Build-Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Baue das Image neu und beobachte die Ausgabe
|
||||||
|
docker compose build --no-cache 2>&1 | tee build.log
|
||||||
|
|
||||||
|
# Suche nach der Version-Ausgabe im Build
|
||||||
|
grep -i "Building version" build.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartetes Ergebnis:** Eine Zeile wie "Building version: v0.1.0.2"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Häufige Probleme und Lösungen
|
||||||
|
|
||||||
|
### Problem 1: Tags nicht auf dem Server
|
||||||
|
```bash
|
||||||
|
git fetch --tags
|
||||||
|
git describe --tags --always
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem 2: Container wurde nicht neu gebaut
|
||||||
|
```bash
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem 3: Alte version.txt im Container
|
||||||
|
```bash
|
||||||
|
# Stoppe Container, lösche Image, baue neu
|
||||||
|
docker compose down
|
||||||
|
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem 4: .git Verzeichnis nicht im Build-Context
|
||||||
|
```bash
|
||||||
|
# Überprüfe ob .git existiert
|
||||||
|
ls -la .git
|
||||||
|
|
||||||
|
# Überprüfe .dockerignore (sollte .git NICHT ausschließen)
|
||||||
|
cat .dockerignore 2>/dev/null || echo "Keine .dockerignore Datei"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vollständiger Neustart (wenn nichts anderes hilft)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stoppe alles
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 2. Lösche alte Images
|
||||||
|
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
|
||||||
|
|
||||||
|
# 3. Hole neueste Änderungen und Tags
|
||||||
|
git pull
|
||||||
|
git fetch --tags
|
||||||
|
|
||||||
|
# 4. Überprüfe Version lokal
|
||||||
|
git describe --tags --always
|
||||||
|
|
||||||
|
# 5. Baue komplett neu
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
# 6. Starte Container
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 7. Überprüfe Logs
|
||||||
|
docker logs hoerdle | grep -i version
|
||||||
|
|
||||||
|
# 8. Teste API
|
||||||
|
curl http://localhost:3010/api/version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging-Befehl für alle Checks auf einmal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "=== Git Tags ===" && \
|
||||||
|
git describe --tags --always && \
|
||||||
|
echo -e "\n=== version.txt im Container ===" && \
|
||||||
|
docker exec hoerdle cat /app/version.txt 2>/dev/null || echo "Container läuft nicht oder Datei fehlt" && \
|
||||||
|
echo -e "\n=== APP_VERSION Env ===" && \
|
||||||
|
docker exec hoerdle env | grep APP_VERSION || echo "Variable nicht gesetzt" && \
|
||||||
|
echo -e "\n=== API Response ===" && \
|
||||||
|
curl -s http://localhost:3010/api/version && \
|
||||||
|
echo -e "\n\n=== Container Created ===" && \
|
||||||
|
docker inspect hoerdle | grep -i created | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
Kopiere diesen Befehl und führe ihn auf dem Remote-Server aus. Schicke mir die Ausgabe!
|
||||||
84
DEPLOYMENT.md
Normal file
84
DEPLOYMENT.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
## Automated Deployment
|
||||||
|
|
||||||
|
Use the deployment script for zero-downtime deployments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This script will:
|
||||||
|
1. Create a database backup
|
||||||
|
2. Pull latest changes from git
|
||||||
|
3. Fetch all git tags (for version display)
|
||||||
|
4. Build the new Docker image
|
||||||
|
5. Restart the container with minimal downtime
|
||||||
|
6. Clean up old images
|
||||||
|
|
||||||
|
## Manual Deployment
|
||||||
|
|
||||||
|
If you need to deploy manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest changes
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# Fetch tags (important for version display!)
|
||||||
|
git fetch --tags
|
||||||
|
|
||||||
|
# Build and restart
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Display
|
||||||
|
|
||||||
|
The app displays the current version in the footer. The version is determined as follows:
|
||||||
|
|
||||||
|
1. **During Docker build**: The version is extracted from git tags using `git describe --tags --always`
|
||||||
|
2. **At runtime**: The version is read from `/app/version.txt` and exposed via the `/api/version` endpoint
|
||||||
|
3. **Local development**: The version is extracted directly from git on each request
|
||||||
|
|
||||||
|
### Building with a specific version
|
||||||
|
|
||||||
|
You can override the version during build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build --build-arg APP_VERSION=v1.2.3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
If the version shows as "dev" or "unknown":
|
||||||
|
|
||||||
|
1. Make sure git tags are pushed to the remote repository:
|
||||||
|
```bash
|
||||||
|
git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
2. On the deployment server, fetch the tags:
|
||||||
|
```bash
|
||||||
|
git fetch --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify tags are available:
|
||||||
|
```bash
|
||||||
|
git describe --tags --always
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Rebuild the Docker image:
|
||||||
|
```bash
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
The container includes a health check that monitors the `/api/daily` endpoint. Check the health status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for the "healthy" status in the STATUS column.
|
||||||
12
Dockerfile
12
Dockerfile
@@ -14,14 +14,22 @@ RUN npm ci
|
|||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Accept version as build argument (optional)
|
||||||
|
ARG APP_VERSION=""
|
||||||
|
|
||||||
# Install git to extract version information
|
# Install git to extract version information
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Extract version from git
|
# Extract version: use build arg if provided, otherwise get from git
|
||||||
RUN git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt
|
RUN if [ -n "$APP_VERSION" ]; then \
|
||||||
|
echo "$APP_VERSION" > /tmp/version.txt; \
|
||||||
|
else \
|
||||||
|
git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt; \
|
||||||
|
fi && \
|
||||||
|
echo "Building version: $(cat /tmp/version.txt)"
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -41,7 +41,23 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- Live-Vorschau beim Hovern über die Waveform.
|
- Live-Vorschau beim Hovern über die Waveform.
|
||||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
- Einzelne Segmente zum Testen abspielen.
|
||||||
|
- Einzelne Segmente zum Testen abspielen.
|
||||||
- Manuelle Speicherung mit visueller Bestätigung.
|
- Manuelle Speicherung mit visueller Bestätigung.
|
||||||
|
- **News & Announcements:**
|
||||||
|
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||||
|
- **Markdown Support:** Formatierung von Texten, Links und Listen.
|
||||||
|
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
|
||||||
|
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||||
|
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||||
|
- Verwaltung über das Admin-Dashboard.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
|||||||
67
WHITE_LABEL.md
Normal file
67
WHITE_LABEL.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Game from '@/components/Game';
|
import Game from '@/components/Game';
|
||||||
|
import NewsSection from '@/components/NewsSection';
|
||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
@@ -102,6 +103,7 @@ export default async function GenrePage({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<NewsSection />
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,6 +51,20 @@ interface Song {
|
|||||||
excludeFromGlobal: boolean;
|
excludeFromGlobal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface News {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
author: string | null;
|
||||||
|
publishedAt: string;
|
||||||
|
featured: boolean;
|
||||||
|
specialId: number | null;
|
||||||
|
special: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
@@ -92,6 +106,20 @@ export default function AdminPage() {
|
|||||||
const [editSpecialEndDate, setEditSpecialEndDate] = useState('');
|
const [editSpecialEndDate, setEditSpecialEndDate] = useState('');
|
||||||
const [editSpecialCurator, setEditSpecialCurator] = useState('');
|
const [editSpecialCurator, setEditSpecialCurator] = useState('');
|
||||||
|
|
||||||
|
// News state
|
||||||
|
const [news, setNews] = useState<News[]>([]);
|
||||||
|
const [newNewsTitle, setNewNewsTitle] = useState('');
|
||||||
|
const [newNewsContent, setNewNewsContent] = useState('');
|
||||||
|
const [newNewsAuthor, setNewNewsAuthor] = useState('');
|
||||||
|
const [newNewsFeatured, setNewNewsFeatured] = useState(false);
|
||||||
|
const [newNewsSpecialId, setNewNewsSpecialId] = useState<number | null>(null);
|
||||||
|
const [editingNewsId, setEditingNewsId] = useState<number | null>(null);
|
||||||
|
const [editNewsTitle, setEditNewsTitle] = useState('');
|
||||||
|
const [editNewsContent, setEditNewsContent] = useState('');
|
||||||
|
const [editNewsAuthor, setEditNewsAuthor] = useState('');
|
||||||
|
const [editNewsFeatured, setEditNewsFeatured] = useState(false);
|
||||||
|
const [editNewsSpecialId, setEditNewsSpecialId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Edit state
|
// Edit state
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [editTitle, setEditTitle] = useState('');
|
const [editTitle, setEditTitle] = useState('');
|
||||||
@@ -142,6 +170,8 @@ export default function AdminPage() {
|
|||||||
fetchSongs();
|
fetchSongs();
|
||||||
fetchGenres();
|
fetchGenres();
|
||||||
fetchDailyPuzzles();
|
fetchDailyPuzzles();
|
||||||
|
fetchSpecials();
|
||||||
|
fetchNews();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -156,6 +186,8 @@ export default function AdminPage() {
|
|||||||
fetchSongs();
|
fetchSongs();
|
||||||
fetchGenres();
|
fetchGenres();
|
||||||
fetchDailyPuzzles();
|
fetchDailyPuzzles();
|
||||||
|
fetchSpecials();
|
||||||
|
fetchNews();
|
||||||
} else {
|
} else {
|
||||||
alert('Wrong password');
|
alert('Wrong password');
|
||||||
}
|
}
|
||||||
@@ -394,6 +426,94 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// News functions
|
||||||
|
const fetchNews = async () => {
|
||||||
|
const res = await fetch('/api/news', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setNews(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNews = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newNewsTitle.trim() || !newNewsContent.trim()) return;
|
||||||
|
|
||||||
|
const res = await fetch('/api/news', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: newNewsTitle,
|
||||||
|
content: newNewsContent,
|
||||||
|
author: newNewsAuthor || null,
|
||||||
|
featured: newNewsFeatured,
|
||||||
|
specialId: newNewsSpecialId
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setNewNewsTitle('');
|
||||||
|
setNewNewsContent('');
|
||||||
|
setNewNewsAuthor('');
|
||||||
|
setNewNewsFeatured(false);
|
||||||
|
setNewNewsSpecialId(null);
|
||||||
|
fetchNews();
|
||||||
|
} else {
|
||||||
|
alert('Failed to create news');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditNews = (newsItem: News) => {
|
||||||
|
setEditingNewsId(newsItem.id);
|
||||||
|
setEditNewsTitle(newsItem.title);
|
||||||
|
setEditNewsContent(newsItem.content);
|
||||||
|
setEditNewsAuthor(newsItem.author || '');
|
||||||
|
setEditNewsFeatured(newsItem.featured);
|
||||||
|
setEditNewsSpecialId(newsItem.specialId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEditedNews = async () => {
|
||||||
|
if (editingNewsId === null) return;
|
||||||
|
|
||||||
|
const res = await fetch('/api/news', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: editingNewsId,
|
||||||
|
title: editNewsTitle,
|
||||||
|
content: editNewsContent,
|
||||||
|
author: editNewsAuthor || null,
|
||||||
|
featured: editNewsFeatured,
|
||||||
|
specialId: editNewsSpecialId
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setEditingNewsId(null);
|
||||||
|
fetchNews();
|
||||||
|
} else {
|
||||||
|
alert('Failed to update news');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteNews = async (id: number) => {
|
||||||
|
if (!confirm('Delete this news item?')) return;
|
||||||
|
|
||||||
|
const res = await fetch('/api/news', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
fetchNews();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete news');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Load specials after auth
|
// Load specials after auth
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) fetchSpecials();
|
if (isAuthenticated) fetchSpecials();
|
||||||
@@ -1182,6 +1302,163 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* News Management */}
|
||||||
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage News & Announcements</h2>
|
||||||
|
<form onSubmit={handleCreateNews} style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newNewsTitle}
|
||||||
|
onChange={e => setNewNewsTitle(e.target.value)}
|
||||||
|
placeholder="News Title"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={newNewsContent}
|
||||||
|
onChange={e => setNewNewsContent(e.target.value)}
|
||||||
|
placeholder="Content (Markdown supported)"
|
||||||
|
className="form-input"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newNewsAuthor}
|
||||||
|
onChange={e => setNewNewsAuthor(e.target.value)}
|
||||||
|
placeholder="Author (optional)"
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: '200px' }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={newNewsSpecialId || ''}
|
||||||
|
onChange={e => setNewNewsSpecialId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: '200px' }}
|
||||||
|
>
|
||||||
|
<option value="">No Special Link</option>
|
||||||
|
{specials.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newNewsFeatured}
|
||||||
|
onChange={e => setNewNewsFeatured(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Featured
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn-primary">Add News</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{news.map(newsItem => (
|
||||||
|
<div key={newsItem.id} style={{
|
||||||
|
background: newsItem.featured ? '#fef3c7' : '#f3f4f6',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: newsItem.featured ? '2px solid #f59e0b' : '1px solid #e5e7eb'
|
||||||
|
}}>
|
||||||
|
{editingNewsId === newsItem.id ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editNewsTitle}
|
||||||
|
onChange={e => setEditNewsTitle(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={editNewsContent}
|
||||||
|
onChange={e => setEditNewsContent(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
rows={4}
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editNewsAuthor}
|
||||||
|
onChange={e => setEditNewsAuthor(e.target.value)}
|
||||||
|
placeholder="Author"
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: '200px' }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={editNewsSpecialId || ''}
|
||||||
|
onChange={e => setEditNewsSpecialId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: '200px' }}
|
||||||
|
>
|
||||||
|
<option value="">No Special Link</option>
|
||||||
|
{specials.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editNewsFeatured}
|
||||||
|
onChange={e => setEditNewsFeatured(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Featured
|
||||||
|
</label>
|
||||||
|
<button onClick={saveEditedNews} className="btn-primary">Save</button>
|
||||||
|
<button onClick={() => setEditingNewsId(null)} className="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||||
|
{newsItem.featured && (
|
||||||
|
<span style={{
|
||||||
|
background: '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
padding: '0.125rem 0.375rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
fontSize: '0.625rem',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
⭐ FEATURED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: '600' }}>{newsItem.title}</h3>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#666', marginBottom: '0.5rem' }}>
|
||||||
|
{new Date(newsItem.publishedAt).toLocaleDateString('de-DE')}
|
||||||
|
{newsItem.author && ` • by ${newsItem.author}`}
|
||||||
|
{newsItem.special && ` • ★ ${newsItem.special.name}`}
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{newsItem.content.length > 150
|
||||||
|
? newsItem.content.substring(0, 150) + '...'
|
||||||
|
: newsItem.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem', marginLeft: '1rem' }}>
|
||||||
|
<button onClick={() => startEditNews(newsItem)} className="btn-secondary" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>Edit</button>
|
||||||
|
<button onClick={() => handleDeleteNews(newsItem.id)} className="btn-danger" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{news.length === 0 && (
|
||||||
|
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', padding: '1rem' }}>
|
||||||
|
No news items yet. Create one above!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload Songs</h2>
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload Songs</h2>
|
||||||
<form onSubmit={handleBatchUpload}>
|
<form onSubmit={handleBatchUpload}>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
146
app/api/news/route.ts
Normal file
146
app/api/news/route.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// GET /api/news - Public endpoint to fetch news
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '10');
|
||||||
|
const featuredOnly = searchParams.get('featured') === 'true';
|
||||||
|
|
||||||
|
const where = featuredOnly ? { featured: true } : {};
|
||||||
|
|
||||||
|
const news = await prisma.news.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { publishedAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
special: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(news);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching news:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch news' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/news - Create news (requires auth)
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) {
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, content, author, featured, specialId } = body;
|
||||||
|
|
||||||
|
if (!title || !content) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Title and content are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const news = await prisma.news.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
author: author || null,
|
||||||
|
featured: featured || false,
|
||||||
|
specialId: specialId || null
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
special: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(news, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating news:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to create news' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/news - Update news (requires auth)
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) {
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, title, content, author, featured, specialId } = body;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (title !== undefined) updateData.title = title;
|
||||||
|
if (content !== undefined) updateData.content = content;
|
||||||
|
if (author !== undefined) updateData.author = author || null;
|
||||||
|
if (featured !== undefined) updateData.featured = featured;
|
||||||
|
if (specialId !== undefined) updateData.specialId = specialId || null;
|
||||||
|
|
||||||
|
const news = await prisma.news.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
special: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(news);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating news:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update news' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/news - Delete news (requires auth)
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) {
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id } = body;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.news.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting news:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to delete news' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,32 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
// First check if version is set via environment variable (Docker build)
|
// First check if version file exists (Docker deployment)
|
||||||
|
// Try both /app/version.txt (Docker) and ./version.txt (local)
|
||||||
|
const versionPaths = [
|
||||||
|
'/app/version.txt',
|
||||||
|
join(process.cwd(), 'version.txt')
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const versionFilePath of versionPaths) {
|
||||||
|
if (existsSync(versionFilePath)) {
|
||||||
|
const version = readFileSync(versionFilePath, 'utf-8').trim();
|
||||||
|
if (version && version !== 'unknown') {
|
||||||
|
return NextResponse.json({ version });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check environment variable
|
||||||
if (process.env.APP_VERSION) {
|
if (process.env.APP_VERSION) {
|
||||||
return NextResponse.json({ version: process.env.APP_VERSION });
|
return NextResponse.json({ version: process.env.APP_VERSION });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get the git tag/version
|
// Fallback: try to get from git (local development)
|
||||||
let version = 'dev';
|
let version = 'dev';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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,7 +1,10 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
import { config } from "@/lib/config";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -13,12 +16,12 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Hördle",
|
title: config.appName,
|
||||||
description: "Daily music guessing game - Guess the song from short audio clips",
|
description: config.appDescription,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: "#000000",
|
themeColor: config.colors.themeColor,
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
maximumScale: 1,
|
||||||
@@ -34,6 +37,14 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<Script
|
||||||
|
defer
|
||||||
|
data-domain={config.plausibleDomain}
|
||||||
|
src={config.plausibleScriptSrc}
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
{children}
|
{children}
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
10
app/page.tsx
10
app/page.tsx
@@ -1,4 +1,6 @@
|
|||||||
import Game from '@/components/Game';
|
import Game from '@/components/Game';
|
||||||
|
import NewsSection from '@/components/NewsSection';
|
||||||
|
import OnboardingTour from '@/components/OnboardingTour';
|
||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
@@ -29,7 +31,7 @@ export default async function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<div className="tooltip">
|
<div className="tooltip">
|
||||||
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
|
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
|
||||||
@@ -93,7 +95,13 @@ export default async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="tour-news">
|
||||||
|
<NewsSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||||
|
<OnboardingTour />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Game from '@/components/Game';
|
import Game from '@/components/Game';
|
||||||
|
import NewsSection from '@/components/NewsSection';
|
||||||
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
|
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
@@ -44,7 +45,10 @@ export default async function SpecialPage({ params }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
|
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
|
||||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
const genres = await prisma.genre.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
|
||||||
const activeSpecials = specials.filter(s => {
|
const activeSpecials = specials.filter(s => {
|
||||||
@@ -94,6 +98,7 @@ export default async function SpecialPage({ params }: PageProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<NewsSection />
|
||||||
<Game
|
<Game
|
||||||
dailyPuzzle={dailyPuzzle}
|
dailyPuzzle={dailyPuzzle}
|
||||||
genre={decodedName}
|
genre={decodedName}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
{' '}·{' '}
|
{' '}·{' '}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||||
|
|
||||||
interface AudioPlayerProps {
|
interface AudioPlayerProps {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -9,39 +9,112 @@ interface AudioPlayerProps {
|
|||||||
onPlay?: () => void;
|
onPlay?: () => void;
|
||||||
onReplay?: () => void;
|
onReplay?: () => void;
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
|
onHasPlayedChange?: (hasPlayed: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false }: AudioPlayerProps) {
|
export interface AudioPlayerRef {
|
||||||
|
play: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false, onHasPlayedChange }, ref) => {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
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
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
onPlay?.();
|
onPlay?.();
|
||||||
setHasPlayedOnce(true);
|
setHasPlayedOnce(true);
|
||||||
|
onHasPlayedChange?.(true); // Notify parent
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.log("Autoplay prevented:", error);
|
console.log("Autoplay prevented:", error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [src, unlockedSeconds, startTime, autoPlay]);
|
}
|
||||||
|
}, [src, unlockedSeconds, startTime, autoPlay, processedSrc, processedUnlockedSeconds]);
|
||||||
|
|
||||||
|
// Expose play method to parent component
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
play: () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
const playPromise = audioRef.current.play();
|
||||||
|
if (playPromise !== undefined) {
|
||||||
|
playPromise
|
||||||
|
.then(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
onPlay?.();
|
||||||
|
if (!hasPlayedOnce) {
|
||||||
|
setHasPlayedOnce(true);
|
||||||
|
onHasPlayedChange?.(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error("Play failed:", error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
@@ -56,6 +129,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
|
|||||||
onReplay?.();
|
onReplay?.();
|
||||||
} else {
|
} else {
|
||||||
setHasPlayedOnce(true);
|
setHasPlayedOnce(true);
|
||||||
|
onHasPlayedChange?.(true); // Notify parent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
@@ -112,4 +186,10 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
AudioPlayer.displayName = 'AudioPlayer';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default AudioPlayer;
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { config } from '@/lib/config';
|
||||||
import AudioPlayer from './AudioPlayer';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
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;
|
||||||
@@ -37,6 +45,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const [timeUntilNext, setTimeUntilNext] = useState('');
|
const [timeUntilNext, setTimeUntilNext] = useState('');
|
||||||
const [hasRated, setHasRated] = useState(false);
|
const [hasRated, setHasRated] = useState(false);
|
||||||
const [showYearModal, setShowYearModal] = useState(false);
|
const [showYearModal, setShowYearModal] = useState(false);
|
||||||
|
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
||||||
|
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateCountdown = () => {
|
const updateCountdown = () => {
|
||||||
@@ -74,7 +84,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 {
|
||||||
@@ -101,6 +111,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);
|
||||||
@@ -110,19 +131,54 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => setIsProcessingGuess(false), 500);
|
setTimeout(() => setIsProcessingGuess(false), 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStartAudio = () => {
|
||||||
|
// This will be called when user clicks "Start" button on first attempt
|
||||||
|
// Trigger the audio player to start playing
|
||||||
|
audioPlayerRef.current?.play();
|
||||||
|
setHasPlayedAudio(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
|
// If user hasn't played audio yet on first attempt, start it instead of skipping
|
||||||
|
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
|
||||||
|
handleStartAudio();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLastAction('SKIP');
|
setLastAction('SKIP');
|
||||||
addGuess("SKIPPED", false);
|
addGuess("SKIPPED", false);
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -133,6 +189,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,6 +208,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));
|
||||||
};
|
};
|
||||||
@@ -148,6 +228,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);
|
||||||
};
|
};
|
||||||
@@ -176,7 +270,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||||
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
|
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
|
||||||
|
|
||||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
let shareUrl = `https://${config.domain}`;
|
||||||
if (genre) {
|
if (genre) {
|
||||||
if (isSpecial) {
|
if (isSpecial) {
|
||||||
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
||||||
@@ -185,7 +279,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}\nScore: ${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);
|
||||||
|
|
||||||
@@ -223,10 +317,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);
|
||||||
@@ -236,29 +330,35 @@ 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}
|
Next puzzle in: {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>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||||||
<span>{unlockedSeconds}s unlocked</span>
|
<span>{unlockedSeconds}s 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}
|
||||||
src={dailyPuzzle.audioUrl}
|
src={dailyPuzzle.audioUrl}
|
||||||
unlockedSeconds={unlockedSeconds}
|
unlockedSeconds={unlockedSeconds}
|
||||||
startTime={dailyPuzzle.startTime}
|
startTime={dailyPuzzle.startTime}
|
||||||
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
||||||
onReplay={addReplay}
|
onReplay={addReplay}
|
||||||
|
onHasPlayedChange={setHasPlayedAudio}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="guess-list">
|
<div className="guess-list">
|
||||||
{gameState.guesses.map((guess, i) => {
|
{gameState.guesses.map((guess, i) => {
|
||||||
@@ -276,13 +376,19 @@ 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"
|
||||||
>
|
>
|
||||||
Skip (+{unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)
|
{gameState.guesses.length === 0 && !hasPlayedAudio
|
||||||
|
? 'Start'
|
||||||
|
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -305,11 +411,11 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
{hasWon ? 'You won!' : 'Game Over'}
|
{hasWon ? 'You won!' : 'Game Over'}
|
||||||
</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}
|
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>Score Breakdown</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) => (
|
||||||
@@ -332,9 +438,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
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' }}>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" />
|
||||||
@@ -385,13 +491,13 @@ 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>
|
||||||
);
|
);
|
||||||
@@ -399,6 +505,7 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
|
|||||||
|
|
||||||
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 [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();
|
||||||
@@ -427,6 +534,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',
|
||||||
@@ -450,8 +575,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)' }}>Bonus Round!</h3>
|
||||||
|
<p style={{ marginBottom: '1.5rem', color: 'var(--secondary)' }}>Guess the release year for <strong style={{ color: 'var(--success)' }}>+10 points</strong>!</p>
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -462,20 +589,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>
|
||||||
@@ -483,11 +610,11 @@ 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'
|
||||||
@@ -495,6 +622,34 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
|||||||
>
|
>
|
||||||
Skip Bonus
|
Skip Bonus
|
||||||
</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' }}>Correct!</h3>
|
||||||
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
|
||||||
|
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 Points!</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
|
||||||
|
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>Not quite!</h3>
|
||||||
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>You guessed {feedback.guessedYear}</p>
|
||||||
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>⏭️</div>
|
||||||
|
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>Skipped</h3>
|
||||||
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -505,12 +660,12 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
|||||||
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' }}>Thanks for rating!</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' }}>Rate this puzzle:</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;
|
||||||
@@ -523,7 +678,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'
|
||||||
}}
|
}}
|
||||||
|
|||||||
199
components/NewsSection.tsx
Normal file
199
components/NewsSection.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface NewsItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
author: string | null;
|
||||||
|
publishedAt: string;
|
||||||
|
featured: boolean;
|
||||||
|
special: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsSection() {
|
||||||
|
const [news, setNews] = useState<NewsItem[]>([]);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNews();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchNews = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/news?limit=3');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setNews(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch news:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || news.length === 0) {
|
||||||
|
return null; // Don't show anything if no news
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#f9fafb',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
margin: '1rem auto',
|
||||||
|
maxWidth: '800px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid #e5e7eb'
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>📰 News & Updates</span>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
|
||||||
|
{isExpanded ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0 1rem 1rem 1rem',
|
||||||
|
borderTop: '1px solid #e5e7eb'
|
||||||
|
}}>
|
||||||
|
{news.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 0',
|
||||||
|
borderBottom: index < news.length - 1 ? '1px solid #e5e7eb' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginBottom: '0.25rem'
|
||||||
|
}}>
|
||||||
|
{item.featured && (
|
||||||
|
<span style={{
|
||||||
|
background: '#fef3c7',
|
||||||
|
color: '#92400e',
|
||||||
|
padding: '0.125rem 0.375rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
fontSize: '0.625rem',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
⭐ FEATURED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h3 style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#111827'
|
||||||
|
}}>
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: '#6b7280',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<span>
|
||||||
|
{new Date(item.publishedAt).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{item.author && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>by {item.author}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.special && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<Link
|
||||||
|
href={`/special/${item.special.name}`}
|
||||||
|
style={{
|
||||||
|
color: '#be185d',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
★ {item.special.name}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
className="news-content"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: '#374151',
|
||||||
|
lineHeight: '1.5'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReactMarkdown
|
||||||
|
components={{
|
||||||
|
p: ({ children }) => <p style={{ margin: '0.5rem 0' }}>{children}</p>,
|
||||||
|
a: ({ children, href }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: '#4f46e5', textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
strong: ({ children }) => <strong style={{ fontWeight: '600' }}>{children}</strong>,
|
||||||
|
em: ({ children }) => <em style={{ fontStyle: 'italic' }}>{children}</em>,
|
||||||
|
ul: ({ children }) => <ul style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ul>,
|
||||||
|
ol: ({ children }) => <ol style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ol>,
|
||||||
|
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
components/OnboardingTour.tsx
Normal file
109
components/OnboardingTour.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { driver } from 'driver.js';
|
||||||
|
import 'driver.js/dist/driver.css';
|
||||||
|
|
||||||
|
export default function OnboardingTour() {
|
||||||
|
useEffect(() => {
|
||||||
|
const hasCompletedOnboarding = localStorage.getItem('hoerdle_onboarding_completed');
|
||||||
|
|
||||||
|
if (hasCompletedOnboarding) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const driverObj = driver({
|
||||||
|
showProgress: true,
|
||||||
|
animate: true,
|
||||||
|
allowClose: true,
|
||||||
|
doneBtnText: 'Done',
|
||||||
|
nextBtnText: 'Next',
|
||||||
|
prevBtnText: 'Previous',
|
||||||
|
onDestroyed: () => {
|
||||||
|
localStorage.setItem('hoerdle_onboarding_completed', 'true');
|
||||||
|
},
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
element: '#tour-genres',
|
||||||
|
popover: {
|
||||||
|
title: 'Genres & Specials',
|
||||||
|
description: 'Choose a specific genre or a curated special event here.',
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-news',
|
||||||
|
popover: {
|
||||||
|
title: 'News',
|
||||||
|
description: 'Stay updated with the latest news and announcements.',
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-title',
|
||||||
|
popover: {
|
||||||
|
title: 'Hördle',
|
||||||
|
description: 'This is the daily puzzle. One new song every day per genre.',
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-status',
|
||||||
|
popover: {
|
||||||
|
title: 'Attempts',
|
||||||
|
description: 'You have a limited number of attempts to guess the song.',
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-score',
|
||||||
|
popover: {
|
||||||
|
title: 'Score',
|
||||||
|
description: 'Your current score. Try to keep it high!',
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-player',
|
||||||
|
popover: {
|
||||||
|
title: 'Player',
|
||||||
|
description: 'Listen to the snippet. Each additional play reduces your potential score.',
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-input',
|
||||||
|
popover: {
|
||||||
|
title: 'Input',
|
||||||
|
description: 'Type your guess here. Search for artist or title.',
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-controls',
|
||||||
|
popover: {
|
||||||
|
title: 'Controls',
|
||||||
|
description: 'Start the music or skip to the next snippet if you\'re stuck.',
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
driverObj.drive();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
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',
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -25,11 +25,11 @@ export function middleware(request: NextRequest) {
|
|||||||
// Content Security Policy
|
// Content Security Policy
|
||||||
const csp = [
|
const csp = [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires unsafe-inline/eval
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me", // Next.js requires unsafe-inline/eval
|
||||||
"style-src 'self' 'unsafe-inline'", // Allow inline styles
|
"style-src 'self' 'unsafe-inline'", // Allow inline styles
|
||||||
"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",
|
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
|
||||||
"media-src 'self' blob:",
|
"media-src 'self' blob:",
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|||||||
1187
package-lock.json
generated
1187
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -11,11 +11,13 @@
|
|||||||
"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",
|
||||||
"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",
|
||||||
|
"react-markdown": "^10.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "News" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT 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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "News_publishedAt_idx" ON "News"("publishedAt");
|
||||||
@@ -47,6 +47,7 @@ model Special {
|
|||||||
curator String?
|
curator String?
|
||||||
songs SpecialSong[]
|
songs SpecialSong[]
|
||||||
puzzles DailyPuzzle[]
|
puzzles DailyPuzzle[]
|
||||||
|
news News[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model SpecialSong {
|
model SpecialSong {
|
||||||
@@ -73,3 +74,17 @@ model DailyPuzzle {
|
|||||||
|
|
||||||
@@unique([date, genreId, specialId])
|
@@unique([date, genreId, specialId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model News {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
content String // Markdown format
|
||||||
|
author String? // Optional: curator/admin name
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
featured Boolean @default(false) // Highlight important news
|
||||||
|
specialId Int? // Optional: link to a special
|
||||||
|
special Special? @relation(fields: [specialId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([publishedAt])
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ fi
|
|||||||
echo "📥 Pulling latest changes from git..."
|
echo "📥 Pulling latest changes from git..."
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
|
# Fetch all tags
|
||||||
|
echo "🏷️ Fetching git tags..."
|
||||||
|
git fetch --tags
|
||||||
|
|
||||||
# Build new image in background (doesn't stop running container)
|
# Build new image in background (doesn't stop running container)
|
||||||
echo "🔨 Building new Docker image (this runs while app is still online)..."
|
echo "🔨 Building new Docker image (this runs while app is still online)..."
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|||||||
Reference in New Issue
Block a user