8 Commits

Author SHA1 Message Date
Hördle Bot
9df9a808bf fix: share emoji fills remaining slots with black squares when game is lost 2025-11-27 13:06:01 +01:00
Hördle Bot
5da78c926d fix: share emoji grid shows black square for skipped last attempt 2025-11-27 12:31:52 +01:00
Hördle Bot
120ffaaf2c docs: update docker config and docs for white labeling 2025-11-27 11:26:27 +01:00
Hördle Bot
50511f11ac chore: bump version to 0.1.0.14 2025-11-27 11:20:15 +01:00
Hördle Bot
d69ac28bb3 feat: white label transformation and bugfix for audio stream 2025-11-27 11:19:32 +01:00
Hördle Bot
7a65c58214 feat: add healthcheck endpoint and bump version to 0.1.0.13 2025-11-26 23:39:06 +01:00
Hördle Bot
1a8177430d feat: add health check API endpoint 2025-11-26 23:37:40 +01:00
Hördle Bot
0ebb61515d docs: Add 'prototype' to footer disclaimer in AppFooter. 2025-11-26 20:42:55 +01:00
13 changed files with 336 additions and 90 deletions

View File

@@ -40,6 +40,34 @@ ENV NEXT_TELEMETRY_DISABLED 1
ENV DATABASE_URL="file:./dev.db" ENV DATABASE_URL="file:./dev.db"
RUN node_modules/.bin/prisma generate RUN node_modules/.bin/prisma generate
# White Label Build Arguments
ARG NEXT_PUBLIC_APP_NAME
ARG NEXT_PUBLIC_APP_DESCRIPTION
ARG NEXT_PUBLIC_DOMAIN
ARG NEXT_PUBLIC_TWITTER_HANDLE
ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
ARG NEXT_PUBLIC_THEME_COLOR
ARG NEXT_PUBLIC_BACKGROUND_COLOR
ARG NEXT_PUBLIC_CREDITS_ENABLED
ARG NEXT_PUBLIC_CREDITS_TEXT
ARG NEXT_PUBLIC_CREDITS_LINK_TEXT
ARG NEXT_PUBLIC_CREDITS_LINK_URL
# Pass env vars to build
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
ENV NEXT_PUBLIC_DOMAIN=$NEXT_PUBLIC_DOMAIN
ENV NEXT_PUBLIC_TWITTER_HANDLE=$NEXT_PUBLIC_TWITTER_HANDLE
ENV NEXT_PUBLIC_PLAUSIBLE_DOMAIN=$NEXT_PUBLIC_PLAUSIBLE_DOMAIN
ENV NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=$NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
ENV NEXT_PUBLIC_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR
ENV NEXT_PUBLIC_CREDITS_ENABLED=$NEXT_PUBLIC_CREDITS_ENABLED
ENV NEXT_PUBLIC_CREDITS_TEXT=$NEXT_PUBLIC_CREDITS_TEXT
ENV NEXT_PUBLIC_CREDITS_LINK_TEXT=$NEXT_PUBLIC_CREDITS_LINK_TEXT
ENV NEXT_PUBLIC_CREDITS_LINK_URL=$NEXT_PUBLIC_CREDITS_LINK_URL
RUN npm run build RUN npm run build
# Production image, copy all the files and run next # Production image, copy all the files and run next

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.
@@ -101,6 +109,8 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
Das Projekt ist für den Betrieb mit Docker optimiert. Das Projekt ist für den Betrieb mit Docker optimiert.
👉 **[White Labeling mit Docker? Hier klicken!](WHITE_LABEL.md#docker-deployment)**
1. **Vorbereitung:** 1. **Vorbereitung:**
Kopiere die Beispiel-Konfiguration: Kopiere die Beispiel-Konfiguration:
```bash ```bash

99
WHITE_LABEL.md Normal file
View File

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

View File

@@ -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 {
@@ -253,15 +254,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
for (let i = 0; i < totalGuesses; i++) { for (let i = 0; i < totalGuesses; i++) {
if (i < gameState.guesses.length) { if (i < gameState.guesses.length) {
if (hasWon && i === gameState.guesses.length - 1) { if (gameState.guesses[i] === 'SKIPPED') {
emojiGrid += '🟩';
} else if (gameState.guesses[i] === 'SKIPPED') {
emojiGrid += '⬛'; emojiGrid += '⬛';
} else if (hasWon && i === gameState.guesses.length - 1) {
emojiGrid += '🟩';
} else { } else {
emojiGrid += '🟥'; emojiGrid += '🟥';
} }
} else { } else {
emojiGrid += '⬜'; // If game is lost, fill remaining slots with black squares
emojiGrid += hasLost ? '⬛' : '⬜';
} }
} }
@@ -269,7 +271,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 +280,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 +318,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 +331,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 +412,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 +439,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 +492,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 +578,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 +593,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 +615,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 +630,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 +661,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 +679,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'
}} }}

View File

@@ -4,6 +4,19 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
NEXT_PUBLIC_DOMAIN: ${NEXT_PUBLIC_DOMAIN}
NEXT_PUBLIC_TWITTER_HANDLE: ${NEXT_PUBLIC_TWITTER_HANDLE}
NEXT_PUBLIC_PLAUSIBLE_DOMAIN: ${NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_COLOR}
NEXT_PUBLIC_CREDITS_ENABLED: ${NEXT_PUBLIC_CREDITS_ENABLED}
NEXT_PUBLIC_CREDITS_TEXT: ${NEXT_PUBLIC_CREDITS_TEXT}
NEXT_PUBLIC_CREDITS_LINK_TEXT: ${NEXT_PUBLIC_CREDITS_LINK_TEXT}
NEXT_PUBLIC_CREDITS_LINK_URL: ${NEXT_PUBLIC_CREDITS_LINK_URL}
user: root user: root
restart: always restart: always
ports: ports:

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.15",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -29,4 +29,4 @@
"eslint-config-next": "16.0.3", "eslint-config-next": "16.0.3",
"typescript": "^5" "typescript": "^5"
} }
} }