Compare commits

...

51 Commits

Author SHA1 Message Date
Hördle Bot
2e1f1e599b chore: bump version to 0.1.6.26 2025-12-06 15:39:15 +01:00
Hördle Bot
71c4e2509f feat(admin): add danger zone buttons for resetting ratings and activations
- Added reset all user ratings button to admin danger zone
- Added reset all activations button to admin danger zone
- Created API endpoints: /api/admin/reset-ratings and /api/admin/reset-activations
- Removed old non-localized routes: /app/admin, /app/curator
- Removed unused page.module.css
- All admin functionality now uses localized routes (/[locale]/admin)
2025-12-06 15:38:46 +01:00
Hördle Bot
9cef1c78d3 ... 2025-12-06 14:26:52 +01:00
Hördle Bot
6741eeb7fa feat: Album-Cover-Anzeige in Titelliste mit Tooltip hinzugefügt
- Neue Spalte 'Cover' in der Curator-Titelliste zeigt an, ob ein Album-Cover vorhanden ist
- Tooltip zeigt das Cover-Bild beim Hovern über die Cover-Spalte
- Übersetzungen für DE und EN hinzugefügt
2025-12-06 14:24:00 +01:00
Hördle Bot
71b8e98f23 feat: add hidden flag to specials 2025-12-06 01:35:01 +01:00
Hördle Bot
bc2c0bad59 Bump version to 0.1.6.24 2025-12-06 00:36:02 +01:00
Hördle Bot
812d6ff10d Add timeline display below waveform in waveform editor 2025-12-06 00:36:00 +01:00
Hördle Bot
aed300b1bb Bump version to 0.1.6.23 2025-12-05 23:55:24 +01:00
Hördle Bot
e93b3b9096 Keep playback cursor visible when pausing in waveform editor 2025-12-05 23:55:21 +01:00
Hördle Bot
cdd2ff15d5 Bump version to 0.1.6.22 2025-12-05 22:25:41 +01:00
Hördle Bot
adcfbfa811 Fix pause functionality for waveform editor playback buttons 2025-12-05 22:25:39 +01:00
Hördle Bot
0cdfe90476 Bump version to 0.1.6.21 2025-12-05 22:06:44 +01:00
Hördle Bot
1715ca02ed Remove duplicate back button from curator special editor 2025-12-05 22:06:42 +01:00
Hördle Bot
ece3991d37 Bump version to 0.1.6.20 2025-12-05 21:57:36 +01:00
Hördle Bot
fa3b64f490 Add 'Play Full Title' button to waveform editor 2025-12-05 21:57:34 +01:00
Hördle Bot
fa6f1097dd Bump version to 0.1.6.19 2025-12-05 21:48:00 +01:00
Hördle Bot
d2ec0119ce Fix waveform editor: show end marker for last segment and fix play full section stop functionality 2025-12-05 21:47:57 +01:00
Hördle Bot
8914c552cd Bump version to 0.1.6.18 2025-12-05 21:33:44 +01:00
Hördle Bot
d816422419 Update song list start time after saving changes in waveform editor 2025-12-05 21:33:41 +01:00
Hördle Bot
da777ffcf3 Bump version to 0.1.6.17 2025-12-05 20:56:29 +01:00
Hördle Bot
0d806daf66 Add JSON validation for unlock steps in admin specials management with tooltip error display 2025-12-05 20:56:27 +01:00
Hördle Bot
616cfec3e7 Bump version to 0.1.6.16 2025-12-05 20:41:40 +01:00
Hördle Bot
ac12e45393 Fix curator specials page: resolve redirect loop and add missing translations 2025-12-05 20:41:38 +01:00
Hördle Bot
223eb62973 Bump version to 0.1.6.15 2025-12-05 20:13:31 +01:00
Hördle Bot
dc4bdd36c7 Fix textarea alignment: add box-sizing border-box to prevent overflow 2025-12-05 20:13:27 +01:00
Hördle Bot
136f881252 Bump version to 0.1.6.14 2025-12-05 18:43:15 +01:00
Hördle Bot
fd11048f2c Fix daily puzzle selection: always select from songs with minimum activations 2025-12-05 18:43:09 +01:00
Hördle Bot
c1b448639e Add logo generation scripts and favicon base image 2025-12-05 12:26:54 +01:00
Hördle Bot
97021f016b Add logo files (SVG and PNG) with white background and hördle.de text 2025-12-05 12:26:15 +01:00
Hördle Bot
1991cbd93f Bump version to 0.1.6.13 2025-12-05 11:33:44 +01:00
Hördle Bot
c28c9fe8f0 Fix: Verbesserte Erkennung von umformulierten Nachrichten - nur inhaltliche Änderungen werden erkannt 2025-12-05 11:33:39 +01:00
Hördle Bot
803713dea7 Bump version to 0.1.6.12 2025-12-05 11:20:08 +01:00
Hördle Bot
0e6eba64d9 Security: Update Next.js to 16.0.7 to fix CVE-2025-55182 (React2Shell RCE vulnerability) 2025-12-05 11:18:33 +01:00
Hördle Bot
576b486caf Bump version to 0.1.6.11 2025-12-05 10:55:22 +01:00
Hördle Bot
d8f69631b5 Fix: AI-Nachrichtenverarbeitung - Nur bei geänderten Nachrichten anzeigen, Checkbox für Einverständnis hinzufügen 2025-12-05 10:55:18 +01:00
Hördle Bot
dbcdaf9278 Enhance deployment script: add cleanup for build cache alongside old images 2025-12-04 21:38:04 +01:00
Hördle Bot
2e93d09236 Bump version to 0.1.6.10 2025-12-04 21:26:20 +01:00
Hördle Bot
a1fe62f132 Fix: Verwende Punycode-Domain (xn--hrdle-jua.de) in Share-Nachricht für hördle.de-Benutzer 2025-12-04 21:26:16 +01:00
Hördle Bot
e49c6acc99 Bump version to 0.1.6.9 2025-12-04 20:39:49 +01:00
Hördle Bot
96cc9db7d6 Fix: hasLost/hasWon korrekt beim Reload initialisieren 2025-12-04 20:39:43 +01:00
Hördle Bot
ebc482dc87 Bump version to 0.1.6.8 2025-12-04 20:13:23 +01:00
Hördle Bot
88dd86c344 Fix: Nur wirklich problematische Nachrichten umschreiben 2025-12-04 20:13:20 +01:00
Hördle Bot
623e8b9b82 Update help tooltip for assigning specials in curator upload to improve clarity 2025-12-04 14:45:39 +01:00
Hördle Bot
286ac2d28a Add help tooltip for assigning specials in curator upload 2025-12-04 14:38:33 +01:00
Hördle Bot
c02d3df7ed Load Restic credentials from ~/.restic-env in backup/restore scripts 2025-12-04 13:49:55 +01:00
Hördle Bot
702f47b7e5 Bump version to v0.1.6.7 2025-12-04 13:40:38 +01:00
Hördle Bot
86f3349f80 Fix duplicate toggleUploadSpecial definition in curator client 2025-12-04 13:40:23 +01:00
Hördle Bot
bdb74fb462 Bump version to v0.1.6.6 2025-12-04 13:36:40 +01:00
Hördle Bot
66c0071257 Allow curators to assign specials on upload and update help text 2025-12-04 13:36:24 +01:00
Hördle Bot
76f14087fd Bump version to v0.1.6.5 2025-12-04 13:27:48 +01:00
Hördle Bot
b1ab5bd633 Fix build by redirecting /curator/specials to localized route 2025-12-04 13:27:36 +01:00
44 changed files with 1636 additions and 4605 deletions

1
.cursor/commands/bump.md Normal file
View File

@@ -0,0 +1 @@
teste den build (npm run build), anschließend commit, dann bump zum nächsten patchlevel, git tag und sync

1
.gitignore vendored
View File

@@ -54,3 +54,4 @@ next-env.d.ts
docker-compose.yml docker-compose.yml
scripts/scrape-bahn-expert-statements.js scripts/scrape-bahn-expert-statements.js
docs/bahn-expert-statements.txt docs/bahn-expert-statements.txt
/public/logos.zip

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import CuratorSpecialsPage from '@/app/curator/specials/page'; import CuratorSpecialsClient from '@/app/curator/specials/CuratorSpecialsClient';
export default CuratorSpecialsPage; export default function CuratorSpecialsPage() {
return <CuratorSpecialsClient />;
}

View File

@@ -43,7 +43,9 @@ export default async function Home({
const genres = await prisma.genre.findMany({ const genres = await prisma.genre.findMany({
where: { active: true }, where: { active: true },
}); });
const specials = await prisma.special.findMany(); const specials = await prisma.special.findMany({
where: { hidden: false },
});
// Sort in memory // Sort in memory
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale))); genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));

View File

