Compare commits

...

20 Commits

Author SHA1 Message Date
Hördle Bot
33f8080aa8 Curator: Lokalisierung und einstellbare Paginierung 2025-12-03 13:09:20 +01:00
Hördle Bot
8a102afc0e Admin: Song Library und Upload entfernt 2025-12-03 12:59:26 +01:00
Hördle Bot
38148ace8d Kuratoren-Accounts und Anpassungen im Admin- und Kuratoren-Dashboard 2025-12-03 12:52:38 +01:00
Hördle Bot
49e98ade3c Update credits text in configuration to include a heart emoji for improved sentiment 2025-12-02 19:36:47 +01:00
Hördle Bot
397839cc1f Update credits text in configuration to enhance clarity 2025-12-02 19:29:55 +01:00
Hördle Bot
3fe805129b Change deploy-remote.sh file permissions to make it executable 2025-12-02 15:03:03 +01:00
Hördle Bot
bf9a49a9ac Update SSH password environment variable in remote deployment script 2025-12-02 14:56:36 +01:00
Hördle Bot
9b89cbf8ed Handle temporary SQLite DB during Docker build to prevent errors in sitemap generation 2025-12-02 14:55:50 +01:00
Hördle Bot
7f33e98fb5 Update donation note in costs section and increment version to v0.1.4.12 2025-12-02 14:54:26 +01:00
Hördle Bot
72f8b99092 Adjust costs section donation note and bump version to v0.1.4.11 2025-12-02 14:50:02 +01:00
Hördle Bot
e60daa511b Add donation note for political beauty and bump version to v0.1.4.10 2025-12-02 14:42:46 +01:00
Hördle Bot
19706abacb Bump version to v0.1.4.9 2025-12-02 14:26:17 +01:00
Hördle Bot
170e7b5402 Store political statements in database 2025-12-02 14:14:53 +01:00
Hördle Bot
ade1043c3c chore: Update .gitignore to include new script and documentation files 2025-12-02 14:11:11 +01:00
Hördle Bot
d69af49e24 Bump version to v0.1.4.8 2025-12-02 13:56:45 +01:00
Hördle Bot
63687524e7 Merge branch 'partnerpuzzles' 2025-12-02 13:56:10 +01:00
Hördle Bot
0246cb58ee Include political statements feature files 2025-12-02 13:30:23 +01:00
Hördle Bot
d76aa9f4e9 Bump version to v0.1.4.7 2025-12-02 13:28:33 +01:00
Hördle Bot
28afaf598b Bump version to v0.1.4.6 2025-12-02 11:10:13 +01:00
Hördle Bot
8239753911 feat: Enhance Game component with extra puzzles feature
- Introduce requiredDailyKeys to track daily puzzle completion across genres.
- Implement logic to show an ExtraPuzzlesPopover when all daily puzzles are completed.
- Add localized messages for extra puzzles in both English and German.
- Update GenrePage and Home components to pass requiredDailyKeys to the Game component.
2025-12-02 10:59:22 +01:00
30 changed files with 3262 additions and 1621 deletions

2
.gitignore vendored
View File

@@ -52,3 +52,5 @@ next-env.d.ts
.release-years-migrated .release-years-migrated
.covers-migrated .covers-migrated
docker-compose.yml docker-compose.yml
scripts/scrape-bahn-expert-statements.js
docs/bahn-expert-statements.txt

View File

@@ -87,6 +87,9 @@ export default async function GenrePage({ params }: PageProps) {
return s.launchDate && s.launchDate > now; return s.launchDate && s.launchDate > now;
}); });
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
return ( return (
<> <>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}> <div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
@@ -156,7 +159,7 @@ export default async function GenrePage({ params }: PageProps) {
)} )}
</div> </div>
<NewsSection locale={locale} /> <NewsSection locale={locale} />
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} /> <Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} requiredDailyKeys={requiredDailyKeys} />
</> </>
); );
} }

View File

@@ -98,13 +98,27 @@ export default async function AboutPage({ params }: AboutPageProps) {
</p> </p>
<p <p
style={{ style={{
marginBottom: "0.75rem", marginBottom: "0.5rem",
fontSize: "0.9rem", fontSize: "0.9rem",
color: "#6b7280", color: "#6b7280",
}} }}
> >
{t("costsSheetPrivacyNote")} {t("costsSheetPrivacyNote")}
</p> </p>
<p style={{ marginBottom: "0.75rem" }}>
{t.rich("costsDonationNote", {
link: (chunks) => (
<a
href="https://politicalbeauty.de/ueber-das-ZPS.html"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "underline" }}
>
{chunks}
</a>
),
})}
</p>
</section> </section>
<section style={{ marginBottom: "2rem" }}> <section style={{ marginBottom: "2rem" }}>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
'use client';
import CuratorPageInner from '../../curator/page';
export default function CuratorPage() {
// Wrapper für die lokalisierte Route /[locale]/curator
return <CuratorPageInner />;
}

View File

