Compare commits
18 Commits
v0.1.4.5
...
38148ace8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38148ace8d | ||
|
|
49e98ade3c | ||
|
|
397839cc1f | ||
|
|
3fe805129b | ||
|
|
bf9a49a9ac | ||
|
|
9b89cbf8ed | ||
|
|
7f33e98fb5 | ||
|
|
72f8b99092 | ||
|
|
e60daa511b | ||
|
|
19706abacb | ||
|
|
170e7b5402 | ||
|
|
ade1043c3c | ||
|
|
d69af49e24 | ||
|
|
63687524e7 | ||
|
|
0246cb58ee | ||
|
|
d76aa9f4e9 | ||
|
|
28afaf598b | ||
|
|
8239753911 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ next-env.d.ts
|
|||||||
.release-years-migrated
|
.release-years-migrated
|
||||||
.covers-migrated
|
.covers-migrated
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
scripts/scrape-bahn-expert-statements.js
|
||||||
|
docs/bahn-expert-statements.txt
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export default async function GenrePage({ params }: PageProps) {
|
|||||||
return s.launchDate && s.launchDate > now;
|
return s.launchDate && s.launchDate > now;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||||
|
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||||
@@ -156,7 +159,7 @@ export default async function GenrePage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<NewsSection locale={locale} />
|
<NewsSection locale={locale} />
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} requiredDailyKeys={requiredDailyKeys} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,13 +98,27 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "0.75rem",
|
marginBottom: "0.5rem",
|
||||||
fontSize: "0.9rem",
|
fontSize: "0.9rem",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("costsSheetPrivacyNote")}
|
{t("costsSheetPrivacyNote")}
|
||||||
</p>
|
</p>
|
||||||
|
<p style={{ marginBottom: "0.75rem" }}>
|
||||||
|
{t.rich("costsDonationNote", {
|
||||||
|
link: (chunks) => (
|
||||||
|
<a
|
||||||
|
href="https://politicalbeauty.de/ueber-das-ZPS.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
{chunks}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ marginBottom: "2rem" }}>
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
10
app/[locale]/curator/page.tsx
Normal file
10
app/[locale]/curator/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import CuratorPageInner from '../../curator/page';
|
||||||
|
|
||||||
|
export default function CuratorPage() {
|
||||||
|
// Wrapper für die lokalisierte Route /[locale]/curator
|
||||||
|
return <CuratorPageInner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import { config } from "@/lib/config";
|
|||||||
import { generateBaseMetadata } from "@/lib/metadata";
|
import { generateBaseMetadata } from "@/lib/metadata";
|
||||||
import InstallPrompt from "@/components/InstallPrompt";
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
import AppFooter from "@/components/AppFooter";
|
import AppFooter from "@/components/AppFooter";
|
||||||
|
import PoliticalStatementBanner from "@/components/PoliticalStatementBanner";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -89,6 +90,7 @@ export default async function LocaleLayout({
|
|||||||
{children}
|
{children}
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
|
<PoliticalStatementBanner />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export default async function Home({
|
|||||||
return s.launchDate && s.launchDate > now;
|
return s.launchDate && s.launchDate > now;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||||
|
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
||||||
@@ -149,7 +152,7 @@ export default async function Home({
|
|||||||
<NewsSection locale={locale} />
|
<NewsSection locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
<Game dailyPuzzle={dailyPuzzle} genre={null} requiredDailyKeys={requiredDailyKeys} />
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
182
app/api/curators/route.ts
Normal file
182
app/api/curators/route.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
// Only admin may list and manage curators
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const curators = await prisma.curator.findMany({
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
orderBy: { username: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
curators.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
username: c.username,
|
||||||
|
isGlobalCurator: c.isGlobalCurator,
|
||||||
|
genreIds: c.genres.map(g => g.genreId),
|
||||||
|
specialIds: c.specials.map(s => s.specialId),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { username, password, isGlobalCurator = false, genreIds = [], specialIds = [] } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const curator = await prisma.curator.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
isGlobalCurator: Boolean(isGlobalCurator),
|
||||||
|
genres: {
|
||||||
|
create: (genreIds as number[]).map(id => ({ genreId: id })),
|
||||||
|
},
|
||||||
|
specials: {
|
||||||
|
create: (specialIds as number[]).map(id => ({ specialId: id })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: curator.id,
|
||||||
|
username: curator.username,
|
||||||
|
isGlobalCurator: curator.isGlobalCurator,
|
||||||
|
genreIds: curator.genres.map(g => g.genreId),
|
||||||
|
specialIds: curator.specials.map(s => s.specialId),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating curator:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { id, username, password, isGlobalCurator, genreIds, specialIds } = await request.json();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
if (username !== undefined) data.username = username;
|
||||||
|
if (isGlobalCurator !== undefined) data.isGlobalCurator = Boolean(isGlobalCurator);
|
||||||
|
if (password) {
|
||||||
|
data.passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
const curator = await tx.curator.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(genreIds)) {
|
||||||
|
await tx.curatorGenre.deleteMany({
|
||||||
|
where: { curatorId: curator.id },
|
||||||
|
});
|
||||||
|
if (genreIds.length > 0) {
|
||||||
|
await tx.curatorGenre.createMany({
|
||||||
|
data: (genreIds as number[]).map(gid => ({
|
||||||
|
curatorId: curator.id,
|
||||||
|
genreId: gid,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(specialIds)) {
|
||||||
|
await tx.curatorSpecial.deleteMany({
|
||||||
|
where: { curatorId: curator.id },
|
||||||
|
});
|
||||||
|
if (specialIds.length > 0) {
|
||||||
|
await tx.curatorSpecial.createMany({
|
||||||
|
data: (specialIds as number[]).map(sid => ({
|
||||||
|
curatorId: curator.id,
|
||||||
|
specialId: sid,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalCurator = await tx.curator.findUnique({
|
||||||
|
where: { id: curator.id },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finalCurator) {
|
||||||
|
throw new Error('Curator not found after update');
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalCurator;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: updated.id,
|
||||||
|
username: updated.username,
|
||||||
|
isGlobalCurator: updated.isGlobalCurator,
|
||||||
|
genreIds: updated.genres.map(g => g.genreId),
|
||||||
|
specialIds: updated.specials.map(s => s.specialId),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating curator:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.curator.delete({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting curator:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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,13 +1,60 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { writeFile, unlink } from 'fs/promises';
|
import { writeFile, unlink } from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function getCuratorAssignments(curatorId: number) {
|
||||||
|
const [genres, specials] = await Promise.all([
|
||||||
|
prisma.curatorGenre.findMany({
|
||||||
|
where: { curatorId },
|
||||||
|
select: { genreId: true },
|
||||||
|
}),
|
||||||
|
prisma.curatorSpecial.findMany({
|
||||||
|
where: { curatorId },
|
||||||
|
select: { specialId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
genreIds: new Set(genres.map(g => g.genreId)),
|
||||||
|
specialIds: new Set(specials.map(s => s.specialId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||||
|
if (context.role === 'admin') return true;
|
||||||
|
|
||||||
|
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||||
|
const songSpecialIds = (song.specials || []).map((s: any) => s.specialId ?? s.id);
|
||||||
|
|
||||||
|
// Songs ohne Genres/Specials sind für Kuratoren generell editierbar
|
||||||
|
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasGenre = songGenreIds.some((id: number) => assignments.genreIds.has(id));
|
||||||
|
const hasSpecial = songSpecialIds.some((id: number) => assignments.specialIds.has(id));
|
||||||
|
|
||||||
|
return hasGenre || hasSpecial;
|
||||||
|
}
|
||||||
|
|
||||||
|
function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||||
|
if (context.role === 'admin') return true;
|
||||||
|
|
||||||
|
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||||
|
const songSpecialIds = (song.specials || []).map((s: any) => s.specialId ?? s.id);
|
||||||
|
|
||||||
|
const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id));
|
||||||
|
const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id));
|
||||||
|
|
||||||
|
return allGenresAllowed && allSpecialsAllowed;
|
||||||
|
}
|
||||||
|
|
||||||
// Configure route to handle large file uploads
|
// Configure route to handle large file uploads
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
export const maxDuration = 60; // 60 seconds timeout for uploads
|
||||||
@@ -50,11 +97,11 @@ export async function GET() {
|
|||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
console.log('[UPLOAD] Starting song upload request');
|
console.log('[UPLOAD] Starting song upload request');
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) {
|
if (error || !context) {
|
||||||
console.log('[UPLOAD] Authentication failed');
|
console.log('[UPLOAD] Authentication failed');
|
||||||
return authError;
|
return error!;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -63,10 +110,17 @@ export async function POST(request: Request) {
|
|||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
let title = '';
|
let title = '';
|
||||||
let artist = '';
|
let artist = '';
|
||||||
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
let excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||||
|
|
||||||
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
||||||
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
|
console.log('[UPLOAD] excludeFromGlobal (raw):', excludeFromGlobal);
|
||||||
|
|
||||||
|
// Apply global playlist rules:
|
||||||
|
// - Admin: may control the flag via form data
|
||||||
|
// - Curator: uploads are always excluded from global by default
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
excludeFromGlobal = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
console.error('[UPLOAD] No file provided');
|
console.error('[UPLOAD] No file provided');
|
||||||
@@ -261,9 +315,9 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) return authError;
|
if (error || !context) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||||
@@ -272,6 +326,69 @@ export async function PUT(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load current song with relations for permission checks
|
||||||
|
const existingSong = await prisma.song.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSong) {
|
||||||
|
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let effectiveGenreIds = genreIds as number[] | undefined;
|
||||||
|
let effectiveSpecialIds = specialIds as number[] | undefined;
|
||||||
|
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const assignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
|
||||||
|
if (!curatorCanEditSong(context, existingSong, assignments)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden: You are not allowed to edit this song' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curators may assign genres, but only within their own assignments.
|
||||||
|
// Genres außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
|
||||||
|
if (effectiveGenreIds !== undefined) {
|
||||||
|
const invalidGenre = effectiveGenreIds.some(id => !assignments.genreIds.has(id));
|
||||||
|
if (invalidGenre) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Curators may only assign their own genres' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixedGenreIds = existingSong.genres
|
||||||
|
.filter(g => !assignments.genreIds.has(g.id))
|
||||||
|
.map(g => g.id);
|
||||||
|
const managedGenreIds = effectiveGenreIds.filter(id => assignments.genreIds.has(id));
|
||||||
|
effectiveGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curators may assign specials, but only within their own assignments.
|
||||||
|
// Specials außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
|
||||||
|
if (effectiveSpecialIds !== undefined) {
|
||||||
|
const invalidSpecial = effectiveSpecialIds.some(id => !assignments.specialIds.has(id));
|
||||||
|
if (invalidSpecial) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Curators may only assign their own specials' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSpecials = await prisma.specialSong.findMany({
|
||||||
|
where: { songId: Number(id) }
|
||||||
|
});
|
||||||
|
const fixedSpecialIds = currentSpecials
|
||||||
|
.map(ss => ss.specialId)
|
||||||
|
.filter(sid => !assignments.specialIds.has(sid));
|
||||||
|
const managedSpecialIds = effectiveSpecialIds.filter(id => assignments.specialIds.has(id));
|
||||||
|
effectiveSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data: any = { title, artist };
|
const data: any = { title, artist };
|
||||||
|
|
||||||
// Update releaseYear if provided (can be null to clear it)
|
// Update releaseYear if provided (can be null to clear it)
|
||||||
@@ -280,24 +397,35 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (excludeFromGlobal !== undefined) {
|
if (excludeFromGlobal !== undefined) {
|
||||||
data.excludeFromGlobal = excludeFromGlobal;
|
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) {
|
if (effectiveGenreIds && effectiveGenreIds.length > 0) {
|
||||||
data.genres = {
|
data.genres = {
|
||||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle SpecialSong relations separately
|
// Handle SpecialSong relations separately
|
||||||
if (specialIds !== undefined) {
|
if (effectiveSpecialIds !== undefined) {
|
||||||
// First, get current special assignments
|
// First, get current special assignments
|
||||||
const currentSpecials = await prisma.specialSong.findMany({
|
const currentSpecials = await prisma.specialSong.findMany({
|
||||||
where: { songId: Number(id) }
|
where: { songId: Number(id) }
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||||
const newSpecialIds = specialIds as number[];
|
const newSpecialIds = effectiveSpecialIds as number[];
|
||||||
|
|
||||||
// Delete removed specials
|
// Delete removed specials
|
||||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||||
@@ -344,9 +472,9 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) return authError;
|
if (error || !context) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await request.json();
|
const { id } = await request.json();
|
||||||
@@ -355,15 +483,30 @@ export async function DELETE(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get song to find filename
|
// Get song to find filename and relations for permission checks
|
||||||
const song = await prisma.song.findUnique({
|
const song = await prisma.song.findUnique({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!song) {
|
if (!song) {
|
||||||
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const assignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
|
||||||
|
if (!curatorCanDeleteSong(context, song, assignments)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden: You are not allowed to delete this song' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete file
|
// Delete file
|
||||||
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||||
try {
|
try {
|
||||||
|
|||||||
1300
app/curator/page.tsx
Normal file
1300
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
|
// Dynamic genre pages
|
||||||
try {
|
try {
|
||||||
|
// Während des Docker-Builds wird häufig eine temporäre SQLite-DB (file:./dev.db)
|
||||||
|
// ohne migrierte Tabellen verwendet. In diesem Fall überspringen wir die
|
||||||
|
// Datenbankabfrage und liefern nur die statischen Seiten, um Build-Fehler zu vermeiden.
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
if (dbUrl && dbUrl.startsWith('file:./')) {
|
||||||
|
return staticPages;
|
||||||
|
}
|
||||||
|
|
||||||
const genres = await prisma.genre.findMany({
|
const genres = await prisma.genre.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
});
|
});
|
||||||
|
|||||||
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 AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||||
import GuessInput from './GuessInput';
|
import GuessInput from './GuessInput';
|
||||||
import Statistics from './Statistics';
|
import Statistics from './Statistics';
|
||||||
|
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
|
||||||
import { useGameState } from '../lib/gameState';
|
import { useGameState } from '../lib/gameState';
|
||||||
|
import { getGenreKey } from '@/lib/playerStorage';
|
||||||
|
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
|
||||||
|
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
|
||||||
|
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
|
||||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
|
||||||
// Plausible Analytics
|
// Plausible Analytics
|
||||||
@@ -32,11 +37,14 @@ interface GameProps {
|
|||||||
isSpecial?: boolean;
|
isSpecial?: boolean;
|
||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
unlockSteps?: number[];
|
unlockSteps?: number[];
|
||||||
|
// List of genre keys that zusammen alle Tagesrätsel des Tages repräsentieren (z. B. ['global', 'Rock', 'Pop']).
|
||||||
|
// Wird genutzt, um zu prüfen, ob der Spieler alle Tagesrätsel gespielt hat.
|
||||||
|
requiredDailyKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||||
|
|
||||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS, requiredDailyKeys }: GameProps) {
|
||||||
const t = useTranslations('Game');
|
const t = useTranslations('Game');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||||
@@ -49,6 +57,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const [hasRated, setHasRated] = useState(false);
|
const [hasRated, setHasRated] = useState(false);
|
||||||
const [showYearModal, setShowYearModal] = useState(false);
|
const [showYearModal, setShowYearModal] = useState(false);
|
||||||
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
||||||
|
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||||
|
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,6 +91,37 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
}, [gameState, dailyPuzzle]);
|
}, [gameState, dailyPuzzle]);
|
||||||
|
|
||||||
|
// Track gespielte Tagesrätsel & entscheide, ob das Partner-Popover gezeigt werden soll
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gameState || !dailyPuzzle) return;
|
||||||
|
|
||||||
|
const gameEnded = gameState.isSolved || gameState.isFailed;
|
||||||
|
if (!gameEnded) return;
|
||||||
|
|
||||||
|
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||||
|
markDailyPuzzlePlayedToday(genreKey);
|
||||||
|
|
||||||
|
if (!requiredDailyKeys || requiredDailyKeys.length === 0) return;
|
||||||
|
if (hasSeenExtraPuzzlesPopoverToday()) return;
|
||||||
|
if (!hasPlayedAllDailyPuzzlesForToday(requiredDailyKeys)) return;
|
||||||
|
|
||||||
|
const partnerPuzzle = getRandomExternalPuzzle();
|
||||||
|
if (!partnerPuzzle) return;
|
||||||
|
|
||||||
|
setExtraPuzzle(partnerPuzzle);
|
||||||
|
setShowExtraPuzzlesPopover(true);
|
||||||
|
markExtraPuzzlesPopoverShownToday();
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('extra_puzzles_popover_shown', {
|
||||||
|
props: {
|
||||||
|
partner: partnerPuzzle.id,
|
||||||
|
url: partnerPuzzle.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [gameState?.isSolved, gameState?.isFailed, dailyPuzzle?.id, genre, isSpecial, requiredDailyKeys]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLastAction(null);
|
setLastAction(null);
|
||||||
}, [dailyPuzzle?.id]);
|
}, [dailyPuzzle?.id]);
|
||||||
@@ -490,6 +531,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
onSkip={handleYearSkip}
|
onSkip={handleYearSkip}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showExtraPuzzlesPopover && extraPuzzle && (
|
||||||
|
<ExtraPuzzlesPopover
|
||||||
|
puzzle={extraPuzzle}
|
||||||
|
onClose={() => setShowExtraPuzzlesPopover(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
58
lib/auth.ts
58
lib/auth.ts
@@ -1,4 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient, Curator } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export type StaffContext =
|
||||||
|
| { role: 'admin' }
|
||||||
|
| { role: 'curator'; curator: Curator };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication middleware for admin API routes
|
* Authentication middleware for admin API routes
|
||||||
@@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise<NextRespon
|
|||||||
return null; // Auth successful
|
return null; // Auth successful
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve current staff (admin or curator) from headers.
|
||||||
|
*
|
||||||
|
* Admin:
|
||||||
|
* - x-admin-auth: 'authenticated'
|
||||||
|
*
|
||||||
|
* Curator:
|
||||||
|
* - x-curator-auth: 'authenticated'
|
||||||
|
* - x-curator-username: <username>
|
||||||
|
*/
|
||||||
|
export async function getStaffContext(request: NextRequest): Promise<StaffContext | null> {
|
||||||
|
const adminHeader = request.headers.get('x-admin-auth');
|
||||||
|
if (adminHeader === 'authenticated') {
|
||||||
|
return { role: 'admin' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const curatorAuth = request.headers.get('x-curator-auth');
|
||||||
|
const curatorUsername = request.headers.get('x-curator-username');
|
||||||
|
|
||||||
|
if (curatorAuth === 'authenticated' && curatorUsername) {
|
||||||
|
const curator = await prisma.curator.findUnique({
|
||||||
|
where: { username: curatorUsername },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (curator) {
|
||||||
|
return { role: 'curator', curator };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require that the current request is authenticated as staff (admin or curator).
|
||||||
|
* Returns either an error response or a resolved context.
|
||||||
|
*/
|
||||||
|
export async function requireStaffAuth(request: NextRequest): Promise<{ error?: NextResponse; context?: StaffContext }> {
|
||||||
|
const context = await getStaffContext(request);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'Unauthorized - Staff authentication required' },
|
||||||
|
{ status: 401 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { context };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to verify admin password
|
* Helper to verify admin password
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const config = {
|
|||||||
},
|
},
|
||||||
credits: {
|
credits: {
|
||||||
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
|
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
|
||||||
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Made with 💚, ☕ and 🍺 by',
|
||||||
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||||
},
|
},
|
||||||
|
|||||||
47
lib/externalPuzzles.ts
Normal file
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -64,6 +64,12 @@
|
|||||||
"special": "Special",
|
"special": "Special",
|
||||||
"genre": "Genre"
|
"genre": "Genre"
|
||||||
},
|
},
|
||||||
|
"ExtraPuzzles": {
|
||||||
|
"title": "Noch nicht genug Rätsel?",
|
||||||
|
"message": "Hey, hast du Lust auf weitere Rätsel? Dann schau doch mal bei {name} vorbei!",
|
||||||
|
"cta": "Zu {name}",
|
||||||
|
"close": "Schließen"
|
||||||
|
},
|
||||||
"Statistics": {
|
"Statistics": {
|
||||||
"yourStatistics": "Deine Statistiken",
|
"yourStatistics": "Deine Statistiken",
|
||||||
"totalPuzzles": "Gesamte Rätsel",
|
"totalPuzzles": "Gesamte Rätsel",
|
||||||
@@ -151,9 +157,17 @@
|
|||||||
"artist": "Interpret",
|
"artist": "Interpret",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"deletePuzzle": "Löschen",
|
"deletePuzzle": "Löschen",
|
||||||
"wrongPassword": "Falsches Passwort"
|
"wrongPassword": "Falsches Passwort",
|
||||||
|
"manageCurators": "Kuratoren verwalten",
|
||||||
|
"addCurator": "Kurator hinzufügen",
|
||||||
|
"curatorUsername": "Benutzername",
|
||||||
|
"curatorPassword": "Passwort (bei Leer lassen: nicht ändern)",
|
||||||
|
"isGlobalCurator": "Globaler Kurator (darf Global-Flag ändern)",
|
||||||
|
"assignedGenres": "Zugeordnete Genres",
|
||||||
|
"assignedSpecials": "Zugeordnete Specials",
|
||||||
|
"noCurators": "Noch keine Kuratoren angelegt."
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "Über Hördle & Impressum",
|
"title": "Über Hördle & Impressum",
|
||||||
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
|
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
|
||||||
"projectTitle": "Über dieses Projekt",
|
"projectTitle": "Über dieses Projekt",
|
||||||
@@ -165,12 +179,13 @@
|
|||||||
"imprintEmailLabel": "E-Mail:",
|
"imprintEmailLabel": "E-Mail:",
|
||||||
"costsTitle": "Laufende Kosten des Projekts",
|
"costsTitle": "Laufende Kosten des Projekts",
|
||||||
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
||||||
|
"costsDonationNote": "Alle Einnahmen, die die Betriebskosten des Projekts übersteigen, werden am Jahresende an die Aktion <link>Zentrum für politische Schönheit</link> gespendet.",
|
||||||
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
||||||
"costsServer": "Server / vServer für App und Tracking",
|
"costsServer": "Server / vServer für App und Tracking",
|
||||||
"costsEmail": "E-Mail-Hosting",
|
"costsEmail": "E-Mail-Hosting",
|
||||||
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
|
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
|
||||||
"costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.",
|
"costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.",
|
||||||
"costsSheetPrivacyNote": "Beim Aufruf oder Einbetten der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
|
"costsSheetPrivacyNote": "Beim Aufruf der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
|
||||||
"supportTitle": "Hördle unterstützen",
|
"supportTitle": "Hördle unterstützen",
|
||||||
"supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:",
|
"supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:",
|
||||||
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",
|
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",
|
||||||
|
|||||||
@@ -64,6 +64,12 @@
|
|||||||
"special": "Special",
|
"special": "Special",
|
||||||
"genre": "Genre"
|
"genre": "Genre"
|
||||||
},
|
},
|
||||||
|
"ExtraPuzzles": {
|
||||||
|
"title": "Still in the mood for puzzles?",
|
||||||
|
"message": "Hey, would you like to try some more puzzles? Then take a look at {name}!",
|
||||||
|
"cta": "Go to {name}",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
"Statistics": {
|
"Statistics": {
|
||||||
"yourStatistics": "Your Statistics",
|
"yourStatistics": "Your Statistics",
|
||||||
"totalPuzzles": "Total puzzles",
|
"totalPuzzles": "Total puzzles",
|
||||||
@@ -151,7 +157,15 @@
|
|||||||
"artist": "Artist",
|
"artist": "Artist",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"deletePuzzle": "Delete",
|
"deletePuzzle": "Delete",
|
||||||
"wrongPassword": "Wrong password"
|
"wrongPassword": "Wrong password",
|
||||||
|
"manageCurators": "Manage Curators",
|
||||||
|
"addCurator": "Add Curator",
|
||||||
|
"curatorUsername": "Username",
|
||||||
|
"curatorPassword": "Password (leave empty to keep)",
|
||||||
|
"isGlobalCurator": "Global curator (may change global flag)",
|
||||||
|
"assignedGenres": "Assigned genres",
|
||||||
|
"assignedSpecials": "Assigned specials",
|
||||||
|
"noCurators": "No curators created yet."
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "About Hördle & Imprint",
|
"title": "About Hördle & Imprint",
|
||||||
@@ -165,12 +179,13 @@
|
|||||||
"imprintEmailLabel": "Email:",
|
"imprintEmailLabel": "Email:",
|
||||||
"costsTitle": "Ongoing costs of the project",
|
"costsTitle": "Ongoing costs of the project",
|
||||||
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
||||||
|
"costsDonationNote": "All income that exceeds the operating costs of the project will be donated at the end of the year to the campaign <link>Zentrum für politische Schönheit</link>.",
|
||||||
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
||||||
"costsServer": "Servers / vServers for the app and tracking",
|
"costsServer": "Servers / vServers for the app and tracking",
|
||||||
"costsEmail": "Email hosting",
|
"costsEmail": "Email hosting",
|
||||||
"costsLicenses": "Possible fees for copyrights or other licenses",
|
"costsLicenses": "Possible fees for copyrights or other licenses",
|
||||||
"costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.",
|
"costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.",
|
||||||
"costsSheetPrivacyNote": "When accessing or embedding the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
|
"costsSheetPrivacyNote": "When accessing the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
|
||||||
"supportTitle": "Support Hördle",
|
"supportTitle": "Support Hördle",
|
||||||
"supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:",
|
"supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:",
|
||||||
"supportSepaTitle": "SEPA Bank Transfer (preferred)",
|
"supportSepaTitle": "SEPA Bank Transfer (preferred)",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.4.5",
|
"version": "0.1.4.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -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)
|
active Boolean @default(true)
|
||||||
songs Song[]
|
songs Song[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
|
curatorGenres CuratorGenre[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Special {
|
model Special {
|
||||||
@@ -48,6 +49,7 @@ model Special {
|
|||||||
songs SpecialSong[]
|
songs SpecialSong[]
|
||||||
puzzles DailyPuzzle[]
|
puzzles DailyPuzzle[]
|
||||||
news News[]
|
news News[]
|
||||||
|
curatorSpecials CuratorSpecial[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model SpecialSong {
|
model SpecialSong {
|
||||||
@@ -101,3 +103,49 @@ model PlayerState {
|
|||||||
@@unique([identifier, genreKey])
|
@@unique([identifier, genreKey])
|
||||||
@@index([identifier])
|
@@index([identifier])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Curator {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String
|
||||||
|
isGlobalCurator Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
genres CuratorGenre[]
|
||||||
|
specials CuratorSpecial[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model CuratorGenre {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
curatorId Int
|
||||||
|
genreId Int
|
||||||
|
|
||||||
|
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
|
||||||
|
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([curatorId, genreId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CuratorSpecial {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
curatorId Int
|
||||||
|
specialId Int
|
||||||
|
|
||||||
|
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
|
||||||
|
special Special @relation(fields: [specialId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([curatorId, specialId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PoliticalStatement {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
locale String
|
||||||
|
text String
|
||||||
|
active Boolean @default(true)
|
||||||
|
source String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([locale, active])
|
||||||
|
}
|
||||||
|
|||||||
38
scripts/deploy-remote.sh
Executable file
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