Compare commits
34 Commits
v0.1.4.2
...
693817b18c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
693817b18c | ||
|
|
41336e3af3 | ||
|
|
d7ec691469 | ||
|
|
5e1700712e | ||
|
|
f691384a34 | ||
|
|
f0d75c591a | ||
|
|
1f34d5813e | ||
|
|
33f8080aa8 | ||
|
|
8a102afc0e | ||
|
|
38148ace8d | ||
|
|
49e98ade3c | ||
|
|
397839cc1f | ||
|
|
3fe805129b | ||
|
|
bf9a49a9ac | ||
|
|
9b89cbf8ed | ||
|
|
7f33e98fb5 | ||
|
|
72f8b99092 | ||
|
|
e60daa511b | ||
|
|
19706abacb | ||
|
|
170e7b5402 | ||
|
|
ade1043c3c | ||
|
|
d69af49e24 | ||
|
|
63687524e7 | ||
|
|
0246cb58ee | ||
|
|
d76aa9f4e9 | ||
|
|
28afaf598b | ||
|
|
8239753911 | ||
|
|
0bfcf0737e | ||
|
|
5409196008 | ||
|
|
a59f6f747e | ||
|
|
dc763c88a3 | ||
|
|
1613bf0dda | ||
|
|
b872e87b50 | ||
|
|
87c1ee63ec |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ next-env.d.ts
|
||||
.release-years-migrated
|
||||
.covers-migrated
|
||||
docker-compose.yml
|
||||
scripts/scrape-bahn-expert-statements.js
|
||||
docs/bahn-expert-statements.txt
|
||||
|
||||
@@ -77,13 +77,15 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
||||
|
||||
- **Start-Punktestand:** 90 Punkte
|
||||
- **Richtige Antwort:** +20 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
|
||||
- **Überspringen (Skip):** -5 Punkte
|
||||
- **Snippet erneut abspielen (Replay):** -1 Punkt
|
||||
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
|
||||
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
|
||||
- **Minimum:** Der Punktestand kann nicht unter 0 fallen.
|
||||
|
||||
**Hinweis:** Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die automatische Verlängerung des Audio-Snippets (unlockSteps) abgezogen, um die Verwendung dieses Hilfsmittels zu reflektieren.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 16 (App Router)
|
||||
|
||||
@@ -87,6 +87,9 @@ export default async function GenrePage({ params }: PageProps) {
|
||||
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 (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
@@ -156,7 +159,7 @@ export default async function GenrePage({ params }: PageProps) {
|
||||
)}
|
||||
</div>
|
||||
<NewsSection locale={locale} />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} requiredDailyKeys={requiredDailyKeys} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,13 +98,27 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
marginBottom: "0.75rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.9rem",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{t("costsSheetPrivacyNote")}
|
||||
</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 style={{ marginBottom: "2rem" }}>
|
||||
@@ -206,6 +220,58 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "#f9fafb",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: "600",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("supportCuratorTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
{t("supportCuratorText")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "#f9fafb",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: "600",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("supportReportBugTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
{t.rich("supportReportBugText", {
|
||||
email: (chunks) => (
|
||||
<a
|
||||
href="mailto:admin@hoerdle.de"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
11
app/[locale]/curator/page.tsx
Normal file
11
app/[locale]/curator/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import CuratorPageInner from '../../curator/page';
|
||||
|
||||
export default function CuratorPage() {
|
||||
// Wrapper für die lokalisierte Route /[locale]/curator
|
||||
// Hinweis: Pfad '../../curator/page' zeigt von 'app/[locale]/curator' korrekt auf 'app/curator/page'.
|
||||
return <CuratorPageInner />;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { config } from "@/lib/config";
|
||||
import { generateBaseMetadata } from "@/lib/metadata";
|
||||
import InstallPrompt from "@/components/InstallPrompt";
|
||||
import AppFooter from "@/components/AppFooter";
|
||||
import PoliticalStatementBanner from "@/components/PoliticalStatementBanner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -89,6 +90,7 @@ export default async function LocaleLayout({
|
||||
{children}
|
||||
<InstallPrompt />
|
||||
<AppFooter />
|
||||
<PoliticalStatementBanner />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -61,6 +61,9 @@ export default async function Home({
|
||||
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 (
|
||||
<>
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} requiredDailyKeys={requiredDailyKeys} />
|
||||
<OnboardingTour />
|
||||
</>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
42
app/api/curator/login/route.ts
Normal file
42
app/api/curator/login/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
app/api/curator/me/route.ts
Normal file
38
app/api/curator/me/route.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
200
app/api/curators/route.ts
Normal file
200
app/api/curators/route.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient, Prisma } 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);
|
||||
|
||||
// Handle unique username constraint violation explicitly
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
return NextResponse.json(
|
||||
{ error: 'A curator with this username already exists.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Handle unique username constraint violation explicitly for updates
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
return NextResponse.json(
|
||||
{ error: 'A curator with this username already exists.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,19 +6,21 @@ const prisma = new PrismaClient();
|
||||
/**
|
||||
* POST /api/player-id/suggest
|
||||
*
|
||||
* Tries to find a player ID based on recently updated states for a genre.
|
||||
* This helps synchronize player IDs across different domains (hoerdle.de and hördle.de).
|
||||
* Tries to find a base player ID based on recently updated states for a genre and device.
|
||||
* This helps synchronize player IDs across different domains (hoerdle.de and hördle.de)
|
||||
* on the same device.
|
||||
*
|
||||
* Request body:
|
||||
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
||||
* - deviceId: Device identifier (UUID)
|
||||
*
|
||||
* Returns:
|
||||
* - playerId: Suggested player ID (UUID) if found, null otherwise
|
||||
* - basePlayerId: Suggested base player ID (UUID) if found, null otherwise
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { genreKey } = body;
|
||||
const { genreKey, deviceId } = body;
|
||||
|
||||
if (!genreKey || typeof genreKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
@@ -32,6 +34,41 @@ export async function POST(request: Request) {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setHours(cutoffDate.getHours() - 48);
|
||||
|
||||
// If deviceId is provided, search for states with matching device ID
|
||||
// Format: {basePlayerId}:{deviceId}
|
||||
if (deviceId && typeof deviceId === 'string') {
|
||||
// Search for states with the same device ID
|
||||
const recentStates = await prisma.playerState.findMany({
|
||||
where: {
|
||||
genreKey: genreKey,
|
||||
lastPlayed: {
|
||||
gte: cutoffDate,
|
||||
},
|
||||
identifier: {
|
||||
endsWith: `:${deviceId}`,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lastPlayed: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (recentStates.length > 0) {
|
||||
const recentState = recentStates[0];
|
||||
// Extract base player ID from full identifier
|
||||
const colonIndex = recentState.identifier.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const basePlayerId = recentState.identifier.substring(0, colonIndex);
|
||||
return NextResponse.json({
|
||||
basePlayerId: basePlayerId,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Find any recent state for this genre (legacy support)
|
||||
const recentState = await prisma.playerState.findFirst({
|
||||
where: {
|
||||
genreKey: genreKey,
|
||||
@@ -45,16 +82,26 @@ export async function POST(request: Request) {
|
||||
});
|
||||
|
||||
if (recentState) {
|
||||
// Return the player ID from the most recent state
|
||||
// Extract base player ID if format is basePlayerId:deviceId
|
||||
const colonIndex = recentState.identifier.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const basePlayerId = recentState.identifier.substring(0, colonIndex);
|
||||
return NextResponse.json({
|
||||
playerId: recentState.identifier,
|
||||
basePlayerId: basePlayerId,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
} else {
|
||||
// Legacy format: return as-is
|
||||
return NextResponse.json({
|
||||
basePlayerId: recentState.identifier,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// No recent state found
|
||||
return NextResponse.json({
|
||||
playerId: null,
|
||||
basePlayerId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[player-id/suggest] Error finding player ID:', error);
|
||||
|
||||
@@ -7,10 +7,30 @@ const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Validate UUID format (basic check)
|
||||
* Supports both legacy format (single UUID) and new format (basePlayerId:deviceId)
|
||||
*/
|
||||
function isValidUUID(uuid: string): boolean {
|
||||
function isValidPlayerId(playerId: string): boolean {
|
||||
// Legacy format: single UUID
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(uuid);
|
||||
|
||||
// New format: basePlayerId:deviceId (two UUIDs separated by colon)
|
||||
const combinedRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
return uuidRegex.test(playerId) || combinedRegex.test(playerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base player ID from full player ID
|
||||
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
|
||||
* Legacy: {uuid} -> {uuid}
|
||||
*/
|
||||
function extractBasePlayerId(fullPlayerId: string): string {
|
||||
const colonIndex = fullPlayerId.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
// Legacy format (no device ID) - return as is
|
||||
return fullPlayerId;
|
||||
}
|
||||
return fullPlayerId.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +53,7 @@ export async function GET(request: Request) {
|
||||
|
||||
// Get player identifier from header
|
||||
const playerId = request.headers.get('X-Player-Id');
|
||||
if (!playerId || !isValidUUID(playerId)) {
|
||||
if (!playerId || !isValidPlayerId(playerId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing player identifier' },
|
||||
{ status: 400 }
|
||||
@@ -109,7 +129,7 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
// Get player identifier from header
|
||||
const playerId = request.headers.get('X-Player-Id');
|
||||
if (!playerId || !isValidUUID(playerId)) {
|
||||
if (!playerId || !isValidPlayerId(playerId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing player identifier' },
|
||||
{ status: 400 }
|
||||
|
||||
113
app/api/political-statements/route.ts
Normal file
113
app/api/political-statements/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,88 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { writeFile, unlink } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { parseBuffer } from 'music-metadata';
|
||||
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||
|
||||
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);
|
||||
// `song.specials` kann je nach Context entweder ein Array von
|
||||
// - `Special` (mit `id`)
|
||||
// - `SpecialSong` (mit `specialId`)
|
||||
// - `SpecialSong` (mit Relation `special.id`)
|
||||
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
if (s?.id != null) return s.id;
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.id;
|
||||
return undefined;
|
||||
})
|
||||
.filter((id: any): id is number => typeof id === 'number');
|
||||
|
||||
// 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) => {
|
||||
if (s?.id != null) return s.id;
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.id;
|
||||
return undefined;
|
||||
})
|
||||
.filter((id: any): id is number => typeof id === 'number');
|
||||
|
||||
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
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
// Alle Zugriffe auf die Songliste erfordern Staff-Auth (Admin oder Kurator)
|
||||
const { error, context } = await requireStaffAuth(request);
|
||||
if (error || !context) return error!;
|
||||
|
||||
const songs = await prisma.song.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
@@ -26,8 +96,31 @@ export async function GET() {
|
||||
},
|
||||
});
|
||||
|
||||
let visibleSongs = songs;
|
||||
|
||||
if (context.role === 'curator') {
|
||||
const assignments = await getCuratorAssignments(context.curator.id);
|
||||
|
||||
visibleSongs = songs.filter(song => {
|
||||
const songGenreIds = song.genres.map(g => g.id);
|
||||
// `song.specials` ist hier ein Array von SpecialSong mit Relation `special`,
|
||||
// wir nutzen konsistent die Special-ID.
|
||||
const songSpecialIds = song.specials.map(ss => ss.special.id);
|
||||
|
||||
// Songs ohne Genres/Specials sind immer sichtbar
|
||||
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasGenre = songGenreIds.some(id => assignments.genreIds.has(id));
|
||||
const hasSpecial = songSpecialIds.some(id => assignments.specialIds.has(id));
|
||||
|
||||
return hasGenre || hasSpecial;
|
||||
});
|
||||
}
|
||||
|
||||
// Map to include activation count and flatten specials
|
||||
const songsWithActivations = songs.map(song => ({
|
||||
const songsWithActivations = visibleSongs.map(song => ({
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
@@ -50,11 +143,11 @@ export async function GET() {
|
||||
export async function POST(request: Request) {
|
||||
console.log('[UPLOAD] Starting song upload request');
|
||||
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) {
|
||||
console.log('[UPLOAD] Authentication failed');
|
||||
return authError;
|
||||
return error!;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -63,10 +156,17 @@ export async function POST(request: Request) {
|
||||
const file = formData.get('file') as File;
|
||||
let title = '';
|
||||
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] 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) {
|
||||
console.error('[UPLOAD] No file provided');
|
||||
@@ -261,9 +361,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) return error!;
|
||||
|
||||
try {
|
||||
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||
@@ -272,6 +372,69 @@ export async function PUT(request: Request) {
|
||||
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 };
|
||||
|
||||
// Update releaseYear if provided (can be null to clear it)
|
||||
@@ -280,24 +443,36 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (genreIds) {
|
||||
// Wenn effectiveGenreIds definiert ist, auch leere Arrays übernehmen (löscht alle Zuordnungen).
|
||||
if (effectiveGenreIds !== undefined) {
|
||||
data.genres = {
|
||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
||||
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
||||
};
|
||||
}
|
||||
|
||||
// Handle SpecialSong relations separately
|
||||
if (specialIds !== undefined) {
|
||||
if (effectiveSpecialIds !== undefined) {
|
||||
// First, get current special assignments
|
||||
const currentSpecials = await prisma.specialSong.findMany({
|
||||
where: { songId: Number(id) }
|
||||
});
|
||||
|
||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||
const newSpecialIds = specialIds as number[];
|
||||
const newSpecialIds = effectiveSpecialIds as number[];
|
||||
|
||||
// Delete removed specials
|
||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||
@@ -344,9 +519,9 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) return error!;
|
||||
|
||||
try {
|
||||
const { id } = await request.json();
|
||||
@@ -355,15 +530,30 @@ export async function DELETE(request: Request) {
|
||||
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({
|
||||
where: { id: Number(id) },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!song) {
|
||||
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
|
||||
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||
try {
|
||||
|
||||
1332
app/curator/page.tsx
Normal file
1332
app/curator/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
|
||||
// Dynamic genre pages
|
||||
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({
|
||||
where: { active: true },
|
||||
});
|
||||
|
||||
98
components/ExtraPuzzlesPopover.tsx
Normal file
98
components/ExtraPuzzlesPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ import { useTranslations, useLocale } from 'next-intl';
|
||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||
import GuessInput from './GuessInput';
|
||||
import Statistics from './Statistics';
|
||||
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
|
||||
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';
|
||||
|
||||
// Plausible Analytics
|
||||
@@ -32,11 +37,14 @@ interface GameProps {
|
||||
isSpecial?: boolean;
|
||||
maxAttempts?: 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];
|
||||
|
||||
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 locale = useLocale();
|
||||
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 [showYearModal, setShowYearModal] = useState(false);
|
||||
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
||||
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,6 +91,37 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
}, [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(() => {
|
||||
setLastAction(null);
|
||||
}, [dailyPuzzle?.id]);
|
||||
@@ -284,8 +325,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||
|
||||
// Use current domain from window.location to support both hoerdle.de and hördle.de
|
||||
const currentHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
||||
// 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.
|
||||
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
||||
const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
|
||||
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
||||
let shareUrl = `${protocol}//${currentHost}`;
|
||||
// Add locale prefix if not default (en)
|
||||
@@ -488,6 +531,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
onSkip={handleYearSkip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showExtraPuzzlesPopover && extraPuzzle && (
|
||||
<ExtraPuzzlesPopover
|
||||
puzzle={extraPuzzle}
|
||||
onClose={() => setShowExtraPuzzlesPopover(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -687,7 +737,11 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div
|
||||
className="star-rating"
|
||||
title={t('ratingTooltip')}
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
|
||||
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
||||
{[...Array(5)].map((_, index) => {
|
||||
|
||||
95
components/PoliticalStatementBanner.tsx
Normal file
95
components/PoliticalStatementBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
293
docs/SCORING_OPTIONS.md
Normal file
293
docs/SCORING_OPTIONS.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Scoring-System Optionen
|
||||
|
||||
## Problem-Analyse
|
||||
|
||||
### Aktuelle Situation
|
||||
- **Start:** 90 Punkte
|
||||
- **Richtige Antwort:** +20 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
|
||||
- **Skip:** -5 Punkte
|
||||
- **Replay:** -1 Punkt
|
||||
|
||||
### Problem (vor der Änderung)
|
||||
Bei vielen Versuchen kam man mit einem relativ hohen Score heraus:
|
||||
- Beispiel (alt): 7 Versuche = 90 + 20 - (6 × 3) = **92 Punkte**
|
||||
|
||||
### Lösung (aktuell implementiert)
|
||||
Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die Track-Verlängerung (unlockSteps) abgezogen:
|
||||
- Beispiel (neu): 7 Versuche = 90 + 20 - (6 × 8) = **62 Punkte**
|
||||
- Start: 90 Punkte
|
||||
- 6 falsche Versuche: -48 Punkte (6 × -8, bestehend aus -3 für falsch + -5 für Verlängerung)
|
||||
- 1 richtiger Versuch: +20 Punkte
|
||||
- **Ergebnis: 62 Punkte**
|
||||
|
||||
Dies spiegelt nun besser die tatsächliche Leistung wider. Das System bleibt motivierend, da richtige Antworten weiterhin belohnt werden.
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Progressive Abzüge ⚠️ (Intransparent)
|
||||
|
||||
### Konzept
|
||||
Abzüge steigen mit jedem Versuch, aber das System ist schwer nachvollziehbar.
|
||||
|
||||
```
|
||||
- Versuch 1-2: -2 Punkte pro falscher Antwort
|
||||
- Versuch 3-4: -4 Punkte pro falscher Antwort
|
||||
- Versuch 5-6: -6 Punkte pro falscher Antwort
|
||||
- Versuch 7: -8 Punkte
|
||||
```
|
||||
|
||||
### Beispiel
|
||||
Bei 7 Versuchen: 90 + 20 - (2+2+4+4+6+6) = **86 Punkte**
|
||||
|
||||
### Probleme
|
||||
- **Intransparent**: Spieler müssen sich merken, welche Abzüge in welcher Runde gelten
|
||||
- **Schwer erklärbar**: Das Regelwerk ist komplex
|
||||
- **Unklar im UI**: Aktuelle Abzüge sind nicht sofort ersichtlich
|
||||
|
||||
### Vorteile
|
||||
- Progressive Bestrafung für viele Versuche
|
||||
- Fairer als aktuelles System
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Bonus-Malus-System
|
||||
|
||||
### Konzept
|
||||
Höhere Belohnungen für frühe Erfolge + progressive Abzüge.
|
||||
|
||||
```
|
||||
Start: 90 Punkte
|
||||
|
||||
Richtige Antwort (Bonus abhängig vom Versuch):
|
||||
- Versuch 1: +30 Punkte (sehr gut!)
|
||||
- Versuch 2: +25 Punkte (gut!)
|
||||
- Versuch 3: +20 Punkte (okay)
|
||||
- Versuch 4: +15 Punkte
|
||||
- Versuch 5+: +10 Punkte
|
||||
|
||||
Falsche Antwort (progressive Abzüge):
|
||||
- Versuch 1-2: -3 Punkte
|
||||
- Versuch 3-4: -5 Punkte
|
||||
- Versuch 5-6: -8 Punkte
|
||||
- Versuch 7: -10 Punkte
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: 90 + 30 = **120 Punkte** ⭐
|
||||
- Gelöst in Versuch 4 (nach 3 Fehlern): 90 + 15 - (3+5+5) = **92 Punkte**
|
||||
- Gelöst in Versuch 7 (nach 6 Fehlern): 90 + 10 - (3+5+5+8+8+10) = **61 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Transparent**: Klare Regeln pro Versuch
|
||||
- **Motivierend**: Hohe Belohnungen für schnelles Lösen
|
||||
- **Fair**: Späte Erfolge werden abgewertet
|
||||
|
||||
### Nachteile
|
||||
- Etwas komplexer als aktuelles System
|
||||
- Muss im UI klar kommuniziert werden
|
||||
|
||||
---
|
||||
|
||||
## Option 3: Effizienz-Multiplikator
|
||||
|
||||
### Konzept
|
||||
Basis-System bleibt, aber Multiplikator basierend auf Versuchszahl.
|
||||
|
||||
```
|
||||
Basis-System (wie aktuell, aber mit höheren Abzügen):
|
||||
- Falsche Antwort: -5 Punkte (statt -3)
|
||||
- Skip: -7 Punkte (statt -5)
|
||||
|
||||
Bonus-Multiplikatoren (basierend auf Versuch, in dem gelöst wurde):
|
||||
- Gelöst in 1-2 Versuchen: ×1.2 (20% Bonus)
|
||||
- Gelöst in 3-4 Versuchen: ×1.1 (10% Bonus)
|
||||
- Gelöst in 5-6 Versuchen: ×1.0 (kein Bonus)
|
||||
- Gelöst in 7 Versuchen: ×0.9 (10% Abzug)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 2 (1 Fehler): (90 + 20 - 5) × 1.2 = **126 Punkte**
|
||||
- Gelöst in Versuch 4 (3 Fehler): (90 + 20 - 15) × 1.1 = **104.5 → 105 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- Multiplikator ist einfach zu verstehen ("20% Bonus für schnelles Lösen")
|
||||
- Basis-System bleibt ähnlich
|
||||
- Gerechte Bestrafung für viele Versuche
|
||||
|
||||
### Nachteile
|
||||
- Multiplikatoren müssen berechnet werden (könnte kompliziert wirken)
|
||||
- Kombination aus Basis + Multiplikator kann verwirrend sein
|
||||
|
||||
---
|
||||
|
||||
## Option 4: Kombiniertes System
|
||||
|
||||
### Konzept
|
||||
Höhere Abzüge + kleine Motivations-Boni.
|
||||
|
||||
```
|
||||
Basis-System (höhere Abzüge):
|
||||
- Falsche Antwort: -5 Punkte (statt -3)
|
||||
- Skip: -7 Punkte (statt -5)
|
||||
- Richtige Antwort: +20 Punkte (bleibt)
|
||||
|
||||
Motivations-Boni:
|
||||
- "Erstversuch" Bonus: +2 Punkte wenn erster Versuch nicht skipped wurde
|
||||
- "Perfekter Durchlauf": +5 Bonus wenn kein Skip verwendet wurde
|
||||
- "Knapp daneben": +1 Punkt für Versuche, die fast richtig waren (optional, komplex)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: 90 + 20 + 2 + 5 = **117 Punkte**
|
||||
- Gelöst in Versuch 4 (3 Fehler, kein Skip): 90 + 20 - 15 + 5 = **100 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler, 2 Skips): 90 + 20 - 30 - 14 = **66 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Einfach verständlich**: Basis + kleine Boni
|
||||
- **Motivierend**: Positive Verstärkung für gutes Verhalten
|
||||
- **Fair**: Höhere Abzüge sorgen für differenzierten Score
|
||||
|
||||
### Nachteile
|
||||
- Mehrere kleine Boni können unübersichtlich werden
|
||||
- "Knapp daneben" ist schwer zu implementieren
|
||||
|
||||
---
|
||||
|
||||
## Option 5: Streak-System (Langfristige Motivation)
|
||||
|
||||
### Konzept
|
||||
Zusätzliche Belohnungen für konsequentes Spielen über mehrere Tage.
|
||||
|
||||
```
|
||||
Tägliche Streaks:
|
||||
- 3 Tage in Folge gelöst: +5 Bonus-Punkte
|
||||
- 7 Tage: +10 Bonus-Punkte
|
||||
- 30 Tage: +15 Bonus-Punkte
|
||||
```
|
||||
|
||||
**Kombiniert mit einem der anderen Systeme** (z.B. Option 2 oder 4).
|
||||
|
||||
### Vorteile
|
||||
- Langfristige Spielermotivation
|
||||
- Belohnt Engagement
|
||||
|
||||
### Nachteile
|
||||
- Braucht Tracking über mehrere Tage
|
||||
- Löst nicht das Hauptproblem (zu hoher Score bei vielen Versuchen)
|
||||
|
||||
---
|
||||
|
||||
## Option 6: Multiplikator-System (Vereinfacht)
|
||||
|
||||
### Konzept
|
||||
Höhere Abzüge + einfache Multiplikatoren für Versuchszahl.
|
||||
|
||||
```
|
||||
Höhere Basis-Abzüge:
|
||||
- Falsche Antwort: -5 Punkte
|
||||
- Skip: -7 Punkte
|
||||
|
||||
Multiplikator basierend auf Versuch, in dem gelöst wurde:
|
||||
- Versuch 1: ×1.5 (50% Bonus) → Sehr schnelles Lösen
|
||||
- Versuch 2: ×1.3 (30% Bonus)
|
||||
- Versuch 3: ×1.1 (10% Bonus)
|
||||
- Versuch 4: ×1.0 (kein Bonus/Aufschlag)
|
||||
- Versuch 5+: ×0.9 (10% Abzug)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: (90 + 20) × 1.5 = **165 Punkte** ⭐⭐⭐
|
||||
- Gelöst in Versuch 3 (2 Fehler): (90 + 20 - 10) × 1.1 = **110 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Sehr transparent**: "50% Bonus für Erstversuch" ist einfach zu verstehen
|
||||
- **Stark motivierend**: Hohe Belohnungen für schnelles Lösen
|
||||
- **Fair**: Viele Versuche = niedriger Score
|
||||
|
||||
### Nachteile
|
||||
- Multiplikatoren könnten als zu komplex empfunden werden
|
||||
- Hohe Scores bei frühen Erfolgen (könnte als "zu leicht" empfunden werden)
|
||||
|
||||
---
|
||||
|
||||
## Empfehlungen
|
||||
|
||||
### Für Transparenz und Einfachheit: **Option 2 oder Option 4**
|
||||
|
||||
**Option 2 (Bonus-Malus)** ist am transparentesten:
|
||||
- Klare Werte pro Versuch
|
||||
- Einfach zu kommunizieren: "Erstversuch gibt +30, jeder weitere Versuch reduziert den Bonus"
|
||||
- Fair und motivierend
|
||||
|
||||
**Option 4 (Kombiniert)** ist am einfachsten:
|
||||
- Basis-System bleibt ähnlich (nur höhere Abzüge)
|
||||
- Zusätzliche kleine Boni sind optional und motivierend
|
||||
- Sehr einfach zu verstehen
|
||||
|
||||
### Für maximale Motivation: **Option 6**
|
||||
|
||||
- Hohe Belohnungen für schnelles Lösen
|
||||
- Einfache Multiplikatoren ("50% Bonus")
|
||||
- Sehr fair für viele Versuche
|
||||
|
||||
---
|
||||
|
||||
## Implementierungs-Hinweise
|
||||
|
||||
### UI-Kommunikation
|
||||
Welche Option auch gewählt wird - sie muss im Spiel klar kommuniziert werden:
|
||||
- Tooltips bei Versuchen
|
||||
- Score-Breakdown zeigt Abzüge/Boni pro Versuch
|
||||
- Vorschau: "Dieser Versuch würde X Punkte kosten/geben"
|
||||
|
||||
### Testing
|
||||
Vor der Implementierung sollten verschiedene Szenarien durchgespielt werden:
|
||||
- Erstversuch-Lösung
|
||||
- Mittlere Versuche (3-4)
|
||||
- Knappe Lösung (6-7 Versuche)
|
||||
- Mit/ohne Skips
|
||||
- Mit/ohne Replays
|
||||
|
||||
### Migration
|
||||
- Bestehende Scores können nicht einfach migriert werden
|
||||
- Neue Regeln gelten ab Start des neuen Systems
|
||||
- Eventuell: "New Scoring System" Ankündigung
|
||||
|
||||
---
|
||||
|
||||
## ✅ Implementiert: Abzüge für zusätzliche Sekunden
|
||||
|
||||
**Status:** ✅ **Aktuell implementiert**
|
||||
|
||||
Bei falschen Rateversuchen werden zusätzlich **-5 Punkte für die Track-Verlängerung** abgezogen:
|
||||
- Falsche Antwort (Rateversuch): -3 Punkte (falsch) + -5 Punkte (Verlängerung) = **-8 Punkte total**
|
||||
- Skip: -5 Punkte (kein zusätzlicher Abzug, da Skip keine Verlängerung bedeutet)
|
||||
|
||||
**Vorteile:**
|
||||
- ✅ Reflektiert den "Hilfsmittel"-Charakter der zusätzlichen Sekunden
|
||||
- ✅ Macht viele Versuche deutlich teurer
|
||||
- ✅ Fairer Score bei vielen Versuchen
|
||||
- ✅ Transparent: Klar getrennt als "Wrong guess" und "Track extension"
|
||||
|
||||
**Hinweis:** Dies ist die erste Anpassung des Scoring-Systems. Weitere Optionen (siehe oben) können in Zukunft ergänzt werden.
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
1. Sollen Replays weiterhin -1 Punkt kosten?
|
||||
2. Soll das Jahr-Bonus-System (+10) beibehalten werden?
|
||||
3. Wie wichtig ist Backward-Compatibility mit bestehenden Scores?
|
||||
4. Soll es eine "Preview"-Funktion geben ("Dieser Versuch kostet X Punkte")?
|
||||
5. Sollen zusätzlich freigeschaltete Sekunden (Unlock-Steps) zusätzlich Punkte kosten?
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
📝 **Erstellt:** 2024-12-01
|
||||
✅ **Erste Änderung implementiert:** 2024-12-01 - Track-Verlängerung kostet jetzt -5 Punkte bei falschen Rateversuchen
|
||||
🔄 **Status:** Teilweise umgesetzt
|
||||
💡 **Nächste Schritte:** Weitere Optionen können bei Bedarf ergänzt werden (siehe Optionen oben)
|
||||
|
||||
58
lib/auth.ts
58
lib/auth.ts
@@ -1,4 +1,11 @@
|
||||
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
|
||||
@@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise<NextRespon
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,7 @@ export const config = {
|
||||
},
|
||||
credits: {
|
||||
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',
|
||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||
},
|
||||
|
||||
47
lib/externalPuzzles.ts
Normal file
47
lib/externalPuzzles.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
68
lib/extraPuzzlesTracker.ts
Normal file
68
lib/extraPuzzlesTracker.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -200,6 +200,9 @@ export function useGameState(
|
||||
} else {
|
||||
newScore -= 3;
|
||||
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
|
||||
// Additional penalty for track extension (unlock steps)
|
||||
newScore -= 5;
|
||||
newBreakdown.push({ value: -5, reason: 'Track extension' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
175
lib/playerId.ts
175
lib/playerId.ts
@@ -4,14 +4,20 @@
|
||||
* Generates and manages a unique player identifier (UUID) that is stored
|
||||
* in localStorage. This identifier is used to sync game states across
|
||||
* different domains (hoerdle.de and hördle.de).
|
||||
*
|
||||
* Device-specific isolation:
|
||||
* - Each device has its own device ID stored in localStorage
|
||||
* - Player ID format: {basePlayerId}:{deviceId}
|
||||
* - This allows cross-domain sync on the same device while keeping devices isolated
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'hoerdle_player_id';
|
||||
const STORAGE_KEY_PLAYER = 'hoerdle_player_id';
|
||||
const STORAGE_KEY_DEVICE = 'hoerdle_device_id';
|
||||
|
||||
/**
|
||||
* Generate a UUID v4
|
||||
*/
|
||||
function generatePlayerId(): string {
|
||||
function generateUUID(): string {
|
||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
@@ -21,68 +27,143 @@ function generatePlayerId(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find an existing player ID from the backend
|
||||
* Get or create a device ID (unique per device)
|
||||
*
|
||||
* The device ID is stored in localStorage and persists across sessions.
|
||||
* This allows device-specific isolation of game states.
|
||||
*
|
||||
* @returns Device identifier (UUID v4)
|
||||
*/
|
||||
export function getOrCreateDeviceId(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let deviceId = localStorage.getItem(STORAGE_KEY_DEVICE);
|
||||
if (!deviceId) {
|
||||
deviceId = generateUUID();
|
||||
localStorage.setItem(STORAGE_KEY_DEVICE, deviceId);
|
||||
}
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device ID without creating a new one
|
||||
*
|
||||
* @returns Device identifier or null if not set
|
||||
*/
|
||||
export function getDeviceId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(STORAGE_KEY_DEVICE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a base player ID (for cross-domain sync)
|
||||
*/
|
||||
function generateBasePlayerId(): string {
|
||||
return generateUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find an existing base player ID from the backend
|
||||
*
|
||||
* Extracts the base player ID from a full player ID (format: {basePlayerId}:{deviceId})
|
||||
*
|
||||
* @param genreKey - Genre key to search for
|
||||
* @returns Player ID if found, null otherwise
|
||||
* @returns Base player ID if found, null otherwise
|
||||
*/
|
||||
async function findExistingPlayerId(genreKey: string): Promise<string | null> {
|
||||
async function findExistingBasePlayerId(genreKey: string): Promise<string | null> {
|
||||
try {
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
const response = await fetch('/api/player-id/suggest', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ genreKey }),
|
||||
body: JSON.stringify({ genreKey, deviceId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.playerId) {
|
||||
return data.playerId;
|
||||
if (data.basePlayerId) {
|
||||
return data.basePlayerId;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[playerId] Failed to find existing player ID:', error);
|
||||
console.warn('[playerId] Failed to find existing base player ID:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine base player ID and device ID into full player ID
|
||||
* Format: {basePlayerId}:{deviceId}
|
||||
*/
|
||||
function combinePlayerId(basePlayerId: string, deviceId: string): string {
|
||||
return `${basePlayerId}:${deviceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base player ID from full player ID
|
||||
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
|
||||
*/
|
||||
function extractBasePlayerId(fullPlayerId: string): string {
|
||||
const colonIndex = fullPlayerId.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
// Legacy format (no device ID) - return as is
|
||||
return fullPlayerId;
|
||||
}
|
||||
return fullPlayerId.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a player identifier
|
||||
*
|
||||
* If no identifier exists in localStorage, tries to find an existing one from the backend
|
||||
* (based on recently updated states). If none found, generates a new UUID.
|
||||
* This enables cross-domain synchronization between hoerdle.de and hördle.de.
|
||||
* Player ID format: {basePlayerId}:{deviceId}
|
||||
*
|
||||
* @param genreKey - Optional genre key to search for existing player ID
|
||||
* @returns Player identifier (UUID v4)
|
||||
* If no identifier exists in localStorage, tries to find an existing base player ID
|
||||
* from the backend (for cross-domain sync). If none found, generates a new base ID.
|
||||
* The device ID is always device-specific.
|
||||
*
|
||||
* This enables:
|
||||
* - Cross-domain synchronization on the same device (same base player ID)
|
||||
* - Device isolation (different device IDs)
|
||||
*
|
||||
* @param genreKey - Optional genre key to search for existing base player ID
|
||||
* @returns Full player identifier ({basePlayerId}:{deviceId})
|
||||
*/
|
||||
export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<string> {
|
||||
if (typeof window === 'undefined') {
|
||||
// Server-side: return empty string (not used on server)
|
||||
return '';
|
||||
}
|
||||
|
||||
let playerId = localStorage.getItem(STORAGE_KEY);
|
||||
// Always get/create device ID (device-specific)
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
|
||||
if (!playerId) {
|
||||
// Try to find an existing player ID from backend if genreKey is provided
|
||||
// Try to get base player ID from localStorage
|
||||
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
|
||||
if (!basePlayerId) {
|
||||
// Try to find an existing base player ID from backend if genreKey is provided
|
||||
if (genreKey) {
|
||||
const existingId = await findExistingPlayerId(genreKey);
|
||||
if (existingId) {
|
||||
playerId = existingId;
|
||||
localStorage.setItem(STORAGE_KEY, playerId);
|
||||
return playerId;
|
||||
const existingBaseId = await findExistingBasePlayerId(genreKey);
|
||||
if (existingBaseId) {
|
||||
basePlayerId = existingBaseId;
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new UUID if no existing ID found
|
||||
playerId = generatePlayerId();
|
||||
localStorage.setItem(STORAGE_KEY, playerId);
|
||||
// Generate new base player ID if no existing one found
|
||||
if (!basePlayerId) {
|
||||
basePlayerId = generateBasePlayerId();
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
return playerId;
|
||||
// Combine base player ID with device ID
|
||||
return combinePlayerId(basePlayerId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,31 +171,53 @@ export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<strin
|
||||
*
|
||||
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
|
||||
*
|
||||
* @returns Player identifier (UUID v4)
|
||||
* @returns Full player identifier ({basePlayerId}:{deviceId})
|
||||
*/
|
||||
export function getOrCreatePlayerId(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
// Server-side: return empty string (not used on server)
|
||||
return '';
|
||||
}
|
||||
|
||||
let playerId = localStorage.getItem(STORAGE_KEY);
|
||||
if (!playerId) {
|
||||
playerId = generatePlayerId();
|
||||
localStorage.setItem(STORAGE_KEY, playerId);
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
|
||||
if (!basePlayerId) {
|
||||
basePlayerId = generateBasePlayerId();
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
return playerId;
|
||||
|
||||
return combinePlayerId(basePlayerId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current player identifier without creating a new one
|
||||
*
|
||||
* @returns Player identifier or null if not set
|
||||
* @returns Full player identifier ({basePlayerId}:{deviceId}) or null if not set
|
||||
*/
|
||||
export function getPlayerId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
const deviceId = getDeviceId();
|
||||
const basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
|
||||
if (!deviceId || !basePlayerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return combinePlayerId(basePlayerId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base player ID (for debugging/logging)
|
||||
*
|
||||
* @returns Base player ID or null if not set
|
||||
*/
|
||||
export function getBasePlayerId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,9 @@ export async function savePlayerState(
|
||||
statistics: Statistics
|
||||
): Promise<void> {
|
||||
try {
|
||||
const playerId = getOrCreatePlayerId();
|
||||
// Use async version to ensure device ID is included
|
||||
const { getOrCreatePlayerIdAsync } = await import('./playerId');
|
||||
const playerId = await getOrCreatePlayerIdAsync();
|
||||
if (!playerId) {
|
||||
console.warn('[playerStorage] No player ID available, cannot save state');
|
||||
return;
|
||||
|
||||
94
lib/politicalStatements.ts
Normal file
94
lib/politicalStatements.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
|
||||
"thanksForRating": "Danke für die Bewertung!",
|
||||
"rateThisPuzzle": "Bewerte dieses Rätsel:",
|
||||
"ratingTooltip": "Hilf unseren Kuratoren, gute Rätsel zu machen!",
|
||||
"shared": "✓ Geteilt!",
|
||||
"copied": "✓ Kopiert!",
|
||||
"shareFailed": "✗ Fehlgeschlagen",
|
||||
@@ -63,6 +64,12 @@
|
||||
"special": "Special",
|
||||
"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": {
|
||||
"yourStatistics": "Deine Statistiken",
|
||||
"totalPuzzles": "Gesamte Rätsel",
|
||||
@@ -150,7 +157,81 @@
|
||||
"artist": "Interpret",
|
||||
"actions": "Aktionen",
|
||||
"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": {
|
||||
"title": "Über Hördle & Impressum",
|
||||
@@ -164,12 +245,13 @@
|
||||
"imprintEmailLabel": "E-Mail:",
|
||||
"costsTitle": "Laufende Kosten des Projekts",
|
||||
"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)",
|
||||
"costsServer": "Server / vServer für App und Tracking",
|
||||
"costsEmail": "E-Mail-Hosting",
|
||||
"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>.",
|
||||
"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",
|
||||
"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)",
|
||||
@@ -179,6 +261,10 @@
|
||||
"supportPaypalLink": "paypal.me/MBusche",
|
||||
"supportSteadyTitle": "Steady",
|
||||
"supportSteadyDescription": "Regelmäßige Unterstützung über Steady",
|
||||
"supportCuratorTitle": "Als Kurator bewerben",
|
||||
"supportCuratorText": "Du hast gute Kenntnisse in einem Genre und möchtest dich als Kurator bewerben? Wir freuen uns über deine Nachricht!",
|
||||
"supportReportBugTitle": "Fehler melden",
|
||||
"supportReportBugText": "Fehler in der App gefunden? Bitte melde sie per E-Mail an <email>admin@hoerdle.de</email>.",
|
||||
"privacyTitle": "Datenschutz",
|
||||
"privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.",
|
||||
"privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
|
||||
"thanksForRating": "Thanks for rating!",
|
||||
"rateThisPuzzle": "Rate this puzzle:",
|
||||
"ratingTooltip": "Help our curators create good puzzles!",
|
||||
"shared": "✓ Shared!",
|
||||
"copied": "✓ Copied!",
|
||||
"shareFailed": "✗ Failed",
|
||||
@@ -63,6 +64,12 @@
|
||||
"special": "Special",
|
||||
"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": {
|
||||
"yourStatistics": "Your Statistics",
|
||||
"totalPuzzles": "Total puzzles",
|
||||
@@ -150,7 +157,81 @@
|
||||
"artist": "Artist",
|
||||
"actions": "Actions",
|
||||
"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": {
|
||||
"title": "About Hördle & Imprint",
|
||||
@@ -164,12 +245,13 @@
|
||||
"imprintEmailLabel": "Email:",
|
||||
"costsTitle": "Ongoing costs of the project",
|
||||
"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)",
|
||||
"costsServer": "Servers / vServers for the app and tracking",
|
||||
"costsEmail": "Email hosting",
|
||||
"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>.",
|
||||
"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",
|
||||
"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)",
|
||||
@@ -179,6 +261,10 @@
|
||||
"supportPaypalLink": "paypal.me/MBusche",
|
||||
"supportSteadyTitle": "Steady",
|
||||
"supportSteadyDescription": "Regular support via Steady",
|
||||
"supportCuratorTitle": "Apply as Curator",
|
||||
"supportCuratorText": "Do you have good knowledge in a genre and would like to apply as a curator? We'd be happy to hear from you!",
|
||||
"supportReportBugTitle": "Report Bugs",
|
||||
"supportReportBugText": "Found a bug in the app? Please report it via email to <email>admin@hoerdle.de</email>.",
|
||||
"privacyTitle": "Privacy",
|
||||
"privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.",
|
||||
"privacyPlausibleTitle": "Self-hosted Plausible Analytics",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.4.2",
|
||||
"version": "0.1.4.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -33,6 +33,7 @@ model Genre {
|
||||
active Boolean @default(true)
|
||||
songs Song[]
|
||||
dailyPuzzles DailyPuzzle[]
|
||||
curatorGenres CuratorGenre[]
|
||||
}
|
||||
|
||||
model Special {
|
||||
@@ -48,6 +49,7 @@ model Special {
|
||||
songs SpecialSong[]
|
||||
puzzles DailyPuzzle[]
|
||||
news News[]
|
||||
curatorSpecials CuratorSpecial[]
|
||||
}
|
||||
|
||||
model SpecialSong {
|
||||
@@ -101,3 +103,49 @@ model PlayerState {
|
||||
@@unique([identifier, genreKey])
|
||||
@@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
38
scripts/deploy-remote.sh
Executable 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."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user