5 Commits

Author SHA1 Message Date
Hördle Bot
50511f11ac chore: bump version to 0.1.0.14 2025-11-27 11:20:15 +01:00
Hördle Bot
d69ac28bb3 feat: white label transformation and bugfix for audio stream 2025-11-27 11:19:32 +01:00
Hördle Bot
7a65c58214 feat: add healthcheck endpoint and bump version to 0.1.0.13 2025-11-26 23:39:06 +01:00
Hördle Bot
1a8177430d feat: add health check API endpoint 2025-11-26 23:37:40 +01:00
Hördle Bot
0ebb61515d docs: Add 'prototype' to footer disclaimer in AppFooter. 2025-11-26 20:42:55 +01:00
11 changed files with 256 additions and 86 deletions

View File

@@ -48,9 +48,17 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- **Markdown Support:** Formatierung von Texten, Links und Listen. - **Markdown Support:** Formatierung von Texten, Links und Listen.
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible). - **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
- **Featured News:** Hervorhebung wichtiger Ankündigungen. - **Featured News:** Hervorhebung wichtiger Ankündigungen.
- **Special-Verknüpfung:** Direkte Links zu Specials in News-Beiträgen. - Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
- Verwaltung über das Admin-Dashboard. - Verwaltung über das Admin-Dashboard.
## White Labeling
Hördle ist "White Label Ready". Das bedeutet, du kannst das Branding (Name, Farben, Logos) komplett anpassen, ohne den Code zu ändern.
👉 **[Anleitung zur Anpassung (White Label Guide)](WHITE_LABEL.md)**
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
## Spielregeln & Punktesystem ## Spielregeln & Punktesystem
Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und dabei einen möglichst hohen Highscore zu erzielen. Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und dabei einen möglichst hohen Highscore zu erzielen.

67
WHITE_LABEL.md Normal file
View 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.

View File

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

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

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

View File

@@ -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 */

View File

@@ -3,6 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script"; 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"],
@@ -14,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,
@@ -38,8 +40,8 @@ export default function RootLayout({
<head> <head>
<Script <Script
defer defer
data-domain="hoerdle.elpatron.me" data-domain={config.plausibleDomain}
src="https://plausible.elpatron.me/js/script.js" src={config.plausibleScriptSrc}
strategy="beforeInteractive" strategy="beforeInteractive"
/> />
</head> </head>

View File

@@ -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',

View File

@@ -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 && (
<> <>
{' '}·{' '} {' '}·{' '}

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { config } from '@/lib/config';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer'; import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
import GuessInput from './GuessInput'; import GuessInput from './GuessInput';
@@ -83,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 {
@@ -269,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)}`;
@@ -278,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);
@@ -316,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);
@@ -329,8 +330,8 @@ 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 id="tour-title" 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>
@@ -410,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) => (
@@ -437,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" />
@@ -490,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>
); );
@@ -576,8 +577,8 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
}}> }}>
{!feedback.show ? ( {!feedback.show ? (
<> <>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3> <h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: 'var(--primary)' }}>Bonus Round!</h3>
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p> <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',
@@ -591,17 +592,17 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
onClick={() => handleGuess(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>
@@ -613,7 +614,7 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
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'
@@ -628,23 +629,23 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
feedback.correct ? ( feedback.correct ? (
<> <>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div> <div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#10b981', marginBottom: '0.5rem' }}>Correct!</h3> <h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>Correct!</h3>
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p> <p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#10b981', marginTop: '1rem' }}>+10 Points!</p> <p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 Points!</p>
</> </>
) : ( ) : (
<> <>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div> <div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ef4444', marginBottom: '0.5rem' }}>Not quite!</h3> <h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>Not quite!</h3>
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>You guessed {feedback.guessedYear}</p> <p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>You guessed {feedback.guessedYear}</p>
<p style={{ fontSize: '1.2rem', color: '#4b5563', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></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> <div style={{ fontSize: '4rem', marginBottom: '1rem' }}></div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#6b7280', marginBottom: '0.5rem' }}>Skipped</h3> <h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>Skipped</h3>
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p> <p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
</> </>
)} )}
</div> </div>
@@ -659,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;
@@ -677,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'
}} }}

18
lib/config.ts Normal file
View File

@@ -0,0 +1,18 @@
export const config = {
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.elpatron.me',
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'hoerdle.elpatron.me',
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.elpatron.me/js/script.js',
colors: {
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
},
credits: {
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
}
};

View File

@@ -1,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",