Compare commits
4 Commits
v0.1.6.3
...
curate-spe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7121271a | ||
|
|
12cc81905e | ||
|
|
b46e9e3882 | ||
|
|
332688d693 |
@@ -61,6 +61,8 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- **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.
|
||||
- **KI-gestützte Formulierungshilfe:** Nachrichten können vor dem Absenden auf Wunsch automatisch von einer KI umformuliert/verbessert werden.
|
||||
- **Einklappbares Kommentar-Formular:** Das Nachrichtenformular ist dezent als einklappbarer Bereich eingebunden und stört den Spielfluss nicht.
|
||||
- **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).
|
||||
|
||||
@@ -1320,20 +1320,45 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
||||
</form>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{specials.map(special => (
|
||||
<div key={special.id} style={{
|
||||
background: '#f3f4f6',
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontSize: '0.875rem'
|
||||
}}>
|
||||
<span>{getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})</span>
|
||||
{special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {getLocalizedValue(special.subtitle, activeTab)}</span>}
|
||||
<Link href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>{t('curate')}</Link>
|
||||
<button onClick={() => startEditSpecial(special)} className="btn-secondary" style={{ marginRight: '0.5rem' }}>{t('edit')}</button>
|
||||
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">{t('delete')}</button>
|
||||
<div
|
||||
key={special.id}
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})
|
||||
</span>
|
||||
{special.subtitle && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#666',
|
||||
marginLeft: '0.25rem',
|
||||
}}
|
||||
>
|
||||
- {getLocalizedValue(special.subtitle, activeTab)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => startEditSpecial(special)}
|
||||
className="btn-secondary"
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
>
|
||||
{t('edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSpecial(special.id)}
|
||||
className="btn-danger"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
7
app/[locale]/admin/specials/[id]/page.tsx
Normal file
7
app/[locale]/admin/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import SpecialEditorPage from '@/app/admin/specials/[id]/page';
|
||||
|
||||
export default SpecialEditorPage;
|
||||
|
||||
|
||||
7
app/[locale]/curator/specials/[id]/page.tsx
Normal file
7
app/[locale]/curator/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import CuratorSpecialEditorPage from '@/app/curator/specials/[id]/page';
|
||||
|
||||
export default CuratorSpecialEditorPage;
|
||||
|
||||
|
||||
7
app/[locale]/curator/specials/page.tsx
Normal file
7
app/[locale]/curator/specials/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import CuratorSpecialsPage from '@/app/curator/specials/page';
|
||||
|
||||
export default CuratorSpecialsPage;
|
||||
|
||||
|
||||
@@ -638,20 +638,45 @@ export default function AdminPage() {
|
||||
</form>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{specials.map(special => (
|
||||
<div key={special.id} style={{
|
||||
background: '#f3f4f6',
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontSize: '0.875rem'
|
||||
}}>
|
||||
<span>{special.name} ({special._count?.songs || 0})</span>
|
||||
{special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {special.subtitle}</span>}
|
||||
<a href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>Curate</a>
|
||||
<button onClick={() => startEditSpecial(special)} className="btn-secondary" style={{ marginRight: '0.5rem' }}>Edit</button>
|
||||
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button>
|
||||
<div
|
||||
key={special.id}
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{special.name} ({special._count?.songs || 0})
|
||||
</span>
|
||||
{special.subtitle && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#666',
|
||||
marginLeft: '0.25rem',
|
||||
}}
|
||||
>
|
||||
- {special.subtitle}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => startEditSpecial(special)}
|
||||
className="btn-secondary"
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSpecial(special.id)}
|
||||
className="btn-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,102 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import WaveformEditor from '@/components/WaveformEditor';
|
||||
|
||||
interface Song {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface SpecialSong {
|
||||
id: number;
|
||||
songId: number;
|
||||
startTime: number;
|
||||
order: number | null;
|
||||
song: Song;
|
||||
}
|
||||
|
||||
interface Special {
|
||||
id: number;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
maxAttempts: number;
|
||||
unlockSteps: string;
|
||||
songs: SpecialSong[];
|
||||
}
|
||||
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
|
||||
|
||||
export default function SpecialEditorPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const specialId = params.id as string;
|
||||
|
||||
const [special, setSpecial] = useState<Special | null>(null);
|
||||
const [selectedSongId, setSelectedSongId] = useState<number | null>(null);
|
||||
// Locale aus dem Pfad ableiten (/en/..., /de/...)
|
||||
const localeFromPath = pathname?.split('/')[1] as 'de' | 'en' | undefined;
|
||||
const locale: 'de' | 'en' = localeFromPath === 'de' || localeFromPath === 'en' ? localeFromPath : 'en';
|
||||
|
||||
const [special, setSpecial] = useState<CurateSpecial | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pendingStartTime, setPendingStartTime] = useState<number | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSpecial = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/specials/${specialId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSpecial(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching special:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSpecial();
|
||||
}, [specialId]);
|
||||
|
||||
const fetchSpecial = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/specials/${specialId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSpecial(data);
|
||||
if (data.songs.length > 0) {
|
||||
setSelectedSongId(data.songs[0].songId);
|
||||
// Initialize pendingStartTime with the current startTime of the first song
|
||||
setPendingStartTime(data.songs[0].startTime);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching special:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleSaveStartTime = async (songId: number, startTime: number) => {
|
||||
const res = await fetch(`/api/specials/${specialId}/songs`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ songId, startTime }),
|
||||
});
|
||||
|
||||
const handleStartTimeChange = (newStartTime: number) => {
|
||||
setPendingStartTime(newStartTime);
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!special || !selectedSongId || pendingStartTime === null) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/specials/${specialId}/songs`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ songId: selectedSongId, startTime: pendingStartTime })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Update local state
|
||||
setSpecial(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
songs: prev.songs.map(ss =>
|
||||
ss.songId === selectedSongId ? { ...ss, startTime: pendingStartTime } : ss
|
||||
)
|
||||
};
|
||||
});
|
||||
setHasUnsavedChanges(false);
|
||||
setPendingStartTime(null); // Reset pending state after saving
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating start time:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => res.statusText || 'Unknown error');
|
||||
console.error('Error updating special song (admin):', res.status, errorText);
|
||||
throw new Error(`Failed to save start time: ${errorText}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,116 +66,16 @@ export default function SpecialEditorPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId);
|
||||
const unlockSteps = JSON.parse(special.unlockSteps);
|
||||
const totalDuration = unlockSteps[unlockSteps.length - 1];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<button
|
||||
onClick={() => router.push('/admin')}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '1rem'
|
||||
}}
|
||||
>
|
||||
← Back to Admin
|
||||
</button>
|
||||
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||
Edit Special: {special.name}
|
||||
</h1>
|
||||
{special.subtitle && (
|
||||
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
|
||||
{special.subtitle}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ color: '#666', marginTop: '0.5rem' }}>
|
||||
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{special.songs.length === 0 ? (
|
||||
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
|
||||
<p>No songs assigned to this special yet.</p>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
||||
Go back to the admin dashboard to add songs to this special.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||
Select Song to Curate
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
|
||||
{special.songs.map(ss => (
|
||||
<div
|
||||
key={ss.songId}
|
||||
onClick={() => setSelectedSongId(ss.songId)}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
|
||||
color: selectedSongId === ss.songId ? 'white' : 'black',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
|
||||
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
|
||||
Start: {ss.startTime}s
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSpecialSong && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||
Curate: {selectedSpecialSong.song.title}
|
||||
</h2>
|
||||
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
|
||||
Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasUnsavedChanges || saving}
|
||||
style={{
|
||||
padding: '0.5rem 1.5rem',
|
||||
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
|
||||
color: hasUnsavedChanges ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{saving ? '💾 Saving...' : hasUnsavedChanges ? '💾 Save Changes' : '✓ Saved'}
|
||||
</button>
|
||||
</div>
|
||||
<WaveformEditor
|
||||
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
|
||||
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
|
||||
duration={totalDuration}
|
||||
unlockSteps={unlockSteps}
|
||||
onStartTimeChange={handleStartTimeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CurateSpecialEditor
|
||||
special={special}
|
||||
locale={locale}
|
||||
onBack={() => router.push('/admin')}
|
||||
onSaveStartTime={handleSaveStartTime}
|
||||
backLabel="← Back to Admin"
|
||||
headerPrefix="Edit Special:"
|
||||
noSongsSubHint="Go back to the admin dashboard to add songs to this special."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
58
app/api/curator/specials/[id]/route.ts
Normal file
58
app/api/curator/specials/[id]/route.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error, context } = await requireStaffAuth(request);
|
||||
if (error || !context) return error!;
|
||||
|
||||
if (context.role !== 'curator') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only curators can access this endpoint' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const specialId = Number(id);
|
||||
if (!specialId || Number.isNaN(specialId)) {
|
||||
return NextResponse.json({ error: 'Invalid special id' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Prüfen, ob dieses Special dem Kurator zugeordnet ist
|
||||
const assignment = await prisma.curatorSpecial.findFirst({
|
||||
where: { curatorId: context.curator.id, specialId },
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: You are not allowed to access this special' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const special = await prisma.special.findUnique({
|
||||
where: { id: specialId },
|
||||
include: {
|
||||
songs: {
|
||||
include: {
|
||||
song: true,
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!special) {
|
||||
return NextResponse.json({ error: 'Special not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(special);
|
||||
}
|
||||
|
||||
|
||||
69
app/api/curator/specials/[id]/songs/route.ts
Normal file
69
app/api/curator/specials/[id]/songs/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireStaffAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error, context } = await requireStaffAuth(request);
|
||||
if (error || !context) return error!;
|
||||
|
||||
if (context.role !== 'curator') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only curators can access this endpoint' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const specialId = Number(id);
|
||||
const { songId, startTime, order } = await request.json();
|
||||
|
||||
if (!specialId || Number.isNaN(specialId)) {
|
||||
return NextResponse.json({ error: 'Invalid special id' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!songId || typeof startTime !== 'number') {
|
||||
return NextResponse.json({ error: 'Missing songId or startTime' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Prüfen, ob dieses Special dem Kurator zugeordnet ist
|
||||
const assignment = await prisma.curatorSpecial.findFirst({
|
||||
where: { curatorId: context.curator.id, specialId },
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: You are not allowed to edit this special' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const specialSong = await prisma.specialSong.update({
|
||||
where: {
|
||||
specialId_songId: {
|
||||
specialId,
|
||||
songId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
startTime,
|
||||
order,
|
||||
},
|
||||
include: {
|
||||
song: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(specialSong);
|
||||
} catch (e) {
|
||||
console.error('Error updating curator special song:', e);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
app/api/curator/specials/route.ts
Normal file
47
app/api/curator/specials/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireStaffAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { error, context } = await requireStaffAuth(request);
|
||||
if (error || !context) return error!;
|
||||
|
||||
if (context.role !== 'curator') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only curators can access this endpoint' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Specials, die diesem Kurator zugewiesen sind
|
||||
const assignments = await prisma.curatorSpecial.findMany({
|
||||
where: { curatorId: context.curator.id },
|
||||
select: { specialId: true },
|
||||
});
|
||||
|
||||
if (assignments.length === 0) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const specialIds = assignments.map(a => a.specialId);
|
||||
|
||||
const specials = await prisma.special.findMany({
|
||||
where: { id: { in: specialIds } },
|
||||
include: {
|
||||
songs: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
const result = specials.map(special => ({
|
||||
id: special.id,
|
||||
name: special.name,
|
||||
songCount: special.songs.length,
|
||||
}));
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -807,6 +807,25 @@ export default function CuratorPageClient() {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<Link
|
||||
href="/curator/specials"
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.9rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
lineHeight: '1.5',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
✨ {t('curateSpecialsButton')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/curator/help"
|
||||
style={{
|
||||
|
||||
137
app/curator/specials/[id]/page.tsx
Normal file
137
app/curator/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
|
||||
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
|
||||
|
||||
export default function CuratorSpecialEditorPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const urlLocale = pathname?.split('/')[1] as 'de' | 'en' | undefined;
|
||||
const intlLocale = useLocale() as 'de' | 'en';
|
||||
const locale: 'de' | 'en' = urlLocale === 'de' || urlLocale === 'en' ? urlLocale : intlLocale;
|
||||
const t = useTranslations('Curator');
|
||||
|
||||
const specialId = params?.id as string;
|
||||
|
||||
const [special, setSpecial] = useState<CurateSpecial | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSpecial = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/curator/specials/${specialId}`, {
|
||||
headers: getCuratorAuthHeaders(),
|
||||
});
|
||||
if (res.status === 403) {
|
||||
setError(t('specialForbidden'));
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError('Failed to load special');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setSpecial(data);
|
||||
} catch (e) {
|
||||
setError('Failed to load special');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (specialId) {
|
||||
fetchSpecial();
|
||||
}
|
||||
}, [specialId, t]);
|
||||
|
||||
const handleSaveStartTime = async (songId: number, startTime: number) => {
|
||||
const res = await fetch(`/api/curator/specials/${specialId}/songs`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getCuratorAuthHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ songId, startTime }),
|
||||
});
|
||||
if (res.status === 403) {
|
||||
setError(t('specialForbidden'));
|
||||
} else if (!res.ok) {
|
||||
setError('Failed to save changes');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p>{t('loadingData')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => router.push(`/${locale}/curator`)}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: 'none',
|
||||
background: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('backToDashboard')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!special) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p>{t('specialNotFound')}</p>
|
||||
<button
|
||||
onClick={() => router.push(`/${locale}/curator`)}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: 'none',
|
||||
background: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('backToDashboard')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CurateSpecialEditor
|
||||
special={special}
|
||||
locale={locale}
|
||||
onBack={() => router.push(`/${locale}/curator/specials`)}
|
||||
onSaveStartTime={handleSaveStartTime}
|
||||
backLabel={t('backToCuratorSpecials')}
|
||||
headerPrefix={t('curateSpecialHeaderPrefix')}
|
||||
noSongsHint={t('curateSpecialNoSongs')}
|
||||
noSongsSubHint={t('curateSpecialNoSongsSub')}
|
||||
instructionsText={t('curateSpecialInstructions')}
|
||||
savingLabel={t('saving')}
|
||||
saveChangesLabel={t('saveChanges')}
|
||||
savedLabel={t('saved')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
152
app/curator/specials/page.tsx
Normal file
152
app/curator/specials/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Link } from '@/lib/navigation';
|
||||
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
|
||||
|
||||
type LocalizedString = string | { de: string; en: string };
|
||||
|
||||
interface CuratorSpecialSummary {
|
||||
id: number;
|
||||
name: LocalizedString;
|
||||
songCount: number;
|
||||
}
|
||||
|
||||
export default function CuratorSpecialsPage() {
|
||||
const t = useTranslations('Curator');
|
||||
const locale = useLocale();
|
||||
const [specials, setSpecials] = useState<CuratorSpecialSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSpecials = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/curator/specials', {
|
||||
headers: getCuratorAuthHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSpecials(data);
|
||||
} else if (res.status === 403) {
|
||||
setError(t('noSpecialPermissions'));
|
||||
} else {
|
||||
setError('Failed to load specials');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to load specials');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSpecials();
|
||||
}, [t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p>{t('loadingData')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (specials.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p>{t('noSpecialsInScope')}</p>
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<Link
|
||||
href="/curator"
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
textDecoration: 'none',
|
||||
color: '#111827',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{t('backToDashboard')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolveLocalized = (value: LocalizedString, locale: string): string => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
const loc = locale === 'de' || locale === 'en' ? locale : 'en';
|
||||
return value[loc] ?? value.en ?? value.de;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||
{t('curateSpecialsTitle')}
|
||||
</h1>
|
||||
<Link
|
||||
href="/curator"
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
textDecoration: 'none',
|
||||
color: '#111827',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{t('backToDashboard')}
|
||||
</Link>
|
||||
</div>
|
||||
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>
|
||||
{t('curateSpecialsDescription')}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||
{specials.map(special => (
|
||||
<Link
|
||||
key={special.id}
|
||||
href={`/curator/specials/${special.id}`}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: 'white',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: '0.25rem' }}>
|
||||
{resolveLocalized(special.name, String(locale))}
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.875rem', color: '#6b7280' }}>
|
||||
{t('curateSpecialSongCount', { count: special.songCount })}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem', textAlign: 'right', fontSize: '0.875rem', color: '#4f46e5' }}>
|
||||
{t('curateSpecialOpen')}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
212
components/CurateSpecialEditor.tsx
Normal file
212
components/CurateSpecialEditor.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import WaveformEditor from '@/components/WaveformEditor';
|
||||
|
||||
export type LocalizedString = string | { de: string; en: string };
|
||||
|
||||
export interface CurateSpecialSong {
|
||||
id: number;
|
||||
songId: number;
|
||||
startTime: number;
|
||||
order: number | null;
|
||||
song: {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
filename: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CurateSpecial {
|
||||
id: number;
|
||||
name: LocalizedString;
|
||||
subtitle?: LocalizedString | null;
|
||||
maxAttempts: number;
|
||||
unlockSteps: string;
|
||||
songs: CurateSpecialSong[];
|
||||
}
|
||||
|
||||
export interface CurateSpecialEditorProps {
|
||||
special: CurateSpecial;
|
||||
locale: 'de' | 'en';
|
||||
onBack: () => void;
|
||||
onSaveStartTime: (songId: number, startTime: number) => Promise<void>;
|
||||
backLabel?: string;
|
||||
headerPrefix?: string;
|
||||
noSongsHint?: string;
|
||||
noSongsSubHint?: string;
|
||||
instructionsText?: string;
|
||||
savingLabel?: string;
|
||||
saveChangesLabel?: string;
|
||||
savedLabel?: string;
|
||||
}
|
||||
|
||||
const resolveLocalized = (value: LocalizedString | null | undefined, locale: 'de' | 'en'): string | undefined => {
|
||||
if (!value) return undefined;
|
||||
if (typeof value === 'string') return value;
|
||||
return value[locale] ?? value.en ?? value.de;
|
||||
};
|
||||
|
||||
export default function CurateSpecialEditor({
|
||||
special,
|
||||
locale,
|
||||
onBack,
|
||||
onSaveStartTime,
|
||||
backLabel = '← Back',
|
||||
headerPrefix = 'Edit Special:',
|
||||
noSongsHint = 'No songs assigned to this special yet.',
|
||||
noSongsSubHint = 'Go back to the dashboard to add songs to this special.',
|
||||
instructionsText = 'Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.',
|
||||
savingLabel = '💾 Saving...',
|
||||
saveChangesLabel = '💾 Save Changes',
|
||||
savedLabel = '✓ Saved',
|
||||
}: CurateSpecialEditorProps) {
|
||||
const [selectedSongId, setSelectedSongId] = useState<number | null>(
|
||||
special.songs.length > 0 ? special.songs[0].songId : null
|
||||
);
|
||||
const [pendingStartTime, setPendingStartTime] = useState<number | null>(
|
||||
special.songs.length > 0 ? special.songs[0].startTime : null
|
||||
);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const specialName = resolveLocalized(special.name, locale) ?? `Special #${special.id}`;
|
||||
const specialSubtitle = resolveLocalized(special.subtitle ?? null, locale);
|
||||
|
||||
const unlockSteps = JSON.parse(special.unlockSteps);
|
||||
const totalDuration = unlockSteps[unlockSteps.length - 1];
|
||||
|
||||
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId) ?? null;
|
||||
|
||||
const handleStartTimeChange = (newStartTime: number) => {
|
||||
setPendingStartTime(newStartTime);
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedSongId || pendingStartTime === null) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSaveStartTime(selectedSongId, pendingStartTime);
|
||||
setHasUnsavedChanges(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '1rem'
|
||||
}}
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||
{headerPrefix} {specialName}
|
||||
</h1>
|
||||
{specialSubtitle && (
|
||||
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
|
||||
{specialSubtitle}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ color: '#666', marginTop: '0.5rem' }}>
|
||||
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{special.songs.length === 0 ? (
|
||||
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
|
||||
<p>{noSongsHint}</p>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
||||
{noSongsSubHint}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||
Select Song to Curate
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
|
||||
{special.songs.map(ss => (
|
||||
<div
|
||||
key={ss.songId}
|
||||
onClick={() => {
|
||||
setSelectedSongId(ss.songId);
|
||||
setPendingStartTime(ss.startTime);
|
||||
setHasUnsavedChanges(false);
|
||||
}}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
|
||||
color: selectedSongId === ss.songId ? 'white' : 'black',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
|
||||
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
|
||||
Start: {ss.startTime}s
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSpecialSong && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||
Curate: {selectedSpecialSong.song.title}
|
||||
</h2>
|
||||
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
|
||||
{instructionsText}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasUnsavedChanges || saving}
|
||||
style={{
|
||||
padding: '0.5rem 1.5rem',
|
||||
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
|
||||
color: hasUnsavedChanges ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{saving ? savingLabel : hasUnsavedChanges ? saveChangesLabel : savedLabel}
|
||||
</button>
|
||||
</div>
|
||||
<WaveformEditor
|
||||
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
|
||||
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
|
||||
duration={totalDuration}
|
||||
unlockSteps={unlockSteps}
|
||||
onStartTimeChange={handleStartTimeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
17
lib/curatorAuth.ts
Normal file
17
lib/curatorAuth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function getCuratorAuthHeaders() {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
'x-curator-auth': '',
|
||||
'x-curator-username': '',
|
||||
};
|
||||
}
|
||||
|
||||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||||
const username = localStorage.getItem('hoerdle_curator_username') || '';
|
||||
return {
|
||||
'x-curator-auth': authToken || '',
|
||||
'x-curator-username': username,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -274,7 +274,25 @@
|
||||
"noBatchOperations": "Keine Batch-Operationen angegeben",
|
||||
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
|
||||
"batchUpdateError": "Fehler: {error}",
|
||||
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung"
|
||||
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung",
|
||||
"backToDashboard": "Zurück zum Dashboard",
|
||||
"curateSpecialsButton": "Specials kuratieren",
|
||||
"curateSpecialsTitle": "Deine Specials kuratieren",
|
||||
"curateSpecialsDescription": "Hier kannst du die Startzeiten der Songs in deinen zugewiesenen Specials für das Rätsel feinjustieren.",
|
||||
"noSpecialPermissions": "Dir sind keine Specials zugeordnet.",
|
||||
"noSpecialsInScope": "Keine Specials zum Kuratieren vorhanden.",
|
||||
"curateSpecialSongCount": "{count, plural, one {# Song} other {# Songs}} in diesem Special",
|
||||
"curateSpecialOpen": "Öffnen",
|
||||
"specialForbidden": "Du darfst dieses Special nicht bearbeiten.",
|
||||
"specialNotFound": "Special nicht gefunden.",
|
||||
"backToCuratorSpecials": "Zurück zur Special-Übersicht",
|
||||
"curateSpecialHeaderPrefix": "Special kuratieren:",
|
||||
"curateSpecialNoSongs": "Diesem Special sind noch keine Songs zugeordnet.",
|
||||
"curateSpecialNoSongsSub": "Gehe zurück zu deinem Dashboard, um diesem Special Songs zuzuordnen.",
|
||||
"curateSpecialInstructions": "Klicke auf die Waveform, um zu wählen, wo das Rätsel starten soll. Der hervorgehobene Bereich zeigt, was die Spieler hören.",
|
||||
"saving": "💾 Speichere...",
|
||||
"saveChanges": "💾 Änderungen speichern",
|
||||
"saved": "✓ Gespeichert"
|
||||
},
|
||||
"CuratorHelp": {
|
||||
"title": "Kurator-Hilfe & Handbuch",
|
||||
|
||||
@@ -274,7 +274,25 @@
|
||||
"noBatchOperations": "No batch operations specified",
|
||||
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
|
||||
"batchUpdateError": "Error: {error}",
|
||||
"batchUpdateNetworkError": "Network error during batch update"
|
||||
"batchUpdateNetworkError": "Network error during batch update",
|
||||
"backToDashboard": "Back to dashboard",
|
||||
"curateSpecialsButton": "Curate Specials",
|
||||
"curateSpecialsTitle": "Curate your Specials",
|
||||
"curateSpecialsDescription": "Here you can fine-tune the start times of the songs in your assigned specials for the puzzle.",
|
||||
"noSpecialPermissions": "You do not have any specials assigned to you.",
|
||||
"noSpecialsInScope": "No specials available for you to curate.",
|
||||
"curateSpecialSongCount": "{count, plural, one {# song} other {# songs}} in this special",
|
||||
"curateSpecialOpen": "Open",
|
||||
"specialForbidden": "You are not allowed to edit this special.",
|
||||
"specialNotFound": "Special not found.",
|
||||
"backToCuratorSpecials": "Back to specials overview",
|
||||
"curateSpecialHeaderPrefix": "Curate Special:",
|
||||
"curateSpecialNoSongs": "No songs assigned to this special yet.",
|
||||
"curateSpecialNoSongsSub": "Go back to your dashboard to add songs to this special.",
|
||||
"curateSpecialInstructions": "Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.",
|
||||
"saving": "💾 Saving...",
|
||||
"saveChanges": "💾 Save Changes",
|
||||
"saved": "✓ Saved"
|
||||
},
|
||||
"CuratorHelp": {
|
||||
"title": "Curator Help & Manual",
|
||||
|
||||
98
scripts/restore-restic.sh
Normal file
98
scripts/restore-restic.sh
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# Restic restore script for Hördle deployment
|
||||
# Restores files from the Restic repository created by backup-restic.sh
|
||||
#
|
||||
# Usage:
|
||||
# scripts/restore-restic.sh [SNAPSHOT] [TARGET_DIR]
|
||||
#
|
||||
# SNAPSHOT : Optional. Restic snapshot reference (ID, tag, or "latest").
|
||||
# Defaults to "latest".
|
||||
# TARGET_DIR : Optional. Directory to restore into.
|
||||
# Defaults to "./restic-restore-<DATE>-<TIME>".
|
||||
#
|
||||
# Examples:
|
||||
# scripts/restore-restic.sh
|
||||
# → Restore latest snapshot into a new timestamped directory
|
||||
#
|
||||
# scripts/restore-restic.sh latest ./restore-latest
|
||||
# → Restore latest snapshot into ./restore-latest
|
||||
#
|
||||
# scripts/restore-restic.sh d3adb33f ./restore-commit
|
||||
# → Restore specific snapshot ID into ./restore-commit
|
||||
|
||||
set -e
|
||||
|
||||
echo "💾 Restoring from Restic backup..."
|
||||
|
||||
if ! command -v restic >/dev/null 2>&1; then
|
||||
echo "❌ restic nicht im PATH gefunden. Bitte installiere restic oder füge es zum PATH hinzu."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Erforderliche Umgebungsvariablen prüfen
|
||||
if [ -z "$RESTIC_PASSWORD" ]; then
|
||||
echo "❌ RESTIC_PASSWORD ist nicht gesetzt. Abbruch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$RESTIC_AUTH_USER" ] || [ -z "$RESTIC_AUTH_PASSWORD" ]; then
|
||||
echo "❌ RESTIC_AUTH_USER oder RESTIC_AUTH_PASSWORD ist nicht gesetzt. Abbruch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Repository-URL auf Basis des Backup-Skripts
|
||||
RESTIC_REPO="rest:https://${RESTIC_AUTH_USER}:${RESTIC_AUTH_PASSWORD}@restic.elpatron.me/"
|
||||
|
||||
# Passwort für restic exportieren
|
||||
export RESTIC_PASSWORD
|
||||
|
||||
# Snapshot-Referenz und Zielverzeichnis bestimmen
|
||||
SNAPSHOT_REF="${1:-latest}"
|
||||
TIMESTAMP="$(date +%Y-%m-%d_%H-%M-%S)"
|
||||
DEFAULT_TARGET_DIR="./restic-restore-${TIMESTAMP}"
|
||||
TARGET_DIR="${2:-$DEFAULT_TARGET_DIR}"
|
||||
|
||||
echo " Repository : $RESTIC_REPO"
|
||||
echo " Snapshot : $SNAPSHOT_REF"
|
||||
echo " Zielordner : $TARGET_DIR"
|
||||
|
||||
# Prüfen, ob Repository existiert
|
||||
if ! restic -r "$RESTIC_REPO" snapshots >/dev/null 2>&1; then
|
||||
echo "❌ Kein gültiges Restic-Repository gefunden (oder keine Snapshots vorhanden)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Zielverzeichnis vorbereiten
|
||||
if [ -e "$TARGET_DIR" ] && [ ! -d "$TARGET_DIR" ]; then
|
||||
echo "❌ $TARGET_DIR existiert und ist kein Verzeichnis. Abbruch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$TARGET_DIR" ]; then
|
||||
echo " Erstelle Zielverzeichnis $TARGET_DIR ..."
|
||||
mkdir -p "$TARGET_DIR"
|
||||
fi
|
||||
|
||||
echo " Verfügbare Snapshots (gekürzt):"
|
||||
restic -r "$RESTIC_REPO" snapshots --compact || true
|
||||
echo
|
||||
|
||||
echo " Starte Restic-Restore..."
|
||||
|
||||
RESTIC_EXIT_CODE=0
|
||||
|
||||
# Standard-Restore: gesamtes Repo in Zielverzeichnis
|
||||
# (Das spiegelt die beim Backup gesicherten Pfade unterhalb von TARGET_DIR.)
|
||||
restic -r "$RESTIC_REPO" restore "$SNAPSHOT_REF" \
|
||||
--target "$TARGET_DIR" || RESTIC_EXIT_CODE=$?
|
||||
|
||||
if [ $RESTIC_EXIT_CODE -eq 0 ]; then
|
||||
echo "✅ Restic-Restore erfolgreich abgeschlossen."
|
||||
echo " Wiederhergestellte Daten befinden sich in: $TARGET_DIR"
|
||||
exit 0
|
||||
else
|
||||
echo "⚠️ Restic-Restore fehlgeschlagen (Exit-Code: $RESTIC_EXIT_CODE)."
|
||||
exit $RESTIC_EXIT_CODE
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user