Compare commits
29 Commits
38148ace8d
...
v0.1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2548c2870 | ||
|
|
40d6ea75f0 | ||
|
|
0054facbe7 | ||
|
|
95bcf9ed1e | ||
|
|
08fedf9881 | ||
|
|
cd564b5d8c | ||
|
|
863539a5e9 | ||
|
|
2fa8aa0042 | ||
|
|
8ecf430bf5 | ||
|
|
71abb7c322 | ||
|
|
b730c6637a | ||
|
|
6e93529bc3 | ||
|
|
990e1927e9 | ||
|
|
d7fee047c2 | ||
|
|
28d14ff099 | ||
|
|
b1493b44bf | ||
|
|
b8a803b76e | ||
|
|
e2bdf0fc88 | ||
|
|
2cb9af8d2b | ||
|
|
d6ad01b00e | ||
|
|
693817b18c | ||
|
|
41336e3af3 | ||
|
|
d7ec691469 | ||
|
|
5e1700712e | ||
|
|
f691384a34 | ||
|
|
f0d75c591a | ||
|
|
1f34d5813e | ||
|
|
33f8080aa8 | ||
|
|
8a102afc0e |
@@ -786,7 +786,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
||||
|
||||
const handleSaveCurator = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!curatorUsername.trim()) return;
|
||||
if (!curatorUsername.trim()) {
|
||||
alert('Bitte einen Benutzernamen eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Beim Anlegen eines neuen Kurators ist ein Passwort Pflicht.
|
||||
if (!editingCuratorId && !curatorPassword.trim()) {
|
||||
alert('Für neue Kuratoren muss ein Passwort gesetzt werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
username: curatorUsername.trim(),
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 />;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
194
app/api/curator-comment/route.ts
Normal file
194
app/api/curator-comment/route.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { rateLimit } from '@/lib/rateLimit';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Rate limiting: 3 requests per minute
|
||||
const rateLimitError = rateLimit(request, { windowMs: 60000, maxRequests: 3 });
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
try {
|
||||
const { puzzleId, genreId, message, playerIdentifier } = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!puzzleId || !message || !playerIdentifier) {
|
||||
return NextResponse.json(
|
||||
{ error: 'puzzleId, message, and playerIdentifier are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Message validation: max 2000 characters, no empty message
|
||||
const trimmedMessage = message.trim();
|
||||
if (trimmedMessage.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message cannot be empty' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (trimmedMessage.length > 2000) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message too long. Maximum 2000 characters allowed.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// PlayerIdentifier validation: Check if it exists in PlayerState
|
||||
const playerState = await prisma.playerState.findFirst({
|
||||
where: {
|
||||
identifier: playerIdentifier
|
||||
}
|
||||
});
|
||||
|
||||
if (!playerState) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid player identifier' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Puzzle validation: Check if puzzle exists and matches genreId
|
||||
const puzzle = await prisma.dailyPuzzle.findUnique({
|
||||
where: { id: Number(puzzleId) },
|
||||
include: {
|
||||
song: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!puzzle) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Puzzle not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate genreId matches puzzle (if genreId is provided)
|
||||
if (genreId !== null && genreId !== undefined) {
|
||||
if (puzzle.genreId !== Number(genreId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Puzzle does not match the provided genre' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If no genreId provided, use puzzle's genreId
|
||||
// For global puzzles, genreId is null
|
||||
}
|
||||
|
||||
// Rate limit check: Check if comment already exists for this playerIdentifier + puzzleId
|
||||
const existingComment = await prisma.curatorComment.findUnique({
|
||||
where: {
|
||||
playerIdentifier_puzzleId: {
|
||||
playerIdentifier: playerIdentifier,
|
||||
puzzleId: Number(puzzleId)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existingComment) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You have already sent a comment for this puzzle' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine responsible curators
|
||||
const finalGenreId = genreId !== null && genreId !== undefined ? Number(genreId) : puzzle.genreId;
|
||||
const specialId = puzzle.specialId;
|
||||
|
||||
let curatorIds: number[] = [];
|
||||
const allCuratorIds = new Set<number>();
|
||||
|
||||
// Get all global curators (always included)
|
||||
const globalCurators = await prisma.curator.findMany({
|
||||
where: {
|
||||
isGlobalCurator: true
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
});
|
||||
globalCurators.forEach(gc => allCuratorIds.add(gc.id));
|
||||
|
||||
// Check for special puzzle first (takes precedence)
|
||||
if (specialId !== null) {
|
||||
// Special puzzle: Get curators for this special + all global curators
|
||||
const specialCurators = await prisma.curatorSpecial.findMany({
|
||||
where: {
|
||||
specialId: specialId
|
||||
},
|
||||
select: {
|
||||
curatorId: true
|
||||
}
|
||||
});
|
||||
specialCurators.forEach(cs => allCuratorIds.add(cs.curatorId));
|
||||
} else if (finalGenreId !== null) {
|
||||
// Genre puzzle: Get curators for this genre + all global curators
|
||||
const genreCurators = await prisma.curatorGenre.findMany({
|
||||
where: {
|
||||
genreId: finalGenreId
|
||||
},
|
||||
select: {
|
||||
curatorId: true
|
||||
}
|
||||
});
|
||||
genreCurators.forEach(cg => allCuratorIds.add(cg.curatorId));
|
||||
}
|
||||
// else: Global puzzle - only global curators (already added above)
|
||||
|
||||
curatorIds = Array.from(allCuratorIds);
|
||||
|
||||
if (curatorIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No curators found for this puzzle' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create comment and recipients in a transaction
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Create the comment
|
||||
const comment = await tx.curatorComment.create({
|
||||
data: {
|
||||
playerIdentifier: playerIdentifier,
|
||||
puzzleId: Number(puzzleId),
|
||||
genreId: finalGenreId,
|
||||
message: trimmedMessage
|
||||
}
|
||||
});
|
||||
|
||||
// Create recipients for all curators
|
||||
await tx.curatorCommentRecipient.createMany({
|
||||
data: curatorIds.map(curatorId => ({
|
||||
commentId: comment.id,
|
||||
curatorId: curatorId
|
||||
}))
|
||||
});
|
||||
|
||||
return comment;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
commentId: result.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating curator comment:', error);
|
||||
|
||||
// Handle unique constraint violation (shouldn't happen due to our check, but just in case)
|
||||
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You have already sent a comment for this puzzle' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
66
app/api/curator-comments/[id]/archive/route.ts
Normal file
66
app/api/curator-comments/[id]/archive/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireStaffAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// Require curator authentication
|
||||
const { error, context } = await requireStaffAuth(request);
|
||||
if (error || !context) {
|
||||
return error!;
|
||||
}
|
||||
|
||||
// Only curators can archive comments
|
||||
if (context.role !== 'curator') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only curators can archive comments' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const commentId = Number(id);
|
||||
const curatorId = context.curator.id;
|
||||
|
||||
// Verify that this comment belongs to this curator
|
||||
const recipient = await prisma.curatorCommentRecipient.findUnique({
|
||||
where: {
|
||||
commentId_curatorId: {
|
||||
commentId: commentId,
|
||||
curatorId: curatorId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Comment not found or access denied' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update archived flag
|
||||
await prisma.curatorCommentRecipient.update({
|
||||
where: {
|
||||
id: recipient.id
|
||||
},
|
||||
data: {
|
||||
archived: true
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error archiving comment:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
66
app/api/curator-comments/[id]/read/route.ts
Normal file
66
app/api/curator-comments/[id]/read/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireStaffAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// Require curator authentication
|
||||
const { error, context } = await requireStaffAuth(request);
|
||||
if (error || !context) {
|
||||
return error!;
|
||||
}
|
||||
|
||||
// Only curators can mark comments as read
|
||||
if (context.role !== 'curator') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only curators can mark comments as read' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const commentId = Number(id);
|
||||
const curatorId = context.curator.id;
|
||||
|
||||
// Verify that this comment belongs to this curator
|
||||
const recipient = await prisma.curatorCommentRecipient.findUnique({
|
||||
where: {
|
||||
commentId_curatorId: {
|
||||
commentId: commentId,
|
||||
curatorId: curatorId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Comment not found or access denied' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update readAt timestamp
|
||||
await prisma.curatorCommentRecipient.update({
|
||||
where: {
|
||||
id: recipient.id
|
||||
},
|
||||
data: {
|
||||
readAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error marking comment as read:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
139
app/api/curator-comments/route.ts
Normal file
139
app/api/curator-comments/route.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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) {
|
||||
// Require curator authentication
|
||||
const { error, context } = await requireStaffAuth(request);
|
||||
if (error || !context) {
|
||||
return error!;
|
||||
}
|
||||
|
||||
// Only curators can view comments (not admins directly)
|
||||
if (context.role !== 'curator') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only curators can view comments' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const curatorId = context.curator.id;
|
||||
|
||||
// Get all non-archived comments for this curator, ordered by creation date (newest first)
|
||||
const comments = await prisma.curatorCommentRecipient.findMany({
|
||||
where: {
|
||||
curatorId: curatorId,
|
||||
archived: false
|
||||
},
|
||||
include: {
|
||||
comment: {
|
||||
include: {
|
||||
puzzle: {
|
||||
include: {
|
||||
song: {
|
||||
select: {
|
||||
title: true,
|
||||
artist: true
|
||||
}
|
||||
},
|
||||
genre: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
special: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
comment: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Format the response with puzzle context
|
||||
const formattedComments = await Promise.all(comments.map(async (recipient) => {
|
||||
const puzzle = recipient.comment.puzzle;
|
||||
|
||||
// Calculate puzzle number
|
||||
let puzzleNumber = 0;
|
||||
if (puzzle.specialId) {
|
||||
// Special puzzle
|
||||
puzzleNumber = await prisma.dailyPuzzle.count({
|
||||
where: {
|
||||
specialId: puzzle.specialId,
|
||||
date: {
|
||||
lte: puzzle.date
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (puzzle.genreId) {
|
||||
// Genre puzzle
|
||||
puzzleNumber = await prisma.dailyPuzzle.count({
|
||||
where: {
|
||||
genreId: puzzle.genreId,
|
||||
date: {
|
||||
lte: puzzle.date
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Global puzzle
|
||||
puzzleNumber = await prisma.dailyPuzzle.count({
|
||||
where: {
|
||||
genreId: null,
|
||||
specialId: null,
|
||||
date: {
|
||||
lte: puzzle.date
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: recipient.comment.id,
|
||||
message: recipient.comment.message,
|
||||
createdAt: recipient.comment.createdAt,
|
||||
readAt: recipient.readAt,
|
||||
puzzle: {
|
||||
id: puzzle.id,
|
||||
date: puzzle.date,
|
||||
puzzleNumber: puzzleNumber,
|
||||
song: {
|
||||
title: puzzle.song.title,
|
||||
artist: puzzle.song.artist
|
||||
},
|
||||
genre: puzzle.genre ? {
|
||||
id: puzzle.genre.id,
|
||||
name: puzzle.genre.name
|
||||
} : null,
|
||||
special: puzzle.special ? {
|
||||
id: puzzle.special.id,
|
||||
name: puzzle.special.name
|
||||
} : null
|
||||
}
|
||||
};
|
||||
}));
|
||||
|
||||
return NextResponse.json(formattedComments);
|
||||
} catch (error) {
|
||||
console.error('Error fetching curator comments:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
@@ -69,6 +69,15 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
@@ -153,6 +162,15 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
|
||||
21
app/api/public-songs/route.ts
Normal file
21
app/api/public-songs/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Öffentliche, schreibgeschützte Song-Liste für das Spiel (GuessInput etc.).
|
||||
// Kein Auth, nur Lesen der nötigsten Felder.
|
||||
export async function GET() {
|
||||
const songs = await prisma.song.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
artist: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(songs);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,19 @@ function curatorCanEditSong(context: StaffContext, song: any, assignments: { gen
|
||||
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);
|
||||
// `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) {
|
||||
@@ -47,7 +59,14 @@ function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { g
|
||||
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 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));
|
||||
@@ -59,7 +78,11 @@ function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { g
|
||||
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: {
|
||||
@@ -73,8 +96,33 @@ 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`.
|
||||
// Es kann theoretisch verwaiste Einträge ohne `special` geben → defensiv optional chainen.
|
||||
const songSpecialIds = song.specials
|
||||
.map(ss => ss.special?.id)
|
||||
.filter((id): id is number => typeof id === 'number');
|
||||
|
||||
// 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,
|
||||
@@ -85,7 +133,10 @@ export async function GET() {
|
||||
activations: song.puzzles.length,
|
||||
puzzles: song.puzzles,
|
||||
genres: song.genres,
|
||||
specials: song.specials.map(ss => ss.special),
|
||||
// Nur Specials mit existierender Relation durchreichen, um undefinierte Einträge zu vermeiden.
|
||||
specials: song.specials
|
||||
.map(ss => ss.special)
|
||||
.filter((s): s is any => !!s),
|
||||
averageRating: song.averageRating,
|
||||
ratingCount: song.ratingCount,
|
||||
excludeFromGlobal: song.excludeFromGlobal,
|
||||
@@ -411,57 +462,62 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveGenreIds && effectiveGenreIds.length > 0) {
|
||||
// Wenn effectiveGenreIds definiert ist, auch leere Arrays übernehmen (löscht alle Zuordnungen).
|
||||
if (effectiveGenreIds !== undefined) {
|
||||
data.genres = {
|
||||
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
||||
};
|
||||
}
|
||||
|
||||
// Handle SpecialSong relations separately
|
||||
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 = effectiveSpecialIds as number[];
|
||||
|
||||
// Delete removed specials
|
||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||
if (toDelete.length > 0) {
|
||||
await prisma.specialSong.deleteMany({
|
||||
where: {
|
||||
songId: Number(id),
|
||||
specialId: { in: toDelete }
|
||||
}
|
||||
// Execute all database write operations in a transaction to ensure consistency
|
||||
const updatedSong = await prisma.$transaction(async (tx) => {
|
||||
// Handle SpecialSong relations separately
|
||||
if (effectiveSpecialIds !== undefined) {
|
||||
// First, get current special assignments (within transaction)
|
||||
const currentSpecials = await tx.specialSong.findMany({
|
||||
where: { songId: Number(id) }
|
||||
});
|
||||
}
|
||||
|
||||
// Add new specials
|
||||
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||
if (toAdd.length > 0) {
|
||||
await prisma.specialSong.createMany({
|
||||
data: toAdd.map(specialId => ({
|
||||
songId: Number(id),
|
||||
specialId,
|
||||
startTime: 0
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||
const newSpecialIds = effectiveSpecialIds as number[];
|
||||
|
||||
const updatedSong = await prisma.song.update({
|
||||
where: { id: Number(id) },
|
||||
data,
|
||||
include: {
|
||||
genres: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
// Delete removed specials
|
||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||
if (toDelete.length > 0) {
|
||||
await tx.specialSong.deleteMany({
|
||||
where: {
|
||||
songId: Number(id),
|
||||
specialId: { in: toDelete }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add new specials
|
||||
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||
if (toAdd.length > 0) {
|
||||
await tx.specialSong.createMany({
|
||||
data: toAdd.map(specialId => ({
|
||||
songId: Number(id),
|
||||
specialId,
|
||||
startTime: 0
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update song (this also handles genre relations via Prisma's set operation)
|
||||
return await tx.song.update({
|
||||
where: { id: Number(id) },
|
||||
data,
|
||||
include: {
|
||||
genres: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json(updatedSong);
|
||||
@@ -507,7 +563,7 @@ export async function DELETE(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete file
|
||||
// Delete files first (outside transaction, as file system operations can't be rolled back)
|
||||
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||
try {
|
||||
await unlink(filePath);
|
||||
@@ -526,9 +582,11 @@ export async function DELETE(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database (will cascade delete related puzzles)
|
||||
await prisma.song.delete({
|
||||
where: { id: Number(id) },
|
||||
// Delete from database in transaction (will cascade delete related puzzles, SpecialSong, etc.)
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.song.delete({
|
||||
where: { id: Number(id) },
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
1579
app/curator/CuratorPageClient.tsx
Normal file
1579
app/curator/CuratorPageClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1299
app/curator/page.tsx
1299
app/curator/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ 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 { getOrCreatePlayerId } from '@/lib/playerId';
|
||||
|
||||
// Plausible Analytics
|
||||
declare global {
|
||||
@@ -60,6 +61,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [commentSending, setCommentSending] = useState(false);
|
||||
const [commentSent, setCommentSent] = useState(false);
|
||||
const [commentError, setCommentError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCountdown = () => {
|
||||
@@ -134,6 +139,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
} else {
|
||||
setHasRated(false);
|
||||
}
|
||||
|
||||
// Check if comment already sent for this puzzle
|
||||
const playerIdentifier = getOrCreatePlayerId();
|
||||
if (playerIdentifier) {
|
||||
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
|
||||
if (commentedPuzzles.includes(dailyPuzzle.id)) {
|
||||
setCommentSent(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [dailyPuzzle]);
|
||||
|
||||
@@ -300,6 +314,59 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
};
|
||||
|
||||
const handleCommentSubmit = async () => {
|
||||
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCommentSending(true);
|
||||
setCommentError(null);
|
||||
|
||||
try {
|
||||
const playerIdentifier = getOrCreatePlayerId();
|
||||
if (!playerIdentifier) {
|
||||
throw new Error('Could not get player identifier');
|
||||
}
|
||||
|
||||
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle
|
||||
const genreId = isSpecial ? null : null; // API will determine from puzzle
|
||||
|
||||
const response = await fetch('/api/curator-comment', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
puzzleId: dailyPuzzle.id,
|
||||
genreId: genreId,
|
||||
message: commentText.trim(),
|
||||
playerIdentifier: playerIdentifier
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to send comment');
|
||||
}
|
||||
|
||||
setCommentSent(true);
|
||||
setCommentText('');
|
||||
|
||||
// Store in localStorage that comment was sent
|
||||
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
|
||||
if (!commentedPuzzles.includes(dailyPuzzle.id)) {
|
||||
commentedPuzzles.push(dailyPuzzle.id);
|
||||
localStorage.setItem(`${config.appName.toLowerCase()}_commented_puzzles`, JSON.stringify(commentedPuzzles));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending comment:', error);
|
||||
setCommentError(error instanceof Error ? error.message : 'Failed to send comment');
|
||||
} finally {
|
||||
setCommentSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||||
|
||||
const handleShare = async () => {
|
||||
@@ -391,6 +458,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
};
|
||||
|
||||
// Aktuelle Attempt-Anzeige:
|
||||
// - Während des Spiels: nächster Versuch = guesses.length + 1
|
||||
// - Nach Spielende (gelöst oder verloren): letzter Versuch = guesses.length
|
||||
const currentAttempt = (gameState.isSolved || gameState.isFailed)
|
||||
? gameState.guesses.length
|
||||
: gameState.guesses.length + 1;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="header">
|
||||
@@ -403,7 +477,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
<main className="game-board">
|
||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||
<div id="tour-status" className="status-bar">
|
||||
<span>{t('attempt')} {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||||
<span>{t('attempt')} {currentAttempt} / {maxAttempts}</span>
|
||||
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
||||
</div>
|
||||
|
||||
@@ -512,14 +586,80 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.25rem', textAlign: 'center' }}>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>
|
||||
{t('shareExplanation')}
|
||||
</p>
|
||||
<button onClick={handleShare} className="btn-primary">
|
||||
{shareText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comment Form */}
|
||||
{!commentSent && (
|
||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
{t('sendComment')}
|
||||
</h3>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
|
||||
{t('commentHelp')}
|
||||
</p>
|
||||
<textarea
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder={t('commentPlaceholder')}
|
||||
maxLength={2000}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '100px',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical',
|
||||
marginBottom: '0.5rem'
|
||||
}}
|
||||
disabled={commentSending}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
|
||||
{commentText.length}/2000
|
||||
</span>
|
||||
{commentError && (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
|
||||
{commentError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCommentSubmit}
|
||||
disabled={!commentText.trim() || commentSending || commentSent}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
width: '100%',
|
||||
opacity: (!commentText.trim() || commentSending || commentSent) ? 0.5 : 1,
|
||||
cursor: (!commentText.trim() || commentSending || commentSent) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{commentSending ? t('sending') : t('sendComment')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commentSent && (
|
||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center' }}>
|
||||
{t('commentSent')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statistics && <Statistics statistics={statistics} />}
|
||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||
{shareText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -22,9 +22,25 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/songs')
|
||||
.then(res => res.json())
|
||||
.then(data => setSongs(data));
|
||||
fetch('/api/public-songs')
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load songs: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (Array.isArray(data)) {
|
||||
setSongs(data);
|
||||
} else {
|
||||
console.error('Unexpected songs payload in GuessInput:', data);
|
||||
setSongs([]);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading songs for GuessInput:', err);
|
||||
setSongs([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -49,13 +49,15 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
|
||||
// Calculate total weight
|
||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||
|
||||
// Pick a random song based on weights
|
||||
// Pick a random song based on weights using cumulative weights
|
||||
// This ensures proper distribution and handles edge cases
|
||||
let random = Math.random() * totalWeight;
|
||||
let selectedSong = weightedSongs[0].song;
|
||||
let selectedSong = weightedSongs[weightedSongs.length - 1].song; // Fallback to last song
|
||||
|
||||
let cumulativeWeight = 0;
|
||||
for (const item of weightedSongs) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
cumulativeWeight += item.weight;
|
||||
if (random <= cumulativeWeight) {
|
||||
selectedSong = item.song;
|
||||
break;
|
||||
}
|
||||
@@ -156,11 +158,13 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
|
||||
|
||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
let selectedSpecialSong = weightedSongs[0].specialSong;
|
||||
let selectedSpecialSong = weightedSongs[weightedSongs.length - 1].specialSong; // Fallback to last song
|
||||
|
||||
// Pick a random song based on weights using cumulative weights
|
||||
let cumulativeWeight = 0;
|
||||
for (const item of weightedSongs) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
cumulativeWeight += item.weight;
|
||||
if (random <= cumulativeWeight) {
|
||||
selectedSpecialSong = item.specialSong;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
|
||||
"theSongWas": "Das Lied war:",
|
||||
"score": "Punkte",
|
||||
"shareExplanation": "Teile dein Ergebnis mit Freund:innen – so hilfst du, Hördle bekannter zu machen.",
|
||||
"scoreBreakdown": "Punkteaufschlüsselung",
|
||||
"albumCover": "Album-Cover",
|
||||
"released": "Veröffentlicht",
|
||||
@@ -56,6 +57,13 @@
|
||||
"points": "Punkte",
|
||||
"skipBonus": "Bonus überspringen",
|
||||
"notQuite": "Nicht ganz!",
|
||||
"sendComment": "Nachricht an Kurator senden",
|
||||
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres...",
|
||||
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
|
||||
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
|
||||
"commentError": "Fehler beim Senden der Nachricht",
|
||||
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
|
||||
"sending": "Wird gesendet...",
|
||||
"youGuessed": "Du hast geraten",
|
||||
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
||||
"skipped": "Übersprungen",
|
||||
@@ -167,6 +175,85 @@
|
||||
"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:",
|
||||
"commentsTitle": "Kommentare",
|
||||
"showComments": "Kommentare anzeigen",
|
||||
"hideComments": "Kommentare ausblenden",
|
||||
"loadingComments": "Kommentare werden geladen...",
|
||||
"noComments": "Keine Kommentare vorhanden.",
|
||||
"loadCommentsError": "Fehler beim Laden der Kommentare.",
|
||||
"commentFromPuzzle": "Kommentar zu Puzzle",
|
||||
"commentGenre": "Genre",
|
||||
"unreadComment": "Ungelesen",
|
||||
"archiveComment": "Archivieren",
|
||||
"archiveCommentConfirm": "Möchtest du diesen Kommentar wirklich archivieren?",
|
||||
"archiveCommentError": "Fehler beim Archivieren des Kommentars.",
|
||||
"newComments": "neu"
|
||||
},
|
||||
"About": {
|
||||
"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.",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"comeBackTomorrow": "Come back tomorrow for a new song.",
|
||||
"theSongWas": "The song was:",
|
||||
"score": "Score",
|
||||
"shareExplanation": "Share your result with friends – your support helps Hördle grow.",
|
||||
"scoreBreakdown": "Score Breakdown",
|
||||
"albumCover": "Album Cover",
|
||||
"released": "Released",
|
||||
@@ -56,6 +57,13 @@
|
||||
"points": "points",
|
||||
"skipBonus": "Skip Bonus",
|
||||
"notQuite": "Not quite!",
|
||||
"sendComment": "Send message to curator",
|
||||
"commentPlaceholder": "Write a message to the curators of this genre...",
|
||||
"commentHelp": "Share your thoughts about the puzzle with the curators. Your message will be displayed to them.",
|
||||
"commentSent": "✓ Message sent! Thank you for your feedback.",
|
||||
"commentError": "Error sending message",
|
||||
"commentRateLimited": "You have already sent a message for this puzzle.",
|
||||
"sending": "Sending...",
|
||||
"youGuessed": "You guessed",
|
||||
"actuallyReleasedIn": "Actually released in",
|
||||
"skipped": "Skipped",
|
||||
@@ -167,6 +175,85 @@
|
||||
"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:",
|
||||
"commentsTitle": "Comments",
|
||||
"showComments": "Show comments",
|
||||
"hideComments": "Hide comments",
|
||||
"loadingComments": "Loading comments...",
|
||||
"noComments": "No comments available.",
|
||||
"loadCommentsError": "Error loading comments.",
|
||||
"commentFromPuzzle": "Comment from puzzle",
|
||||
"commentGenre": "Genre",
|
||||
"unreadComment": "Unread",
|
||||
"archiveComment": "Archive",
|
||||
"archiveCommentConfirm": "Do you really want to archive this comment?",
|
||||
"archiveCommentError": "Error archiving comment.",
|
||||
"newComments": "new"
|
||||
},
|
||||
"About": {
|
||||
"title": "About Hördle & Imprint",
|
||||
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.4.11",
|
||||
"version": "0.1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "CuratorComment" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"playerIdentifier" TEXT NOT NULL,
|
||||
"puzzleId" INTEGER NOT NULL,
|
||||
"genreId" INTEGER,
|
||||
"message" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "CuratorComment_puzzleId_fkey" FOREIGN KEY ("puzzleId") REFERENCES "DailyPuzzle" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "CuratorComment_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CuratorCommentRecipient" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"commentId" INTEGER NOT NULL,
|
||||
"curatorId" INTEGER NOT NULL,
|
||||
"readAt" DATETIME,
|
||||
CONSTRAINT "CuratorCommentRecipient_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "CuratorComment" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "CuratorCommentRecipient_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CuratorComment_playerIdentifier_puzzleId_key" ON "CuratorComment"("playerIdentifier", "puzzleId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CuratorComment_genreId_idx" ON "CuratorComment"("genreId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CuratorCommentRecipient_commentId_curatorId_key" ON "CuratorCommentRecipient"("commentId", "curatorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CuratorCommentRecipient_curatorId_idx" ON "CuratorCommentRecipient"("curatorId");
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CuratorCommentRecipient" ADD COLUMN "archived" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
@@ -34,6 +34,7 @@ model Genre {
|
||||
songs Song[]
|
||||
dailyPuzzles DailyPuzzle[]
|
||||
curatorGenres CuratorGenre[]
|
||||
comments CuratorComment[]
|
||||
}
|
||||
|
||||
model Special {
|
||||
@@ -73,6 +74,7 @@ model DailyPuzzle {
|
||||
genre Genre? @relation(fields: [genreId], references: [id])
|
||||
specialId Int?
|
||||
special Special? @relation(fields: [specialId], references: [id])
|
||||
comments CuratorComment[]
|
||||
|
||||
@@unique([date, genreId, specialId])
|
||||
}
|
||||
@@ -114,6 +116,7 @@ model Curator {
|
||||
|
||||
genres CuratorGenre[]
|
||||
specials CuratorSpecial[]
|
||||
commentRecipients CuratorCommentRecipient[]
|
||||
}
|
||||
|
||||
model CuratorGenre {
|
||||
@@ -149,3 +152,32 @@ model PoliticalStatement {
|
||||
|
||||
@@index([locale, active])
|
||||
}
|
||||
|
||||
model CuratorComment {
|
||||
id Int @id @default(autoincrement())
|
||||
playerIdentifier String
|
||||
puzzleId Int
|
||||
puzzle DailyPuzzle @relation(fields: [puzzleId], references: [id], onDelete: Cascade)
|
||||
genreId Int?
|
||||
genre Genre? @relation(fields: [genreId], references: [id], onDelete: SetNull)
|
||||
message String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
recipients CuratorCommentRecipient[]
|
||||
|
||||
@@unique([playerIdentifier, puzzleId])
|
||||
@@index([genreId])
|
||||
}
|
||||
|
||||
model CuratorCommentRecipient {
|
||||
id Int @id @default(autoincrement())
|
||||
commentId Int
|
||||
comment CuratorComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
curatorId Int
|
||||
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
|
||||
readAt DateTime?
|
||||
archived Boolean @default(false)
|
||||
|
||||
@@unique([commentId, curatorId])
|
||||
@@index([curatorId])
|
||||
}
|
||||
|
||||
77
scripts/backup-restic.sh
Executable file
77
scripts/backup-restic.sh
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# Restic backup script for Hördle deployment
|
||||
# Creates a backup snapshot with tags and handles errors gracefully
|
||||
|
||||
set -e
|
||||
|
||||
echo "💾 Creating Restic backup..."
|
||||
|
||||
if ! command -v restic >/dev/null 2>&1; then
|
||||
echo "⚠️ restic not found in PATH, skipping Restic backup"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check required environment variables
|
||||
if [ -z "$RESTIC_PASSWORD" ]; then
|
||||
echo "⚠️ RESTIC_PASSWORD not set, skipping Restic backup"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$RESTIC_AUTH_USER" ] || [ -z "$RESTIC_AUTH_PASSWORD" ]; then
|
||||
echo "⚠️ RESTIC_AUTH_USER or RESTIC_AUTH_PASSWORD not set, skipping Restic backup"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Build repository URL
|
||||
RESTIC_REPO="rest:https://${RESTIC_AUTH_USER}:${RESTIC_AUTH_PASSWORD}@restic.elpatron.me/"
|
||||
|
||||
# Get current commit hash for tagging
|
||||
CURRENT_COMMIT_SHORT="$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')"
|
||||
CURRENT_DATE="$(date +%Y-%m-%d)"
|
||||
|
||||
# Export password for restic
|
||||
export RESTIC_PASSWORD
|
||||
|
||||
# Check if repository exists, initialize if not
|
||||
if ! restic -r "$RESTIC_REPO" snapshots >/dev/null 2>&1; then
|
||||
echo " Initializing Restic repository..."
|
||||
if ! restic -r "$RESTIC_REPO" init >/dev/null 2>&1; then
|
||||
echo "⚠️ Failed to initialize Restic repository, skipping backup"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create backup with tags
|
||||
# Backup important directories: backups, config files, but exclude node_modules, .git, etc.
|
||||
echo " Creating Restic snapshot..."
|
||||
RESTIC_EXIT_CODE=0
|
||||
restic -r "$RESTIC_REPO" backup \
|
||||
--tag deployment \
|
||||
--tag hoerdle \
|
||||
--tag "date:${CURRENT_DATE}" \
|
||||
--tag "commit:${CURRENT_COMMIT_SHORT}" \
|
||||
--exclude='.git' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.next' \
|
||||
--exclude='*.log' \
|
||||
./backups \
|
||||
./data \
|
||||
./public/uploads \
|
||||
docker-compose.yml \
|
||||
.env \
|
||||
package.json \
|
||||
prisma/schema.prisma \
|
||||
prisma/migrations \
|
||||
scripts/ || RESTIC_EXIT_CODE=$?
|
||||
|
||||
if [ $RESTIC_EXIT_CODE -eq 0 ]; then
|
||||
echo "✅ Restic backup completed successfully"
|
||||
exit 0
|
||||
elif [ $RESTIC_EXIT_CODE -eq 3 ]; then
|
||||
echo "⚠️ Restic backup completed with warnings (some files could not be read), continuing..."
|
||||
exit 0
|
||||
else
|
||||
echo "⚠️ Restic backup failed (exit code: $RESTIC_EXIT_CODE), continuing deployment..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting optimized deployment..."
|
||||
echo "🚀 Starting optimized deployment with full rollback support..."
|
||||
|
||||
# Backup database
|
||||
echo "💾 Creating database backup..."
|
||||
# Backup database (per Deployment, inkl. Metadaten für Rollback)
|
||||
echo "💾 Creating database backup for this deployment..."
|
||||
|
||||
# Try to find database path from docker-compose.yml or .env
|
||||
DB_PATH=""
|
||||
@@ -26,16 +26,29 @@ if [ -n "$DB_PATH" ]; then
|
||||
# Convert container path to host path if needed
|
||||
# /app/data/prod.db -> ./data/prod.db
|
||||
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
|
||||
|
||||
|
||||
if [ -f "$DB_PATH" ]; then
|
||||
# Create backups directory
|
||||
mkdir -p ./backups
|
||||
|
||||
|
||||
# Create timestamped backup
|
||||
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_$(date +%Y%m%d_%H%M%S).db"
|
||||
DEPLOY_TS="$(date +%Y%m%d_%H%M%S)"
|
||||
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_${DEPLOY_TS}.db"
|
||||
cp "$DB_PATH" "$BACKUP_FILE"
|
||||
echo "✅ Database backed up to: $BACKUP_FILE"
|
||||
|
||||
|
||||
# Store metadata for restore (Timestamp, DB-Path, Git-Commit)
|
||||
CURRENT_COMMIT="$(git rev-parse HEAD || echo 'unknown')"
|
||||
{
|
||||
echo "timestamp=${DEPLOY_TS}"
|
||||
echo "db_path=${DB_PATH}"
|
||||
echo "backup_file=${BACKUP_FILE}"
|
||||
echo "git_commit=${CURRENT_COMMIT}"
|
||||
} > "./backups/last_deploy.meta"
|
||||
|
||||
# Append to history manifest (eine Zeile pro Deployment)
|
||||
echo "${DEPLOY_TS}|${DB_PATH}|${BACKUP_FILE}|${CURRENT_COMMIT}" >> "./backups/deploy_history.log"
|
||||
|
||||
# Keep only last 10 backups
|
||||
ls -t ./backups/*.db | tail -n +11 | xargs -r rm
|
||||
echo "🧹 Cleaned old backups (keeping last 10)"
|
||||
@@ -46,13 +59,13 @@ else
|
||||
echo "⚠️ Could not determine database path from config files"
|
||||
fi
|
||||
|
||||
# Pull latest changes
|
||||
echo "📥 Pulling latest changes from git..."
|
||||
git pull
|
||||
# Restic backup to remote repository
|
||||
./scripts/backup-restic.sh
|
||||
|
||||
# Fetch all tags
|
||||
echo "🏷️ Fetching git tags..."
|
||||
git fetch --tags
|
||||
# Nur neueste Version holen (shallow fetch), vollständiges Repo ist im Deployment nicht nötig
|
||||
echo "📥 Fetching latest commit (shallow clone) from git..."
|
||||
git fetch --prune --tags --depth=1 origin master
|
||||
git reset --hard origin/master
|
||||
|
||||
# Prüfe und erstelle/repariere Netzwerk falls nötig
|
||||
echo "🌐 Prüfe Docker-Netzwerk..."
|
||||
|
||||
93
scripts/restore.sh
Normal file
93
scripts/restore.sh
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🧯 Hördle restore script – Rollback auf früheres Datenbank-Backup"
|
||||
|
||||
# Hilfsfunktion für Fehlerausgabe
|
||||
die() {
|
||||
echo "❌ $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Backup-Verzeichnis
|
||||
BACKUP_DIR="./backups"
|
||||
|
||||
if [ ! -d "$BACKUP_DIR" ]; then
|
||||
die "Kein Backup-Verzeichnis gefunden (${BACKUP_DIR}). Es scheint noch kein Deployment-Backup erstellt worden zu sein."
|
||||
fi
|
||||
|
||||
# Argument: gewünschter Backup-Timestamp oder 'latest'
|
||||
TARGET="$1"
|
||||
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo "⚙️ Nutzung:"
|
||||
echo " ./scripts/restore.sh latest # neuestes Backup zurückspielen"
|
||||
echo " ./scripts/restore.sh 20250101_120000 # bestimmtes Backup (Timestamp aus Dateiname)"
|
||||
echo ""
|
||||
echo "Verfügbare Backups:"
|
||||
ls -1 "${BACKUP_DIR}"/*.db 2>/dev/null || echo " (keine .db-Backups gefunden)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# DB-Pfad wie in deploy.sh bestimmen
|
||||
DB_PATH=""
|
||||
|
||||
if [ -f "docker-compose.yml" ]; then
|
||||
DB_PATH=$(grep -oP 'DATABASE_URL=file:\K[^\s]+' docker-compose.yml | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$DB_PATH" ] && [ -f ".env" ]; then
|
||||
DB_PATH=$(grep -oP '^DATABASE_URL=file:\K.+' .env | head -1)
|
||||
fi
|
||||
|
||||
DB_PATH=$(echo "$DB_PATH" | tr -d '"' | tr -d "'")
|
||||
|
||||
if [ -z "$DB_PATH" ]; then
|
||||
die "Konnte den Datenbank-Pfad aus docker-compose.yml oder .env nicht ermitteln."
|
||||
fi
|
||||
|
||||
# Containerpfad zu Hostpfad umbauen (/app/... -> ./...)
|
||||
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
|
||||
|
||||
echo "📁 Ziel-Datenbank-Datei: $DB_PATH"
|
||||
|
||||
# Backup-Datei bestimmen
|
||||
if [ "$TARGET" = "latest" ]; then
|
||||
BACKUP_FILE=$(ls -t "${BACKUP_DIR}"/*.db 2>/dev/null | head -1 || true)
|
||||
[ -z "$BACKUP_FILE" ] && die "Kein Backup gefunden."
|
||||
else
|
||||
# Versuchen, exakten Dateinamen zu finden
|
||||
if [ -f "${BACKUP_DIR}/${TARGET}" ]; then
|
||||
BACKUP_FILE="${BACKUP_DIR}/${TARGET}"
|
||||
else
|
||||
# Versuchen, anhand des Timestamps ein Backup zu finden
|
||||
BACKUP_FILE=$(ls "${BACKUP_DIR}"/*"${TARGET}"*.db 2>/dev/null | head -1 || true)
|
||||
fi
|
||||
|
||||
[ -z "$BACKUP_FILE" ] && die "Kein Backup für '${TARGET}' gefunden."
|
||||
fi
|
||||
|
||||
echo "⏪ Verwende Backup-Datei: $BACKUP_FILE"
|
||||
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
die "Backup-Datei existiert nicht: $BACKUP_FILE"
|
||||
fi
|
||||
|
||||
read -p "❗ Dies überschreibt die aktuelle Datenbank-Datei. Fortfahren? [y/N] " CONFIRM
|
||||
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
|
||||
echo "Abgebrochen."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📦 Kopiere Backup nach: $DB_PATH"
|
||||
cp "$BACKUP_FILE" "$DB_PATH"
|
||||
|
||||
echo "🔄 Starte Docker-Container neu..."
|
||||
docker compose restart hoerdle
|
||||
|
||||
echo "✅ Restore abgeschlossen."
|
||||
echo "ℹ️ Hinweis: Der Code-Stand (Git-Commit) ist nicht automatisch zurückgedreht."
|
||||
echo " Falls du auch die App-Version zurückrollen möchtest, checke lokal den passenden Commit/Tag aus"
|
||||
echo " und führe anschließend wieder ./scripts/deploy.sh aus."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user