Compare commits
43 Commits
33f8080aa8
...
v0.1.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b204a35628 | ||
|
|
c62f8f91e5 | ||
|
|
6fbb3f4718 | ||
|
|
5136c3add1 | ||
|
|
c250b5fff9 | ||
|
|
4074cdfe00 | ||
|
|
65425ac15c | ||
|
|
7879b63498 | ||
|
|
91ebaa0e44 | ||
|
|
a61caa2d13 | ||
|
|
52a15b7504 | ||
|
|
00160d9602 | ||
|
|
296a227d22 | ||
|
|
50ca51b143 | ||
|
|
afe6e12afc | ||
|
|
91b12ad859 | ||
|
|
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 |
35
README.md
35
README.md
@@ -15,6 +15,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Bearbeitung von Metadaten.
|
||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
||||
- **Kuratoren-Verwaltung:** Erstellen und Verwalten von Kurator-Accounts mit Zuweisung zu Genres und Specials.
|
||||
- **Cover Art:**
|
||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||
@@ -42,7 +43,6 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Live-Vorschau beim Hovern über die Waveform.
|
||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Manuelle Speicherung mit visueller Bestätigung.
|
||||
- **News & Announcements:**
|
||||
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||
@@ -51,6 +51,25 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||
- Verwaltung über das Admin-Dashboard.
|
||||
- **Kurator-System:**
|
||||
- **Kurator-Accounts:** Separate Login-Accounts für Kuratoren (nicht Admins).
|
||||
- **Genre- & Special-Zuweisung:** Kuratoren können einzelnen Genres oder Specials zugewiesen werden.
|
||||
- **Global-Kuratoren:** Optionale globale Kuratoren, die für alle Rätsel zuständig sind.
|
||||
- **Kurator-Dashboard:** Eigene Dashboard-Seite (`/curator` oder `/de/curator`, `/en/curator`) für Kuratoren.
|
||||
- **Song-Verwaltung:** Kuratoren können Songs hochladen, bearbeiten und Genres/Specials zuweisen.
|
||||
- **Batch-Edit:** Mehrere Titel gleichzeitig bearbeiten (Genre/Special Toggle, Artist ändern, Exclude Global Flag setzen).
|
||||
- **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren.
|
||||
- **Spieler-Kommentare:**
|
||||
- **Feedback an Kuratoren:** Spieler können nach Abschluss eines Rätsels optional eine Nachricht an die Kuratoren senden.
|
||||
- **Automatische Zuordnung:** Kommentare werden automatisch an relevante Kuratoren verteilt (Genre-Kuratoren, Special-Kuratoren, Global-Kuratoren).
|
||||
- **Rate-Limiting:** Pro Spieler nur ein Kommentar pro Puzzle möglich.
|
||||
- **Kontext-Informationen:** Kommentare enthalten vollständigen Rätsel-Kontext (Hördle #, Genre/Special, Titel/Artist).
|
||||
- **Kommentar-Verwaltung:** Kuratoren sehen Kommentare in ihrem Dashboard mit Badge für neue/ungelesene Nachrichten.
|
||||
- **Analytics:**
|
||||
- **Plausible Analytics:** Integration mit Plausible Analytics für anonyme Nutzungsstatistiken.
|
||||
- **Automatisches Domain-Tracking:** Unterstützt mehrere Domains mit automatischer Erkennung.
|
||||
- **Privacy-First:** Keine Cookies, kein Cross-Site-Tracking.
|
||||
- 👉 **[Plausible Setup-Dokumentation](docs/PLAUSIBLE_SETUP.md)**
|
||||
|
||||
## Internationalisierung (i18n)
|
||||
|
||||
@@ -139,6 +158,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
||||
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
||||
- `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`: URL zum Plausible Analytics Script (z.B. `https://plausible.example.com/js/script.js`, optional)
|
||||
|
||||
2. **Starten:**
|
||||
```bash
|
||||
@@ -156,7 +176,18 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
- URL: `/de/admin` oder `/en/admin`
|
||||
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
||||
|
||||
5. **Special Curation & Scheduling verwenden:**
|
||||
5. **Kurator-Zugang:**
|
||||
- URL: `/de/curator` oder `/en/curator`
|
||||
- Kurator-Accounts werden vom Admin erstellt und verwaltet.
|
||||
- Kuratoren können Songs hochladen und verwalten, sowie Kommentare von Spielern einsehen.
|
||||
- **Batch-Edit-Funktionalität:**
|
||||
- Mehrere Titel über Checkboxen auswählen
|
||||
- Genre/Special Toggle (hinzufügen/entfernen)
|
||||
- Artist-Änderung für alle ausgewählten Titel
|
||||
- Exclude Global Flag setzen/entfernen (nur für Global-Kuratoren)
|
||||
- Toolbar erscheint automatisch bei Auswahl von Titeln
|
||||
|
||||
6. **Special Curation & Scheduling verwenden:**
|
||||
- Erstelle ein Special im Admin-Dashboard:
|
||||
- Gib Name, Max Attempts und Unlock Steps ein.
|
||||
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
8
app/[locale]/curator/help/page.tsx
Normal file
8
app/[locale]/curator/help/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import CuratorHelpInner from '../../../curator/help/page';
|
||||
|
||||
export default function CuratorHelpPage() {
|
||||
return <CuratorHelpInner />;
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
266
app/api/songs/batch/route.ts
Normal file
266
app/api/songs/batch/route.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function getCuratorAssignments(curatorId: number) {
|
||||
const [genres, specials] = await Promise.all([
|
||||
prisma.curatorGenre.findMany({
|
||||
where: { curatorId },
|
||||
select: { genreId: true },
|
||||
}),
|
||||
prisma.curatorSpecial.findMany({
|
||||
where: { curatorId },
|
||||
select: { specialId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
genreIds: new Set(genres.map(g => g.genreId)),
|
||||
specialIds: new Set(specials.map(s => s.specialId)),
|
||||
};
|
||||
}
|
||||
|
||||
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||
if (context.role === 'admin') return true;
|
||||
|
||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.id;
|
||||
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||
return undefined;
|
||||
})
|
||||
.filter((id: any): id is number => typeof id === 'number');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) return error!;
|
||||
|
||||
try {
|
||||
const { songIds, genreToggleIds, specialToggleIds, artist, excludeFromGlobal } = await request.json();
|
||||
|
||||
if (!songIds || !Array.isArray(songIds) || songIds.length === 0) {
|
||||
return NextResponse.json({ error: 'Missing or invalid songIds array' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate that at least one operation is requested
|
||||
const hasGenreToggle = genreToggleIds && Array.isArray(genreToggleIds) && genreToggleIds.length > 0;
|
||||
const hasSpecialToggle = specialToggleIds && Array.isArray(specialToggleIds) && specialToggleIds.length > 0;
|
||||
const hasArtistChange = artist !== undefined && artist !== null && artist.trim() !== '';
|
||||
const hasExcludeGlobalChange = excludeFromGlobal !== undefined;
|
||||
|
||||
if (!hasGenreToggle && !hasSpecialToggle && !hasArtistChange && !hasExcludeGlobalChange) {
|
||||
return NextResponse.json({ error: 'No update operations specified' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate artist if provided
|
||||
if (hasArtistChange && artist.trim() === '') {
|
||||
return NextResponse.json({ error: 'Artist cannot be empty' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate excludeFromGlobal permission
|
||||
if (hasExcludeGlobalChange && context.role === 'curator' && !context.curator.isGlobalCurator) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
let assignments: { genreIds: Set<number>; specialIds: Set<number> } | null = null;
|
||||
if (context.role === 'curator') {
|
||||
const curatorAssignments = await getCuratorAssignments(context.curator.id);
|
||||
assignments = curatorAssignments;
|
||||
|
||||
// Validate genre/special toggles are within curator's assignments
|
||||
if (hasGenreToggle) {
|
||||
const invalidGenre = genreToggleIds.some((id: number) => !curatorAssignments.genreIds.has(id));
|
||||
if (invalidGenre) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Curators may only toggle their own genres' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSpecialToggle) {
|
||||
const invalidSpecial = specialToggleIds.some((id: number) => !curatorAssignments.specialIds.has(id));
|
||||
if (invalidSpecial) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Curators may only toggle their own specials' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load all songs with relations for permission checks
|
||||
const songs = await prisma.song.findMany({
|
||||
where: { id: { in: songIds.map((id: any) => Number(id)) } },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (songs.length === 0) {
|
||||
return NextResponse.json({ error: 'No songs found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Filter songs that can be edited
|
||||
const editableSongs = context.role === 'admin'
|
||||
? songs
|
||||
: songs.filter(song => curatorCanEditSong(context, song, assignments!));
|
||||
|
||||
if (editableSongs.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No songs can be edited with current permissions' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const results = {
|
||||
total: songIds.length,
|
||||
processed: editableSongs.length,
|
||||
skipped: songs.length - editableSongs.length,
|
||||
success: 0,
|
||||
errors: [] as Array<{ songId: number; error: string }>,
|
||||
};
|
||||
|
||||
// Process each song in a transaction
|
||||
for (const song of editableSongs) {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const updateData: any = {};
|
||||
|
||||
// Handle artist change
|
||||
if (hasArtistChange) {
|
||||
updateData.artist = artist.trim();
|
||||
}
|
||||
|
||||
// Handle excludeFromGlobal change
|
||||
if (hasExcludeGlobalChange) {
|
||||
updateData.excludeFromGlobal = excludeFromGlobal;
|
||||
}
|
||||
|
||||
// Handle genre toggles
|
||||
if (hasGenreToggle) {
|
||||
const currentGenreIds = song.genres.map(g => g.id);
|
||||
const genreIdsToToggle = genreToggleIds as number[];
|
||||
|
||||
// Determine which genres to add/remove
|
||||
const genresToAdd = genreIdsToToggle.filter(id => !currentGenreIds.includes(id));
|
||||
const genresToRemove = genreIdsToToggle.filter(id => currentGenreIds.includes(id));
|
||||
|
||||
// For curators, preserve genres they can't manage
|
||||
let finalGenreIds: number[];
|
||||
if (context.role === 'curator') {
|
||||
const fixedGenreIds = currentGenreIds.filter(gid => !assignments!.genreIds.has(gid));
|
||||
const managedGenreIds = currentGenreIds
|
||||
.filter(gid => assignments!.genreIds.has(gid) && !genresToRemove.includes(gid))
|
||||
.concat(genresToAdd);
|
||||
finalGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
|
||||
} else {
|
||||
const newGenreIds = currentGenreIds
|
||||
.filter(id => !genresToRemove.includes(id))
|
||||
.concat(genresToAdd);
|
||||
finalGenreIds = Array.from(new Set(newGenreIds));
|
||||
}
|
||||
|
||||
updateData.genres = {
|
||||
set: finalGenreIds.map(gId => ({ id: gId }))
|
||||
};
|
||||
}
|
||||
|
||||
// Update song basic data
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await tx.song.update({
|
||||
where: { id: song.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle special toggles
|
||||
if (hasSpecialToggle) {
|
||||
const currentSpecials = await tx.specialSong.findMany({
|
||||
where: { songId: song.id }
|
||||
});
|
||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||
const specialIdsToToggle = specialToggleIds as number[];
|
||||
|
||||
// Determine which specials to add/remove
|
||||
const specialsToAdd = specialIdsToToggle.filter(id => !currentSpecialIds.includes(id));
|
||||
const specialsToRemove = specialIdsToToggle.filter(id => currentSpecialIds.includes(id));
|
||||
|
||||
// For curators, preserve specials they can't manage
|
||||
let finalSpecialIds: number[];
|
||||
if (context.role === 'curator') {
|
||||
const fixedSpecialIds = currentSpecialIds.filter(sid => !assignments!.specialIds.has(sid));
|
||||
const managedSpecialIds = currentSpecialIds
|
||||
.filter(sid => assignments!.specialIds.has(sid) && !specialsToRemove.includes(sid))
|
||||
.concat(specialsToAdd);
|
||||
finalSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
|
||||
} else {
|
||||
const newSpecialIds = currentSpecialIds
|
||||
.filter(id => !specialsToRemove.includes(id))
|
||||
.concat(specialsToAdd);
|
||||
finalSpecialIds = Array.from(new Set(newSpecialIds));
|
||||
}
|
||||
|
||||
// Delete removed specials
|
||||
const toDelete = currentSpecialIds.filter(sid => !finalSpecialIds.includes(sid));
|
||||
if (toDelete.length > 0) {
|
||||
await tx.specialSong.deleteMany({
|
||||
where: {
|
||||
songId: song.id,
|
||||
specialId: { in: toDelete }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add new specials
|
||||
const toAdd = finalSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||
if (toAdd.length > 0) {
|
||||
await tx.specialSong.createMany({
|
||||
data: toAdd.map(specialId => ({
|
||||
songId: song.id,
|
||||
specialId,
|
||||
startTime: 0
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
results.success++;
|
||||
} catch (error: any) {
|
||||
results.errors.push({
|
||||
songId: song.id,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error in batch update:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,23 @@ 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.
|
||||
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
|
||||
// Daher zuerst specialId oder special.id prüfen.
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.id;
|
||||
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||
if (s?.id != null && s?.specialId == null && s?.special == null) return s.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 +63,18 @@ 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);
|
||||
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
|
||||
// Daher zuerst specialId oder special.id prüfen.
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.id;
|
||||
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||
if (s?.id != null && s?.specialId == null && s?.special == null) return s.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 +86,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 +104,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 +141,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,
|
||||
@@ -331,7 +390,11 @@ export async function PUT(request: Request) {
|
||||
where: { id: Number(id) },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -411,57 +474,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 +575,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 +594,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 });
|
||||
|
||||
2049
app/curator/CuratorPageClient.tsx
Normal file
2049
app/curator/CuratorPageClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
149
app/curator/help/CuratorHelpClient.tsx
Normal file
149
app/curator/help/CuratorHelpClient.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Link } from '@/lib/navigation';
|
||||
|
||||
export default function CuratorHelpClient() {
|
||||
const t = useTranslations('CuratorHelp');
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
|
||||
<header style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>{t('title')}</h1>
|
||||
<Link
|
||||
href="/curator"
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{t('backToDashboard')}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{/* Einführung */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('introductionTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('introductionText')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('permissionsTitle')}</h3>
|
||||
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('permission1')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('permission2')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('permission3')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('permission4')}</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#fef3c7', borderRadius: '0.375rem', border: '1px solid #fbbf24' }}>
|
||||
<strong>{t('note')}:</strong> {t('permissionNote')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Song-Upload */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('uploadTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('uploadStepsTitle')}</h3>
|
||||
<ol style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep1')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep2')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep3')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep4')}</li>
|
||||
</ol>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('uploadBestPracticesTitle')}</h3>
|
||||
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice1')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice2')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice3')}</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#dbeafe', borderRadius: '0.375rem', border: '1px solid #3b82f6' }}>
|
||||
<strong>{t('tip')}:</strong> {t('uploadTip')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Song-Bearbeitung */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('editingTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('singleEditTitle')}</h3>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('singleEditText')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('batchEditTitle')}</h3>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('batchEditText')}</p>
|
||||
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature1')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature2')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature3')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature4')}</li>
|
||||
</ul>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('genreSpecialAssignmentTitle')}</h3>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('genreSpecialAssignmentText')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Kommentar-Verwaltung */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('commentsTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('commentsText')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('commentsActionsTitle')}</h3>
|
||||
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}><strong>{t('markAsRead')}:</strong> {t('markAsReadText')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}><strong>{t('archive')}:</strong> {t('archiveText')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Best Practices */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('bestPracticesTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice1')}</li>
|
||||
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice2')}</li>
|
||||
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice3')}</li>
|
||||
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice4')}</li>
|
||||
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice5')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('troubleshootingTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ1')}</h3>
|
||||
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA1')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ2')}</h3>
|
||||
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA2')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ3')}</h3>
|
||||
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA3')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ4')}</h3>
|
||||
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA4')}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
8
app/curator/help/page.tsx
Normal file
8
app/curator/help/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import CuratorHelpClient from './CuratorHelpClient';
|
||||
|
||||
export default function CuratorHelpPage() {
|
||||
return <CuratorHelpClient />;
|
||||
}
|
||||
|
||||
1325
app/curator/page.tsx
1325
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(() => {
|
||||
|
||||
175
components/HelpTooltip.tsx
Normal file
175
components/HelpTooltip.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface HelpTooltipProps {
|
||||
shortText: string; // Text für Hover
|
||||
longText: string; // Text für Click/Modal
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export default function HelpTooltip({ shortText, longText, position = 'top' }: HelpTooltipProps) {
|
||||
const t = useTranslations('CuratorHelp');
|
||||
const [showHover, setShowHover] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
tooltipRef.current &&
|
||||
!tooltipRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowModal(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (showModal) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [showModal]);
|
||||
|
||||
const positionStyles = {
|
||||
top: { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: '0.5rem' },
|
||||
bottom: { top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: '0.5rem' },
|
||||
left: { right: '100%', top: '50%', transform: 'translateY(-50%)', marginRight: '0.5rem' },
|
||||
right: { left: '100%', top: '50%', transform: 'translateY(-50%)', marginLeft: '0.5rem' },
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setShowModal(!showModal)}
|
||||
onMouseEnter={() => setShowHover(true)}
|
||||
onMouseLeave={() => setShowHover(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#6b7280',
|
||||
fontSize: '1rem',
|
||||
padding: '0.25rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
width: '1.5rem',
|
||||
height: '1.5rem',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
aria-label="Help"
|
||||
>
|
||||
ℹ
|
||||
</button>
|
||||
|
||||
{/* Hover Tooltip */}
|
||||
{showHover && !showModal && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
...positionStyles[position],
|
||||
background: '#1f2937',
|
||||
color: 'white',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'normal',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
maxWidth: '250px',
|
||||
}}
|
||||
>
|
||||
{shortText}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
...(position === 'top' && { top: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderTop: '6px solid #1f2937' }),
|
||||
...(position === 'bottom' && { bottom: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderBottom: '6px solid #1f2937' }),
|
||||
...(position === 'left' && { left: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: '6px solid transparent', borderBottom: '6px solid transparent', borderLeft: '6px solid #1f2937' }),
|
||||
...(position === 'right' && { right: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: '6px solid transparent', borderBottom: '6px solid transparent', borderRight: '6px solid #1f2937' }),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal für detaillierte Informationen */}
|
||||
{showModal && (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 9998,
|
||||
}}
|
||||
onClick={() => setShowModal(false)}
|
||||
/>
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)',
|
||||
maxWidth: '500px',
|
||||
width: '90%',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 'bold' }}>{t('modalTitle')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '1.5rem',
|
||||
cursor: 'pointer',
|
||||
color: '#6b7280',
|
||||
padding: '0',
|
||||
lineHeight: '1',
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', lineHeight: '1.6', whiteSpace: 'pre-wrap' }}>
|
||||
{longText}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
125
messages/de.json
125
messages/de.json
@@ -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",
|
||||
@@ -231,7 +239,122 @@
|
||||
"loadingData": "Lade Daten...",
|
||||
"loggedInAs": "Eingeloggt als {username}",
|
||||
"globalCuratorSuffix": " (Globaler Kurator)",
|
||||
"pageSizeLabel": "Pro Seite:"
|
||||
"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",
|
||||
"batchEditTitle": "Batch-Bearbeitung",
|
||||
"clearSelection": "Auswahl aufheben",
|
||||
"batchToggleGenres": "Genres umschalten",
|
||||
"batchToggleSpecials": "Specials umschalten",
|
||||
"batchChangeArtist": "Artist ändern",
|
||||
"batchArtistPlaceholder": "Neuen Artist-Namen eingeben",
|
||||
"batchExcludeGlobal": "Von Global ausschließen",
|
||||
"batchNoChange": "Keine Änderung",
|
||||
"batchExclude": "Ausschließen",
|
||||
"batchInclude": "Einschließen",
|
||||
"batchUpdating": "Aktualisiere...",
|
||||
"batchApply": "Änderungen anwenden",
|
||||
"selectAll": "Alle auswählen",
|
||||
"selectSong": "Titel auswählen",
|
||||
"cannotEditSong": "Dieser Titel kann nicht bearbeitet werden",
|
||||
"noSongsSelected": "Keine Titel ausgewählt",
|
||||
"noBatchOperations": "Keine Batch-Operationen angegeben",
|
||||
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
|
||||
"batchUpdateError": "Fehler: {error}",
|
||||
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung"
|
||||
},
|
||||
"CuratorHelp": {
|
||||
"title": "Kurator-Hilfe & Handbuch",
|
||||
"backToDashboard": "Zurück zum Dashboard",
|
||||
"helpButton": "Hilfe",
|
||||
"modalTitle": "Hilfe",
|
||||
"introductionTitle": "Einführung",
|
||||
"introductionText": "Als Kurator bist du verantwortlich für die Verwaltung von Songs in deinen zugewiesenen Genres und Specials. Dieses Dashboard ermöglicht es dir, Musik für das Hördle-Spiel hochzuladen, zu bearbeiten und zu organisieren.",
|
||||
"permissionsTitle": "Deine Berechtigungen",
|
||||
"permission1": "MP3-Dateien hochladen und deinen Genres zuordnen",
|
||||
"permission2": "Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind",
|
||||
"permission3": "Songs löschen, die ausschließlich deinen Genres/Specials zugeordnet sind",
|
||||
"permission4": "Kommentare von Spielern zu deinen Rätseln einsehen und verwalten",
|
||||
"note": "Hinweis",
|
||||
"permissionNote": "Du kannst nur Songs bearbeiten oder löschen, die deinen Genres/Specials zugeordnet sind. Songs, die anderen Kuratoren zugeordnet sind, kannst du nicht ändern.",
|
||||
"uploadTitle": "Songs hochladen",
|
||||
"uploadStepsTitle": "Schritt-für-Schritt-Anleitung",
|
||||
"uploadStep1": "MP3-Dateien in den Upload-Bereich ziehen oder klicken, um Dateien auszuwählen",
|
||||
"uploadStep2": "Ein oder mehrere Genres auswählen, um sie den hochgeladenen Songs zuzuordnen",
|
||||
"uploadStep3": "Auf 'Upload starten' klicken, um den Upload-Prozess zu beginnen",
|
||||
"uploadStep4": "Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus den Dateien",
|
||||
"uploadBestPracticesTitle": "Best Practices",
|
||||
"uploadBestPractice1": "Stelle sicher, dass MP3-Dateien korrekte ID3-Tags (Titel, Artist) für die automatische Metadaten-Extraktion haben",
|
||||
"uploadBestPractice2": "Passende Genres vor dem Upload auswählen, um spätere manuelle Zuordnung zu vermeiden",
|
||||
"uploadBestPractice3": "Vor dem Upload auf Duplikate prüfen - das System warnt dich, wenn ein Song bereits existiert",
|
||||
"tip": "Tipp",
|
||||
"uploadTip": "Alle von Kuratoren hochgeladenen Songs werden automatisch von der globalen Playlist ausgeschlossen. Nur Admins können diese Einstellung ändern.",
|
||||
"editingTitle": "Songs bearbeiten",
|
||||
"singleEditTitle": "Einzelne Song-Bearbeitung",
|
||||
"singleEditText": "Klicke auf den Bearbeiten-Button (✏️) neben einem Song, um Titel, Artist, Erscheinungsjahr, Genres, Specials oder das Exclude-Global-Flag zu ändern. Nur Songs, die du bearbeiten kannst, haben einen aktiven Bearbeiten-Button.",
|
||||
"batchEditTitle": "Batch-Bearbeitung",
|
||||
"batchEditText": "Wähle mehrere Songs über die Checkboxen aus, dann nutze die Batch-Bearbeitungs-Toolbar, um Änderungen auf alle ausgewählten Songs gleichzeitig anzuwenden:",
|
||||
"batchEditFeature1": "Genre Toggle: Genres zu allen ausgewählten Songs hinzufügen oder entfernen",
|
||||
"batchEditFeature2": "Special Toggle: Specials zu allen ausgewählten Songs hinzufügen oder entfernen",
|
||||
"batchEditFeature3": "Artist ändern: Gleichen Artist-Namen für alle ausgewählten Songs setzen",
|
||||
"batchEditFeature4": "Exclude Global Flag: Exclude-Global-Flag setzen oder entfernen (nur Global-Kuratoren)",
|
||||
"genreSpecialAssignmentTitle": "Genre & Special Zuordnung",
|
||||
"genreSpecialAssignmentText": "Du kannst Songs nur Genres und Specials zuordnen, für die du verantwortlich bist. Songs können mehrere Genres und Specials haben. Beim Bearbeiten kannst du Zuordnungen umschalten - wenn ein Genre/Special bereits zugeordnet ist, wird es entfernt; wenn nicht, wird es hinzugefügt.",
|
||||
"commentsTitle": "Kommentare verwalten",
|
||||
"commentsText": "Spieler können dir Feedback zu Rätseln in deinen Genres oder Specials senden. Kommentare erscheinen in deinem Dashboard mit einem Badge für ungelesene Nachrichten.",
|
||||
"commentsActionsTitle": "Verfügbare Aktionen",
|
||||
"markAsRead": "Als gelesen markieren",
|
||||
"markAsReadText": "Klicke auf einen Kommentar, um ihn als gelesen zu markieren. Gelesene Kommentare werden mit einem grauen Rahmen angezeigt.",
|
||||
"archive": "Archivieren",
|
||||
"archiveText": "Archiviere Kommentare, die du nicht mehr benötigst. Archivierte Kommentare werden aus deiner Ansicht entfernt.",
|
||||
"bestPracticesTitle": "Best Practices für Kuratoren",
|
||||
"bestPractice1": "Metadaten korrekt halten: Stelle sicher, dass Song-Titel und Artist-Namen korrekt und konsistent sind",
|
||||
"bestPractice2": "Passende Genres verwenden: Ordne Songs den relevantesten Genres zu, um Spielern zu helfen, Musik zu entdecken",
|
||||
"bestPractice3": "Auf Kommentare reagieren: Prüfe Kommentare regelmäßig und berücksichtige Spieler-Feedback beim Kuratieren",
|
||||
"bestPractice4": "Qualität wahren: Überprüfe hochgeladene Songs auf Audio-Qualität und Metadaten-Genauigkeit",
|
||||
"bestPractice5": "Batch-Bearbeitung effizient nutzen: Wenn du ähnliche Änderungen an mehreren Songs vornehmen musst, nutze die Batch-Bearbeitung, um Zeit zu sparen",
|
||||
"troubleshootingTitle": "Troubleshooting",
|
||||
"troubleshootingQ1": "Warum kann ich einen Song nicht bearbeiten?",
|
||||
"troubleshootingA1": "Du kannst nur Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Wenn ein Song keine Genres/Specials zugeordnet hat, kannst du ihn bearbeiten. Wenn er nur anderen Kuratoren zugeordnet ist, kannst du ihn nicht bearbeiten.",
|
||||
"troubleshootingQ2": "Warum kann ich einen Song nicht löschen?",
|
||||
"troubleshootingA2": "Du kannst nur Songs löschen, die ausschließlich deinen Genres/Specials zugeordnet sind. Wenn ein Song Genres oder Specials hat, die anderen Kuratoren zugeordnet sind, kannst du ihn nicht löschen.",
|
||||
"troubleshootingQ3": "Warum kann ich ein Genre/Special nicht zuordnen?",
|
||||
"troubleshootingA3": "Du kannst Songs nur Genres und Specials zuordnen, für die du verantwortlich bist. Wende dich an den Admin, wenn du Zugriff auf zusätzliche Genres oder Specials benötigst.",
|
||||
"troubleshootingQ4": "Warum ist die Exclude-Global-Checkbox deaktiviert?",
|
||||
"troubleshootingA4": "Nur Global-Kuratoren können das Exclude-Global-Flag ändern. Wenn du diese Berechtigung benötigst, wende dich an den Admin.",
|
||||
"tooltipDashboardShort": "Übersicht über dein Kuratoren-Dashboard",
|
||||
"tooltipDashboardLong": "Dies ist dein Haupt-Dashboard, wo du Songs hochladen, deine Track-Liste verwalten und Kommentare von Spielern einsehen kannst. Nutze den Hilfe-Button (❓), um auf das vollständige Handbuch zuzugreifen.",
|
||||
"tooltipUploadShort": "MP3-Dateien zu deinen Genres hochladen",
|
||||
"tooltipUploadLong": "Ziehe MP3-Dateien per Drag & Drop oder klicke, um sie auszuwählen. Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus ID3-Tags. Wähle Genres vor dem Upload aus, um Songs automatisch zuzuordnen. Alle Kuratoren-Uploads sind standardmäßig von der globalen Playlist ausgeschlossen.",
|
||||
"tooltipGenreAssignmentShort": "Genres zu hochgeladenen Songs zuordnen",
|
||||
"tooltipGenreAssignmentLong": "Wähle ein oder mehrere Genres vor dem Upload aus. Die ausgewählten Genres werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Genres zuordnen, für die du verantwortlich bist. Wenn du keine Genres auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.",
|
||||
"tooltipTracklistShort": "Deine Songs verwalten",
|
||||
"tooltipTracklistLong": "Diese Tabelle zeigt alle Songs in deinen Genres und Specials. Du kannst suchen, filtern, sortieren und Songs bearbeiten. Nutze die Checkboxen, um mehrere Songs für die Batch-Bearbeitung auszuwählen. Nur Songs, die du bearbeiten kannst, haben eine aktive Checkbox.",
|
||||
"tooltipSearchShort": "Nach Titel oder Artist suchen",
|
||||
"tooltipSearchLong": "Tippe in das Suchfeld, um Songs nach Titel oder Artist-Namen zu filtern. Die Suche ist nicht case-sensitive und findet Teiltexte. Leere das Suchfeld, um alle Songs wieder anzuzeigen.",
|
||||
"tooltipFilterShort": "Nach Genre, Special oder Global-Flag filtern",
|
||||
"tooltipFilterLong": "Nutze das Filter-Dropdown, um nur Songs aus einem bestimmten Genre, Special oder Songs, die von der globalen Playlist ausgeschlossen sind, anzuzeigen. Kombiniere mit der Suche für präzisere Filterung.",
|
||||
"tooltipBatchEditShort": "Mehrere Songs gleichzeitig bearbeiten",
|
||||
"tooltipBatchEditLong": "Wähle mehrere Songs über Checkboxen aus, dann nutze die Batch-Bearbeitungs-Toolbar, um Änderungen auf alle ausgewählten Songs gleichzeitig anzuwenden. Du kannst Genres/Specials umschalten, Artist-Namen ändern oder das Exclude-Global-Flag ändern (nur Global-Kuratoren).",
|
||||
"tooltipBatchGenreToggleShort": "Genres hinzufügen oder entfernen",
|
||||
"tooltipBatchGenreToggleLong": "Wähle Genres zum Umschalten aus. Wenn ein ausgewählter Song das Genre bereits hat, wird es entfernt. Wenn nicht, wird es hinzugefügt. Dies ermöglicht es dir, schnell Genres zu mehreren Songs hinzuzufügen oder zu entfernen.",
|
||||
"tooltipBatchSpecialToggleShort": "Specials hinzufügen oder entfernen",
|
||||
"tooltipBatchSpecialToggleLong": "Wähle Specials zum Umschalten aus. Wenn ein ausgewählter Song das Special bereits hat, wird es entfernt. Wenn nicht, wird es hinzugefügt. Du kannst nur Specials umschalten, für die du verantwortlich bist.",
|
||||
"tooltipBatchArtistShort": "Artist für alle ausgewählten Songs ändern",
|
||||
"tooltipBatchArtistLong": "Gib einen neuen Artist-Namen ein, um ihn für alle ausgewählten Songs zu setzen. Dies ist nützlich, um Artist-Namen zu korrigieren oder Namenskonventionen über mehrere Songs hinweg zu standardisieren.",
|
||||
"tooltipCommentsShort": "Spieler-Feedback und Kommentare",
|
||||
"tooltipCommentsLong": "Spieler können dir Nachrichten zu Rätseln in deinen Genres oder Specials senden. Ungelesene Kommentare sind mit einem blauen Rahmen und Badge hervorgehoben. Klicke auf einen Kommentar, um ihn als gelesen zu markieren, oder archiviere ihn, wenn du ihn nicht mehr benötigst."
|
||||
},
|
||||
"About": {
|
||||
"title": "Über Hördle & Impressum",
|
||||
|
||||
125
messages/en.json
125
messages/en.json
@@ -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",
|
||||
@@ -231,7 +239,122 @@
|
||||
"loadingData": "Loading data...",
|
||||
"loggedInAs": "Logged in as {username}",
|
||||
"globalCuratorSuffix": " (Global curator)",
|
||||
"pageSizeLabel": "Per page:"
|
||||
"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",
|
||||
"batchEditTitle": "Batch Edit",
|
||||
"clearSelection": "Clear Selection",
|
||||
"batchToggleGenres": "Toggle Genres",
|
||||
"batchToggleSpecials": "Toggle Specials",
|
||||
"batchChangeArtist": "Change Artist",
|
||||
"batchArtistPlaceholder": "Enter new artist name",
|
||||
"batchExcludeGlobal": "Exclude from Global",
|
||||
"batchNoChange": "No change",
|
||||
"batchExclude": "Exclude",
|
||||
"batchInclude": "Include",
|
||||
"batchUpdating": "Updating...",
|
||||
"batchApply": "Apply Changes",
|
||||
"selectAll": "Select all",
|
||||
"selectSong": "Select song",
|
||||
"cannotEditSong": "Cannot edit this song",
|
||||
"noSongsSelected": "No songs selected",
|
||||
"noBatchOperations": "No batch operations specified",
|
||||
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
|
||||
"batchUpdateError": "Error: {error}",
|
||||
"batchUpdateNetworkError": "Network error during batch update"
|
||||
},
|
||||
"CuratorHelp": {
|
||||
"title": "Curator Help & Manual",
|
||||
"backToDashboard": "Back to Dashboard",
|
||||
"helpButton": "Help",
|
||||
"modalTitle": "Help",
|
||||
"introductionTitle": "Introduction",
|
||||
"introductionText": "As a curator, you are responsible for managing songs within your assigned genres and specials. This dashboard allows you to upload, edit, and organize music for the Hördle game.",
|
||||
"permissionsTitle": "Your Permissions",
|
||||
"permission1": "Upload MP3 files and assign them to your genres",
|
||||
"permission2": "Edit songs that are assigned to at least one of your genres or specials",
|
||||
"permission3": "Delete songs that are exclusively assigned to your genres/specials",
|
||||
"permission4": "View and manage comments from players about your puzzles",
|
||||
"note": "Note",
|
||||
"permissionNote": "You can only edit or delete songs that are assigned to your genres/specials. Songs assigned to other curators' genres cannot be modified by you.",
|
||||
"uploadTitle": "Uploading Songs",
|
||||
"uploadStepsTitle": "Step-by-Step Guide",
|
||||
"uploadStep1": "Drag MP3 files into the upload area or click to select files",
|
||||
"uploadStep2": "Select one or more genres to assign to the uploaded songs",
|
||||
"uploadStep3": "Click 'Start upload' to begin the upload process",
|
||||
"uploadStep4": "The system will automatically extract metadata (title, artist, release year) from the files",
|
||||
"uploadBestPracticesTitle": "Best Practices",
|
||||
"uploadBestPractice1": "Ensure MP3 files have correct ID3 tags (title, artist) for automatic metadata extraction",
|
||||
"uploadBestPractice2": "Select appropriate genres before uploading to avoid manual assignment later",
|
||||
"uploadBestPractice3": "Check for duplicates before uploading - the system will warn you if a song already exists",
|
||||
"tip": "Tip",
|
||||
"uploadTip": "All songs uploaded by curators are automatically excluded from the global playlist. Only admins can change this setting.",
|
||||
"editingTitle": "Editing Songs",
|
||||
"singleEditTitle": "Single Song Editing",
|
||||
"singleEditText": "Click the edit button (✏️) next to a song to modify its title, artist, release year, genres, specials, or exclude-from-global flag. Only songs you can edit will have an active edit button.",
|
||||
"batchEditTitle": "Batch Editing",
|
||||
"batchEditText": "Select multiple songs using the checkboxes, then use the batch edit toolbar to apply changes to all selected songs at once:",
|
||||
"batchEditFeature1": "Genre Toggle: Add or remove genres from all selected songs",
|
||||
"batchEditFeature2": "Special Toggle: Add or remove specials from all selected songs",
|
||||
"batchEditFeature3": "Artist Change: Set the same artist name for all selected songs",
|
||||
"batchEditFeature4": "Exclude Global Flag: Set or remove the exclude-from-global flag (Global Curators only)",
|
||||
"genreSpecialAssignmentTitle": "Genre & Special Assignment",
|
||||
"genreSpecialAssignmentText": "You can only assign songs to genres and specials that you are responsible for. Songs can have multiple genres and specials. When editing, you can toggle assignments - if a genre/special is already assigned, it will be removed; if not, it will be added.",
|
||||
"commentsTitle": "Managing Comments",
|
||||
"commentsText": "Players can send you feedback about puzzles in your genres or specials. Comments appear in your dashboard with a badge showing unread messages.",
|
||||
"commentsActionsTitle": "Available Actions",
|
||||
"markAsRead": "Mark as Read",
|
||||
"markAsReadText": "Click on a comment to mark it as read. Read comments are displayed with a gray border.",
|
||||
"archive": "Archive",
|
||||
"archiveText": "Archive comments you no longer need. Archived comments are removed from your view.",
|
||||
"bestPracticesTitle": "Best Practices for Curators",
|
||||
"bestPractice1": "Keep metadata accurate: Ensure song titles and artist names are correct and consistent",
|
||||
"bestPractice2": "Use appropriate genres: Assign songs to the most relevant genres to help players discover music",
|
||||
"bestPractice3": "Respond to comments: Check comments regularly and consider player feedback when curating",
|
||||
"bestPractice4": "Maintain quality: Review uploaded songs for audio quality and metadata accuracy",
|
||||
"bestPractice5": "Use batch editing efficiently: When making similar changes to multiple songs, use batch edit to save time",
|
||||
"troubleshootingTitle": "Troubleshooting",
|
||||
"troubleshootingQ1": "Why can't I edit a song?",
|
||||
"troubleshootingA1": "You can only edit songs that are assigned to at least one of your genres or specials. If a song has no genres/specials assigned, you can edit it. If it's assigned to other curators' genres only, you cannot edit it.",
|
||||
"troubleshootingQ2": "Why can't I delete a song?",
|
||||
"troubleshootingA2": "You can only delete songs that are exclusively assigned to your genres/specials. If a song has any genres or specials assigned to other curators, you cannot delete it.",
|
||||
"troubleshootingQ3": "Why can't I assign a genre/special?",
|
||||
"troubleshootingA3": "You can only assign songs to genres and specials that you are responsible for. Contact the admin if you need access to additional genres or specials.",
|
||||
"troubleshootingQ4": "Why is the exclude-from-global checkbox disabled?",
|
||||
"troubleshootingA4": "Only Global Curators can change the exclude-from-global flag. If you need this permission, contact the admin.",
|
||||
"tooltipDashboardShort": "Overview of your curator dashboard",
|
||||
"tooltipDashboardLong": "This is your main dashboard where you can upload songs, manage your track list, and view comments from players. Use the help button (❓) to access the full manual.",
|
||||
"tooltipUploadShort": "Upload MP3 files to your genres",
|
||||
"tooltipUploadLong": "Drag and drop MP3 files or click to select. The system will automatically extract metadata (title, artist, release year) from ID3 tags. Select genres before uploading to automatically assign songs. All curator uploads are excluded from the global playlist by default.",
|
||||
"tooltipGenreAssignmentShort": "Assign genres to uploaded songs",
|
||||
"tooltipGenreAssignmentLong": "Select one or more genres before uploading. The selected genres will be assigned to all successfully uploaded songs. You can only assign genres that you are responsible for. If you don't select any genres, you can assign them later by editing the songs.",
|
||||
"tooltipTracklistShort": "Manage your songs",
|
||||
"tooltipTracklistLong": "This table shows all songs in your genres and specials. You can search, filter, sort, and edit songs. Use the checkboxes to select multiple songs for batch editing. Only songs you can edit will have an active checkbox.",
|
||||
"tooltipSearchShort": "Search by title or artist",
|
||||
"tooltipSearchLong": "Type in the search box to filter songs by title or artist name. The search is case-insensitive and matches partial text. Clear the search to show all songs again.",
|
||||
"tooltipFilterShort": "Filter by genre, special, or global flag",
|
||||
"tooltipFilterLong": "Use the filter dropdown to show only songs from a specific genre, special, or songs excluded from the global playlist. Combine with search for more precise filtering.",
|
||||
"tooltipBatchEditShort": "Edit multiple songs at once",
|
||||
"tooltipBatchEditLong": "Select multiple songs using checkboxes, then use the batch edit toolbar to apply changes to all selected songs simultaneously. You can toggle genres/specials, change artist names, or modify the exclude-from-global flag (Global Curators only).",
|
||||
"tooltipBatchGenreToggleShort": "Add or remove genres",
|
||||
"tooltipBatchGenreToggleLong": "Select genres to toggle. If a selected song already has the genre, it will be removed. If it doesn't have the genre, it will be added. This allows you to quickly add or remove genres from multiple songs at once.",
|
||||
"tooltipBatchSpecialToggleShort": "Add or remove specials",
|
||||
"tooltipBatchSpecialToggleLong": "Select specials to toggle. If a selected song already has the special, it will be removed. If it doesn't have the special, it will be added. You can only toggle specials you are responsible for.",
|
||||
"tooltipBatchArtistShort": "Change artist for all selected songs",
|
||||
"tooltipBatchArtistLong": "Enter a new artist name to set it for all selected songs. This is useful for correcting artist names or standardizing naming conventions across multiple songs.",
|
||||
"tooltipCommentsShort": "Player feedback and comments",
|
||||
"tooltipCommentsLong": "Players can send you messages about puzzles in your genres or specials. Unread comments are highlighted with a blue border and badge. Click on a comment to mark it as read, or archive it if you no longer need it."
|
||||
},
|
||||
"About": {
|
||||
"title": "About Hördle & Imprint",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.4.11",
|
||||
"version": "0.1.6.2",
|
||||
"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