@@ -11,6 +11,7 @@ import { config } from "@/lib/config";
import { generateBaseMetadata } from "@/lib/metadata"; import { generateBaseMetadata } from "@/lib/metadata";
import InstallPrompt from "@/components/InstallPrompt"; import InstallPrompt from "@/components/InstallPrompt";
import AppFooter from "@/components/AppFooter"; import AppFooter from "@/components/AppFooter";
import PoliticalStatementBanner from "@/components/PoliticalStatementBanner";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -89,6 +90,7 @@ export default async function LocaleLayout({
{children} {children}
<InstallPrompt /> <InstallPrompt />
<AppFooter /> <AppFooter />
<PoliticalStatementBanner />
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -61,6 +61,9 @@ export default async function Home({
return s.launchDate && s.launchDate > now; return s.launchDate && s.launchDate > now;
}); });
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
return ( return (
<> <>
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}> <div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
@@ -149,7 +152,7 @@ export default async function Home({
<NewsSection locale={locale} /> <NewsSection locale={locale} />
</div> </div>
<Game dailyPuzzle={dailyPuzzle} genre={null} /> <Game dailyPuzzle={dailyPuzzle} genre={null} requiredDailyKeys={requiredDailyKeys} />
<OnboardingTour /> <OnboardingTour />
</> </>
); );

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
export async function POST(request: NextRequest) {
try {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
}
const curator = await prisma.curator.findUnique({
where: { username },
});
if (!curator) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const isValid = await bcrypt.compare(password, curator.passwordHash);
if (!isValid) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
return NextResponse.json({
success: true,
curator: {
id: curator.id,
username: curator.username,
isGlobalCurator: curator.isGlobalCurator,
},
});
} catch (error) {
console.error('Curator login error:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireStaffAuth } from '@/lib/auth';
const prisma = new PrismaClient();
export async function GET(request: NextRequest) {
const { error, context } = await requireStaffAuth(request);
if (error || !context) return error!;
if (context.role !== 'curator') {
return NextResponse.json(
{ error: 'Only curators can access this endpoint' },
{ status: 403 }
);
}
const [genres, specials] = await Promise.all([
prisma.curatorGenre.findMany({
where: { curatorId: context.curator.id },
select: { genreId: true },
}),
prisma.curatorSpecial.findMany({
where: { curatorId: context.curator.id },
select: { specialId: true },
}),
]);
return NextResponse.json({
id: context.curator.id,
username: context.curator.username,
isGlobalCurator: context.curator.isGlobalCurator,
genreIds: genres.map(g => g.genreId),
specialIds: specials.map(s => s.specialId),
});
}

182
app/api/curators/route.ts Normal file
View File

@@ -0,0 +1,182 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
export async function GET(request: NextRequest) {
// Only admin may list and manage curators
const authError = await requireAdminAuth(request);
if (authError) return authError;
const curators = await prisma.curator.findMany({
include: {
genres: true,
specials: true,
},
orderBy: { username: 'asc' },
});
return NextResponse.json(
curators.map(c => ({
id: c.id,
username: c.username,
isGlobalCurator: c.isGlobalCurator,
genreIds: c.genres.map(g => g.genreId),
specialIds: c.specials.map(s => s.specialId),
}))
);
}
export async function POST(request: NextRequest) {
const authError = await requireAdminAuth(request);
if (authError) return authError;
const { username, password, isGlobalCurator = false, genreIds = [], specialIds = [] } = await request.json();
if (!username || !password) {
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
}
const passwordHash = await bcrypt.hash(password, 10);
try {
const curator = await prisma.curator.create({
data: {
username,
passwordHash,
isGlobalCurator: Boolean(isGlobalCurator),
genres: {
create: (genreIds as number[]).map(id => ({ genreId: id })),
},
specials: {
create: (specialIds as number[]).map(id => ({ specialId: id })),
},
},
include: {
genres: true,
specials: true,
},
});
return NextResponse.json({
id: curator.id,
username: curator.username,
isGlobalCurator: curator.isGlobalCurator,
genreIds: curator.genres.map(g => g.genreId),
specialIds: curator.specials.map(s => s.specialId),
});
} catch (error) {
console.error('Error creating curator:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
const authError = await requireAdminAuth(request);
if (authError) return authError;
const { id, username, password, isGlobalCurator, genreIds, specialIds } = await request.json();
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 });
}
const data: any = {};
if (username !== undefined) data.username = username;
if (isGlobalCurator !== undefined) data.isGlobalCurator = Boolean(isGlobalCurator);
if (password) {
data.passwordHash = await bcrypt.hash(password, 10);
}
try {
const updated = await prisma.$transaction(async (tx) => {
const curator = await tx.curator.update({
where: { id: Number(id) },
data,
include: {
genres: true,
specials: true,
},
});
if (Array.isArray(genreIds)) {
await tx.curatorGenre.deleteMany({
where: { curatorId: curator.id },
});
if (genreIds.length > 0) {
await tx.curatorGenre.createMany({
data: (genreIds as number[]).map(gid => ({
curatorId: curator.id,
genreId: gid,
})),
});
}
}
if (Array.isArray(specialIds)) {
await tx.curatorSpecial.deleteMany({
where: { curatorId: curator.id },
});
if (specialIds.length > 0) {
await tx.curatorSpecial.createMany({
data: (specialIds as number[]).map(sid => ({
curatorId: curator.id,
specialId: sid,
})),
});
}
}
const finalCurator = await tx.curator.findUnique({
where: { id: curator.id },
include: {
genres: true,
specials: true,
},
});
if (!finalCurator) {
throw new Error('Curator not found after update');
}
return finalCurator;
});
return NextResponse.json({
id: updated.id,
username: updated.username,
isGlobalCurator: updated.isGlobalCurator,
genreIds: updated.genres.map(g => g.genreId),
specialIds: updated.specials.map(s => s.specialId),
});
} catch (error) {
console.error('Error updating curator:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const authError = await requireAdminAuth(request);
if (authError) return authError;
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 });
}
try {
await prisma.curator.delete({
where: { id: Number(id) },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting curator:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,113 @@
import { NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/auth';
import {
getRandomActiveStatement,
getAllStatements,
createStatement,
updateStatement,
deleteStatement,
} from '@/lib/politicalStatements';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale') || 'en';
const admin = searchParams.get('admin') === 'true';
if (admin) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
const statements = await getAllStatements(locale);
return NextResponse.json(statements);
}
const statement = await getRandomActiveStatement(locale);
return NextResponse.json(statement);
} catch (error) {
console.error('[political-statements] GET failed:', error);
return NextResponse.json({ error: 'Failed to load political statements' }, { status: 500 });
}
}
export async function POST(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { locale, text, active = true, source } = body;
if (!locale || typeof text !== 'string' || !text.trim()) {
return NextResponse.json({ error: 'locale and text are required' }, { status: 400 });
}
const created = await createStatement(locale, { text: text.trim(), active, source });
return NextResponse.json(created, { status: 201 });
} catch (error) {
console.error('[political-statements] POST failed:', error);
return NextResponse.json({ error: 'Failed to create statement' }, { status: 500 });
}
}
export async function PUT(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { locale, id, text, active, source } = body;
if (!locale || typeof id !== 'number') {
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
}
const updated = await updateStatement(locale, id, {
text: typeof text === 'string' ? text.trim() : undefined,
active,
source,
});
if (!updated) {
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
}
return NextResponse.json(updated);
} catch (error) {
console.error('[political-statements] PUT failed:', error);
return NextResponse.json({ error: 'Failed to update statement' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { locale, id } = body;
if (!locale || typeof id !== 'number') {
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
}
const ok = await deleteStatement(locale, id);
if (!ok) {
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('[political-statements] DELETE failed:', error);
return NextResponse.json({ error: 'Failed to delete statement' }, { status: 500 });
}
}

View File

@@ -1,13 +1,60 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { writeFile, unlink } from 'fs/promises'; import { writeFile, unlink } from 'fs/promises';
import path from 'path'; import path from 'path';
import { parseBuffer } from 'music-metadata'; import { parseBuffer } from 'music-metadata';
import { isDuplicateSong } from '@/lib/fuzzyMatch'; import { isDuplicateSong } from '@/lib/fuzzyMatch';
import { requireAdminAuth } from '@/lib/auth'; import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function getCuratorAssignments(curatorId: number) {
const [genres, specials] = await Promise.all([
prisma.curatorGenre.findMany({
where: { curatorId },
select: { genreId: true },
}),
prisma.curatorSpecial.findMany({
where: { curatorId },
select: { specialId: true },
}),
]);
return {
genreIds: new Set(genres.map(g => g.genreId)),
specialIds: new Set(specials.map(s => s.specialId)),
};
}
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
if (context.role === 'admin') return true;
const songGenreIds = (song.genres || []).map((g: any) => g.id);
const songSpecialIds = (song.specials || []).map((s: any) => s.specialId ?? s.id);
// Songs ohne Genres/Specials sind für Kuratoren generell editierbar
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
return true;
}
const hasGenre = songGenreIds.some((id: number) => assignments.genreIds.has(id));
const hasSpecial = songSpecialIds.some((id: number) => assignments.specialIds.has(id));
return hasGenre || hasSpecial;
}
function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
if (context.role === 'admin') return true;
const songGenreIds = (song.genres || []).map((g: any) => g.id);
const songSpecialIds = (song.specials || []).map((s: any) => s.specialId ?? s.id);
const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id));
const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id));
return allGenresAllowed && allSpecialsAllowed;
}
// Configure route to handle large file uploads // Configure route to handle large file uploads
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const maxDuration = 60; // 60 seconds timeout for uploads export const maxDuration = 60; // 60 seconds timeout for uploads
@@ -50,11 +97,11 @@ export async function GET() {
export async function POST(request: Request) { export async function POST(request: Request) {
console.log('[UPLOAD] Starting song upload request'); console.log('[UPLOAD] Starting song upload request');
// Check authentication // Check authentication (admin or curator)
const authError = await requireAdminAuth(request as any); const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (authError) { if (error || !context) {
console.log('[UPLOAD] Authentication failed'); console.log('[UPLOAD] Authentication failed');
return authError; return error!;
} }
try { try {
@@ -63,10 +110,17 @@ export async function POST(request: Request) {
const file = formData.get('file') as File; const file = formData.get('file') as File;
let title = ''; let title = '';
let artist = ''; let artist = '';
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true'; let excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type); console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal); console.log('[UPLOAD] excludeFromGlobal (raw):', excludeFromGlobal);
// Apply global playlist rules:
// - Admin: may control the flag via form data
// - Curator: uploads are always excluded from global by default
if (context.role === 'curator') {
excludeFromGlobal = true;
}
if (!file) { if (!file) {
console.error('[UPLOAD] No file provided'); console.error('[UPLOAD] No file provided');
@@ -261,9 +315,9 @@ export async function POST(request: Request) {
} }
export async function PUT(request: Request) { export async function PUT(request: Request) {
// Check authentication // Check authentication (admin or curator)
const authError = await requireAdminAuth(request as any); const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (authError) return authError; if (error || !context) return error!;
try { try {
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json(); const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
@@ -272,6 +326,69 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
} }
// Load current song with relations for permission checks
const existingSong = await prisma.song.findUnique({
where: { id: Number(id) },
include: {
genres: true,
specials: true,
},
});
if (!existingSong) {
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
}
let effectiveGenreIds = genreIds as number[] | undefined;
let effectiveSpecialIds = specialIds as number[] | undefined;
if (context.role === 'curator') {
const assignments = await getCuratorAssignments(context.curator.id);
if (!curatorCanEditSong(context, existingSong, assignments)) {
return NextResponse.json({ error: 'Forbidden: You are not allowed to edit this song' }, { status: 403 });
}
// Curators may assign genres, but only within their own assignments.
// Genres außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
if (effectiveGenreIds !== undefined) {
const invalidGenre = effectiveGenreIds.some(id => !assignments.genreIds.has(id));
if (invalidGenre) {
return NextResponse.json(
{ error: 'Curators may only assign their own genres' },
{ status: 403 }
);
}
const fixedGenreIds = existingSong.genres
.filter(g => !assignments.genreIds.has(g.id))
.map(g => g.id);
const managedGenreIds = effectiveGenreIds.filter(id => assignments.genreIds.has(id));
effectiveGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
}
// Curators may assign specials, but only within their own assignments.
// Specials außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
if (effectiveSpecialIds !== undefined) {
const invalidSpecial = effectiveSpecialIds.some(id => !assignments.specialIds.has(id));
if (invalidSpecial) {
return NextResponse.json(
{ error: 'Curators may only assign their own specials' },
{ status: 403 }
);
}
const currentSpecials = await prisma.specialSong.findMany({
where: { songId: Number(id) }
});
const fixedSpecialIds = currentSpecials
.map(ss => ss.specialId)
.filter(sid => !assignments.specialIds.has(sid));
const managedSpecialIds = effectiveSpecialIds.filter(id => assignments.specialIds.has(id));
effectiveSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
}
}
const data: any = { title, artist }; const data: any = { title, artist };
// Update releaseYear if provided (can be null to clear it) // Update releaseYear if provided (can be null to clear it)
@@ -280,24 +397,35 @@ export async function PUT(request: Request) {
} }
if (excludeFromGlobal !== undefined) { if (excludeFromGlobal !== undefined) {
if (context.role === 'admin') {
data.excludeFromGlobal = excludeFromGlobal;
} else {
// Curators may only change the flag if they are global curators
if (!context.curator.isGlobalCurator) {
return NextResponse.json(
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
{ status: 403 }
);
}
data.excludeFromGlobal = excludeFromGlobal; data.excludeFromGlobal = excludeFromGlobal;
} }
}
if (genreIds) { if (effectiveGenreIds && effectiveGenreIds.length > 0) {
data.genres = { data.genres = {
set: genreIds.map((gId: number) => ({ id: gId })) set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
}; };
} }
// Handle SpecialSong relations separately // Handle SpecialSong relations separately
if (specialIds !== undefined) { if (effectiveSpecialIds !== undefined) {
// First, get current special assignments // First, get current special assignments
const currentSpecials = await prisma.specialSong.findMany({ const currentSpecials = await prisma.specialSong.findMany({
where: { songId: Number(id) } where: { songId: Number(id) }
}); });
const currentSpecialIds = currentSpecials.map(ss => ss.specialId); const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
const newSpecialIds = specialIds as number[]; const newSpecialIds = effectiveSpecialIds as number[];
// Delete removed specials // Delete removed specials
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid)); const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
@@ -344,9 +472,9 @@ export async function PUT(request: Request) {
} }
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
// Check authentication // Check authentication (admin or curator)
const authError = await requireAdminAuth(request as any); const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (authError) return authError; if (error || !context) return error!;
try { try {
const { id } = await request.json(); const { id } = await request.json();
@@ -355,15 +483,30 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 }); return NextResponse.json({ error: 'Missing id' }, { status: 400 });
} }
// Get song to find filename // Get song to find filename and relations for permission checks
const song = await prisma.song.findUnique({ const song = await prisma.song.findUnique({
where: { id: Number(id) }, where: { id: Number(id) },
include: {
genres: true,
specials: true,
},
}); });
if (!song) { if (!song) {
return NextResponse.json({ error: 'Song not found' }, { status: 404 }); return NextResponse.json({ error: 'Song not found' }, { status: 404 });
} }
if (context.role === 'curator') {
const assignments = await getCuratorAssignments(context.curator.id);
if (!curatorCanDeleteSong(context, song, assignments)) {
return NextResponse.json(
{ error: 'Forbidden: You are not allowed to delete this song' },
{ status: 403 }
);
}
}
// Delete file // Delete file
const filePath = path.join(process.cwd(), 'public/uploads', song.filename); const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
try { try {

1326
app/curator/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Dynamic genre pages // Dynamic genre pages
try { try {
// Während des Docker-Builds wird häufig eine temporäre SQLite-DB (file:./dev.db)
// ohne migrierte Tabellen verwendet. In diesem Fall überspringen wir die
// Datenbankabfrage und liefern nur die statischen Seiten, um Build-Fehler zu vermeiden.
const dbUrl = process.env.DATABASE_URL;
if (dbUrl && dbUrl.startsWith('file:./')) {
return staticPages;
}
const genres = await prisma.genre.findMany({ const genres = await prisma.genre.findMany({
where: { active: true }, where: { active: true },
}); });

View File

@@ -0,0 +1,98 @@
'use client';
import { useTranslations, useLocale } from 'next-intl';
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
interface ExtraPuzzlesPopoverProps {
puzzle: ExternalPuzzle;
onClose: () => void;
}
export default function ExtraPuzzlesPopover({ puzzle, onClose }: ExtraPuzzlesPopoverProps) {
const t = useTranslations('ExtraPuzzles');
const locale = useLocale();
const name = locale === 'de' ? puzzle.nameDe : puzzle.nameEn;
const handleClick = () => {
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('extra_puzzles_click', {
props: {
partner: puzzle.id,
url: puzzle.url,
},
});
}
onClose();
};
return (
<div
style={{
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
zIndex: 1100,
maxWidth: '320px',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
borderRadius: '0.75rem',
background: 'white',
padding: '1rem 1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>
{t('title')}
</h3>
<button
onClick={onClose}
aria-label={t('close')}
style={{
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: '1.1rem',
lineHeight: 1,
color: '#6b7280',
}}
>
×
</button>
</div>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#4b5563' }}>
{t('message', { name })}
</p>
<a
href={puzzle.url}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.4rem',
marginTop: '0.25rem',
padding: '0.5rem 0.75rem',
borderRadius: '999px',
border: 'none',
background: 'linear-gradient(135deg, #4f46e5, #ec4899)',
color: 'white',
fontSize: '0.9rem',
fontWeight: 600,
textDecoration: 'none',
cursor: 'pointer',
}}
>
{t('cta', { name })}
</a>
</div>
);
}

View File

@@ -6,7 +6,12 @@ import { useTranslations, useLocale } from 'next-intl';
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer'; import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
import GuessInput from './GuessInput'; import GuessInput from './GuessInput';
import Statistics from './Statistics'; import Statistics from './Statistics';
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
import { useGameState } from '../lib/gameState'; import { useGameState } from '../lib/gameState';
import { getGenreKey } from '@/lib/playerStorage';
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
import { sendGotifyNotification, submitRating } from '../app/actions'; import { sendGotifyNotification, submitRating } from '../app/actions';
// Plausible Analytics // Plausible Analytics
@@ -32,11 +37,14 @@ interface GameProps {
isSpecial?: boolean; isSpecial?: boolean;
maxAttempts?: number; maxAttempts?: number;
unlockSteps?: number[]; unlockSteps?: number[];
// List of genre keys that zusammen alle Tagesrätsel des Tages repräsentieren (z. B. ['global', 'Rock', 'Pop']).
// Wird genutzt, um zu prüfen, ob der Spieler alle Tagesrätsel gespielt hat.
requiredDailyKeys?: string[];
} }
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60]; const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) { export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS, requiredDailyKeys }: GameProps) {
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);
@@ -49,6 +57,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [hasRated, setHasRated] = useState(false); const [hasRated, setHasRated] = useState(false);
const [showYearModal, setShowYearModal] = useState(false); const [showYearModal, setShowYearModal] = useState(false);
const [hasPlayedAudio, setHasPlayedAudio] = useState(false); const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
const audioPlayerRef = useRef<AudioPlayerRef>(null); const audioPlayerRef = useRef<AudioPlayerRef>(null);
useEffect(() => { useEffect(() => {
@@ -81,6 +91,37 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
} }
}, [gameState, dailyPuzzle]); }, [gameState, dailyPuzzle]);
// Track gespielte Tagesrätsel & entscheide, ob das Partner-Popover gezeigt werden soll
useEffect(() => {
if (!gameState || !dailyPuzzle) return;
const gameEnded = gameState.isSolved || gameState.isFailed;
if (!gameEnded) return;
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
markDailyPuzzlePlayedToday(genreKey);
if (!requiredDailyKeys || requiredDailyKeys.length === 0) return;
if (hasSeenExtraPuzzlesPopoverToday()) return;
if (!hasPlayedAllDailyPuzzlesForToday(requiredDailyKeys)) return;
const partnerPuzzle = getRandomExternalPuzzle();
if (!partnerPuzzle) return;
setExtraPuzzle(partnerPuzzle);
setShowExtraPuzzlesPopover(true);
markExtraPuzzlesPopoverShownToday();
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('extra_puzzles_popover_shown', {
props: {
partner: partnerPuzzle.id,
url: partnerPuzzle.url,
},
});
}
}, [gameState?.isSolved, gameState?.isFailed, dailyPuzzle?.id, genre, isSpecial, requiredDailyKeys]);
useEffect(() => { useEffect(() => {
setLastAction(null); setLastAction(null);
}, [dailyPuzzle?.id]); }, [dailyPuzzle?.id]);
@@ -490,6 +531,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
onSkip={handleYearSkip} onSkip={handleYearSkip}
/> />
)} )}
{showExtraPuzzlesPopover && extraPuzzle && (
<ExtraPuzzlesPopover
puzzle={extraPuzzle}
onClose={() => setShowExtraPuzzlesPopover(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,95 @@
'use client';
import { useEffect, useState } from 'react';
import { useLocale } from 'next-intl';
interface ApiStatement {
id: number;
text: string;
active?: boolean;
}
export default function PoliticalStatementBanner() {
const locale = useLocale();
const [statement, setStatement] = useState<ApiStatement | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const today = new Date().toISOString().split('T')[0];
const storageKey = `hoerdle_political_statement_shown_${today}_${locale}`;
try {
const alreadyShown = typeof window !== 'undefined' && window.localStorage.getItem(storageKey);
if (alreadyShown) {
return;
}
} catch {
// ignore localStorage errors
}
let timeoutId: number | undefined;
const fetchStatement = async () => {
try {
const res = await fetch(`/api/political-statements?locale=${encodeURIComponent(locale)}`, {
cache: 'no-store',
});
if (!res.ok) return;
const data = await res.json();
if (!data || !data.text) return;
setStatement(data);
setVisible(true);
timeoutId = window.setTimeout(() => {
setVisible(false);
try {
window.localStorage.setItem(storageKey, 'true');
} catch {
// ignore
}
}, 5000);
} catch (e) {
console.warn('[PoliticalStatementBanner] Failed to load statement', e);
}
};
fetchStatement();
return () => {
if (timeoutId) {
window.clearTimeout(timeoutId);
}
};
}, [locale]);
if (!visible || !statement) return null;
return (
<div
style={{
position: 'fixed',
bottom: '1.25rem',
left: '50%',
transform: 'translateX(-50%)',
maxWidth: '640px',
width: 'calc(100% - 2.5rem)',
zIndex: 1050,
background: 'rgba(17,24,39,0.95)',
color: '#e5e7eb',
padding: '0.75rem 1rem',
borderRadius: '999px',
fontSize: '0.85rem',
lineHeight: 1.4,
boxShadow: '0 10px 25px rgba(0,0,0,0.45)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}
>
<span style={{ fontSize: '0.9rem' }}></span>
<span style={{ flex: 1 }}>{statement.text}</span>
</div>
);
}

View File

@@ -1,4 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient, Curator } from '@prisma/client';
const prisma = new PrismaClient();
export type StaffContext =
| { role: 'admin' }
| { role: 'curator'; curator: Curator };
/** /**
* Authentication middleware for admin API routes * Authentication middleware for admin API routes
@@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise<NextRespon
return null; // Auth successful return null; // Auth successful
} }
/**
* Resolve current staff (admin or curator) from headers.
*
* Admin:
* - x-admin-auth: 'authenticated'
*
* Curator:
* - x-curator-auth: 'authenticated'
* - x-curator-username: <username>
*/
export async function getStaffContext(request: NextRequest): Promise<StaffContext | null> {
const adminHeader = request.headers.get('x-admin-auth');
if (adminHeader === 'authenticated') {
return { role: 'admin' };
}
const curatorAuth = request.headers.get('x-curator-auth');
const curatorUsername = request.headers.get('x-curator-username');
if (curatorAuth === 'authenticated' && curatorUsername) {
const curator = await prisma.curator.findUnique({
where: { username: curatorUsername },
});
if (curator) {
return { role: 'curator', curator };
}
}
return null;
}
/**
* Require that the current request is authenticated as staff (admin or curator).
* Returns either an error response or a resolved context.
*/
export async function requireStaffAuth(request: NextRequest): Promise<{ error?: NextResponse; context?: StaffContext }> {
const context = await getStaffContext(request);
if (!context) {
return {
error: NextResponse.json(
{ error: 'Unauthorized - Staff authentication required' },
{ status: 401 }
),
};
}
return { context };
}
/** /**
* Helper to verify admin password * Helper to verify admin password
*/ */

View File

@@ -9,7 +9,7 @@ export const config = {
}, },
credits: { credits: {
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false', enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by', text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Made with 💚, ☕ and 🍺 by',
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social', linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron', linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
}, },

47
lib/externalPuzzles.ts Normal file
View File

@@ -0,0 +1,47 @@
export type ExternalPuzzle = {
id: string;
nameDe: string;
nameEn: string;
url: string;
isActive?: boolean;
};
/**
* Zentrale Liste externer Rätselangebote.
*
* Erweiterung: Einfach neuen Eintrag in dieses Array hinzufügen.
*/
export const externalPuzzles: ExternalPuzzle[] = [
{
id: 'pastpuzzle',
nameDe: 'Past Puzzle',
nameEn: 'Past Puzzle',
url: 'https://www.pastpuzzle.de/#/',
isActive: true,
},
{
id: 'woerdle',
nameDe: 'Wördle',
nameEn: 'Wördle',
url: 'https://www.wördle.de',
isActive: true,
},
{
id: 'ciddle',
nameDe: 'Ciddle',
nameEn: 'Ciddle',
url: 'https://ciddle.winklerweb.net',
isActive: true,
},
];
export function getRandomExternalPuzzle(): ExternalPuzzle | null {
const activePuzzles = externalPuzzles.filter(p => p.isActive !== false);
if (activePuzzles.length === 0) {
return null;
}
const index = Math.floor(Math.random() * activePuzzles.length);
return activePuzzles[index] ?? null;
}

View File

@@ -0,0 +1,68 @@
import { getTodayISOString } from './dateUtils';
const DAILY_PLAYED_PREFIX = 'hoerdle_daily_played_';
const EXTRA_POPOVER_PREFIX = 'hoerdle_extra_puzzles_shown_';
function getTodayKey(prefix: string): string | null {
if (typeof window === 'undefined') return null;
const today = getTodayISOString();
return `${prefix}${today}`;
}
export function markDailyPuzzlePlayedToday(genreKey: string) {
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
if (!storageKey) return;
try {
const raw = window.localStorage.getItem(storageKey);
const list: string[] = raw ? JSON.parse(raw) : [];
if (!list.includes(genreKey)) {
list.push(genreKey);
window.localStorage.setItem(storageKey, JSON.stringify(list));
}
} catch (e) {
console.warn('[extraPuzzles] Failed to mark daily puzzle as played', e);
}
}
export function hasPlayedAllDailyPuzzlesForToday(requiredGenreKeys: string[]): boolean {
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
if (!storageKey) return false;
try {
const raw = window.localStorage.getItem(storageKey);
const played: string[] = raw ? JSON.parse(raw) : [];
if (!Array.isArray(played) || played.length === 0) {
return false;
}
return requiredGenreKeys.every(key => played.includes(key));
} catch (e) {
console.warn('[extraPuzzles] Failed to read played puzzles', e);
return false;
}
}
export function hasSeenExtraPuzzlesPopoverToday(): boolean {
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
if (!storageKey) return false;
try {
return window.localStorage.getItem(storageKey) === 'true';
} catch (e) {
console.warn('[extraPuzzles] Failed to read popover state', e);
return false;
}
}
export function markExtraPuzzlesPopoverShownToday() {
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
if (!storageKey) return;
try {
window.localStorage.setItem(storageKey, 'true');
} catch (e) {
console.warn('[extraPuzzles] Failed to persist popover state', e);
}
}

View File

@@ -0,0 +1,94 @@
import { PrismaClient, PoliticalStatement as PrismaPoliticalStatement } from '@prisma/client';
const prisma = new PrismaClient();
export type PoliticalStatement = {
id: number;
locale: string;
text: string;
active: boolean;
source?: string | null;
};
function mapFromPrisma(stmt: PrismaPoliticalStatement): PoliticalStatement {
return {
id: stmt.id,
locale: stmt.locale,
text: stmt.text,
active: stmt.active,
source: stmt.source,
};
}
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const all = await prisma.politicalStatement.findMany({
where: {
locale: safeLocale,
active: true,
},
});
if (all.length === 0) {
return null;
}
const index = Math.floor(Math.random() * all.length);
return mapFromPrisma(all[index]);
}
export async function getAllStatements(locale: string): Promise<PoliticalStatement[]> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const all = await prisma.politicalStatement.findMany({
where: { locale: safeLocale },
orderBy: { id: 'asc' },
});
return all.map(mapFromPrisma);
}
export async function createStatement(locale: string, input: Omit<PoliticalStatement, 'id' | 'locale'>): Promise<PoliticalStatement> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const created = await prisma.politicalStatement.create({
data: {
locale: safeLocale,
text: input.text,
active: input.active ?? true,
source: input.source ?? null,
},
});
return mapFromPrisma(created);
}
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id' | 'locale'>>): Promise<PoliticalStatement | null> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
// Optional: sicherstellen, dass das Statement zu dieser Locale gehört
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
if (!existing || existing.locale !== safeLocale) {
return null;
}
const updated = await prisma.politicalStatement.update({
where: { id },
data: {
text: input.text ?? existing.text,
active: input.active ?? existing.active,
source: input.source !== undefined ? input.source : existing.source,
},
});
return mapFromPrisma(updated);
}
export async function deleteStatement(locale: string, id: number): Promise<boolean> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
if (!existing || existing.locale !== safeLocale) {
return false;
}
await prisma.politicalStatement.delete({ where: { id } });
return true;
}

