Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df9a808bf | ||
|
|
5da78c926d | ||
|
|
120ffaaf2c | ||
|
|
50511f11ac | ||
|
|
d69ac28bb3 | ||
|
|
7a65c58214 | ||
|
|
1a8177430d | ||
|
|
0ebb61515d | ||
|
|
dede11d22b | ||
|
|
4b96b95bff |
28
Dockerfile
28
Dockerfile
@@ -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
|
||||||
|
|||||||
12
README.md
12
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.
|
- **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
99
WHITE_LABEL.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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
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;
|
--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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { config } from '@/lib/config';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
export default function AppFooter() {
|
export default function AppFooter() {
|
||||||
@@ -12,14 +13,15 @@ export default function AppFooter() {
|
|||||||
.catch(() => setVersion(''));
|
.catch(() => setVersion(''));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!config.credits.enabled) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
<p>
|
<p>
|
||||||
Vibe coded with ☕ and 🍺 by{' '}
|
{config.credits.text}{' '}
|
||||||
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
<a href={config.credits.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||||
@elpatron@digitalcourage.social
|
{config.credits.linkText}
|
||||||
</a>
|
</a>
|
||||||
{' '}- for personal use among friends only!
|
|
||||||
{version && (
|
{version && (
|
||||||
<>
|
<>
|
||||||
{' '}·{' '}
|
{' '}·{' '}
|
||||||
|
|||||||
@@ -1,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';
|
||||||
@@ -7,6 +8,13 @@ import Statistics from './Statistics';
|
|||||||
import { useGameState } from '../lib/gameState';
|
import { useGameState } from '../lib/gameState';
|
||||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
|
||||||
|
// Plausible Analytics
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface GameProps {
|
interface GameProps {
|
||||||
dailyPuzzle: {
|
dailyPuzzle: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -76,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 {
|
||||||
@@ -103,6 +111,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
if (song.id === dailyPuzzle.songId) {
|
if (song.id === dailyPuzzle.songId) {
|
||||||
addGuess(song.title, true);
|
addGuess(song.title, true);
|
||||||
setHasWon(true);
|
setHasWon(true);
|
||||||
|
// Track puzzle solved event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length + 1,
|
||||||
|
score: gameState.score + 20, // Include the win bonus
|
||||||
|
outcome: 'won'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// Notification sent after year guess or skip
|
// Notification sent after year guess or skip
|
||||||
if (!dailyPuzzle.releaseYear) {
|
if (!dailyPuzzle.releaseYear) {
|
||||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||||
@@ -112,6 +131,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
setHasWon(false);
|
||||||
|
// Track puzzle lost event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: maxAttempts,
|
||||||
|
score: 0,
|
||||||
|
outcome: 'lost'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +168,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
setHasWon(false);
|
||||||
|
// Track puzzle lost event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: maxAttempts,
|
||||||
|
score: 0,
|
||||||
|
outcome: 'lost'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -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
|
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
setHasWon(false);
|
||||||
|
// Track puzzle lost event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length + 1,
|
||||||
|
score: 0,
|
||||||
|
outcome: 'lost'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,6 +208,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
addYearBonus(correct);
|
addYearBonus(correct);
|
||||||
setShowYearModal(false);
|
setShowYearModal(false);
|
||||||
|
|
||||||
|
// Update the puzzle_solved event with year bonus result
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length,
|
||||||
|
score: gameState.score + (correct ? 10 : 0), // Include year bonus if correct
|
||||||
|
outcome: 'won',
|
||||||
|
year_bonus: correct ? 'correct' : 'incorrect'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification now that game is fully complete
|
// Send notification now that game is fully complete
|
||||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||||||
};
|
};
|
||||||
@@ -163,6 +228,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const handleYearSkip = () => {
|
const handleYearSkip = () => {
|
||||||
skipYearBonus();
|
skipYearBonus();
|
||||||
setShowYearModal(false);
|
setShowYearModal(false);
|
||||||
|
|
||||||
|
// Update the puzzle_solved event with year bonus result
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length,
|
||||||
|
score: gameState.score, // Score already includes win bonus
|
||||||
|
outcome: 'won',
|
||||||
|
year_bonus: 'skipped'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification now that game is fully complete
|
// Send notification now that game is fully complete
|
||||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||||
};
|
};
|
||||||
@@ -175,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 ? '⬛' : '⬜';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,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)}`;
|
||||||
@@ -200,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);
|
||||||
|
|
||||||
@@ -238,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);
|
||||||
@@ -251,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>
|
||||||
@@ -332,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) => (
|
||||||
@@ -359,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" />
|
||||||
@@ -412,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>
|
||||||
);
|
);
|
||||||
@@ -498,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',
|
||||||
@@ -513,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>
|
||||||
@@ -535,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'
|
||||||
@@ -550,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>
|
||||||
@@ -581,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;
|
||||||
@@ -599,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'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
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",
|
"name": "hoerdle",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user