Compare commits
7 Commits
v0.1.0.10
...
50511f11ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50511f11ac | ||
|
|
d69ac28bb3 | ||
|
|
7a65c58214 | ||
|
|
1a8177430d | ||
|
|
0ebb61515d | ||
|
|
dede11d22b | ||
|
|
4b96b95bff |
10
README.md
10
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.
|
||||
|
||||
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.
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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({
|
||||
<head>
|
||||
<Script
|
||||
defer
|
||||
data-domain="hoerdle.elpatron.me"
|
||||
src="https://plausible.elpatron.me/js/script.js"
|
||||
data-domain={config.plausibleDomain}
|
||||
src={config.plausibleScriptSrc}
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
</head>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
Vibe coded with ☕ and 🍺 by{' '}
|
||||
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
||||
@elpatron@digitalcourage.social
|
||||
{config.credits.text}{' '}
|
||||
<a href={config.credits.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
{config.credits.linkText}
|
||||
</a>
|
||||
{' '}- for personal use among friends only!
|
||||
{version && (
|
||||
<>
|
||||
{' '}·{' '}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { config } from '@/lib/config';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||
import GuessInput from './GuessInput';
|
||||
@@ -7,6 +8,13 @@ import Statistics from './Statistics';
|
||||
import { useGameState } from '../lib/gameState';
|
||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||
|
||||
// Plausible Analytics
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
|
||||
}
|
||||
}
|
||||
|
||||
interface GameProps {
|
||||
dailyPuzzle: {
|
||||
id: number;
|
||||
@@ -76,7 +84,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
useEffect(() => {
|
||||
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)) {
|
||||
setHasRated(true);
|
||||
} else {
|
||||
@@ -103,6 +111,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (song.id === dailyPuzzle.songId) {
|
||||
addGuess(song.title, 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
|
||||
if (!dailyPuzzle.releaseYear) {
|
||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
@@ -112,6 +131,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -138,6 +168,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
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
|
||||
}
|
||||
};
|
||||
@@ -148,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
|
||||
setHasLost(true);
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -156,6 +208,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
addYearBonus(correct);
|
||||
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
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||||
};
|
||||
@@ -163,6 +228,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const handleYearSkip = () => {
|
||||
skipYearBonus();
|
||||
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
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
};
|
||||
@@ -191,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 genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
|
||||
|
||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
||||
let shareUrl = `https://${config.domain}`;
|
||||
if (genre) {
|
||||
if (isSpecial) {
|
||||
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
||||
@@ -200,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);
|
||||
|
||||
@@ -238,10 +317,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
||||
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)) {
|
||||
ratedPuzzles.push(dailyPuzzle.id);
|
||||
localStorage.setItem('hoerdle_rated_puzzles', JSON.stringify(ratedPuzzles));
|
||||
localStorage.setItem(`${config.appName.toLowerCase()}_rated_puzzles`, JSON.stringify(ratedPuzzles));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit rating', error);
|
||||
@@ -251,8 +330,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="header">
|
||||
<h1 id="tour-title" className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
||||
<h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||
<div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
||||
Next puzzle in: {timeUntilNext}
|
||||
</div>
|
||||
</header>
|
||||
@@ -332,11 +411,11 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
{hasWon ? 'You won!' : 'Game Over'}
|
||||
</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}
|
||||
</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>
|
||||
<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) => (
|
||||
@@ -359,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)' }}
|
||||
/>
|
||||
<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 && (
|
||||
<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%' }}>
|
||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||
@@ -412,13 +491,13 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
|
||||
textAlign: 'center',
|
||||
margin: '0.5rem 0',
|
||||
padding: '0.5rem',
|
||||
background: '#f3f4f6',
|
||||
background: 'var(--muted)',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'monospace',
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
@@ -498,8 +577,8 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
}}>
|
||||
{!feedback.show ? (
|
||||
<>
|
||||
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
|
||||
<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={{
|
||||
display: 'grid',
|
||||
@@ -513,17 +592,17 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
onClick={() => handleGuess(year)}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: '#f3f4f6',
|
||||
border: '2px solid #e5e7eb',
|
||||
background: 'var(--muted)',
|
||||
border: '2px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
color: 'var(--secondary)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
|
||||
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
||||
onMouseOver={e => e.currentTarget.style.borderColor = 'var(--success)'}
|
||||
onMouseOut={e => e.currentTarget.style.borderColor = 'var(--border)'}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
@@ -535,7 +614,7 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#6b7280',
|
||||
color: 'var(--muted-foreground)',
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
@@ -550,23 +629,23 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
feedback.correct ? (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#10b981', marginBottom: '0.5rem' }}>Correct!</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
|
||||
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#10b981', marginTop: '1rem' }}>+10 Points!</p>
|
||||
<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: '#ef4444', marginBottom: '0.5rem' }}>Not quite!</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>You guessed {feedback.guessedYear}</p>
|
||||
<p style={{ fontSize: '1.2rem', color: '#4b5563', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></p>
|
||||
<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: '#6b7280', marginBottom: '0.5rem' }}>Skipped</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
|
||||
<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>
|
||||
@@ -581,12 +660,12 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
||||
const [rating, setRating] = useState(0);
|
||||
|
||||
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 (
|
||||
<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' }}>
|
||||
{[...Array(5)].map((_, index) => {
|
||||
const ratingValue = index + 1;
|
||||
@@ -599,7 +678,7 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '2rem',
|
||||
color: ratingValue <= (hover || rating) ? '#ffc107' : '#9ca3af',
|
||||
color: ratingValue <= (hover || rating) ? 'var(--warning)' : 'var(--muted-foreground)',
|
||||
transition: 'color 0.2s',
|
||||
padding: '0 0.25rem'
|
||||
}}
|
||||
|
||||
18
lib/config.ts
Normal file
18
lib/config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const config = {
|
||||
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
|
||||
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
|
||||
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.elpatron.me',
|
||||
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
|
||||
plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'hoerdle.elpatron.me',
|
||||
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.elpatron.me/js/script.js',
|
||||
colors: {
|
||||
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
|
||||
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
|
||||
},
|
||||
credits: {
|
||||
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
|
||||
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
||||
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.0.14",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -29,4 +29,4 @@
|
||||
"eslint-config-next": "16.0.3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user