diff --git a/README.md b/README.md index fdacbc6..c5ba1e3 100644 --- a/README.md +++ b/README.md @@ -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. - **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible). - **Featured News:** Hervorhebung wichtiger Ankündigungen. - - **Special-Verknüpfung:** Direkte Links zu Specials in News-Beiträgen. + - Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen. - Verwaltung über das Admin-Dashboard. +## White Labeling + +Hördle ist "White Label Ready". Das bedeutet, du kannst das Branding (Name, Farben, Logos) komplett anpassen, ohne den Code zu ändern. + +👉 **[Anleitung zur Anpassung (White Label Guide)](WHITE_LABEL.md)** + +Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen. + ## Spielregeln & Punktesystem Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und dabei einen möglichst hohen Highscore zu erzielen. diff --git a/WHITE_LABEL.md b/WHITE_LABEL.md new file mode 100644 index 0000000..1524d8e --- /dev/null +++ b/WHITE_LABEL.md @@ -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. diff --git a/app/api/audio/[filename]/route.ts b/app/api/audio/[filename]/route.ts index 6002d03..71c4c09 100644 --- a/app/api/audio/[filename]/route.ts +++ b/app/api/audio/[filename]/route.ts @@ -44,11 +44,35 @@ export async function GET( const stream = createReadStream(filePath, { start, end }); // Convert Node stream to Web stream + const readable = new ReadableStream({ start(controller) { - stream.on('data', (chunk: any) => controller.enqueue(chunk)); - stream.on('end', () => controller.close()); - stream.on('error', (err: any) => controller.error(err)); + let isClosed = false; + + stream.on('data', (chunk: any) => { + if (isClosed) return; + try { + controller.enqueue(chunk); + } catch (e) { + isClosed = true; + stream.destroy(); + } + }); + + stream.on('end', () => { + if (isClosed) return; + isClosed = true; + controller.close(); + }); + + stream.on('error', (err: any) => { + if (isClosed) return; + isClosed = true; + controller.error(err); + }); + }, + cancel() { + stream.destroy(); } }); @@ -68,9 +92,32 @@ export async function GET( // Convert Node stream to Web stream const readable = new ReadableStream({ start(controller) { - stream.on('data', (chunk: any) => controller.enqueue(chunk)); - stream.on('end', () => controller.close()); - stream.on('error', (err: any) => controller.error(err)); + let isClosed = false; + + stream.on('data', (chunk: any) => { + if (isClosed) return; + try { + controller.enqueue(chunk); + } catch (e) { + isClosed = true; + stream.destroy(); + } + }); + + stream.on('end', () => { + if (isClosed) return; + isClosed = true; + controller.close(); + }); + + stream.on('error', (err: any) => { + if (isClosed) return; + isClosed = true; + controller.error(err); + }); + }, + cancel() { + stream.destroy(); } }); diff --git a/app/globals.css b/app/globals.css index c2f1704..709523a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,6 +2,24 @@ --foreground-rgb: 0, 0, 0; --background-start-rgb: 214, 219, 220; --background-end-rgb: 255, 255, 255; + + /* Theme Colors */ + --primary: #000000; + --primary-foreground: #ffffff; + --secondary: #4b5563; + --secondary-foreground: #ffffff; + --accent: #667eea; + --accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --success: #22c55e; + --success-foreground: #ffffff; + --danger: #ef4444; + --danger-foreground: #ffffff; + --warning: #ffc107; + --muted: #f3f4f6; + --muted-foreground: #6b7280; + --border: #e5e7eb; + --input: #d1d5db; + --ring: #000000; } body { @@ -51,13 +69,13 @@ body { display: flex; justify-content: space-between; font-size: 0.875rem; - color: #666; + color: var(--muted-foreground); margin-bottom: 0.5rem; } /* Audio Player */ .audio-player { - background: #f3f4f6; + background: var(--muted); padding: 1rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); @@ -73,8 +91,8 @@ body { width: 3rem; height: 3rem; border-radius: 50%; - background: #000; - color: #fff; + background: var(--primary); + color: var(--primary-foreground); border: none; display: flex; align-items: center; @@ -85,19 +103,20 @@ body { .play-button:hover { background: #333; + /* Keep for now or add --primary-hover */ } .progress-bar-container { flex: 1; height: 0.5rem; - background: #d1d5db; + background: var(--input); border-radius: 999px; overflow: hidden; } .progress-bar { height: 100%; - background: #22c55e; + background: var(--success); transition: width 0.1s linear; } @@ -114,7 +133,7 @@ body { gap: 0.5rem; padding: 0.5rem; background: #f9fafb; - border: 1px solid #e5e7eb; + border: 1px solid var(--border); border-radius: 0.25rem; font-size: 0.875rem; } @@ -125,7 +144,7 @@ body { } .guess-text { - color: #ef4444; + color: var(--danger); /* Red for wrong */ } @@ -135,7 +154,7 @@ body { } .guess-text.correct { - color: #22c55e; + color: var(--success); } /* Input */ @@ -148,14 +167,14 @@ body { .guess-input { width: 100%; padding: 0.75rem; - border: 1px solid #d1d5db; + border: 1px solid var(--input); border-radius: 0.25rem; font-size: 1rem; box-sizing: border-box; } .guess-input:focus { - outline: 2px solid #000; + outline: 2px solid var(--ring); border-color: transparent; } @@ -163,7 +182,7 @@ body { position: absolute; width: 100%; background: #fff; - border: 1px solid #d1d5db; + border: 1px solid var(--input); border-radius: 0.25rem; margin-top: 0.25rem; max-height: 15rem; @@ -177,11 +196,11 @@ body { .suggestion-item { padding: 0.75rem; cursor: pointer; - border-bottom: 1px solid #f3f4f6; + border-bottom: 1px solid var(--muted); } .suggestion-item:hover { - background: #f3f4f6; + background: var(--muted); } .suggestion-title { @@ -190,14 +209,14 @@ body { .suggestion-artist { font-size: 0.875rem; - color: #666; + color: var(--muted-foreground); } .skip-button { width: 100%; padding: 1rem 1.5rem; margin-top: 1rem; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: var(--accent-gradient); color: white; border: none; border-radius: 0.5rem; @@ -246,7 +265,7 @@ body { } .admin-card { - background: #f3f4f6; + background: var(--muted); padding: 2rem; border-radius: 0.5rem; } @@ -265,14 +284,14 @@ body { .form-input { width: 100%; padding: 0.5rem; - border: 1px solid #d1d5db; + border: 1px solid var(--input); border-radius: 0.25rem; box-sizing: border-box; } .btn-primary { - background: #000; - color: #fff; + background: var(--primary); + color: var(--primary-foreground); padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; @@ -292,8 +311,8 @@ body { } .btn-secondary { - background: #4b5563; - color: #fff; + background: var(--secondary); + color: var(--secondary-foreground); padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; @@ -312,8 +331,8 @@ body { } .btn-danger { - background: #ef4444; - color: #fff; + background: var(--danger); + color: var(--danger-foreground); padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; @@ -337,8 +356,8 @@ body { padding: 2rem 1rem 1rem; text-align: center; font-size: 0.875rem; - color: #666; - border-top: 1px solid #e5e7eb; + color: var(--muted-foreground); + border-top: 1px solid var(--border); width: 100%; } @@ -347,7 +366,7 @@ body { } .app-footer a { - color: #000; + color: var(--primary); text-decoration: none; font-weight: 500; } @@ -375,7 +394,7 @@ body { font-size: 0.875rem; text-align: center; margin: 0 0 1rem 0; - color: #666; + color: var(--muted-foreground); } .statistics-grid { @@ -391,7 +410,7 @@ body { padding: 0.75rem 0.5rem; background: rgba(255, 255, 255, 0.8); border-radius: 0.375rem; - border: 1px solid #e5e7eb; + border: 1px solid var(--border); } .stat-badge { @@ -401,7 +420,7 @@ body { .stat-label { font-size: 0.75rem; - color: #666; + color: var(--muted-foreground); margin-bottom: 0.25rem; text-align: center; } @@ -409,7 +428,7 @@ body { .stat-count { font-size: 1.25rem; font-weight: bold; - color: #000; + color: var(--primary); } /* Tooltip */ diff --git a/app/layout.tsx b/app/layout.tsx index e33f83e..0b4990a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google"; import Script from "next/script"; import "./globals.css"; +import { config } from "@/lib/config"; + const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], @@ -14,12 +16,12 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Hördle", - description: "Daily music guessing game - Guess the song from short audio clips", + title: config.appName, + description: config.appDescription, }; export const viewport: Viewport = { - themeColor: "#000000", + themeColor: config.colors.themeColor, width: "device-width", initialScale: 1, maximumScale: 1, @@ -38,8 +40,8 @@ export default function RootLayout({
diff --git a/app/manifest.ts b/app/manifest.ts index 0c3af1d..31352f0 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -1,14 +1,15 @@ import type { MetadataRoute } from 'next' +import { config } from '@/lib/config' export default function manifest(): MetadataRoute.Manifest { return { - name: 'Hördle', - short_name: 'Hördle', - description: 'Daily music guessing game - Guess the song from short audio clips', + name: config.appName, + short_name: config.appName, + description: config.appDescription, start_url: '/', display: 'standalone', - background_color: '#ffffff', - theme_color: '#000000', + background_color: config.colors.backgroundColor, + theme_color: config.colors.themeColor, icons: [ { src: '/favicon.ico', diff --git a/components/AppFooter.tsx b/components/AppFooter.tsx index 1f388e2..65b5a4d 100644 --- a/components/AppFooter.tsx +++ b/components/AppFooter.tsx @@ -1,5 +1,6 @@ 'use client'; +import { config } from '@/lib/config'; import { useEffect, useState } from 'react'; export default function AppFooter() { @@ -12,14 +13,15 @@ export default function AppFooter() { .catch(() => setVersion('')); }, []); + if (!config.credits.enabled) return null; + return (