@@ -86,6 +86,7 @@ export default async function SpecialPage({ params }: PageProps) {
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale))); specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
const activeSpecials = specials.filter(s => { const activeSpecials = specials.filter(s => {
if (s.hidden) return false;
const sStarted = !s.launchDate || s.launchDate <= now; const sStarted = !s.launchDate || s.launchDate <= now;
const sEnded = s.endDate && s.endDate < now; const sEnded = s.endDate && s.endDate < now;
return sStarted && !sEnded; return sStarted && !sEnded;

View File

@@ -1,14 +0,0 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Hördle Admin Dashboard",
description: "Admin dashboard for managing songs and daily puzzles",
};
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter, usePathname } from 'next/navigation';
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
export default function SpecialEditorPage() {
const params = useParams();
const router = useRouter();
const pathname = usePathname();
const specialId = params.id as string;
// Locale aus dem Pfad ableiten (/en/..., /de/...)
const localeFromPath = pathname?.split('/')[1] as 'de' | 'en' | undefined;
const locale: 'de' | 'en' = localeFromPath === 'de' || localeFromPath === 'en' ? localeFromPath : 'en';
const [special, setSpecial] = useState<CurateSpecial | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchSpecial = async () => {
try {
const res = await fetch(`/api/specials/${specialId}`);
if (res.ok) {
const data = await res.json();
setSpecial(data);
}
} catch (error) {
console.error('Error fetching special:', error);
} finally {
setLoading(false);
}
};
fetchSpecial();
}, [specialId]);
const handleSaveStartTime = async (songId: number, startTime: number) => {
const res = await fetch(`/api/specials/${specialId}/songs`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songId, startTime }),
});
if (!res.ok) {
const errorText = await res.text().catch(() => res.statusText || 'Unknown error');
console.error('Error updating special song (admin):', res.status, errorText);
throw new Error(`Failed to save start time: ${errorText}`);
}
};
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>Loading...</p>
</div>
);
}
if (!special) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>Special not found</p>
<button onClick={() => router.push('/admin')}>Back to Admin</button>
</div>
);
}
return (
<CurateSpecialEditor
special={special}
locale={locale}
onBack={() => router.push('/admin')}
onSaveStartTime={handleSaveStartTime}
backLabel="← Back to Admin"
headerPrefix="Edit Special:"
noSongsSubHint="Go back to the admin dashboard to add songs to this special."
/>
);
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
try {
// Delete all daily puzzles (activations)
const result = await prisma.dailyPuzzle.deleteMany({});
return NextResponse.json({
success: true,
message: `Successfully deleted ${result.count} daily puzzles (activations)`,
count: result.count,
});
} catch (error) {
console.error('Error resetting activations:', error);
return NextResponse.json(
{ error: 'Failed to reset activations' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
try {
// Reset all song ratings to 0
const result = await prisma.song.updateMany({
data: {
averageRating: 0,
ratingCount: 0,
},
});
return NextResponse.json({
success: true,
message: `Successfully reset ratings for ${result.count} songs`,
count: result.count,
});
} catch (error) {
console.error('Error resetting ratings:', error);
return NextResponse.json(
{ error: 'Failed to reset ratings' },
{ status: 500 }
);
}
}

View File

@@ -20,11 +20,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ rewrittenMessage: message }); return NextResponse.json({ rewrittenMessage: message });
} }
const prompt = `Rewrite the following message to express the COMPLETE OPPOSITE meaning and opinion. const prompt = `You are a content moderation assistant. Analyze the following message and determine if it is truly inappropriate, unfriendly, sexist, or offensive.
If the message is negative or critical, rewrite it to be overwhelmingly positive, praising, and appreciative.
If the message is positive, rewrite it to be critical or negative. Rules:
Maintain the original language (German or English). - ONLY rewrite the message if it is genuinely unfriendly, sexist, inappropriate, or offensive
Return ONLY the rewritten message text, nothing else. - If the message is polite, constructive, or even just neutral/critical feedback, return it UNCHANGED
- If the message needs rewriting, rewrite it to express the COMPLETE OPPOSITE meaning - make it positive, respectful, and appreciative
- Maintain the original language (German or English)
- Return ONLY the message text (either unchanged original or rewritten version), nothing else
Message: "${message}"`; Message: "${message}"`;
@@ -58,8 +61,39 @@ Message: "${message}"`;
const data = await response.json(); const data = await response.json();
let rewrittenMessage = data.choices?.[0]?.message?.content?.trim() || message; let rewrittenMessage = data.choices?.[0]?.message?.content?.trim() || message;
// Add suffix // Remove any explanatory comments in parentheses that the AI might add
rewrittenMessage += " (autocorrected by Polite-Bot)"; // e.g., "(This message is a friendly, positive comment expressing appreciation. No rewriting is necessary.)"
rewrittenMessage = rewrittenMessage.replace(/\s*\([^)]*\)\s*/g, '').trim();
// Remove surrounding quotes if present (AI sometimes adds quotes)
// Handle both single and double quotes, and multiple layers of quotes
rewrittenMessage = rewrittenMessage.replace(/^["']+|["']+$/g, '').trim();
// Normalize both messages for comparison (remove extra whitespace, normalize quotes, case-insensitive)
const normalizeForComparison = (text: string): string => {
return text
.trim()
.replace(/["']/g, '') // Remove all quotes for comparison
.replace(/\s+/g, ' ') // Normalize whitespace
.toLowerCase()
.replace(/[.,!?;:]\s*$/, ''); // Remove trailing punctuation for comparison
};
const originalTrimmed = message.trim();
const rewrittenTrimmed = rewrittenMessage.trim();
const originalNormalized = normalizeForComparison(originalTrimmed);
const rewrittenNormalized = normalizeForComparison(rewrittenTrimmed);
// Check if message was actually changed (content-wise, not just formatting)
// Only consider it changed if the normalized content is different
const wasChanged = originalNormalized !== rewrittenNormalized;
if (wasChanged) {
rewrittenMessage = rewrittenTrimmed + " (autocorrected by Polite-Bot)";
} else {
// Return original message if not changed (without suffix)
rewrittenMessage = originalTrimmed;
}
return NextResponse.json({ rewrittenMessage }); return NextResponse.json({ rewrittenMessage });

View File

@@ -43,18 +43,20 @@ export async function PUT(
try { try {
const { id } = await params; const { id } = await params;
const specialId = parseInt(id); const specialId = parseInt(id);
const { name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json(); const { name, maxAttempts, unlockSteps, launchDate, endDate, curator, hidden } = await request.json();
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (maxAttempts !== undefined) updateData.maxAttempts = maxAttempts;
if (unlockSteps !== undefined) updateData.unlockSteps = typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(unlockSteps);
if (launchDate !== undefined) updateData.launchDate = launchDate ? new Date(launchDate) : null;
if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null;
if (curator !== undefined) updateData.curator = curator || null;
if (hidden !== undefined) updateData.hidden = Boolean(hidden);
const special = await prisma.special.update({ const special = await prisma.special.update({
where: { id: specialId }, where: { id: specialId },
data: { data: updateData
name,
maxAttempts,
unlockSteps: typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(unlockSteps),
launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
}
}); });
return NextResponse.json(special); return NextResponse.json(special);

View File

@@ -35,11 +35,26 @@ export async function POST(request: Request) {
const authError = await requireAdminAuth(request as any); const authError = await requireAdminAuth(request as any);
if (authError) return authError; if (authError) return authError;
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json(); const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator, hidden = false } = await request.json();
if (!name) { if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 }); return NextResponse.json({ error: 'Name is required' }, { status: 400 });
} }
// Validate unlockSteps JSON
if (unlockSteps) {
try {
const parsed = JSON.parse(unlockSteps);
if (!Array.isArray(parsed)) {
return NextResponse.json({ error: 'Unlock steps must be a JSON array' }, { status: 400 });
}
if (parsed.some((item: any) => typeof item !== 'number' || item < 1)) {
return NextResponse.json({ error: 'All unlock step values must be positive numbers' }, { status: 400 });
}
} catch (e) {
return NextResponse.json({ error: 'Invalid JSON format for unlock steps. Please use an array of numbers, e.g. [2,4,7,11,16,30,60]' }, { status: 400 });
}
}
// Ensure name is stored as JSON // Ensure name is stored as JSON
const nameData = typeof name === 'string' ? { de: name, en: name } : name; const nameData = typeof name === 'string' ? { de: name, en: name } : name;
const subtitleData = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null; const subtitleData = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
@@ -53,6 +68,7 @@ export async function POST(request: Request) {
launchDate: launchDate ? new Date(launchDate) : null, launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null, endDate: endDate ? new Date(endDate) : null,
curator: curator || null, curator: curator || null,
hidden: Boolean(hidden),
}, },
}); });
return NextResponse.json(special); return NextResponse.json(special);
@@ -76,11 +92,26 @@ export async function PUT(request: Request) {
const authError = await requireAdminAuth(request as any); const authError = await requireAdminAuth(request as any);
if (authError) return authError; if (authError) return authError;
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json(); const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator, hidden } = await request.json();
if (!id) { if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 }); return NextResponse.json({ error: 'ID required' }, { status: 400 });
} }
// Validate unlockSteps JSON if provided
if (unlockSteps !== undefined) {
try {
const parsed = JSON.parse(unlockSteps);
if (!Array.isArray(parsed)) {
return NextResponse.json({ error: 'Unlock steps must be a JSON array' }, { status: 400 });
}
if (parsed.some((item: any) => typeof item !== 'number' || item < 1)) {
return NextResponse.json({ error: 'All unlock step values must be positive numbers' }, { status: 400 });
}
} catch (e) {
return NextResponse.json({ error: 'Invalid JSON format for unlock steps. Please use an array of numbers, e.g. [2,4,7,11,16,30,60]' }, { status: 400 });
}
}
const updateData: any = {}; const updateData: any = {};
if (name) updateData.name = typeof name === 'string' ? { de: name, en: name } : name; if (name) updateData.name = typeof name === 'string' ? { de: name, en: name } : name;
if (subtitle !== undefined) updateData.subtitle = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null; if (subtitle !== undefined) updateData.subtitle = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
@@ -89,6 +120,7 @@ export async function PUT(request: Request) {
if (launchDate !== undefined) updateData.launchDate = launchDate ? new Date(launchDate) : null; if (launchDate !== undefined) updateData.launchDate = launchDate ? new Date(launchDate) : null;
if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null; if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null;
if (curator !== undefined) updateData.curator = curator || null; if (curator !== undefined) updateData.curator = curator || null;
if (hidden !== undefined) updateData.hidden = Boolean(hidden);
const updated = await prisma.special.update({ const updated = await prisma.special.update({
where: { id: Number(id) }, where: { id: Number(id) },

File diff suppressed because it is too large Load Diff

View File

@@ -1,171 +0,0 @@
'use client';
import { useTranslations, useLocale } from 'next-intl';
import { Link } from '@/lib/navigation';
export default function CuratorHelpClient() {
const t = useTranslations('CuratorHelp');
const locale = useLocale();
return (
<main style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
<header style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>{t('title')}</h1>
<Link
href="/curator"
style={{
padding: '0.5rem 1rem',
background: '#6b7280',
color: 'white',
textDecoration: 'none',
borderRadius: '0.375rem',
fontSize: '0.9rem',
}}
>
{t('backToDashboard')}
</Link>
</div>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Einführung */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('introductionTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<p style={{ marginBottom: '1rem' }}>{t('introductionText')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('permissionsTitle')}</h3>
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.5rem' }}>{t('permission1')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('permission2')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('permission3')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('permission4')}</li>
</ul>
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#fef3c7', borderRadius: '0.375rem', border: '1px solid #fbbf24' }}>
<strong>{t('note')}:</strong> {t('permissionNote')}
</p>
</div>
</section>
{/* Song-Upload */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('uploadTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('uploadStepsTitle')}</h3>
<ol style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep1')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep2')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep3')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep4')}</li>
</ol>
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('uploadBestPracticesTitle')}</h3>
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice1')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice2')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice3')}</li>
</ul>
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#dbeafe', borderRadius: '0.375rem', border: '1px solid #3b82f6' }}>
<strong>{t('tip')}:</strong> {t('uploadTip')}
</p>
</div>
</section>
{/* Song-Bearbeitung */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('editingTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('singleEditTitle')}</h3>
<p style={{ marginBottom: '1rem' }}>{t('singleEditText')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('batchEditTitle')}</h3>
<p style={{ marginBottom: '1rem' }}>{t('batchEditText')}</p>
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature1')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature2')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature3')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature4')}</li>
</ul>
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('genreSpecialAssignmentTitle')}</h3>
<p style={{ marginBottom: '1rem' }}>{t('genreSpecialAssignmentText')}</p>
</div>
</section>
{/* Specials kuratieren */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('curateSpecialsHelpTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<p style={{ marginBottom: '1rem' }}>{t('curateSpecialsHelpIntro')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>
{t('curateSpecialsHelpStepsTitle')}
</h3>
<ol style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep1')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep2')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep3')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep4')}</li>
</ol>
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#fef3c7', borderRadius: '0.375rem', border: '1px solid #fbbf24' }}>
<strong>{t('note')}:</strong> {t('curateSpecialsPermissionsNote')}
</p>
</div>
</section>
{/* Kommentar-Verwaltung */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('commentsTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<p style={{ marginBottom: '1rem' }}>{t('commentsText')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('commentsActionsTitle')}</h3>
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.5rem' }}><strong>{t('markAsRead')}:</strong> {t('markAsReadText')}</li>
<li style={{ marginBottom: '0.5rem' }}><strong>{t('archive')}:</strong> {t('archiveText')}</li>
</ul>
</div>
</section>
{/* Best Practices */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('bestPracticesTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice1')}</li>
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice2')}</li>
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice3')}</li>
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice4')}</li>
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice5')}</li>
</ul>
</div>
</section>
{/* Troubleshooting */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('troubleshootingTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ1')}</h3>
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA1')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ2')}</h3>
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA2')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ3')}</h3>
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA3')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ4')}</h3>
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA4')}</p>
</div>
</section>
</div>
</main>
);
}

View File

@@ -1,8 +0,0 @@
export const dynamic = 'force-dynamic';
import CuratorHelpClient from './CuratorHelpClient';
export default function CuratorHelpPage() {
return <CuratorHelpClient />;
}

View File

@@ -1,11 +0,0 @@
// Server-Wrapper für die Kuratoren-Seite.
// Markiert die Route als dynamisch und rendert die eigentliche Client-Komponente.
export const dynamic = 'force-dynamic';
import CuratorPageClient from './CuratorPageClient';
export default function CuratorPage() {
return <CuratorPageClient />;
}

View File

@@ -1,168 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter, usePathname } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
import HelpTooltip from '@/components/HelpTooltip';
export default function CuratorSpecialEditorPage() {
const params = useParams();
const router = useRouter();
const pathname = usePathname();
const urlLocale = pathname?.split('/')[1] as 'de' | 'en' | undefined;
const intlLocale = useLocale() as 'de' | 'en';
const locale: 'de' | 'en' = urlLocale === 'de' || urlLocale === 'en' ? urlLocale : intlLocale;
const t = useTranslations('Curator');
const tHelp = useTranslations('CuratorHelp');
const specialId = params?.id as string;
const [special, setSpecial] = useState<CurateSpecial | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSpecial = async () => {
try {
setLoading(true);
const res = await fetch(`/api/curator/specials/${specialId}`, {
headers: getCuratorAuthHeaders(),
});
if (res.status === 403) {
setError(t('specialForbidden'));
return;
}
if (!res.ok) {
setError('Failed to load special');
return;
}
const data = await res.json();
setSpecial(data);
} catch (e) {
setError('Failed to load special');
} finally {
setLoading(false);
}
};
if (specialId) {
fetchSpecial();
}
}, [specialId, t]);
const handleSaveStartTime = async (songId: number, startTime: number) => {
const res = await fetch(`/api/curator/specials/${specialId}/songs`, {
method: 'PUT',
headers: {
...getCuratorAuthHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ songId, startTime }),
});
if (res.status === 403) {
setError(t('specialForbidden'));
} else if (!res.ok) {
setError('Failed to save changes');
}
};
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('loadingData')}</p>
</div>
);
}
if (error) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{error}</p>
<button
onClick={() => router.push(`/${locale}/curator`)}
style={{
marginTop: '1rem',
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
border: 'none',
background: '#e5e7eb',
cursor: 'pointer',
}}
>
{t('backToDashboard')}
</button>
</div>
);
}
if (!special) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('specialNotFound')}</p>
<button
onClick={() => router.push(`/${locale}/curator`)}
style={{
marginTop: '1rem',
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
border: 'none',
background: '#e5e7eb',
cursor: 'pointer',
}}
>
{t('backToDashboard')}
</button>
</div>
);
}
return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold' }}>
{t('curateSpecialHeaderPrefix')}
</h1>
<HelpTooltip
shortText={tHelp('tooltipCurateSpecialEditorShort')}
longText={tHelp('tooltipCurateSpecialEditorLong')}
position="bottom"
/>
</div>
<button
type="button"
onClick={() => router.push(`/${locale}/curator/specials`)}
style={{
padding: '0.5rem 1rem',
background: '#e5e7eb',
borderRadius: '0.5rem',
border: 'none',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
{t('backToCuratorSpecials')}
</button>
</div>
<CurateSpecialEditor
special={special}
locale={locale}
onBack={() => router.push(`/${locale}/curator/specials`)}
onSaveStartTime={handleSaveStartTime}
backLabel={t('backToCuratorSpecials')}
headerPrefix={t('curateSpecialHeaderPrefix')}
noSongsHint={t('curateSpecialNoSongs')}
noSongsSubHint={t('curateSpecialNoSongsSub')}
instructionsText={t('curateSpecialInstructions')}
savingLabel={t('saving')}
saveChangesLabel={t('saveChanges')}
savedLabel={t('saved')}
/>
</div>
);
}

View File

@@ -1,161 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Link } from '@/lib/navigation';
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
import HelpTooltip from '@/components/HelpTooltip';
type LocalizedString = string | { de: string; en: string };
interface CuratorSpecialSummary {
id: number;
name: LocalizedString;
songCount: number;
}
export default function CuratorSpecialsPage() {
const t = useTranslations('Curator');
const tHelp = useTranslations('CuratorHelp');
const locale = useLocale();
const [specials, setSpecials] = useState<CuratorSpecialSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSpecials = async () => {
try {
setLoading(true);
const res = await fetch('/api/curator/specials', {
headers: getCuratorAuthHeaders(),
});
if (res.ok) {
const data = await res.json();
setSpecials(data);
} else if (res.status === 403) {
setError(t('noSpecialPermissions'));
} else {
setError('Failed to load specials');
}
} catch (e) {
setError('Failed to load specials');
} finally {
setLoading(false);
}
};
fetchSpecials();
}, [t]);
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('loadingData')}</p>
</div>
);
}
if (error) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{error}</p>
</div>
);
}
if (specials.length === 0) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('noSpecialsInScope')}</p>
<div style={{ marginTop: '1.5rem' }}>
<Link
href="/curator"
style={{
padding: '0.5rem 1rem',
background: '#e5e7eb',
borderRadius: '0.5rem',
textDecoration: 'none',
color: '#111827',
fontSize: '0.9rem',
}}
>
{t('backToDashboard')}
</Link>
</div>
</div>
);
}
const resolveLocalized = (value: LocalizedString, locale: string): string => {
if (!value) return '';
if (typeof value === 'string') return value;
const loc = locale === 'de' || locale === 'en' ? locale : 'en';
return value[loc] ?? value.en ?? value.de;
};
return (
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{t('curateSpecialsTitle')}
</h1>
<HelpTooltip
shortText={tHelp('tooltipCurateSpecialsShort')}
longText={tHelp('tooltipCurateSpecialsLong')}
position="bottom"
/>
</div>
<Link
href="/curator"
style={{
padding: '0.5rem 1rem',
background: '#e5e7eb',
borderRadius: '0.5rem',
textDecoration: 'none',
color: '#111827',
fontSize: '0.9rem',
}}
>
{t('backToDashboard')}
</Link>
</div>
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>
{t('curateSpecialsDescription')}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
{specials.map(special => (
<Link
key={special.id}
href={`/curator/specials/${special.id}`}
style={{
padding: '1rem',
borderRadius: '0.75rem',
border: '1px solid #e5e7eb',
background: 'white',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
textDecoration: 'none',
color: 'inherit',
}}
>
<div>
<h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: '0.25rem' }}>
{resolveLocalized(special.name, String(locale))}
</h2>
<p style={{ fontSize: '0.875rem', color: '#6b7280' }}>
{t('curateSpecialSongCount', { count: special.songCount })}
</p>
</div>
<div style={{ marginTop: '0.75rem', textAlign: 'right', fontSize: '0.875rem', color: '#4f46e5' }}>
{t('curateSpecialOpen')}
</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -1,141 +0,0 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
min-height: 100vh;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}

View File

@@ -98,19 +98,6 @@ export default function CurateSpecialEditor({
return ( return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}> <div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ marginBottom: '2rem' }}> <div style={{ marginBottom: '2rem' }}>
<button
onClick={onBack}
style={{
padding: '0.5rem 1rem',
background: '#e5e7eb',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
marginBottom: '1rem'
}}
>
{backLabel}
</button>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}> <h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{headerPrefix} {specialName} {headerPrefix} {specialName}
</h1> </h1>

View File

@@ -49,8 +49,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const t = useTranslations('Game'); const t = useTranslations('Game');
const locale = useLocale(); const locale = useLocale();
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial); const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
const [hasWon, setHasWon] = useState(false); const [hasWon, setHasWon] = useState(gameState?.isSolved ?? false);
const [hasLost, setHasLost] = useState(false); const [hasLost, setHasLost] = useState(gameState?.isFailed ?? false);
const [shareText, setShareText] = useState(`🔗 ${t('share')}`); const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null); const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
const [isProcessingGuess, setIsProcessingGuess] = useState(false); const [isProcessingGuess, setIsProcessingGuess] = useState(false);
@@ -67,6 +67,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [commentError, setCommentError] = useState<string | null>(null); const [commentError, setCommentError] = useState<string | null>(null);
const [commentCollapsed, setCommentCollapsed] = useState(true); const [commentCollapsed, setCommentCollapsed] = useState(true);
const [rewrittenMessage, setRewrittenMessage] = useState<string | null>(null); const [rewrittenMessage, setRewrittenMessage] = useState<string | null>(null);
const [commentAIConsent, setCommentAIConsent] = useState(false);
useEffect(() => { useEffect(() => {
const updateCountdown = () => { const updateCountdown = () => {
@@ -87,12 +88,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}, []); }, []);
useEffect(() => { useEffect(() => {
if (gameState && dailyPuzzle) { if (gameState) {
setHasWon(gameState.isSolved); setHasWon(gameState.isSolved);
setHasLost(gameState.isFailed); setHasLost(gameState.isFailed);
// Show year modal if won but year not guessed yet and release year is available // Show year modal if won but year not guessed yet and release year is available
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle.releaseYear) { if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle?.releaseYear) {
setShowYearModal(true); setShowYearModal(true);
} }
} }
@@ -317,7 +318,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}; };
const handleCommentSubmit = async () => { const handleCommentSubmit = async () => {
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle) { if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle || !commentAIConsent) {
return; return;
} }
@@ -343,9 +344,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const rewriteData = await rewriteResponse.json(); const rewriteData = await rewriteResponse.json();
if (rewriteData.rewrittenMessage) { if (rewriteData.rewrittenMessage) {
finalMessage = rewriteData.rewrittenMessage; finalMessage = rewriteData.rewrittenMessage;
// If message was changed significantly (simple check), show it // Only show rewritten message if it was actually changed
if (finalMessage !== commentText.trim()) { // The API adds "(autocorrected by Polite-Bot)" suffix only if message was changed
setRewrittenMessage(finalMessage); const wasChanged = finalMessage.includes('(autocorrected by Polite-Bot)');
if (wasChanged) {
// Remove the suffix for display
const displayMessage = finalMessage.replace(/\s*\(autocorrected by Polite-Bot\)\s*/g, '').trim();
setRewrittenMessage(displayMessage);
} else {
// Ensure rewrittenMessage is not set if message wasn't changed
setRewrittenMessage(null);
} }
} }
} }
@@ -416,11 +424,22 @@ 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 ? t('special') : t('genre')}: ${genre}\n` : ''; const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
// Use current domain from window.location to support both hoerdle.de and hördle.de
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
// For users on hördle.de, use Punycode domain (xn--hrdle-jua.de) in share message
// to avoid rendering issues with Unicode domains
let currentHost = rawHost;
if (rawHost === 'hördle.de' || rawHost === 'xn--hrdle-jua.de') {
currentHost = 'xn--hrdle-jua.de';
}
// OLD CODE (commented out - may be needed again in the future):
// Use current domain from window.location to support both hoerdle.de and hördle.de, // Use current domain from window.location to support both hoerdle.de and hördle.de,
// but always share the pretty Unicode-Domain "hördle.de" instead of the Punycode variant. // but always share the pretty Unicode-Domain "hördle.de" instead of the Punycode variant.
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain; // const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
let shareUrl = `${protocol}//${currentHost}`; let shareUrl = `${protocol}//${currentHost}`;
// Add locale prefix if not default (en) // Add locale prefix if not default (en)
if (locale !== 'en') { if (locale !== 'en') {
@@ -662,7 +681,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
fontFamily: 'inherit', fontFamily: 'inherit',
resize: 'vertical', resize: 'vertical',
marginBottom: '0.5rem', marginBottom: '0.5rem',
display: 'block' // Ensure block display for proper alignment display: 'block',
boxSizing: 'border-box' // Ensure padding and border are included in width
}} }}
disabled={commentSending} disabled={commentSending}
/> />
@@ -676,14 +696,26 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</span> </span>
)} )}
</div> </div>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--foreground)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={commentAIConsent}
onChange={(e) => setCommentAIConsent(e.target.checked)}
disabled={commentSending || commentSent}
style={{ marginTop: '0.2rem', cursor: (commentSending || commentSent) ? 'not-allowed' : 'pointer' }}
/>
<span>{t('commentAIConsent')}</span>
</label>
</div>
<button <button
onClick={handleCommentSubmit} onClick={handleCommentSubmit}
disabled={!commentText.trim() || commentSending || commentSent} disabled={!commentText.trim() || commentSending || commentSent || !commentAIConsent}
className="btn-primary" className="btn-primary"
style={{ style={{
width: '100%', width: '100%',
opacity: (!commentText.trim() || commentSending || commentSent) ? 0.5 : 1, opacity: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? 0.5 : 1,
cursor: (!commentText.trim() || commentSending || commentSent) ? 'not-allowed' : 'pointer' cursor: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? 'not-allowed' : 'pointer'
}} }}
> >
{commentSending ? t('sending') : t('sendComment')} {commentSending ? t('sending') : t('sendComment')}
@@ -695,14 +727,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{commentSent && ( {commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}> <div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: rewrittenMessage ? '0.5rem' : 0 }}> {rewrittenMessage ? (
{t('commentSent')} <>
</p> <p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: '0.5rem' }}>
{rewrittenMessage && ( {t('commentSent')}
<div style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', textAlign: 'center' }}> </p>
<p style={{ marginBottom: '0.25rem' }}>{t('commentRewritten')}</p> <div style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', textAlign: 'center' }}>
<p style={{ fontStyle: 'italic' }}>"{rewrittenMessage}"</p> <p style={{ marginBottom: '0.25rem' }}>{t('commentRewritten')}</p>
</div> <p style={{ fontStyle: 'italic' }}>"{rewrittenMessage}"</p>
</div>
</>
) : (
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: 0 }}>
{t('commentThankYou')}
</p>
)} )}
</div> </div>
)} )}

