Compare commits
16 Commits
v0.1.5.1
...
curator-he
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a61caa2d13 | ||
|
|
52a15b7504 | ||
|
|
00160d9602 | ||
|
|
296a227d22 | ||
|
|
50ca51b143 | ||
|
|
afe6e12afc | ||
|
|
91b12ad859 | ||
|
|
d2548c2870 | ||
|
|
40d6ea75f0 | ||
|
|
0054facbe7 | ||
|
|
95bcf9ed1e | ||
|
|
08fedf9881 | ||
|
|
cd564b5d8c | ||
|
|
863539a5e9 | ||
|
|
2fa8aa0042 | ||
|
|
8ecf430bf5 |
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.
|
- Bearbeitung von Metadaten.
|
||||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||||
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
- 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:**
|
- **Cover Art:**
|
||||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
- 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.
|
- Live-Vorschau beim Hovern über die Waveform.
|
||||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
- Einzelne Segmente zum Testen abspielen.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
|
||||||
- Manuelle Speicherung mit visueller Bestätigung.
|
- Manuelle Speicherung mit visueller Bestätigung.
|
||||||
- **News & Announcements:**
|
- **News & Announcements:**
|
||||||
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
- 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.
|
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||||
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||||
- Verwaltung über das Admin-Dashboard.
|
- 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)
|
## 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_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
||||||
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||||
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
- `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:**
|
2. **Starten:**
|
||||||
```bash
|
```bash
|
||||||
@@ -156,7 +176,18 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
- URL: `/de/admin` oder `/en/admin`
|
- URL: `/de/admin` oder `/en/admin`
|
||||||
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
- 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:
|
- Erstelle ein Special im Admin-Dashboard:
|
||||||
- Gib Name, Max Attempts und Unlock Steps ein.
|
- Gib Name, Max Attempts und Unlock Steps ein.
|
||||||
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
||||||
|
|||||||
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
265
app/api/songs/batch/route.ts
Normal file
265
app/api/songs/batch/route.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
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') {
|
||||||
|
assignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
|
||||||
|
// Validate genre/special toggles are within curator's assignments
|
||||||
|
if (hasGenreToggle) {
|
||||||
|
const invalidGenre = genreToggleIds.some((id: number) => !assignments.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) => !assignments.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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -35,11 +35,15 @@ function curatorCanEditSong(context: StaffContext, song: any, assignments: { gen
|
|||||||
// - `SpecialSong` (mit `specialId`)
|
// - `SpecialSong` (mit `specialId`)
|
||||||
// - `SpecialSong` (mit Relation `special.id`)
|
// - `SpecialSong` (mit Relation `special.id`)
|
||||||
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
|
// 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 || [])
|
const songSpecialIds = (song.specials || [])
|
||||||
.map((s: any) => {
|
.map((s: any) => {
|
||||||
if (s?.id != null) return s.id;
|
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||||
if (s?.specialId != null) return s.specialId;
|
if (s?.specialId != null) return s.specialId;
|
||||||
if (s?.special?.id != null) return s.special.id;
|
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;
|
return undefined;
|
||||||
})
|
})
|
||||||
.filter((id: any): id is number => typeof id === 'number');
|
.filter((id: any): id is number => typeof id === 'number');
|
||||||
@@ -59,11 +63,15 @@ function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { g
|
|||||||
if (context.role === 'admin') return true;
|
if (context.role === 'admin') return true;
|
||||||
|
|
||||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
const songGenreIds = (song.genres || []).map((g: any) => g.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 || [])
|
const songSpecialIds = (song.specials || [])
|
||||||
.map((s: any) => {
|
.map((s: any) => {
|
||||||
if (s?.id != null) return s.id;
|
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||||
if (s?.specialId != null) return s.specialId;
|
if (s?.specialId != null) return s.specialId;
|
||||||
if (s?.special?.id != null) return s.special.id;
|
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;
|
return undefined;
|
||||||
})
|
})
|
||||||
.filter((id: any): id is number => typeof id === 'number');
|
.filter((id: any): id is number => typeof id === 'number');
|
||||||
@@ -382,7 +390,11 @@ export async function PUT(request: Request) {
|
|||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
include: {
|
include: {
|
||||||
genres: true,
|
genres: true,
|
||||||
specials: true,
|
specials: {
|
||||||
|
include: {
|
||||||
|
special: true
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -469,51 +481,55 @@ export async function PUT(request: Request) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle SpecialSong relations separately
|
// Execute all database write operations in a transaction to ensure consistency
|
||||||
if (effectiveSpecialIds !== undefined) {
|
const updatedSong = await prisma.$transaction(async (tx) => {
|
||||||
// First, get current special assignments
|
// Handle SpecialSong relations separately
|
||||||
const currentSpecials = await prisma.specialSong.findMany({
|
if (effectiveSpecialIds !== undefined) {
|
||||||
where: { songId: Number(id) }
|
// First, get current special assignments (within transaction)
|
||||||
});
|
const currentSpecials = await tx.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 }
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Add new specials
|
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||||
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
const newSpecialIds = effectiveSpecialIds as number[];
|
||||||
if (toAdd.length > 0) {
|
|
||||||
await prisma.specialSong.createMany({
|
|
||||||
data: toAdd.map(specialId => ({
|
|
||||||
songId: Number(id),
|
|
||||||
specialId,
|
|
||||||
startTime: 0
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedSong = await prisma.song.update({
|
// Delete removed specials
|
||||||
where: { id: Number(id) },
|
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||||
data,
|
if (toDelete.length > 0) {
|
||||||
include: {
|
await tx.specialSong.deleteMany({
|
||||||
genres: true,
|
where: {
|
||||||
specials: {
|
songId: Number(id),
|
||||||
include: {
|
specialId: { in: toDelete }
|
||||||
special: true
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
return NextResponse.json(updatedSong);
|
||||||
@@ -559,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);
|
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||||
try {
|
try {
|
||||||
await unlink(filePath);
|
await unlink(filePath);
|
||||||
@@ -578,9 +594,11 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from database (will cascade delete related puzzles)
|
// Delete from database in transaction (will cascade delete related puzzles, SpecialSong, etc.)
|
||||||
await prisma.song.delete({
|
await prisma.$transaction(async (tx) => {
|
||||||
where: { id: Number(id) },
|
await tx.song.delete({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
|
||||||
interface Genre {
|
interface Genre {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -37,6 +37,30 @@ interface CuratorInfo {
|
|||||||
specialIds: number[];
|
specialIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CuratorComment {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
readAt: string | null;
|
||||||
|
puzzle: {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
puzzleNumber: number;
|
||||||
|
song: {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
};
|
||||||
|
genre: {
|
||||||
|
id: number;
|
||||||
|
name: any;
|
||||||
|
} | null;
|
||||||
|
special: {
|
||||||
|
id: number;
|
||||||
|
name: any;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getCuratorAuthHeaders() {
|
function getCuratorAuthHeaders() {
|
||||||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||||||
const username = localStorage.getItem('hoerdle_curator_username') || '';
|
const username = localStorage.getItem('hoerdle_curator_username') || '';
|
||||||
@@ -58,6 +82,7 @@ function getCuratorUploadHeaders() {
|
|||||||
|
|
||||||
export default function CuratorPageClient() {
|
export default function CuratorPageClient() {
|
||||||
const t = useTranslations('Curator');
|
const t = useTranslations('Curator');
|
||||||
|
const tNav = useTranslations('Navigation');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
@@ -98,6 +123,19 @@ export default function CuratorPageClient() {
|
|||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
||||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
// Comments state
|
||||||
|
const [comments, setComments] = useState<CuratorComment[]>([]);
|
||||||
|
const [loadingComments, setLoadingComments] = useState(false);
|
||||||
|
const [showComments, setShowComments] = useState(false);
|
||||||
|
|
||||||
|
// Batch edit state
|
||||||
|
const [selectedSongIds, setSelectedSongIds] = useState<Set<number>>(new Set());
|
||||||
|
const [batchGenreIds, setBatchGenreIds] = useState<number[]>([]);
|
||||||
|
const [batchSpecialIds, setBatchSpecialIds] = useState<number[]>([]);
|
||||||
|
const [batchArtist, setBatchArtist] = useState('');
|
||||||
|
const [batchExcludeFromGlobal, setBatchExcludeFromGlobal] = useState<boolean | undefined>(undefined);
|
||||||
|
const [isBatchUpdating, setIsBatchUpdating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||||||
@@ -109,15 +147,70 @@ export default function CuratorPageClient() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const bootstrapCuratorData = async () => {
|
const bootstrapCuratorData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await Promise.all([fetchCuratorInfo(), fetchSongs(), fetchGenres(), fetchSpecials()]);
|
await Promise.all([fetchCuratorInfo(), fetchSongs(), fetchGenres(), fetchSpecials(), fetchComments()]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchComments = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingComments(true);
|
||||||
|
const res = await fetch('/api/curator-comments', {
|
||||||
|
headers: getCuratorAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data: CuratorComment[] = await res.json();
|
||||||
|
setComments(data);
|
||||||
|
} else {
|
||||||
|
setMessage(t('loadCommentsError'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(t('loadCommentsError'));
|
||||||
|
} finally {
|
||||||
|
setLoadingComments(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markCommentAsRead = async (commentId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/curator-comments/${commentId}/read`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getCuratorAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
// Update local state
|
||||||
|
setComments(comments.map(c =>
|
||||||
|
c.id === commentId ? { ...c, readAt: new Date().toISOString() } : c
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking comment as read:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const archiveComment = async (commentId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/curator-comments/${commentId}/archive`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getCuratorAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
// Remove comment from local state (archived comments are not shown)
|
||||||
|
setComments(comments.filter(c => c.id !== commentId));
|
||||||
|
} else {
|
||||||
|
setMessage(t('archiveCommentError'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error archiving comment:', error);
|
||||||
|
setMessage(t('archiveCommentError'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchCuratorInfo = async () => {
|
const fetchCuratorInfo = async () => {
|
||||||
const res = await fetch('/api/curator/me', {
|
const res = await fetch('/api/curator/me', {
|
||||||
headers: getCuratorAuthHeaders(),
|
headers: getCuratorAuthHeaders(),
|
||||||
@@ -299,6 +392,96 @@ export default function CuratorPageClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Batch edit functions
|
||||||
|
const toggleSongSelection = (songId: number) => {
|
||||||
|
setSelectedSongIds(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(songId)) {
|
||||||
|
newSet.delete(songId);
|
||||||
|
} else {
|
||||||
|
// Only allow selection of editable songs
|
||||||
|
const song = songs.find(s => s.id === songId);
|
||||||
|
if (song && canEditSong(song)) {
|
||||||
|
newSet.add(songId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllVisible = () => {
|
||||||
|
const editableVisibleIds = visibleSongs
|
||||||
|
.filter(song => canEditSong(song))
|
||||||
|
.map(song => song.id);
|
||||||
|
setSelectedSongIds(new Set(editableVisibleIds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelectedSongIds(new Set());
|
||||||
|
setBatchGenreIds([]);
|
||||||
|
setBatchSpecialIds([]);
|
||||||
|
setBatchArtist('');
|
||||||
|
setBatchExcludeFromGlobal(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchUpdate = async () => {
|
||||||
|
if (selectedSongIds.size === 0) {
|
||||||
|
setMessage(t('noSongsSelected') || 'No songs selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasGenreToggle = batchGenreIds.length > 0;
|
||||||
|
const hasSpecialToggle = batchSpecialIds.length > 0;
|
||||||
|
const hasArtistChange = batchArtist.trim() !== '';
|
||||||
|
const hasExcludeGlobalChange = batchExcludeFromGlobal !== undefined;
|
||||||
|
|
||||||
|
if (!hasGenreToggle && !hasSpecialToggle && !hasArtistChange && !hasExcludeGlobalChange) {
|
||||||
|
setMessage(t('noBatchOperations') || 'No batch operations specified');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBatchUpdating(true);
|
||||||
|
setMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/songs/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getCuratorAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
songIds: Array.from(selectedSongIds),
|
||||||
|
genreToggleIds: hasGenreToggle ? batchGenreIds : undefined,
|
||||||
|
specialToggleIds: hasSpecialToggle ? batchSpecialIds : undefined,
|
||||||
|
artist: hasArtistChange ? batchArtist.trim() : undefined,
|
||||||
|
excludeFromGlobal: hasExcludeGlobalChange ? batchExcludeFromGlobal : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const result = await res.json();
|
||||||
|
await fetchSongs();
|
||||||
|
|
||||||
|
let msg = t('batchUpdateSuccess') || `Successfully updated ${result.success} of ${result.processed} songs`;
|
||||||
|
if (result.skipped > 0) {
|
||||||
|
msg += ` (${result.skipped} skipped)`;
|
||||||
|
}
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
msg += `\nErrors: ${result.errors.map((e: any) => `Song ${e.songId}: ${e.error}`).join(', ')}`;
|
||||||
|
}
|
||||||
|
setMessage(msg);
|
||||||
|
|
||||||
|
// Clear selection after successful update
|
||||||
|
clearSelection();
|
||||||
|
} else {
|
||||||
|
const errText = await res.text();
|
||||||
|
setMessage(t('batchUpdateError') || `Error: ${errText}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setMessage(t('batchUpdateNetworkError') || 'Network error during batch update');
|
||||||
|
} finally {
|
||||||
|
setIsBatchUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
const handleSort = (field: SortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
@@ -633,6 +816,167 @@ export default function CuratorPageClient() {
|
|||||||
<p style={{ marginBottom: '1rem', color: '#b91c1c', whiteSpace: 'pre-line' }}>{message}</p>
|
<p style={{ marginBottom: '1rem', color: '#b91c1c', whiteSpace: 'pre-line' }}>{message}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Comments Section */}
|
||||||
|
{(() => {
|
||||||
|
const unreadCount = comments.filter(c => !c.readAt).length;
|
||||||
|
const hasUnread = unreadCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.75rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', marginBottom: 0 }}>
|
||||||
|
{t('commentsTitle')} ({comments.length})
|
||||||
|
</h2>
|
||||||
|
{hasUnread && (
|
||||||
|
<span style={{
|
||||||
|
padding: '0.25rem 0.75rem',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
background: '#ef4444',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
verticalAlign: 'baseline',
|
||||||
|
lineHeight: '1'
|
||||||
|
}}>
|
||||||
|
{unreadCount} {t('newComments')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowComments(!showComments);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.8rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
background: showComments ? '#3b82f6' : '#fff',
|
||||||
|
color: showComments ? '#fff' : '#000',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showComments ? t('hideComments') : t('showComments')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showComments && (
|
||||||
|
<>
|
||||||
|
{loadingComments ? (
|
||||||
|
<p>{t('loadingComments')}</p>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<p>{t('noComments')}</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{comments.map(comment => {
|
||||||
|
const genreName = comment.puzzle.genre
|
||||||
|
? typeof comment.puzzle.genre.name === 'string'
|
||||||
|
? comment.puzzle.genre.name
|
||||||
|
: comment.puzzle.genre.name?.de ?? comment.puzzle.genre.name?.en
|
||||||
|
: null;
|
||||||
|
const specialName = comment.puzzle.special
|
||||||
|
? typeof comment.puzzle.special.name === 'string'
|
||||||
|
? comment.puzzle.special.name
|
||||||
|
: comment.puzzle.special.name?.de ?? comment.puzzle.special.name?.en
|
||||||
|
: null;
|
||||||
|
const isRead = comment.readAt !== null;
|
||||||
|
|
||||||
|
// Determine category label
|
||||||
|
let categoryLabel = '';
|
||||||
|
if (specialName) {
|
||||||
|
categoryLabel = `★ ${specialName}`;
|
||||||
|
} else if (genreName) {
|
||||||
|
categoryLabel = genreName;
|
||||||
|
} else {
|
||||||
|
categoryLabel = tNav('global');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={comment.id}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: `1px solid ${isRead ? '#d1d5db' : '#3b82f6'}`,
|
||||||
|
background: isRead ? '#f9fafb' : '#eff6ff',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isRead) {
|
||||||
|
markCommentAsRead(comment.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isRead && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0.5rem',
|
||||||
|
right: '0.5rem',
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#3b82f6',
|
||||||
|
}}
|
||||||
|
title={t('unreadComment')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: '0.95rem' }}>
|
||||||
|
Hördle #{comment.puzzle.puzzleNumber}
|
||||||
|
</strong>
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.85rem', color: '#6b7280' }}>
|
||||||
|
({categoryLabel})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: '#6b7280' }}>
|
||||||
|
{new Date(comment.createdAt).toLocaleDateString()} {new Date(comment.createdAt).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: '0.75rem', fontSize: '0.9rem', fontWeight: '500' }}>
|
||||||
|
{comment.puzzle.song.title} - {comment.puzzle.song.artist}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.9rem', whiteSpace: 'pre-wrap', marginBottom: '0.5rem' }}>
|
||||||
|
{comment.message}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm(t('archiveCommentConfirm'))) {
|
||||||
|
archiveComment(comment.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.8rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
background: '#fff',
|
||||||
|
color: '#6b7280',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
title={t('archiveComment')}
|
||||||
|
>
|
||||||
|
{t('archiveComment')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<section style={{ marginBottom: '2rem' }}>
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>{t('uploadSectionTitle')}</h2>
|
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>{t('uploadSectionTitle')}</h2>
|
||||||
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
|
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
|
||||||
@@ -900,6 +1244,197 @@ export default function CuratorPageClient() {
|
|||||||
<p>{t('noSongsInScope')}</p>
|
<p>{t('noSongsInScope')}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Batch Edit Toolbar */}
|
||||||
|
{selectedSongIds.size > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '1rem',
|
||||||
|
padding: '1rem',
|
||||||
|
background: '#f0f9ff',
|
||||||
|
border: '1px solid #bae6fd',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||||
|
<strong style={{ fontSize: '1rem' }}>
|
||||||
|
{t('batchEditTitle') || `Batch Edit: ${selectedSongIds.size} ${selectedSongIds.size === 1 ? 'song' : 'songs'} selected`}
|
||||||
|
</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearSelection}
|
||||||
|
style={{
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('clearSelection') || 'Clear Selection'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{/* Genre Toggle */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
{t('batchToggleGenres') || 'Toggle Genres'}
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{genres
|
||||||
|
.filter(g => curatorInfo?.genreIds?.includes(g.id))
|
||||||
|
.map(genre => (
|
||||||
|
<label
|
||||||
|
key={genre.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: batchGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={batchGenreIds.includes(genre.id)}
|
||||||
|
onChange={() => {
|
||||||
|
setBatchGenreIds(prev =>
|
||||||
|
prev.includes(genre.id)
|
||||||
|
? prev.filter(id => id !== genre.id)
|
||||||
|
: [...prev, genre.id]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{typeof genre.name === 'string'
|
||||||
|
? genre.name
|
||||||
|
: genre.name?.de ?? genre.name?.en}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Special Toggle */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
{t('batchToggleSpecials') || 'Toggle Specials'}
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{specials
|
||||||
|
.filter(s => curatorInfo?.specialIds?.includes(s.id))
|
||||||
|
.map(special => (
|
||||||
|
<label
|
||||||
|
key={special.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: batchSpecialIds.includes(special.id) ? '#fee2e2' : '#f3f4f6',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={batchSpecialIds.includes(special.id)}
|
||||||
|
onChange={() => {
|
||||||
|
setBatchSpecialIds(prev =>
|
||||||
|
prev.includes(special.id)
|
||||||
|
? prev.filter(id => id !== special.id)
|
||||||
|
: [...prev, special.id]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
★{' '}
|
||||||
|
{typeof special.name === 'string'
|
||||||
|
? special.name
|
||||||
|
: special.name?.de ?? special.name?.en}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Artist Change */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
{t('batchChangeArtist') || 'Change Artist'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={batchArtist}
|
||||||
|
onChange={e => setBatchArtist(e.target.value)}
|
||||||
|
placeholder={t('batchArtistPlaceholder') || 'Enter new artist name'}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '400px',
|
||||||
|
padding: '0.4rem 0.6rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exclude Global Flag */}
|
||||||
|
{curatorInfo?.isGlobalCurator && (
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
{t('batchExcludeGlobal') || 'Exclude from Global'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={batchExcludeFromGlobal === undefined ? '' : batchExcludeFromGlobal ? 'true' : 'false'}
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.value === '') {
|
||||||
|
setBatchExcludeFromGlobal(undefined);
|
||||||
|
} else {
|
||||||
|
setBatchExcludeFromGlobal(e.target.value === 'true');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.6rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t('batchNoChange') || 'No change'}</option>
|
||||||
|
<option value="true">{t('batchExclude') || 'Exclude'}</option>
|
||||||
|
<option value="false">{t('batchInclude') || 'Include'}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Apply Button */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBatchUpdate}
|
||||||
|
disabled={isBatchUpdating}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: isBatchUpdating ? '#9ca3af' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: isBatchUpdating ? 'not-allowed' : 'pointer',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isBatchUpdating
|
||||||
|
? (t('batchUpdating') || 'Updating...')
|
||||||
|
: (t('batchApply') || 'Apply Changes')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table
|
<table
|
||||||
style={{
|
style={{
|
||||||
@@ -910,6 +1445,21 @@ export default function CuratorPageClient() {
|
|||||||
>
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #e5e7eb' }}>
|
<tr style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<th style={{ padding: '0.5rem', width: '40px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleSongs.length > 0 && visibleSongs.every(song => canEditSong(song) && selectedSongIds.has(song.id))}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
selectAllVisible();
|
||||||
|
} else {
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
title={t('selectAll') || 'Select all'}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th
|
<th
|
||||||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||||||
onClick={() => handleSort('id')}
|
onClick={() => handleSort('id')}
|
||||||
@@ -968,8 +1518,26 @@ export default function CuratorPageClient() {
|
|||||||
? `${(song.averageRating || 0).toFixed(1)} (${song.ratingCount})`
|
? `${(song.averageRating || 0).toFixed(1)} (${song.ratingCount})`
|
||||||
: '-';
|
: '-';
|
||||||
|
|
||||||
|
const isSelected = selectedSongIds.has(song.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={song.id} style={{ borderBottom: '1px solid #f3f4f6' }}>
|
<tr
|
||||||
|
key={song.id}
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid #f3f4f6',
|
||||||
|
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleSongSelection(song.id)}
|
||||||
|
disabled={!editable}
|
||||||
|
style={{ cursor: editable ? 'pointer' : 'not-allowed' }}
|
||||||
|
title={editable ? (t('selectSong') || 'Select song') : (t('cannotEditSong') || 'Cannot edit this song')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td style={{ padding: '0.5rem' }}>{song.id}</td>
|
<td style={{ padding: '0.5rem' }}>{song.id}</td>
|
||||||
<td style={{ padding: '0.5rem' }}>
|
<td style={{ padding: '0.5rem' }}>
|
||||||
<button
|
<button
|
||||||
@@ -1070,6 +1638,41 @@ export default function CuratorPageClient() {
|
|||||||
: genre.name?.de ?? genre.name?.en}
|
: genre.name?.de ?? genre.name?.en}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
{specials
|
||||||
|
.filter(s => curatorInfo?.specialIds?.includes(s.id))
|
||||||
|
.map(special => (
|
||||||
|
<label
|
||||||
|
key={special.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.15rem 0.4rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: editSpecialIds.includes(special.id)
|
||||||
|
? '#fee2e2'
|
||||||
|
: '#f3f4f6',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editSpecialIds.includes(special.id)}
|
||||||
|
onChange={() =>
|
||||||
|
setEditSpecialIds(prev =>
|
||||||
|
prev.includes(special.id)
|
||||||
|
? prev.filter(id => id !== special.id)
|
||||||
|
: [...prev, special.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
★{' '}
|
||||||
|
{typeof special.name === 'string'
|
||||||
|
? special.name
|
||||||
|
: special.name?.de ?? special.name?.en}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
{song.genres
|
{song.genres
|
||||||
@@ -1091,21 +1694,26 @@ export default function CuratorPageClient() {
|
|||||||
: g.name?.de ?? g.name?.en}
|
: g.name?.de ?? g.name?.en}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{song.specials.map(s => (
|
{song.specials
|
||||||
<span
|
.filter(
|
||||||
key={`s-${s.id}`}
|
s => !curatorInfo?.specialIds?.includes(s.id)
|
||||||
style={{
|
)
|
||||||
padding: '0.1rem 0.4rem',
|
.map(s => (
|
||||||
borderRadius: '999px',
|
<span
|
||||||
background: '#fee2e2',
|
key={`fixed-s-${s.id}`}
|
||||||
fontSize: '0.8rem',
|
style={{
|
||||||
}}
|
padding: '0.1rem 0.4rem',
|
||||||
>
|
borderRadius: '999px',
|
||||||
{typeof s.name === 'string'
|
background: '#fee2e2',
|
||||||
? s.name
|
fontSize: '0.8rem',
|
||||||
: s.name?.de ?? s.name?.en}
|
}}
|
||||||
</span>
|
>
|
||||||
))}
|
★{' '}
|
||||||
|
{typeof s.name === 'string'
|
||||||
|
? s.name
|
||||||
|
: s.name?.de ?? s.name?.en}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { ExternalPuzzle } from '@/lib/externalPuzzles';
|
|||||||
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
|
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
|
||||||
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
|
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
|
||||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
import { getOrCreatePlayerId } from '@/lib/playerId';
|
||||||
|
|
||||||
// Plausible Analytics
|
// Plausible Analytics
|
||||||
declare global {
|
declare global {
|
||||||
@@ -60,6 +61,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||||
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||||
const audioPlayerRef = useRef<AudioPlayerRef>(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(() => {
|
useEffect(() => {
|
||||||
const updateCountdown = () => {
|
const updateCountdown = () => {
|
||||||
@@ -134,6 +139,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
} else {
|
} else {
|
||||||
setHasRated(false);
|
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]);
|
}, [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);
|
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 unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
@@ -532,6 +599,66 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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} />}
|
{statistics && <Statistics statistics={statistics} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -57,6 +57,13 @@
|
|||||||
"points": "Punkte",
|
"points": "Punkte",
|
||||||
"skipBonus": "Bonus überspringen",
|
"skipBonus": "Bonus überspringen",
|
||||||
"notQuite": "Nicht ganz!",
|
"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",
|
"youGuessed": "Du hast geraten",
|
||||||
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
||||||
"skipped": "Übersprungen",
|
"skipped": "Übersprungen",
|
||||||
@@ -232,7 +239,40 @@
|
|||||||
"loadingData": "Lade Daten...",
|
"loadingData": "Lade Daten...",
|
||||||
"loggedInAs": "Eingeloggt als {username}",
|
"loggedInAs": "Eingeloggt als {username}",
|
||||||
"globalCuratorSuffix": " (Globaler Kurator)",
|
"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"
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "Über Hördle & Impressum",
|
"title": "Über Hördle & Impressum",
|
||||||
|
|||||||
@@ -57,6 +57,13 @@
|
|||||||
"points": "points",
|
"points": "points",
|
||||||
"skipBonus": "Skip Bonus",
|
"skipBonus": "Skip Bonus",
|
||||||
"notQuite": "Not quite!",
|
"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",
|
"youGuessed": "You guessed",
|
||||||
"actuallyReleasedIn": "Actually released in",
|
"actuallyReleasedIn": "Actually released in",
|
||||||
"skipped": "Skipped",
|
"skipped": "Skipped",
|
||||||
@@ -232,7 +239,40 @@
|
|||||||
"loadingData": "Loading data...",
|
"loadingData": "Loading data...",
|
||||||
"loggedInAs": "Logged in as {username}",
|
"loggedInAs": "Logged in as {username}",
|
||||||
"globalCuratorSuffix": " (Global curator)",
|
"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"
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "About Hördle & Imprint",
|
"title": "About Hördle & Imprint",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.5.1",
|
"version": "0.1.6.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"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[]
|
songs Song[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
curatorGenres CuratorGenre[]
|
curatorGenres CuratorGenre[]
|
||||||
|
comments CuratorComment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Special {
|
model Special {
|
||||||
@@ -73,6 +74,7 @@ model DailyPuzzle {
|
|||||||
genre Genre? @relation(fields: [genreId], references: [id])
|
genre Genre? @relation(fields: [genreId], references: [id])
|
||||||
specialId Int?
|
specialId Int?
|
||||||
special Special? @relation(fields: [specialId], references: [id])
|
special Special? @relation(fields: [specialId], references: [id])
|
||||||
|
comments CuratorComment[]
|
||||||
|
|
||||||
@@unique([date, genreId, specialId])
|
@@unique([date, genreId, specialId])
|
||||||
}
|
}
|
||||||
@@ -114,6 +116,7 @@ model Curator {
|
|||||||
|
|
||||||
genres CuratorGenre[]
|
genres CuratorGenre[]
|
||||||
specials CuratorSpecial[]
|
specials CuratorSpecial[]
|
||||||
|
commentRecipients CuratorCommentRecipient[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CuratorGenre {
|
model CuratorGenre {
|
||||||
@@ -149,3 +152,32 @@ model PoliticalStatement {
|
|||||||
|
|
||||||
@@index([locale, active])
|
@@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
|
||||||
|
|
||||||
@@ -59,6 +59,9 @@ else
|
|||||||
echo "⚠️ Could not determine database path from config files"
|
echo "⚠️ Could not determine database path from config files"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Restic backup to remote repository
|
||||||
|
./scripts/backup-restic.sh
|
||||||
|
|
||||||
# Nur neueste Version holen (shallow fetch), vollständiges Repo ist im Deployment nicht nötig
|
# Nur neueste Version holen (shallow fetch), vollständiges Repo ist im Deployment nicht nötig
|
||||||
echo "📥 Fetching latest commit (shallow clone) from git..."
|
echo "📥 Fetching latest commit (shallow clone) from git..."
|
||||||
git fetch --prune --tags --depth=1 origin master
|
git fetch --prune --tags --depth=1 origin master
|
||||||
|
|||||||
Reference in New Issue
Block a user