View File

@@ -64,6 +64,12 @@
"special": "Special", "special": "Special",
"genre": "Genre" "genre": "Genre"
}, },
"ExtraPuzzles": {
"title": "Noch nicht genug Rätsel?",
"message": "Hey, hast du Lust auf weitere Rätsel? Dann schau doch mal bei {name} vorbei!",
"cta": "Zu {name}",
"close": "Schließen"
},
"Statistics": { "Statistics": {
"yourStatistics": "Deine Statistiken", "yourStatistics": "Deine Statistiken",
"totalPuzzles": "Gesamte Rätsel", "totalPuzzles": "Gesamte Rätsel",
@@ -151,7 +157,81 @@
"artist": "Interpret", "artist": "Interpret",
"actions": "Aktionen", "actions": "Aktionen",
"deletePuzzle": "Löschen", "deletePuzzle": "Löschen",
"wrongPassword": "Falsches Passwort" "wrongPassword": "Falsches Passwort",
"manageCurators": "Kuratoren verwalten",
"addCurator": "Kurator hinzufügen",
"curatorUsername": "Benutzername",
"curatorPassword": "Passwort (bei Leer lassen: nicht ändern)",
"isGlobalCurator": "Globaler Kurator (darf Global-Flag ändern)",
"assignedGenres": "Zugeordnete Genres",
"assignedSpecials": "Zugeordnete Specials",
"noCurators": "Noch keine Kuratoren angelegt."
},
"Curator": {
"loginTitle": "Kuratoren-Login",
"loginUsername": "Benutzername",
"loginPassword": "Passwort",
"loginButton": "Einloggen",
"logout": "Abmelden",
"loginFailed": "Login fehlgeschlagen.",
"loginNetworkError": "Netzwerkfehler beim Login.",
"loadCuratorError": "Fehler beim Laden der Kuratoren-Informationen.",
"loadSongsError": "Fehler beim Laden der Songs.",
"songUpdated": "Song erfolgreich aktualisiert.",
"saveError": "Fehler beim Speichern: {error}",
"saveNetworkError": "Netzwerkfehler beim Speichern.",
"noDeletePermission": "Du darfst diesen Song nicht löschen.",
"deleteConfirm": "Möchtest du \"{title}\" wirklich löschen?",
"songDeleted": "Song gelöscht.",
"deleteError": "Fehler beim Löschen: {error}",
"deleteNetworkError": "Netzwerkfehler beim Löschen.",
"uploadSectionTitle": "Titel hochladen",
"uploadSectionDescription": "Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert (inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens eines deiner Genres aus, um die Titel zuzuordnen.",
"dropzoneTitleEmpty": "MP3-Dateien hierher ziehen",
"dropzoneTitleWithFiles": "{count} Datei(en) ausgewählt",
"dropzoneSubtitle": "oder klicken, um Dateien auszuwählen",
"selectedFilesTitle": "Ausgewählte Dateien:",
"uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Genres zuordnen",
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
"uploadButtonIdle": "Upload starten",
"uploadButtonUploading": "Lade hoch...",
"uploadSummary": "✅ {success}/{total} Uploads erfolgreich.",
"uploadSummaryDuplicates": "⚠️ {count} Duplikat(e) übersprungen.",
"uploadSummaryFailed": "❌ {count} fehlgeschlagen.",
"uploadResultSuccess": "✅ erfolgreich",
"uploadResultDuplicate": "⚠️ Duplikat: {error}",
"uploadResultError": "❌ Fehler: {error}",
"tracklistTitle": "Titel in deinen Genres & Specials ({count} Titel)",
"tracklistDescription": "Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist. Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden.",
"searchPlaceholder": "Nach Titel oder Artist suchen...",
"filterAll": "Alle Inhalte",
"filterNoGlobal": "🚫 Ohne Global",
"filterReset": "Filter zurücksetzen",
"noSongsInScope": "Keine passenden Songs in deinen Genres/Specials gefunden.",
"columnId": "ID",
"columnPlay": "Play",
"columnTitle": "Titel",
"columnArtist": "Artist",
"columnYear": "Jahr",
"columnGenresSpecials": "Genres / Specials",
"columnAdded": "Hinzugefügt",
"columnActivations": "Aktivierungen",
"columnRating": "Rating",
"columnExcludeGlobal": "Exclude Global",
"columnActions": "Aktionen",
"play": "Abspielen",
"pause": "Pause",
"excludeGlobalYes": "Ja",
"excludeGlobalNo": "Nein",
"excludeGlobalInfo": "Nur globale Kuratoren dürfen dieses Flag ändern.",
"paginationPrev": "Zurück",
"paginationNext": "Weiter",
"paginationLabel": "Seite {page} von {total}",
"loadingData": "Lade Daten...",
"loggedInAs": "Eingeloggt als {username}",
"globalCuratorSuffix": " (Globaler Kurator)",
"pageSizeLabel": "Pro Seite:"
}, },
"About": { "About": {
"title": "Über Hördle & Impressum", "title": "Über Hördle & Impressum",
@@ -165,12 +245,13 @@
"imprintEmailLabel": "E-Mail:", "imprintEmailLabel": "E-Mail:",
"costsTitle": "Laufende Kosten des Projekts", "costsTitle": "Laufende Kosten des Projekts",
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:", "costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
"costsDonationNote": "Alle Einnahmen, die die Betriebskosten des Projekts übersteigen, werden am Jahresende an die Aktion <link>Zentrum für politische Schönheit</link> gespendet.",
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)", "costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
"costsServer": "Server / vServer für App und Tracking", "costsServer": "Server / vServer für App und Tracking",
"costsEmail": "E-Mail-Hosting", "costsEmail": "E-Mail-Hosting",
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen", "costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
"costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.", "costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.",
"costsSheetPrivacyNote": "Beim Aufruf oder Einbetten der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.", "costsSheetPrivacyNote": "Beim Aufruf der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
"supportTitle": "Hördle unterstützen", "supportTitle": "Hördle unterstützen",
"supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:", "supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:",
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)", "supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",