View File

@@ -12,10 +12,14 @@ interface WaveformEditorProps {
export default function WaveformEditor({ audioUrl, startTime, duration, unlockSteps, onStartTimeChange }: WaveformEditorProps) { export default function WaveformEditor({ audioUrl, startTime, duration, unlockSteps, onStartTimeChange }: WaveformEditorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const timelineRef = useRef<HTMLCanvasElement>(null);
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null); const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
const [audioDuration, setAudioDuration] = useState(0); const [audioDuration, setAudioDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [playingSegment, setPlayingSegment] = useState<number | null>(null); const [playingSegment, setPlayingSegment] = useState<number | null>(null);
const [isPlayingFullTitle, setIsPlayingFullTitle] = useState(false);
const [pausedPosition, setPausedPosition] = useState<number | null>(null); // Position when paused
const [pausedType, setPausedType] = useState<'selection' | 'title' | null>(null); // Type of playback that was paused
const [zoom, setZoom] = useState(1); // 1 = full view, higher = zoomed in const [zoom, setZoom] = useState(1); // 1 = full view, higher = zoomed in
const [viewOffset, setViewOffset] = useState(0); // Offset in seconds for panning const [viewOffset, setViewOffset] = useState(0); // Offset in seconds for panning
const [playbackPosition, setPlaybackPosition] = useState<number | null>(null); // Current playback position in seconds const [playbackPosition, setPlaybackPosition] = useState<number | null>(null); // Current playback position in seconds
@@ -55,6 +59,80 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
}; };
}, [audioUrl]); }, [audioUrl]);
// Draw timeline
useEffect(() => {
if (!audioDuration || !timelineRef.current) return;
const timeline = timelineRef.current;
const ctx = timeline.getContext('2d');
if (!ctx) return;
const width = timeline.width;
const height = timeline.height;
// Calculate visible range based on zoom and offset (same as waveform)
const visibleDuration = audioDuration / zoom;
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
const visibleEnd = Math.min(audioDuration, visibleStart + visibleDuration);
// Clear timeline
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
// Draw border
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, width, height);
// Calculate appropriate time interval based on visible duration
let timeInterval = 1; // Start with 1 second
if (visibleDuration > 60) timeInterval = 10;
else if (visibleDuration > 30) timeInterval = 5;
else if (visibleDuration > 10) timeInterval = 2;
else if (visibleDuration > 5) timeInterval = 1;
else if (visibleDuration > 1) timeInterval = 0.5;
else timeInterval = 0.1;
// Draw time markers
ctx.strokeStyle = '#9ca3af';
ctx.lineWidth = 1;
ctx.fillStyle = '#374151';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const startTimeMarker = Math.floor(visibleStart / timeInterval) * timeInterval;
for (let time = startTimeMarker; time <= visibleEnd; time += timeInterval) {
const timePx = ((time - visibleStart) / visibleDuration) * width;
if (timePx >= 0 && timePx <= width) {
// Draw tick mark
ctx.beginPath();
ctx.moveTo(timePx, 0);
ctx.lineTo(timePx, height);
ctx.stroke();
// Draw time label
const timeLabel = time.toFixed(timeInterval < 1 ? 1 : 0);
ctx.fillText(`${timeLabel}s`, timePx, 2);
}
}
// Draw current playback position if playing
if (playbackPosition !== null) {
const playbackPx = ((playbackPosition - visibleStart) / visibleDuration) * width;
if (playbackPx >= 0 && playbackPx <= width) {
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(playbackPx, 0);
ctx.lineTo(playbackPx, height);
ctx.stroke();
}
}
}, [audioDuration, zoom, viewOffset, playbackPosition]);
useEffect(() => { useEffect(() => {
if (!audioBuffer || !canvasRef.current) return; if (!audioBuffer || !canvasRef.current) return;
@@ -133,6 +211,24 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
cumulativeTime = step; cumulativeTime = step;
}); });
// Draw end marker for the last segment (at startTime + duration)
const endTime = startTime + duration;
const endPx = ((endTime - visibleStart) / visibleDuration) * width;
if (endPx >= 0 && endPx <= width) {
ctx.beginPath();
ctx.moveTo(endPx, 0);
ctx.lineTo(endPx, height);
ctx.stroke();
// Draw "End" label
ctx.setLineDash([]);
ctx.fillStyle = '#ef4444';
ctx.font = 'bold 12px sans-serif';
ctx.fillText('End', endPx + 3, 15);
ctx.setLineDash([5, 5]);
}
ctx.setLineDash([]); ctx.setLineDash([]);
// Draw hover preview (semi-transparent) // Draw hover preview (semi-transparent)
@@ -215,11 +311,21 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
setHoverPreviewTime(null); setHoverPreviewTime(null);
}; };
const stopPlayback = () => { const stopPlayback = (savePosition = false) => {
if (savePosition && playbackPosition !== null) {
// Save current position for resume
setPausedPosition(playbackPosition);
// Keep playbackPosition visible (don't set to null) so cursor stays visible
} else {
// Clear paused position if stopping completely
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(null);
}
sourceRef.current?.stop(); sourceRef.current?.stop();
setIsPlaying(false); setIsPlaying(false);
setPlayingSegment(null); setPlayingSegment(null);
setPlaybackPosition(null); setIsPlayingFullTitle(false);
if (animationFrameRef.current) { if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current); cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null; animationFrameRef.current = null;
@@ -287,30 +393,119 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
const handlePlayFull = () => { const handlePlayFull = () => {
if (!audioBuffer || !audioContextRef.current) return; if (!audioBuffer || !audioContextRef.current) return;
if (isPlaying) { // If full selection playback is already playing, pause it
stopPlayback(); if (isPlaying && playingSegment === null && !isPlayingFullTitle) {
} else { stopPlayback(true); // Save position
const source = audioContextRef.current.createBufferSource(); setPausedType('selection');
source.buffer = audioBuffer; return;
source.connect(audioContextRef.current.destination);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = startTime;
source.start(0, startTime, duration);
sourceRef.current = source;
setIsPlaying(true);
setPlaybackPosition(startTime);
source.onended = () => {
setIsPlaying(false);
setPlaybackPosition(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
} }
// Stop any current playback (segment, full selection, or full title)
stopPlayback();
// Determine start position (resume from pause or start from beginning)
const resumePosition = pausedType === 'selection' && pausedPosition !== null
? pausedPosition
: startTime;
const remainingDuration = resumePosition >= startTime + duration
? 0
: (startTime + duration) - resumePosition;
if (remainingDuration <= 0) {
// Already finished, reset
setPausedPosition(null);
setPausedType(null);
return;
}
// Start full selection playback
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = resumePosition;
source.start(0, resumePosition, remainingDuration);
sourceRef.current = source;
setIsPlaying(true);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(resumePosition);
source.onended = () => {
setIsPlaying(false);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPlaybackPosition(null);
setPausedPosition(null);
setPausedType(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
};
const handlePlayFullTitle = () => {
if (!audioBuffer || !audioContextRef.current) return;
// If full title playback is already playing, pause it
if (isPlaying && isPlayingFullTitle) {
stopPlayback(true); // Save position
setPausedType('title');
return;
}
// Stop any current playback (segment, full selection, or full title)
stopPlayback();
// Determine start position (resume from pause or start from beginning)
const resumePosition = pausedType === 'title' && pausedPosition !== null
? pausedPosition
: 0;
const remainingDuration = resumePosition >= audioDuration
? 0
: audioDuration - resumePosition;
if (remainingDuration <= 0) {
// Already finished, reset
setPausedPosition(null);
setPausedType(null);
return;
}
// Start full title playback (from resumePosition to audioDuration)
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = resumePosition;
source.start(0, resumePosition, remainingDuration);
sourceRef.current = source;
setIsPlaying(true);
setPlayingSegment(null);
setIsPlayingFullTitle(true);
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(resumePosition);
source.onended = () => {
setIsPlaying(false);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPlaybackPosition(null);
setPausedPosition(null);
setPausedType(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
}; };
const handleZoomIn = () => setZoom(prev => Math.min(prev * 1.5, 10)); const handleZoomIn = () => setZoom(prev => Math.min(prev * 1.5, 10));
@@ -371,21 +566,38 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
)} )}
</div> </div>
<canvas <div style={{ position: 'relative' }}>
ref={canvasRef} <canvas
width={800} ref={canvasRef}
height={150} width={800}
onClick={handleCanvasClick} height={150}
onMouseMove={handleCanvasMouseMove} onClick={handleCanvasClick}
onMouseLeave={handleCanvasMouseLeave} onMouseMove={handleCanvasMouseMove}
style={{ onMouseLeave={handleCanvasMouseLeave}
width: '100%', style={{
height: 'auto', width: '100%',
cursor: 'pointer', height: 'auto',
border: '1px solid #e5e7eb', cursor: 'pointer',
borderRadius: '0.5rem' border: '1px solid #e5e7eb',
}} borderRadius: '0.5rem 0.5rem 0 0',
/> display: 'block'
}}
/>
<canvas
ref={timelineRef}
width={800}
height={30}
style={{
width: '100%',
height: '30px',
border: '1px solid #e5e7eb',
borderTop: 'none',
borderRadius: '0 0 0.5rem 0.5rem',
display: 'block',
background: '#ffffff'
}}
/>
</div>
{/* Playback Controls */} {/* Playback Controls */}
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
@@ -401,7 +613,29 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
fontWeight: 'bold' fontWeight: 'bold'
}} }}
> >
{isPlaying && playingSegment === null ? '⏸ Pause' : '▶ Play Full Selection'} {isPlaying && playingSegment === null && !isPlayingFullTitle
? '⏸ Pause'
: (pausedType === 'selection' && pausedPosition !== null
? '▶ Resume'
: '▶ Play Full Selection')}
</button>
<button
onClick={handlePlayFullTitle}
style={{
padding: '0.5rem 1rem',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
{isPlaying && isPlayingFullTitle
? '⏸ Pause'
: (pausedType === 'title' && pausedPosition !== null
? '▶ Resume'
: '▶ Play Full Title')}
</button> </button>
<div style={{ fontSize: '0.875rem', color: '#666' }}> <div style={{ fontSize: '0.875rem', color: '#666' }}>

View File

@@ -29,9 +29,7 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
const allSongs = await prisma.song.findMany({ const allSongs = await prisma.song.findMany({
where: whereClause, where: whereClause,
include: { include: {
puzzles: { puzzles: true, // Load ALL puzzles, not just for this genre (to use total activations)
where: { genreId: genreId }
},
}, },
}); });
@@ -40,28 +38,24 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
return null; return null;
} }
// Calculate weights // Find songs with the minimum number of activations (all puzzles, not just for this genre)
const weightedSongs = allSongs.map(song => ({ // Only select from songs with the fewest activations to ensure fair distribution
const songsWithActivations = allSongs.map(song => ({
song, song,
weight: 1.0 / (song.puzzles.length + 1), activations: song.puzzles.length,
})); }));
// Calculate total weight // Find minimum activations
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0); const minActivations = Math.min(...songsWithActivations.map(item => item.activations));
// Pick a random song based on weights using cumulative weights // Filter to only songs with minimum activations
// This ensures proper distribution and handles edge cases const songsWithMinActivations = songsWithActivations
let random = Math.random() * totalWeight; .filter(item => item.activations === minActivations)
let selectedSong = weightedSongs[weightedSongs.length - 1].song; // Fallback to last song .map(item => item.song);
let cumulativeWeight = 0; // Randomly select from songs with minimum activations
for (const item of weightedSongs) { const randomIndex = Math.floor(Math.random() * songsWithMinActivations.length);
cumulativeWeight += item.weight; const selectedSong = songsWithMinActivations[randomIndex];
if (random <= cumulativeWeight) {
selectedSong = item.song;
break;
}
}
// Create the daily puzzle // Create the daily puzzle
try { try {
@@ -141,7 +135,7 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
song: { song: {
include: { include: {
puzzles: { puzzles: {
where: { specialId: special.id } where: { specialId: special.id } // For specials, only count puzzles within this special
} }
} }
} }
@@ -150,25 +144,25 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
if (specialSongs.length === 0) return null; if (specialSongs.length === 0) return null;
// Calculate weights // Find songs with the minimum number of activations within this special
const weightedSongs = specialSongs.map(specialSong => ({ // Note: For specials, we only count puzzles within the special (not all puzzles),
// since specials are curated, separate lists
const songsWithActivations = specialSongs.map(specialSong => ({
specialSong, specialSong,
weight: 1.0 / (specialSong.song.puzzles.length + 1), activations: specialSong.song.puzzles.length,
})); }));
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0); // Find minimum activations
let random = Math.random() * totalWeight; const minActivations = Math.min(...songsWithActivations.map(item => item.activations));
let selectedSpecialSong = weightedSongs[weightedSongs.length - 1].specialSong; // Fallback to last song
// Pick a random song based on weights using cumulative weights // Filter to only songs with minimum activations
let cumulativeWeight = 0; const songsWithMinActivations = songsWithActivations
for (const item of weightedSongs) { .filter(item => item.activations === minActivations)
cumulativeWeight += item.weight; .map(item => item.specialSong);
if (random <= cumulativeWeight) {
selectedSpecialSong = item.specialSong; // Randomly select from songs with minimum activations
break; const randomIndex = Math.floor(Math.random() * songsWithMinActivations.length);
} const selectedSpecialSong = songsWithMinActivations[randomIndex];
}
try { try {
dailyPuzzle = await prisma.dailyPuzzle.create({ dailyPuzzle = await prisma.dailyPuzzle.create({

View File

@@ -61,7 +61,9 @@
"sendCommentCollapsed": "Nachricht an Kurator senden", "sendCommentCollapsed": "Nachricht an Kurator senden",
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres... Bitte bleibe freundlich und höflich.", "commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres... Bitte bleibe freundlich und höflich.",
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.", "commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
"commentAIConsent": "Ich bin damit einverstanden, dass diese Nachricht von einer KI verarbeitet wird, um unfreundliche Nachrichten zu filtern.",
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.", "commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
"commentThankYou": "Vielen Dank für dein Feedback!",
"commentRewritten": "Deine Nachricht wurde automatisch freundlicher formuliert:", "commentRewritten": "Deine Nachricht wurde automatisch freundlicher formuliert:",
"commentError": "Fehler beim Senden der Nachricht", "commentError": "Fehler beim Senden der Nachricht",
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.", "commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
@@ -147,6 +149,10 @@
"subtitle": "Untertitel", "subtitle": "Untertitel",
"maxAttempts": "Max. Versuche", "maxAttempts": "Max. Versuche",
"unlockSteps": "Freischalt-Schritte", "unlockSteps": "Freischalt-Schritte",
"unlockStepsRequired": "Freischalt-Schritte sind erforderlich",
"unlockStepsInvalidJson": "Ungültiges JSON-Format. Bitte verwende ein Array von Zahlen, z.B. [2,4,7,11,16,30,60]",
"unlockStepsMustBeArray": "Freischalt-Schritte müssen ein Array sein",
"unlockStepsMustBePositiveNumbers": "Alle Werte müssen positive Zahlen sein",
"launchDate": "Startdatum", "launchDate": "Startdatum",
"endDate": "Enddatum", "endDate": "Enddatum",
"curator": "Kurator", "curator": "Kurator",
@@ -203,6 +209,7 @@
"selectedFilesTitle": "Ausgewählte Dateien:", "selectedFilesTitle": "Ausgewählte Dateien:",
"uploadProgress": "Upload: {current} / {total}", "uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Genres zuordnen", "assignGenresLabel": "Genres zuordnen",
"assignSpecialsLabel": "Specials zuordnen",
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.", "noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
"uploadButtonIdle": "Upload starten", "uploadButtonIdle": "Upload starten",
"uploadButtonUploading": "Lade hoch...", "uploadButtonUploading": "Lade hoch...",
@@ -224,6 +231,7 @@
"columnTitle": "Titel", "columnTitle": "Titel",
"columnArtist": "Artist", "columnArtist": "Artist",
"columnYear": "Jahr", "columnYear": "Jahr",
"columnCover": "Cover",
"columnGenresSpecials": "Genres / Specials", "columnGenresSpecials": "Genres / Specials",
"columnAdded": "Hinzugefügt", "columnAdded": "Hinzugefügt",
"columnActivations": "Aktivierungen", "columnActivations": "Aktivierungen",
@@ -276,11 +284,13 @@
"batchUpdateError": "Fehler: {error}", "batchUpdateError": "Fehler: {error}",
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung", "batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung",
"backToDashboard": "Zurück zum Dashboard", "backToDashboard": "Zurück zum Dashboard",
"loading": "Laden...",
"curateSpecialsButton": "Specials kuratieren", "curateSpecialsButton": "Specials kuratieren",
"curateSpecialsTitle": "Deine Specials kuratieren", "curateSpecialsTitle": "Deine Specials kuratieren",
"curateSpecialsDescription": "Hier kannst du die Startzeiten der Songs in deinen zugewiesenen Specials für das Rätsel feinjustieren.", "curateSpecialsDescription": "Hier kannst du die Startzeiten der Songs in deinen zugewiesenen Specials für das Rätsel feinjustieren.",
"noSpecialPermissions": "Dir sind keine Specials zugeordnet.", "noSpecialPermissions": "Dir sind keine Specials zugeordnet.",
"noSpecialsInScope": "Keine Specials zum Kuratieren vorhanden.", "noSpecialsInScope": "Keine Specials zum Kuratieren vorhanden.",
"noSpecialsAssigned": "Dir sind keine Specials zugeordnet.",
"curateSpecialSongCount": "{count, plural, one {# Song} other {# Songs}} in diesem Special", "curateSpecialSongCount": "{count, plural, one {# Song} other {# Songs}} in diesem Special",
"curateSpecialOpen": "Öffnen", "curateSpecialOpen": "Öffnen",
"specialForbidden": "Du darfst dieses Special nicht bearbeiten.", "specialForbidden": "Du darfst dieses Special nicht bearbeiten.",
@@ -311,12 +321,12 @@
"uploadTitle": "Songs hochladen", "uploadTitle": "Songs hochladen",
"uploadStepsTitle": "Schritt-für-Schritt-Anleitung", "uploadStepsTitle": "Schritt-für-Schritt-Anleitung",
"uploadStep1": "MP3-Dateien in den Upload-Bereich ziehen oder klicken, um Dateien auszuwählen", "uploadStep1": "MP3-Dateien in den Upload-Bereich ziehen oder klicken, um Dateien auszuwählen",
"uploadStep2": "Ein oder mehrere Genres auswählen, um sie den hochgeladenen Songs zuzuordnen", "uploadStep2": "Ein oder mehrere Genres und falls passend Specials auswählen, um sie den hochgeladenen Songs zuzuordnen",
"uploadStep3": "Auf 'Upload starten' klicken, um den Upload-Prozess zu beginnen", "uploadStep3": "Auf 'Upload starten' klicken, um den Upload-Prozess zu beginnen",
"uploadStep4": "Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus den Dateien", "uploadStep4": "Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus den Dateien",
"uploadBestPracticesTitle": "Best Practices", "uploadBestPracticesTitle": "Best Practices",
"uploadBestPractice1": "Stelle sicher, dass MP3-Dateien korrekte ID3-Tags (Titel, Artist) für die automatische Metadaten-Extraktion haben", "uploadBestPractice1": "Stelle sicher, dass MP3-Dateien korrekte ID3-Tags (Titel, Artist) für die automatische Metadaten-Extraktion haben",
"uploadBestPractice2": "Passende Genres vor dem Upload auswählen, um spätere manuelle Zuordnung zu vermeiden", "uploadBestPractice2": "Passende Genres (und Specials) vor dem Upload auswählen, um spätere manuelle Zuordnung zu vermeiden",
"uploadBestPractice3": "Vor dem Upload auf Duplikate prüfen - das System warnt dich, wenn ein Song bereits existiert", "uploadBestPractice3": "Vor dem Upload auf Duplikate prüfen - das System warnt dich, wenn ein Song bereits existiert",
"tip": "Tipp", "tip": "Tipp",
"uploadTip": "Alle von Kuratoren hochgeladenen Songs werden automatisch von der globalen Playlist ausgeschlossen. Nur Admins können diese Einstellung ändern.", "uploadTip": "Alle von Kuratoren hochgeladenen Songs werden automatisch von der globalen Playlist ausgeschlossen. Nur Admins können diese Einstellung ändern.",
@@ -367,6 +377,8 @@
"tooltipUploadLong": "Ziehe MP3-Dateien per Drag & Drop oder klicke, um sie auszuwählen. Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus ID3-Tags. Wähle Genres vor dem Upload aus, um Songs automatisch zuzuordnen. Alle Kuratoren-Uploads sind standardmäßig von der globalen Playlist ausgeschlossen.", "tooltipUploadLong": "Ziehe MP3-Dateien per Drag & Drop oder klicke, um sie auszuwählen. Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus ID3-Tags. Wähle Genres vor dem Upload aus, um Songs automatisch zuzuordnen. Alle Kuratoren-Uploads sind standardmäßig von der globalen Playlist ausgeschlossen.",
"tooltipGenreAssignmentShort": "Genres zu hochgeladenen Songs zuordnen", "tooltipGenreAssignmentShort": "Genres zu hochgeladenen Songs zuordnen",
"tooltipGenreAssignmentLong": "Wähle ein oder mehrere Genres vor dem Upload aus. Die ausgewählten Genres werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Genres zuordnen, für die du verantwortlich bist. Wenn du keine Genres auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.", "tooltipGenreAssignmentLong": "Wähle ein oder mehrere Genres vor dem Upload aus. Die ausgewählten Genres werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Genres zuordnen, für die du verantwortlich bist. Wenn du keine Genres auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.",
"tooltipSpecialAssignmentShort": "Specials zu hochgeladenen Songs zuordnen",
"tooltipSpecialAssignmentLong": "Wähle ein oder mehrere Specials vor dem Upload aus. Die ausgewählten Specials werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Specials zuordnen, für die du verantwortlich bist. Wenn du keine Specials auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.",
"tooltipTracklistShort": "Deine Songs verwalten", "tooltipTracklistShort": "Deine Songs verwalten",
"tooltipTracklistLong": "Diese Tabelle zeigt alle Songs in deinen Genres und Specials. Du kannst suchen, filtern, sortieren und Songs bearbeiten. Nutze die Checkboxen, um mehrere Songs für die Batch-Bearbeitung auszuwählen. Nur Songs, die du bearbeiten kannst, haben eine aktive Checkbox.", "tooltipTracklistLong": "Diese Tabelle zeigt alle Songs in deinen Genres und Specials. Du kannst suchen, filtern, sortieren und Songs bearbeiten. Nutze die Checkboxen, um mehrere Songs für die Batch-Bearbeitung auszuwählen. Nur Songs, die du bearbeiten kannst, haben eine aktive Checkbox.",
"tooltipSearchShort": "Nach Titel oder Artist suchen", "tooltipSearchShort": "Nach Titel oder Artist suchen",

View File

@@ -61,7 +61,9 @@
"sendCommentCollapsed": "Send message to curator", "sendCommentCollapsed": "Send message to curator",
"commentPlaceholder": "Write a message to the curators of this genre... Please remain friendly and polite.", "commentPlaceholder": "Write a message to the curators of this genre... Please remain friendly and polite.",
"commentHelp": "Share your thoughts on the puzzle with the curators. Your message will be shown to them.", "commentHelp": "Share your thoughts on the puzzle with the curators. Your message will be shown to them.",
"commentAIConsent": "I agree that this message will be processed by an AI to filter unfriendly messages.",
"commentSent": "✓ Message sent! Thank you for your feedback.", "commentSent": "✓ Message sent! Thank you for your feedback.",
"commentThankYou": "Thank you for your feedback!",
"commentRewritten": "Your message was automatically rephrased to be more friendly:", "commentRewritten": "Your message was automatically rephrased to be more friendly:",
"commentError": "Error sending message", "commentError": "Error sending message",
"commentRateLimited": "You have already sent a message for this puzzle.", "commentRateLimited": "You have already sent a message for this puzzle.",
@@ -147,6 +149,10 @@
"subtitle": "Subtitle", "subtitle": "Subtitle",
"maxAttempts": "Max Attempts", "maxAttempts": "Max Attempts",
"unlockSteps": "Unlock Steps", "unlockSteps": "Unlock Steps",
"unlockStepsRequired": "Unlock steps are required",
"unlockStepsInvalidJson": "Invalid JSON format. Please use an array of numbers, e.g. [2,4,7,11,16,30,60]",
"unlockStepsMustBeArray": "Unlock steps must be an array",
"unlockStepsMustBePositiveNumbers": "All values must be positive numbers",
"launchDate": "Launch Date", "launchDate": "Launch Date",
"endDate": "End Date", "endDate": "End Date",
"curator": "Curator", "curator": "Curator",
@@ -203,6 +209,7 @@
"selectedFilesTitle": "Selected files:", "selectedFilesTitle": "Selected files:",
"uploadProgress": "Upload: {current} / {total}", "uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Assign genres", "assignGenresLabel": "Assign genres",
"assignSpecialsLabel": "Assign specials",
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.", "noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
"uploadButtonIdle": "Start upload", "uploadButtonIdle": "Start upload",
"uploadButtonUploading": "Uploading...", "uploadButtonUploading": "Uploading...",
@@ -224,6 +231,7 @@
"columnTitle": "Title", "columnTitle": "Title",
"columnArtist": "Artist", "columnArtist": "Artist",
"columnYear": "Year", "columnYear": "Year",
"columnCover": "Cover",
"columnGenresSpecials": "Genres / Specials", "columnGenresSpecials": "Genres / Specials",
"columnAdded": "Added", "columnAdded": "Added",
"columnActivations": "Activations", "columnActivations": "Activations",
@@ -276,11 +284,13 @@
"batchUpdateError": "Error: {error}", "batchUpdateError": "Error: {error}",
"batchUpdateNetworkError": "Network error during batch update", "batchUpdateNetworkError": "Network error during batch update",
"backToDashboard": "Back to dashboard", "backToDashboard": "Back to dashboard",
"loading": "Loading...",
"curateSpecialsButton": "Curate Specials", "curateSpecialsButton": "Curate Specials",
"curateSpecialsTitle": "Curate your Specials", "curateSpecialsTitle": "Curate your Specials",
"curateSpecialsDescription": "Here you can fine-tune the start times of the songs in your assigned specials for the puzzle.", "curateSpecialsDescription": "Here you can fine-tune the start times of the songs in your assigned specials for the puzzle.",
"noSpecialPermissions": "You do not have any specials assigned to you.", "noSpecialPermissions": "You do not have any specials assigned to you.",
"noSpecialsInScope": "No specials available for you to curate.", "noSpecialsInScope": "No specials available for you to curate.",
"noSpecialsAssigned": "No specials assigned to you.",
"curateSpecialSongCount": "{count, plural, one {# song} other {# songs}} in this special", "curateSpecialSongCount": "{count, plural, one {# song} other {# songs}} in this special",
"curateSpecialOpen": "Open", "curateSpecialOpen": "Open",
"specialForbidden": "You are not allowed to edit this special.", "specialForbidden": "You are not allowed to edit this special.",
@@ -311,12 +321,12 @@
"uploadTitle": "Uploading Songs", "uploadTitle": "Uploading Songs",
"uploadStepsTitle": "Step-by-Step Guide", "uploadStepsTitle": "Step-by-Step Guide",
"uploadStep1": "Drag MP3 files into the upload area or click to select files", "uploadStep1": "Drag MP3 files into the upload area or click to select files",
"uploadStep2": "Select one or more genres to assign to the uploaded songs", "uploadStep2": "Select one or more genres and, if applicable, specials to assign to the uploaded songs",
"uploadStep3": "Click 'Start upload' to begin the upload process", "uploadStep3": "Click 'Start upload' to begin the upload process",
"uploadStep4": "The system will automatically extract metadata (title, artist, release year) from the files", "uploadStep4": "The system will automatically extract metadata (title, artist, release year) from the files",
"uploadBestPracticesTitle": "Best Practices", "uploadBestPracticesTitle": "Best Practices",
"uploadBestPractice1": "Ensure MP3 files have correct ID3 tags (title, artist) for automatic metadata extraction", "uploadBestPractice1": "Ensure MP3 files have correct ID3 tags (title, artist) for automatic metadata extraction",
"uploadBestPractice2": "Select appropriate genres before uploading to avoid manual assignment later", "uploadBestPractice2": "Select appropriate genres (and specials) before uploading to avoid manual assignment later",
"uploadBestPractice3": "Check for duplicates before uploading - the system will warn you if a song already exists", "uploadBestPractice3": "Check for duplicates before uploading - the system will warn you if a song already exists",
"tip": "Tip", "tip": "Tip",
"uploadTip": "All songs uploaded by curators are automatically excluded from the global playlist. Only admins can change this setting.", "uploadTip": "All songs uploaded by curators are automatically excluded from the global playlist. Only admins can change this setting.",
@@ -367,6 +377,8 @@
"tooltipUploadLong": "Drag and drop MP3 files or click to select. The system will automatically extract metadata (title, artist, release year) from ID3 tags. Select genres before uploading to automatically assign songs. All curator uploads are excluded from the global playlist by default.", "tooltipUploadLong": "Drag and drop MP3 files or click to select. The system will automatically extract metadata (title, artist, release year) from ID3 tags. Select genres before uploading to automatically assign songs. All curator uploads are excluded from the global playlist by default.",
"tooltipGenreAssignmentShort": "Assign genres to uploaded songs", "tooltipGenreAssignmentShort": "Assign genres to uploaded songs",
"tooltipGenreAssignmentLong": "Select one or more genres before uploading. The selected genres will be assigned to all successfully uploaded songs. You can only assign genres that you are responsible for. If you don't select any genres, you can assign them later by editing the songs.", "tooltipGenreAssignmentLong": "Select one or more genres before uploading. The selected genres will be assigned to all successfully uploaded songs. You can only assign genres that you are responsible for. If you don't select any genres, you can assign them later by editing the songs.",
"tooltipSpecialAssignmentShort": "Assign specials to uploaded songs",
"tooltipSpecialAssignmentLong": "Select one or more specials before uploading. The selected specials will be assigned to all successfully uploaded songs. You can only assign specials that you are responsible for. If you don't select any specials, you can assign them later by editing the songs.",
"tooltipTracklistShort": "Manage your songs", "tooltipTracklistShort": "Manage your songs",
"tooltipTracklistLong": "This table shows all songs in your genres and specials. You can search, filter, sort, and edit songs. Use the checkboxes to select multiple songs for batch editing. Only songs you can edit will have an active checkbox.", "tooltipTracklistLong": "This table shows all songs in your genres and specials. You can search, filter, sort, and edit songs. Use the checkboxes to select multiple songs for batch editing. Only songs you can edit will have an active checkbox.",
"tooltipSearchShort": "Search by title or artist", "tooltipSearchShort": "Search by title or artist",

100
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{ {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.2", "version": "0.1.6.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.2", "version": "0.1.6.11",
"dependencies": { "dependencies": {
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"driver.js": "^1.4.0", "driver.js": "^1.4.0",
"music-metadata": "^11.10.2", "music-metadata": "^11.10.2",
"next": "16.0.3", "next": "^16.0.7",
"next-intl": "^4.5.6", "next-intl": "^4.5.6",
"prisma": "^6.19.0", "prisma": "^6.19.0",
"react": "19.2.0", "react": "19.2.0",
@@ -28,7 +28,7 @@
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.8.32", "baseline-browser-mapping": "^2.8.32",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.3", "eslint-config-next": "^16.0.7",
"typescript": "^5" "typescript": "^5"
} }
}, },
@@ -1101,15 +1101,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
"integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==", "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.7.tgz",
"integrity": "sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==", "integrity": "sha512-hFrTNZcMEG+k7qxVxZJq3F32Kms130FAhG8lvw2zkKBgAcNOJIxlljNiCjGygvBshvaGBdf88q2CqWtnqezDHA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1117,9 +1117,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
"integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==", "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1133,9 +1133,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
"integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==", "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1149,9 +1149,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
"integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==", "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1165,9 +1165,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
"integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==", "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1181,9 +1181,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
"integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==", "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1197,9 +1197,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
"integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==", "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1213,9 +1213,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
"integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==", "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1229,9 +1229,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
"integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==", "integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3474,13 +3474,13 @@
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.3.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.7.tgz",
"integrity": "sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==", "integrity": "sha512-WubFGLFHfk2KivkdRGfx6cGSFhaQqhERRfyO8BRx+qiGPGp7WLKcPvYC4mdx1z3VhVRcrfFzczjjTrbJZOpnEQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "16.0.3", "@next/eslint-plugin-next": "16.0.7",
"eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2", "eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
@@ -5945,12 +5945,12 @@
} }
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
"integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==", "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.0.3", "@next/env": "16.0.7",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
@@ -5963,14 +5963,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.0.3", "@next/swc-darwin-arm64": "16.0.7",
"@next/swc-darwin-x64": "16.0.3", "@next/swc-darwin-x64": "16.0.7",
"@next/swc-linux-arm64-gnu": "16.0.3", "@next/swc-linux-arm64-gnu": "16.0.7",
"@next/swc-linux-arm64-musl": "16.0.3", "@next/swc-linux-arm64-musl": "16.0.7",
"@next/swc-linux-x64-gnu": "16.0.3", "@next/swc-linux-x64-gnu": "16.0.7",
"@next/swc-linux-x64-musl": "16.0.3", "@next/swc-linux-x64-musl": "16.0.7",
"@next/swc-win32-arm64-msvc": "16.0.3", "@next/swc-win32-arm64-msvc": "16.0.7",
"@next/swc-win32-x64-msvc": "16.0.3", "@next/swc-win32-x64-msvc": "16.0.7",
"sharp": "^0.34.4" "sharp": "^0.34.4"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.6.4", "version": "0.1.6.26",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -13,7 +13,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"driver.js": "^1.4.0", "driver.js": "^1.4.0",
"music-metadata": "^11.10.2", "music-metadata": "^11.10.2",
"next": "16.0.3", "next": "^16.0.7",
"next-intl": "^4.5.6", "next-intl": "^4.5.6",
"prisma": "^6.19.0", "prisma": "^6.19.0",
"react": "19.2.0", "react": "19.2.0",
@@ -29,7 +29,7 @@
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.8.32", "baseline-browser-mapping": "^2.8.32",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.3", "eslint-config-next": "^16.0.7",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Special" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" JSONB NOT NULL,
"subtitle" JSONB,
"maxAttempts" INTEGER NOT NULL DEFAULT 7,
"unlockSteps" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"launchDate" DATETIME,
"endDate" DATETIME,
"curator" TEXT,
"hidden" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Special" ("createdAt", "curator", "endDate", "id", "launchDate", "maxAttempts", "name", "subtitle", "unlockSteps") SELECT "createdAt", "curator", "endDate", "id", "launchDate", "maxAttempts", "name", "subtitle", "unlockSteps" FROM "Special";
DROP TABLE "Special";
ALTER TABLE "new_Special" RENAME TO "Special";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -47,6 +47,7 @@ model Special {
launchDate DateTime? launchDate DateTime?
endDate DateTime? endDate DateTime?
curator String? curator String?
hidden Boolean @default(false)
songs SpecialSong[] songs SpecialSong[]
puzzles DailyPuzzle[] puzzles DailyPuzzle[]
news News[] news News[]

BIN
public/favicon-base.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

BIN
public/logo-1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

BIN
public/logo-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/logo-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
public/logo-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

19
public/logo-large.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 507 KiB

19
public/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -4,6 +4,11 @@
set -e set -e
if [ -f "$HOME/.restic-env" ]; then
# shellcheck source=/dev/null
. "$HOME/.restic-env"
fi
echo "💾 Creating Restic backup..." echo "💾 Creating Restic backup..."
if ! command -v restic >/dev/null 2>&1; then if ! command -v restic >/dev/null 2>&1; then

View File

@@ -0,0 +1,47 @@
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
async function convertSvgToPng(svgPath, pngPath, size) {
try {
const svgBuffer = fs.readFileSync(svgPath);
await sharp(svgBuffer, {
density: 300 // High DPI for better quality
})
.resize(size, size, {
fit: 'contain',
background: { r: 255, g: 255, b: 255, alpha: 0 } // Transparent background
})
.png()
.toFile(pngPath);
console.log(`✅ Created ${pngPath} (${size}x${size})`);
} catch (error) {
console.error(`❌ Error converting ${svgPath}:`, error.message);
}
}
async function main() {
const publicDir = path.join(__dirname, '..', 'public');
// Convert logo.svg to various PNG sizes
const logoPath = path.join(publicDir, 'logo.svg');
if (fs.existsSync(logoPath)) {
await convertSvgToPng(logoPath, path.join(publicDir, 'logo-512.png'), 512);
await convertSvgToPng(logoPath, path.join(publicDir, 'logo-256.png'), 256);
await convertSvgToPng(logoPath, path.join(publicDir, 'logo-128.png'), 128);
}
// Convert logo-large.svg to larger PNG sizes
const logoLargePath = path.join(publicDir, 'logo-large.svg');
if (fs.existsSync(logoLargePath)) {
await convertSvgToPng(logoLargePath, path.join(publicDir, 'logo-1024.png'), 1024);
await convertSvgToPng(logoLargePath, path.join(publicDir, 'logo-512.png'), 512);
}
console.log('\n✨ Logo conversion complete!');
}
main();

View File

@@ -0,0 +1,138 @@
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
async function createLogoWithText(faviconPath, outputPath, size) {
try {
// Load and resize favicon - smaller to leave room for text
const faviconSize = Math.floor(size * 0.65);
const faviconBuffer = await sharp(faviconPath)
.resize(faviconSize, faviconSize, {
fit: 'contain',
background: { r: 255, g: 255, b: 255, alpha: 0 }
})
.toBuffer();
// Create SVG with favicon and text
const textSize = Math.floor(size * 0.12);
const iconY = Math.floor(size * 0.10); // Logo higher up
const textY = Math.floor(size * 0.92); // Text further down
const iconX = Math.floor((size - faviconSize) / 2);
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- White background -->
<rect width="${size}" height="${size}" fill="#ffffff"/>
<image href="data:image/png;base64,${faviconBuffer.toString('base64')}"
x="${iconX}"
y="${iconY}"
width="${faviconSize}"
height="${faviconSize}"/>
<text x="${size / 2}" y="${textY}"
font-family="system-ui, -apple-system, sans-serif"
font-size="${textSize}"
font-weight="bold"
fill="#000000"
text-anchor="middle"
letter-spacing="-0.5">
hördle.de
</text>
</svg>`;
// Convert SVG to PNG with white background
await sharp(Buffer.from(svg))
.resize(size, size)
.png()
.toFile(outputPath);
console.log(`✅ Created ${path.basename(outputPath)} (${size}x${size})`);
} catch (error) {
console.error(`❌ Error creating ${outputPath}:`, error.message);
}
}
async function createSVGLogo(faviconPath, outputPath, size) {
try {
// Load and resize favicon - smaller to leave room for text
const faviconSize = Math.floor(size * 0.65);
const faviconBuffer = await sharp(faviconPath)
.resize(faviconSize, faviconSize, {
fit: 'contain',
background: { r: 255, g: 255, b: 255, alpha: 0 }
})
.toBuffer();
// Create SVG with favicon and text
const textSize = Math.floor(size * 0.12);
const iconY = Math.floor(size * 0.10); // Logo higher up
const textY = Math.floor(size * 0.92); // Text further down
const iconX = Math.floor((size - faviconSize) / 2);
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- White background covering entire image -->
<rect width="${size}" height="${size}" fill="#ffffff"/>
<image href="data:image/png;base64,${faviconBuffer.toString('base64')}"
x="${iconX}"
y="${iconY}"
width="${faviconSize}"
height="${faviconSize}"/>
<text x="${size / 2}" y="${textY}"
font-family="system-ui, -apple-system, sans-serif"
font-size="${textSize}"
font-weight="bold"
fill="#000000"
text-anchor="middle"
letter-spacing="-0.5">
hördle.de
</text>
</svg>`;
fs.writeFileSync(outputPath, svg);
console.log(`✅ Created ${path.basename(outputPath)} (${size}x${size} SVG)`);
} catch (error) {
console.error(`❌ Error creating ${outputPath}:`, error.message);
}
}
async function main() {
const faviconPath = path.join(__dirname, '..', 'app', 'favicon.ico');
const publicDir = path.join(__dirname, '..', 'public');
if (!fs.existsSync(faviconPath)) {
console.error('❌ Favicon not found at', faviconPath);
return;
}
// Extract favicon to PNG first for processing
const tempFavicon = path.join(publicDir, 'favicon-temp.png');
const faviconBuffer = fs.readFileSync(faviconPath);
// Convert ICO to PNG
await sharp(faviconBuffer)
.resize(1024, 1024, { fit: 'contain' })
.png()
.toFile(tempFavicon);
console.log('✅ Extracted favicon to PNG\n');
// Create SVG logo
await createSVGLogo(tempFavicon, path.join(publicDir, 'logo.svg'), 512);
await createSVGLogo(tempFavicon, path.join(publicDir, 'logo-large.svg'), 1024);
// Create PNG logos with text in various sizes
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-128.png'), 128);
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-256.png'), 256);
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-512.png'), 512);
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-1024.png'), 1024);
// Clean up temp file
if (fs.existsSync(tempFavicon)) {
fs.unlinkSync(tempFavicon);
}
console.log('\n✨ Logo creation complete!');
}
main();

View File

@@ -0,0 +1,120 @@
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
async function createLogoWithText(faviconPath, outputPath, size, includeText = true) {
try {
const favicon = await sharp(faviconPath)
.resize(size * 0.7, size * 0.7, {
fit: 'contain',
background: { r: 255, g: 255, b: 255, alpha: 0 }
})
.toBuffer();
// Create SVG with favicon and text
const textSize = Math.floor(size * 0.15);
const spacing = Math.floor(size * 0.05);
const iconSize = Math.floor(size * 0.7);
const iconY = Math.floor(includeText ? size * 0.25 : size * 0.5);
const textY = Math.floor(size * 0.85);
// For now, we'll create a composite image
// First, create the favicon part
const svg = includeText ? `
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="faviconPattern" x="0" y="0" width="1" height="1">
<image href="data:image/png;base64,${favicon.toString('base64')}"
x="${(size - iconSize) / 2}"
y="${iconY - iconSize / 2}"
width="${iconSize}"
height="${iconSize}"/>
</pattern>
</defs>
<rect width="${size}" height="${size}" fill="url(#faviconPattern)"/>
<text x="${size / 2}" y="${textY}"
font-family="system-ui, -apple-system, sans-serif"
font-size="${textSize}"
font-weight="bold"
fill="#000000"
text-anchor="middle"
letter-spacing="-0.5">
Hördle
</text>
</svg>
` : `
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<image href="data:image/png;base64,${favicon.toString('base64')}"
x="${(size - iconSize) / 2}"
y="${(size - iconSize) / 2}"
width="${iconSize}"
height="${iconSize}"/>
</svg>
`;
// Convert SVG to PNG
await sharp(Buffer.from(svg))
.png()
.toFile(outputPath);
console.log(`✅ Created ${outputPath} (${size}x${size})`);
} catch (error) {
console.error(`❌ Error creating ${outputPath}:`, error.message);
}
}
async function main() {
const faviconPath = path.join(__dirname, '..', 'app', 'favicon.ico');
const publicDir = path.join(__dirname, '..', 'public');
if (!fs.existsSync(faviconPath)) {
console.error('❌ Favicon not found at', faviconPath);
return;
}
// Extract favicon to PNG first
const tempFavicon = path.join(publicDir, 'favicon-temp.png');
const faviconBuffer = fs.readFileSync(faviconPath);
// Convert ICO to PNG
await sharp(faviconBuffer)
.resize(1024, 1024, { fit: 'contain' })
.png()
.toFile(tempFavicon);
console.log('✅ Extracted favicon to PNG');
// Create logos with text in various sizes
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-128.png'), 128);
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-256.png'), 256);
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-512.png'), 512);
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-1024.png'), 1024);
// Create SVG version
const faviconPng = await sharp(faviconBuffer)
.resize(512, 512, { fit: 'contain' })
.png()
.toBuffer();
const svgContent = `<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<image id="faviconImg" href="data:image/png;base64,${faviconPng.toString('base64')}" width="358" height="358" x="77" y="77"/>
</defs>
<use href="#faviconImg"/>
<text x="256" y="430" font-family="system-ui, -apple-system, sans-serif" font-size="48" font-weight="bold" fill="#000000" text-anchor="middle" letter-spacing="-0.5">
Hördle
</text>
</svg>`;
fs.writeFileSync(path.join(publicDir, 'logo.svg'), svgContent);
console.log('✅ Created logo.svg');
// Clean up temp file
fs.unlinkSync(tempFavicon);
console.log('\n✨ Logo creation complete!');
}
main();

View File

@@ -88,10 +88,13 @@ docker compose build
echo "🔄 Restarting with new image (minimal downtime)..." echo "🔄 Restarting with new image (minimal downtime)..."
docker compose up -d docker compose up -d
# Clean up old images # Clean up old images and build cache
echo "🧹 Cleaning up old images..." echo "🧹 Cleaning up old images..."
docker image prune -f docker image prune -f
echo "🧹 Cleaning up build cache..."
docker builder prune -f
echo "✅ Deployment complete!" echo "✅ Deployment complete!"
echo "" echo ""
echo "📊 Showing logs (Ctrl+C to exit)..." echo "📊 Showing logs (Ctrl+C to exit)..."

View File

@@ -22,6 +22,12 @@
set -e set -e
# Optional: Restic-Umgebungsvariablen aus ~/.restic-env laden
if [ -f "$HOME/.restic-env" ]; then
# shellcheck source=/dev/null
. "$HOME/.restic-env"
fi
echo "💾 Restoring from Restic backup..." echo "💾 Restoring from Restic backup..."
if ! command -v restic >/dev/null 2>&1; then if ! command -v restic >/dev/null 2>&1; then