View File

@@ -64,6 +64,12 @@
"special": "Special", "special": "Special",
"genre": "Genre" "genre": "Genre"
}, },
"ExtraPuzzles": {
"title": "Still in the mood for puzzles?",
"message": "Hey, would you like to try some more puzzles? Then take a look at {name}!",
"cta": "Go to {name}",
"close": "Close"
},
"Statistics": { "Statistics": {
"yourStatistics": "Your Statistics", "yourStatistics": "Your Statistics",
"totalPuzzles": "Total puzzles", "totalPuzzles": "Total puzzles",
@@ -151,7 +157,81 @@
"artist": "Artist", "artist": "Artist",
"actions": "Actions", "actions": "Actions",
"deletePuzzle": "Delete", "deletePuzzle": "Delete",
"wrongPassword": "Wrong password" "wrongPassword": "Wrong password",
"manageCurators": "Manage Curators",
"addCurator": "Add Curator",
"curatorUsername": "Username",
"curatorPassword": "Password (leave empty to keep)",
"isGlobalCurator": "Global curator (may change global flag)",
"assignedGenres": "Assigned genres",
"assignedSpecials": "Assigned specials",
"noCurators": "No curators created yet."
},
"Curator": {
"loginTitle": "Curator Login",
"loginUsername": "Username",
"loginPassword": "Password",
"loginButton": "Log in",
"logout": "Logout",
"loginFailed": "Login failed.",
"loginNetworkError": "Network error during login.",
"loadCuratorError": "Failed to load curator information.",
"loadSongsError": "Failed to load songs.",
"songUpdated": "Song updated successfully.",
"saveError": "Error while saving: {error}",
"saveNetworkError": "Network error while saving.",
"noDeletePermission": "You are not allowed to delete this song.",
"deleteConfirm": "Do you really want to delete \"{title}\"?",
"songDeleted": "Song deleted.",
"deleteError": "Error while deleting: {error}",
"deleteNetworkError": "Network error while deleting.",
"uploadSectionTitle": "Upload titles",
"uploadSectionDescription": "Drag one or more MP3 files here or select them. The titles will be analysed automatically (including detection of the release year) and excluded from the global playlist. Select at least one of your genres to assign the titles.",
"dropzoneTitleEmpty": "Drag MP3 files here",
"dropzoneTitleWithFiles": "{count} file(s) selected",
"dropzoneSubtitle": "or click to select files",
"selectedFilesTitle": "Selected files:",
"uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Assign genres",
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
"uploadButtonIdle": "Start upload",
"uploadButtonUploading": "Uploading...",
"uploadSummary": "✅ {success}/{total} uploads successful.",
"uploadSummaryDuplicates": "⚠️ {count} duplicate(s) skipped.",
"uploadSummaryFailed": "❌ {count} failed.",
"uploadResultSuccess": "✅ successful",
"uploadResultDuplicate": "⚠️ Duplicate: {error}",
"uploadResultError": "❌ Error: {error}",
"tracklistTitle": "Titles in your genres & specials ({count} titles)",
"tracklistDescription": "You can edit songs that are assigned to at least one of your genres or specials. Deletion is only allowed if a song is assigned exclusively to your genres/specials. Genres, specials, news and political statements can only be managed by the admin.",
"searchPlaceholder": "Search by title or artist...",
"filterAll": "All content",
"filterNoGlobal": "🚫 No global",
"filterReset": "Reset filters",
"noSongsInScope": "No matching songs in your genres/specials.",
"columnId": "ID",
"columnPlay": "Play",
"columnTitle": "Title",
"columnArtist": "Artist",
"columnYear": "Year",
"columnGenresSpecials": "Genres / Specials",
"columnAdded": "Added",
"columnActivations": "Activations",
"columnRating": "Rating",
"columnExcludeGlobal": "Exclude global",
"columnActions": "Actions",
"play": "Play",
"pause": "Pause",
"excludeGlobalYes": "Yes",
"excludeGlobalNo": "No",
"excludeGlobalInfo": "Only global curators may change this flag.",
"paginationPrev": "Previous",
"paginationNext": "Next",
"paginationLabel": "Page {page} of {total}",
"loadingData": "Loading data...",
"loggedInAs": "Logged in as {username}",
"globalCuratorSuffix": " (Global curator)",
"pageSizeLabel": "Per page:"
}, },
"About": { "About": {
"title": "About Hördle & Imprint", "title": "About Hördle & Imprint",
@@ -165,12 +245,13 @@
"imprintEmailLabel": "Email:", "imprintEmailLabel": "Email:",
"costsTitle": "Ongoing costs of the project", "costsTitle": "Ongoing costs of the project",
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:", "costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
"costsDonationNote": "All income that exceeds the operating costs of the project will be donated at the end of the year to the campaign <link>Zentrum für politische Schönheit</link>.",
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)", "costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
"costsServer": "Servers / vServers for the app and tracking", "costsServer": "Servers / vServers for the app and tracking",
"costsEmail": "Email hosting", "costsEmail": "Email hosting",
"costsLicenses": "Possible fees for copyrights or other licenses", "costsLicenses": "Possible fees for copyrights or other licenses",
"costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.", "costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.",
"costsSheetPrivacyNote": "When accessing or embedding the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.", "costsSheetPrivacyNote": "When accessing the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
"supportTitle": "Support Hördle", "supportTitle": "Support Hördle",
"supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:", "supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:",
"supportSepaTitle": "SEPA Bank Transfer (preferred)", "supportSepaTitle": "SEPA Bank Transfer (preferred)",

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.4.5", "version": "0.1.4.11",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "PoliticalStatement" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"locale" TEXT NOT NULL,
"text" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"source" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "PoliticalStatement_locale_active_idx" ON "PoliticalStatement"("locale", "active");

View File

@@ -0,0 +1,36 @@
-- CreateTable
CREATE TABLE "Curator" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"isGlobalCurator" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "CuratorGenre" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"curatorId" INTEGER NOT NULL,
"genreId" INTEGER NOT NULL,
CONSTRAINT "CuratorGenre_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorGenre_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CuratorSpecial" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"curatorId" INTEGER NOT NULL,
"specialId" INTEGER NOT NULL,
CONSTRAINT "CuratorSpecial_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorSpecial_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Curator_username_key" ON "Curator"("username");
-- CreateIndex
CREATE UNIQUE INDEX "CuratorGenre_curatorId_genreId_key" ON "CuratorGenre"("curatorId", "genreId");
-- CreateIndex
CREATE UNIQUE INDEX "CuratorSpecial_curatorId_specialId_key" ON "CuratorSpecial"("curatorId", "specialId");

View File

@@ -33,6 +33,7 @@ model Genre {
active Boolean @default(true) active Boolean @default(true)
songs Song[] songs Song[]
dailyPuzzles DailyPuzzle[] dailyPuzzles DailyPuzzle[]
curatorGenres CuratorGenre[]
} }
model Special { model Special {
@@ -48,6 +49,7 @@ model Special {
songs SpecialSong[] songs SpecialSong[]
puzzles DailyPuzzle[] puzzles DailyPuzzle[]
news News[] news News[]
curatorSpecials CuratorSpecial[]
} }
model SpecialSong { model SpecialSong {
@@ -101,3 +103,49 @@ model PlayerState {
@@unique([identifier, genreKey]) @@unique([identifier, genreKey])
@@index([identifier]) @@index([identifier])
} }
model Curator {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
isGlobalCurator Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
genres CuratorGenre[]
specials CuratorSpecial[]
}
model CuratorGenre {
id Int @id @default(autoincrement())
curatorId Int
genreId Int
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
@@unique([curatorId, genreId])
}
model CuratorSpecial {
id Int @id @default(autoincrement())
curatorId Int
specialId Int
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
special Special @relation(fields: [specialId], references: [id], onDelete: Cascade)
@@unique([curatorId, specialId])
}
model PoliticalStatement {
id Int @id @default(autoincrement())
locale String
text String
active Boolean @default(true)
source String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([locale, active])
}

38
scripts/deploy-remote.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
# Remote-Deployment-Skript für Hördle
# Führt auf dem entfernten Host den Befehl
# ssh docker@100.116.245.76 "cd ~/hoerdle && ./scripts/deploy.sh"
# aus und liest das SSH-Passwort aus der Umgebungsvariablen PROD_SSH_PASSWORD.
#
# Voraussetzungen:
# - sshpass ist lokal installiert (z.B. `sudo apt-get install sshpass`)
# - PROD_SSH_PASSWORD ist im Environment gesetzt
# 1) Passwort im Environment setzen (nur für diese Session)
# export PROD_SSH_PASSWORD='dein-sehr-geheimes-passwort'
# 2) Skript ausführen: ./scripts/deploy-remote.sh
REMOTE_USER="docker"
REMOTE_HOST="100.116.245.76"
REMOTE_CMD='cd ~/hoerdle && ./scripts/deploy.sh'
if ! command -v sshpass >/dev/null 2>&1; then
echo "Fehler: sshpass ist nicht installiert. Bitte mit z.B. 'sudo apt-get install sshpass' nachinstallieren." >&2
exit 1;
fi
if [[ -z "${PROD_SSH_PASSWORD:-}" ]]; then
echo "Fehler: Umgebungsvariable PROD_SSH_PASSWORD ist nicht gesetzt." >&2
echo "Bitte setze sie z.B.: export PROD_SSH_PASSWORD='dein-passwort'" >&2
exit 1
fi
echo "🚀 Starte Remote-Deployment auf ${REMOTE_USER}@${REMOTE_HOST} ..."
sshpass -p "${PROD_SSH_PASSWORD}" \
ssh -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "${REMOTE_CMD}"
echo "✅ Remote-Deployment abgeschlossen."