Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
616cfec3e7 | ||
|
|
ac12e45393 | ||
|
|
223eb62973 | ||
|
|
dc4bdd36c7 | ||
|
|
136f881252 | ||
|
|
fd11048f2c | ||
|
|
c1b448639e | ||
|
|
97021f016b | ||
|
|
1991cbd93f | ||
|
|
c28c9fe8f0 | ||
|
|
803713dea7 | ||
|
|
0e6eba64d9 | ||
|
|
576b486caf | ||
|
|
d8f69631b5 | ||
|
|
dbcdaf9278 | ||
|
|
2e93d09236 | ||
|
|
a1fe62f132 | ||
|
|
e49c6acc99 | ||
|
|
96cc9db7d6 | ||
|
|
ebc482dc87 | ||
|
|
88dd86c344 | ||
|
|
623e8b9b82 | ||
|
|
286ac2d28a | ||
|
|
c02d3df7ed | ||
|
|
702f47b7e5 | ||
|
|
86f3349f80 | ||
|
|
bdb74fb462 | ||
|
|
66c0071257 | ||
|
|
76f14087fd | ||
|
|
b1ab5bd633 | ||
|
|
51c62e7763 | ||
|
|
de6eadfe62 | ||
|
|
b033c3a1bc | ||
|
|
4b7121271a | ||
|
|
12cc81905e | ||
|
|
b46e9e3882 | ||
|
|
332688d693 | ||
|
|
a725694519 | ||
|
|
cdb9803b40 | ||
|
|
7db4e26b2c | ||
|
|
b204a35628 | ||
|
|
c62f8f91e5 | ||
|
|
6fbb3f4718 | ||
|
|
5136c3add1 | ||
|
|
c250b5fff9 | ||
|
|
4074cdfe00 | ||
|
|
65425ac15c | ||
|
|
7879b63498 | ||
|
|
91ebaa0e44 | ||
|
|
a61caa2d13 | ||
|
|
52a15b7504 | ||
|
|
00160d9602 | ||
|
|
296a227d22 | ||
|
|
50ca51b143 | ||
|
|
afe6e12afc | ||
|
|
91b12ad859 |
1
.cursor/commands/bump.md
Normal file
@@ -0,0 +1 @@
|
||||
teste den build (npm run build), anschließend commit, dann bump zum nächsten patchlevel, git tag und sync
|
||||
1
.gitignore
vendored
@@ -54,3 +54,4 @@ next-env.d.ts
|
||||
docker-compose.yml
|
||||
scripts/scrape-bahn-expert-statements.js
|
||||
docs/bahn-expert-statements.txt
|
||||
/public/logos.zip
|
||||
|
||||
44
README.md
@@ -15,6 +15,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Bearbeitung von Metadaten.
|
||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
||||
- **Kuratoren-Verwaltung:** Erstellen und Verwalten von Kurator-Accounts mit Zuweisung zu Genres und Specials.
|
||||
- **Cover Art:**
|
||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||
@@ -42,7 +43,6 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Live-Vorschau beim Hovern über die Waveform.
|
||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Manuelle Speicherung mit visueller Bestätigung.
|
||||
- **News & Announcements:**
|
||||
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||
@@ -51,6 +51,28 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||
- Verwaltung über das Admin-Dashboard.
|
||||
- **Kurator-System:**
|
||||
- **Kurator-Accounts:** Separate Login-Accounts für Kuratoren (nicht Admins).
|
||||
- **Genre- & Special-Zuweisung:** Kuratoren können einzelnen Genres oder Specials zugewiesen werden.
|
||||
- **Global-Kuratoren:** Optionale globale Kuratoren, die für alle Rätsel zuständig sind.
|
||||
- **Kurator-Dashboard:** Eigene Dashboard-Seite (`/curator` oder `/de/curator`, `/en/curator`) für Kuratoren.
|
||||
- **Song-Verwaltung:** Kuratoren können Songs hochladen, bearbeiten und Genres/Specials zuweisen.
|
||||
- **Curate Specials:** Kuratoren können in einem eigenen Bereich („Curate Specials“) die Startzeiten der Songs in ihren zugewiesenen Specials über den Waveform-Editor einstellen – streng begrenzt auf ihre eigenen Specials.
|
||||
- **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.
|
||||
- **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).
|
||||
- **Kommentar-Verwaltung:** Kuratoren sehen Kommentare in ihrem Dashboard mit Badge für neue/ungelesene Nachrichten.
|
||||
- **Analytics:**
|
||||
- **Plausible Analytics:** Integration mit Plausible Analytics für anonyme Nutzungsstatistiken.
|
||||
- **Automatisches Domain-Tracking:** Unterstützt mehrere Domains mit automatischer Erkennung.
|
||||
- **Privacy-First:** Keine Cookies, kein Cross-Site-Tracking.
|
||||
- 👉 **[Plausible Setup-Dokumentation](docs/PLAUSIBLE_SETUP.md)**
|
||||
|
||||
## Internationalisierung (i18n)
|
||||
|
||||
@@ -139,6 +161,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
||||
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
||||
- `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`: URL zum Plausible Analytics Script (z.B. `https://plausible.example.com/js/script.js`, optional)
|
||||
|
||||
2. **Starten:**
|
||||
```bash
|
||||
@@ -156,14 +179,27 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
- URL: `/de/admin` oder `/en/admin`
|
||||
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
||||
|
||||
5. **Special Curation & Scheduling verwenden:**
|
||||
5. **Kurator-Zugang:**
|
||||
- URL: `/de/curator` oder `/en/curator`
|
||||
- Kurator-Accounts werden vom Admin erstellt und verwaltet.
|
||||
- Kuratoren können Songs hochladen und verwalten, sowie Kommentare von Spielern einsehen.
|
||||
- **Batch-Edit-Funktionalität:**
|
||||
- Mehrere Titel über Checkboxen auswählen
|
||||
- Genre/Special Toggle (hinzufügen/entfernen)
|
||||
- Artist-Änderung für alle ausgewählten Titel
|
||||
- Exclude Global Flag setzen/entfernen (nur für Global-Kuratoren)
|
||||
- Toolbar erscheint automatisch bei Auswahl von Titeln
|
||||
|
||||
6. **Special Curation & Scheduling verwenden:**
|
||||
- Erstelle ein Special im Admin-Dashboard:
|
||||
- Gib Name, Max Attempts und Unlock Steps ein.
|
||||
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
||||
- **Optional:** Trage einen Kurator ein.
|
||||
- Weise Songs dem Special zu (über die Song-Bibliothek).
|
||||
- Klicke auf "Curate" neben dem Special.
|
||||
- Nutze den Waveform-Editor um den perfekten Ausschnitt zu wählen:
|
||||
- Die eigentliche Kuratierung (Auswahl des Ausschnitts) findet im **Kuratoren-Dashboard** statt:
|
||||
- Logge dich als Kurator ein und gehe zu `/de/curator` oder `/en/curator`.
|
||||
- Klicke im Dashboard auf **„Curate Specials“**, um eine Liste deiner zugewiesenen Specials zu sehen.
|
||||
- Öffne ein Special und nutze dort den Waveform-Editor, um den perfekten Ausschnitt zu wählen:
|
||||
- **Klicken:** Positioniert die Selektion
|
||||
- **Hovern:** Zeigt Vorschau der neuen Position
|
||||
- **Zoom:** 🔍+ / 🔍− Buttons für detaillierte Ansicht
|
||||
|
||||
@@ -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={{
|
||||
<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>
|
||||
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
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import SpecialEditorPage from '@/app/admin/specials/[id]/page';
|
||||
|
||||
export default SpecialEditorPage;
|
||||
|
||||
|
||||
8
app/[locale]/curator/help/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import CuratorHelpInner from '../../../curator/help/page';
|
||||
|
||||
export default function CuratorHelpPage() {
|
||||
return <CuratorHelpInner />;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
9
app/[locale]/curator/specials/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import CuratorSpecialsClient from '@/app/curator/specials/CuratorSpecialsClient';
|
||||
|
||||
export default function CuratorSpecialsPage() {
|
||||
return <CuratorSpecialsClient />;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,3 +80,30 @@ export async function submitRating(songId: number, rating: number, genre?: strin
|
||||
return { success: false, error: 'Failed to submit rating' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendCommentNotification(puzzleId: number, message: string, originalMessage?: string, genre?: string | null) {
|
||||
try {
|
||||
const title = `New Curator Comment (Puzzle #${puzzleId})`;
|
||||
let body = message;
|
||||
|
||||
if (originalMessage && originalMessage !== message) {
|
||||
body = `Original: ${originalMessage}\n\nRewritten: ${message}`;
|
||||
}
|
||||
|
||||
if (genre) {
|
||||
body = `[${genre}] ${body}`;
|
||||
}
|
||||
|
||||
await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
message: body,
|
||||
priority: 5,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending comment notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
<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>
|
||||
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,60 +1,29 @@
|
||||
'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(() => {
|
||||
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);
|
||||
@@ -63,40 +32,20 @@ export default function SpecialEditorPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTimeChange = (newStartTime: number) => {
|
||||
setPendingStartTime(newStartTime);
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
fetchSpecial();
|
||||
}, [specialId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!special || !selectedSongId || pendingStartTime === null) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
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: selectedSongId, startTime: pendingStartTime })
|
||||
body: JSON.stringify({ songId, startTime }),
|
||||
});
|
||||
|
||||
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}
|
||||
<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."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
try {
|
||||
const { puzzleId, genreId, message, playerIdentifier } = await request.json();
|
||||
const { puzzleId, genreId, message, playerIdentifier, originalMessage } = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!puzzleId || !message || !playerIdentifier) {
|
||||
@@ -28,9 +28,9 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (trimmedMessage.length > 2000) {
|
||||
if (trimmedMessage.length > 300) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message too long. Maximum 2000 characters allowed.' },
|
||||
{ error: 'Message too long. Maximum 300 characters allowed.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -170,6 +170,19 @@ export async function POST(request: NextRequest) {
|
||||
return comment;
|
||||
});
|
||||
|
||||
// Send Gotify notification (fire and forget)
|
||||
const { sendCommentNotification } = await import('@/app/actions');
|
||||
// originalMessage is already available from the initial request.json() call
|
||||
|
||||
// Determine genre name for notification
|
||||
let genreName: string | null = null;
|
||||
if (finalGenreId) {
|
||||
const genreObj = await prisma.genre.findUnique({ where: { id: finalGenreId } });
|
||||
if (genreObj) genreName = genreObj.name as string;
|
||||
}
|
||||
|
||||
sendCommentNotification(Number(puzzleId), trimmedMessage, originalMessage, genreName || null);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
commentId: result.id
|
||||
|
||||
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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
107
app/api/rewrite-message/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
||||
const OPENROUTER_MODEL = 'anthropic/claude-3.5-haiku';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { message } = await request.json();
|
||||
|
||||
if (!message || typeof message !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message is required and must be a string' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
console.error('OPENROUTER_API_KEY is not configured');
|
||||
// Fallback: return original message if API key is missing
|
||||
return NextResponse.json({ rewrittenMessage: message });
|
||||
}
|
||||
|
||||
const prompt = `You are a content moderation assistant. Analyze the following message and determine if it is truly inappropriate, unfriendly, sexist, or offensive.
|
||||
|
||||
Rules:
|
||||
- ONLY rewrite the message if it is genuinely unfriendly, sexist, inappropriate, or offensive
|
||||
- If the message is polite, constructive, or even just neutral/critical feedback, return it UNCHANGED
|
||||
- If the message needs rewriting, rewrite it to express the COMPLETE OPPOSITE meaning - make it positive, respectful, and appreciative
|
||||
- Maintain the original language (German or English)
|
||||
- Return ONLY the message text (either unchanged original or rewritten version), nothing else
|
||||
|
||||
Message: "${message}"`;
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://hoerdle.elpatron.me',
|
||||
'X-Title': 'Hördle Message Rewriter'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: OPENROUTER_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 500
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('OpenRouter API error:', await response.text());
|
||||
// Fallback: return original message
|
||||
return NextResponse.json({ rewrittenMessage: message });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
let rewrittenMessage = data.choices?.[0]?.message?.content?.trim() || message;
|
||||
|
||||
// Remove any explanatory comments in parentheses that the AI might add
|
||||
// e.g., "(This message is a friendly, positive comment expressing appreciation. No rewriting is necessary.)"
|
||||
rewrittenMessage = rewrittenMessage.replace(/\s*\([^)]*\)\s*/g, '').trim();
|
||||
|
||||
// Remove surrounding quotes if present (AI sometimes adds quotes)
|
||||
// Handle both single and double quotes, and multiple layers of quotes
|
||||
rewrittenMessage = rewrittenMessage.replace(/^["']+|["']+$/g, '').trim();
|
||||
|
||||
// Normalize both messages for comparison (remove extra whitespace, normalize quotes, case-insensitive)
|
||||
const normalizeForComparison = (text: string): string => {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/["']/g, '') // Remove all quotes for comparison
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.toLowerCase()
|
||||
.replace(/[.,!?;:]\s*$/, ''); // Remove trailing punctuation for comparison
|
||||
};
|
||||
|
||||
const originalTrimmed = message.trim();
|
||||
const rewrittenTrimmed = rewrittenMessage.trim();
|
||||
const originalNormalized = normalizeForComparison(originalTrimmed);
|
||||
const rewrittenNormalized = normalizeForComparison(rewrittenTrimmed);
|
||||
|
||||
// Check if message was actually changed (content-wise, not just formatting)
|
||||
// Only consider it changed if the normalized content is different
|
||||
const wasChanged = originalNormalized !== rewrittenNormalized;
|
||||
|
||||
if (wasChanged) {
|
||||
rewrittenMessage = rewrittenTrimmed + " (autocorrected by Polite-Bot)";
|
||||
} else {
|
||||
// Return original message if not changed (without suffix)
|
||||
rewrittenMessage = originalTrimmed;
|
||||
}
|
||||
|
||||
return NextResponse.json({ rewrittenMessage });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error rewriting message:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
266
app/api/songs/batch/route.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function getCuratorAssignments(curatorId: number) {
|
||||
const [genres, specials] = await Promise.all([
|
||||
prisma.curatorGenre.findMany({
|
||||
where: { curatorId },
|
||||
select: { genreId: true },
|
||||
}),
|
||||
prisma.curatorSpecial.findMany({
|
||||
where: { curatorId },
|
||||
select: { specialId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
genreIds: new Set(genres.map(g => g.genreId)),
|
||||
specialIds: new Set(specials.map(s => s.specialId)),
|
||||
};
|
||||
}
|
||||
|
||||
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||
if (context.role === 'admin') return true;
|
||||
|
||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.id;
|
||||
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||
return undefined;
|
||||
})
|
||||
.filter((id: any): id is number => typeof id === 'number');
|
||||
|
||||
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasGenre = songGenreIds.some((id: number) => assignments.genreIds.has(id));
|
||||
const hasSpecial = songSpecialIds.some((id: number) => assignments.specialIds.has(id));
|
||||
|
||||
return hasGenre || hasSpecial;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) return error!;
|
||||
|
||||
try {
|
||||
const { songIds, genreToggleIds, specialToggleIds, artist, excludeFromGlobal } = await request.json();
|
||||
|
||||
if (!songIds || !Array.isArray(songIds) || songIds.length === 0) {
|
||||
return NextResponse.json({ error: 'Missing or invalid songIds array' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate that at least one operation is requested
|
||||
const hasGenreToggle = genreToggleIds && Array.isArray(genreToggleIds) && genreToggleIds.length > 0;
|
||||
const hasSpecialToggle = specialToggleIds && Array.isArray(specialToggleIds) && specialToggleIds.length > 0;
|
||||
const hasArtistChange = artist !== undefined && artist !== null && artist.trim() !== '';
|
||||
const hasExcludeGlobalChange = excludeFromGlobal !== undefined;
|
||||
|
||||
if (!hasGenreToggle && !hasSpecialToggle && !hasArtistChange && !hasExcludeGlobalChange) {
|
||||
return NextResponse.json({ error: 'No update operations specified' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate artist if provided
|
||||
if (hasArtistChange && artist.trim() === '') {
|
||||
return NextResponse.json({ error: 'Artist cannot be empty' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate excludeFromGlobal permission
|
||||
if (hasExcludeGlobalChange && context.role === 'curator' && !context.curator.isGlobalCurator) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
let assignments: { genreIds: Set<number>; specialIds: Set<number> } | null = null;
|
||||
if (context.role === 'curator') {
|
||||
const curatorAssignments = await getCuratorAssignments(context.curator.id);
|
||||
assignments = curatorAssignments;
|
||||
|
||||
// Validate genre/special toggles are within curator's assignments
|
||||
if (hasGenreToggle) {
|
||||
const invalidGenre = genreToggleIds.some((id: number) => !curatorAssignments.genreIds.has(id));
|
||||
if (invalidGenre) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Curators may only toggle their own genres' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSpecialToggle) {
|
||||
const invalidSpecial = specialToggleIds.some((id: number) => !curatorAssignments.specialIds.has(id));
|
||||
if (invalidSpecial) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Curators may only toggle their own specials' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load all songs with relations for permission checks
|
||||
const songs = await prisma.song.findMany({
|
||||
where: { id: { in: songIds.map((id: any) => Number(id)) } },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (songs.length === 0) {
|
||||
return NextResponse.json({ error: 'No songs found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Filter songs that can be edited
|
||||
const editableSongs = context.role === 'admin'
|
||||
? songs
|
||||
: songs.filter(song => curatorCanEditSong(context, song, assignments!));
|
||||
|
||||
if (editableSongs.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No songs can be edited with current permissions' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const results = {
|
||||
total: songIds.length,
|
||||
processed: editableSongs.length,
|
||||
skipped: songs.length - editableSongs.length,
|
||||
success: 0,
|
||||
errors: [] as Array<{ songId: number; error: string }>,
|
||||
};
|
||||
|
||||
// Process each song in a transaction
|
||||
for (const song of editableSongs) {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const updateData: any = {};
|
||||
|
||||
// Handle artist change
|
||||
if (hasArtistChange) {
|
||||
updateData.artist = artist.trim();
|
||||
}
|
||||
|
||||
// Handle excludeFromGlobal change
|
||||
if (hasExcludeGlobalChange) {
|
||||
updateData.excludeFromGlobal = excludeFromGlobal;
|
||||
}
|
||||
|
||||
// Handle genre toggles
|
||||
if (hasGenreToggle) {
|
||||
const currentGenreIds = song.genres.map(g => g.id);
|
||||
const genreIdsToToggle = genreToggleIds as number[];
|
||||
|
||||
// Determine which genres to add/remove
|
||||
const genresToAdd = genreIdsToToggle.filter(id => !currentGenreIds.includes(id));
|
||||
const genresToRemove = genreIdsToToggle.filter(id => currentGenreIds.includes(id));
|
||||
|
||||
// For curators, preserve genres they can't manage
|
||||
let finalGenreIds: number[];
|
||||
if (context.role === 'curator') {
|
||||
const fixedGenreIds = currentGenreIds.filter(gid => !assignments!.genreIds.has(gid));
|
||||
const managedGenreIds = currentGenreIds
|
||||
.filter(gid => assignments!.genreIds.has(gid) && !genresToRemove.includes(gid))
|
||||
.concat(genresToAdd);
|
||||
finalGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
|
||||
} else {
|
||||
const newGenreIds = currentGenreIds
|
||||
.filter(id => !genresToRemove.includes(id))
|
||||
.concat(genresToAdd);
|
||||
finalGenreIds = Array.from(new Set(newGenreIds));
|
||||
}
|
||||
|
||||
updateData.genres = {
|
||||
set: finalGenreIds.map(gId => ({ id: gId }))
|
||||
};
|
||||
}
|
||||
|
||||
// Update song basic data
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await tx.song.update({
|
||||
where: { id: song.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle special toggles
|
||||
if (hasSpecialToggle) {
|
||||
const currentSpecials = await tx.specialSong.findMany({
|
||||
where: { songId: song.id }
|
||||
});
|
||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||
const specialIdsToToggle = specialToggleIds as number[];
|
||||
|
||||
// Determine which specials to add/remove
|
||||
const specialsToAdd = specialIdsToToggle.filter(id => !currentSpecialIds.includes(id));
|
||||
const specialsToRemove = specialIdsToToggle.filter(id => currentSpecialIds.includes(id));
|
||||
|
||||
// For curators, preserve specials they can't manage
|
||||
let finalSpecialIds: number[];
|
||||
if (context.role === 'curator') {
|
||||
const fixedSpecialIds = currentSpecialIds.filter(sid => !assignments!.specialIds.has(sid));
|
||||
const managedSpecialIds = currentSpecialIds
|
||||
.filter(sid => assignments!.specialIds.has(sid) && !specialsToRemove.includes(sid))
|
||||
.concat(specialsToAdd);
|
||||
finalSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
|
||||
} else {
|
||||
const newSpecialIds = currentSpecialIds
|
||||
.filter(id => !specialsToRemove.includes(id))
|
||||
.concat(specialsToAdd);
|
||||
finalSpecialIds = Array.from(new Set(newSpecialIds));
|
||||
}
|
||||
|
||||
// Delete removed specials
|
||||
const toDelete = currentSpecialIds.filter(sid => !finalSpecialIds.includes(sid));
|
||||
if (toDelete.length > 0) {
|
||||
await tx.specialSong.deleteMany({
|
||||
where: {
|
||||
songId: song.id,
|
||||
specialId: { in: toDelete }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add new specials
|
||||
const toAdd = finalSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||
if (toAdd.length > 0) {
|
||||
await tx.specialSong.createMany({
|
||||
data: toAdd.map(specialId => ({
|
||||
songId: song.id,
|
||||
specialId,
|
||||
startTime: 0
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
results.success++;
|
||||
} catch (error: any) {
|
||||
results.errors.push({
|
||||
songId: song.id,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error in batch update:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +35,15 @@ function curatorCanEditSong(context: StaffContext, song: any, assignments: { gen
|
||||
// - `SpecialSong` (mit `specialId`)
|
||||
// - `SpecialSong` (mit Relation `special.id`)
|
||||
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
|
||||
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
|
||||
// Daher zuerst specialId oder special.id prüfen.
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
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?.special?.id != null) return s.special.id;
|
||||
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||
return undefined;
|
||||
})
|
||||
.filter((id: any): id is number => typeof id === 'number');
|
||||
@@ -59,11 +63,15 @@ function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { g
|
||||
if (context.role === 'admin') return true;
|
||||
|
||||
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 || [])
|
||||
.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?.special?.id != null) return s.special.id;
|
||||
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||
return undefined;
|
||||
})
|
||||
.filter((id: any): id is number => typeof id === 'number');
|
||||
@@ -206,6 +214,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// Validate and extract metadata from file
|
||||
let metadata;
|
||||
let releaseYear: number | null = null;
|
||||
let validationInfo = {
|
||||
isValid: true,
|
||||
hasCover: false,
|
||||
@@ -236,6 +245,11 @@ export async function POST(request: Request) {
|
||||
artist = metadata.common.albumartist;
|
||||
}
|
||||
|
||||
// Try to extract release year from tags (preferred over external APIs)
|
||||
if (typeof metadata.common.year === 'number') {
|
||||
releaseYear = metadata.common.year;
|
||||
}
|
||||
|
||||
// Validation info
|
||||
validationInfo.hasCover = !!metadata.common.picture?.[0];
|
||||
validationInfo.format = metadata.format.container || 'unknown';
|
||||
@@ -330,18 +344,20 @@ export async function POST(request: Request) {
|
||||
console.error('Failed to extract cover image:', e);
|
||||
}
|
||||
|
||||
// Fetch release year from iTunes
|
||||
let releaseYear = null;
|
||||
// Fetch release year from iTunes only if not already present from tags
|
||||
if (releaseYear == null) {
|
||||
try {
|
||||
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
||||
releaseYear = await getReleaseYearFromItunes(artist, title);
|
||||
const fetchedYear = await getReleaseYearFromItunes(artist, title);
|
||||
|
||||
if (releaseYear) {
|
||||
if (fetchedYear) {
|
||||
releaseYear = fetchedYear;
|
||||
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch release year:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const song = await prisma.song.create({
|
||||
data: {
|
||||
@@ -382,7 +398,11 @@ export async function PUT(request: Request) {
|
||||
where: { id: Number(id) },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Link } from '@/lib/navigation';
|
||||
import HelpTooltip from '@/components/HelpTooltip';
|
||||
|
||||
interface Genre {
|
||||
id: number;
|
||||
@@ -83,6 +85,8 @@ function getCuratorUploadHeaders() {
|
||||
export default function CuratorPageClient() {
|
||||
const t = useTranslations('Curator');
|
||||
const tNav = useTranslations('Navigation');
|
||||
const tHelp = useTranslations('CuratorHelp');
|
||||
const locale = useLocale();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
@@ -103,6 +107,7 @@ export default function CuratorPageClient() {
|
||||
// Upload state (analog zum Admin-Upload, aber vereinfacht)
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
||||
const [uploadSpecialIds, setUploadSpecialIds] = useState<number[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number }>({
|
||||
@@ -129,6 +134,14 @@ export default function CuratorPageClient() {
|
||||
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(() => {
|
||||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||||
const storedUsername = localStorage.getItem('hoerdle_curator_username');
|
||||
@@ -384,6 +397,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) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
@@ -432,6 +535,12 @@ export default function CuratorPageClient() {
|
||||
);
|
||||
};
|
||||
|
||||
const toggleUploadSpecial = (specialId: number) => {
|
||||
setUploadSpecialIds(prev =>
|
||||
prev.includes(specialId) ? prev.filter(id => id !== specialId) : [...prev, specialId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(e.target.files || []);
|
||||
if (selected.length === 0) return;
|
||||
@@ -534,8 +643,8 @@ export default function CuratorPageClient() {
|
||||
setFiles([]);
|
||||
setIsUploading(false);
|
||||
|
||||
// Genres den erfolgreich hochgeladenen Songs zuweisen
|
||||
if (uploadGenreIds.length > 0) {
|
||||
// Genres/Specials den erfolgreich hochgeladenen Songs zuweisen
|
||||
if (uploadGenreIds.length > 0 || uploadSpecialIds.length > 0) {
|
||||
const successfulUploads = results.filter(r => r.success && r.song);
|
||||
for (const result of successfulUploads) {
|
||||
try {
|
||||
@@ -547,12 +656,13 @@ export default function CuratorPageClient() {
|
||||
title: result.song.title,
|
||||
artist: result.song.artist,
|
||||
releaseYear: result.song.releaseYear,
|
||||
genreIds: uploadGenreIds,
|
||||
genreIds: uploadGenreIds.length > 0 ? uploadGenreIds : undefined,
|
||||
specialIds: uploadSpecialIds.length > 0 ? uploadSpecialIds : undefined,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// Fehler beim Genre-Assigning werden nur geloggt, nicht abgebrochen
|
||||
console.error(`Failed to assign genres to ${result.song.title}`);
|
||||
// Fehler beim Zuweisen werden nur geloggt, nicht abgebrochen
|
||||
console.error(`Failed to assign genres/specials to ${result.song.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -689,7 +799,14 @@ export default function CuratorPageClient() {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>Kuratoren-Dashboard</h1>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipDashboardShort')}
|
||||
longText={tHelp('tooltipDashboardLong')}
|
||||
position="bottom"
|
||||
/>
|
||||
</div>
|
||||
{curatorInfo && (
|
||||
<p style={{ color: '#4b5563', fontSize: '0.9rem' }}>
|
||||
{t('loggedInAs', { username: curatorInfo.username })}
|
||||
@@ -697,6 +814,45 @@ export default function CuratorPageClient() {
|
||||
</p>
|
||||
)}
|
||||
</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={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#3b82f6',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
ℹ {tHelp('helpButton')}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
@@ -707,10 +863,17 @@ export default function CuratorPageClient() {
|
||||
border: 'none',
|
||||
borderRadius: '0.375rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
lineHeight: '1.5',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('logout')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && <p>{t('loadingData')}</p>}
|
||||
@@ -727,9 +890,16 @@ export default function CuratorPageClient() {
|
||||
<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' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', marginBottom: 0 }}>
|
||||
{t('commentsTitle')} ({comments.length})
|
||||
</h2>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipCommentsShort')}
|
||||
longText={tHelp('tooltipCommentsLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
{hasUnread && (
|
||||
<span style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
@@ -880,7 +1050,14 @@ export default function CuratorPageClient() {
|
||||
})()}
|
||||
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>{t('uploadSectionTitle')}</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', margin: 0 }}>{t('uploadSectionTitle')}</h2>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipUploadShort')}
|
||||
longText={tHelp('tooltipUploadLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
|
||||
{t('uploadSectionDescription')}
|
||||
</p>
|
||||
@@ -980,7 +1157,16 @@ export default function CuratorPageClient() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignGenresLabel')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('assignGenresLabel')}</div>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipGenreAssignmentShort')}
|
||||
longText={tHelp('tooltipGenreAssignmentLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{genres
|
||||
.filter(g => curatorInfo?.genreIds?.includes(g.id))
|
||||
@@ -1014,6 +1200,47 @@ export default function CuratorPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem', marginTop: '0.5rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('assignSpecialsLabel')}</div>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipSpecialAssignmentShort')}
|
||||
longText={tHelp('tooltipSpecialAssignmentLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<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: uploadSpecialIds.includes(special.id) ? '#fef3c7' : '#f3f4f6',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uploadSpecialIds.includes(special.id)}
|
||||
onChange={() => toggleUploadSpecial(special.id)}
|
||||
/>
|
||||
{typeof special.name === 'string'
|
||||
? special.name
|
||||
: special.name?.de ?? special.name?.en}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUploading || files.length === 0}
|
||||
@@ -1056,15 +1283,23 @@ export default function CuratorPageClient() {
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', margin: 0 }}>
|
||||
{t('tracklistTitle', { count: filteredSongs.length })}
|
||||
</h2>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipTracklistShort')}
|
||||
longText={tHelp('tooltipTracklistLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
|
||||
{t('tracklistDescription')}
|
||||
</p>
|
||||
|
||||
{/* Suche & Filter */}
|
||||
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div style={{ flex: '1', minWidth: '200px', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
@@ -1081,6 +1316,13 @@ export default function CuratorPageClient() {
|
||||
border: '1px solid #d1d5db',
|
||||
}}
|
||||
/>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipSearchShort')}
|
||||
longText={tHelp('tooltipSearchLong')}
|
||||
position="top"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<select
|
||||
value={selectedFilter}
|
||||
onChange={e => {
|
||||
@@ -1120,6 +1362,12 @@ export default function CuratorPageClient() {
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipFilterShort')}
|
||||
longText={tHelp('tooltipFilterLong')}
|
||||
position="top"
|
||||
/>
|
||||
</div>
|
||||
{(searchQuery || selectedFilter) && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1146,6 +1394,225 @@ export default function CuratorPageClient() {
|
||||
<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' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<strong style={{ fontSize: '1rem' }}>
|
||||
{t('batchEditTitle') || `Batch Edit: ${selectedSongIds.size} ${selectedSongIds.size === 1 ? 'song' : 'songs'} selected`}
|
||||
</strong>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipBatchEditShort')}
|
||||
longText={tHelp('tooltipBatchEditLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 500, fontSize: '0.9rem', margin: 0 }}>
|
||||
{t('batchToggleGenres') || 'Toggle Genres'}
|
||||
</label>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipBatchGenreToggleShort')}
|
||||
longText={tHelp('tooltipBatchGenreToggleLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 500, fontSize: '0.9rem', margin: 0 }}>
|
||||
{t('batchToggleSpecials') || 'Toggle Specials'}
|
||||
</label>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipBatchSpecialToggleShort')}
|
||||
longText={tHelp('tooltipBatchSpecialToggleLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 500, fontSize: '0.9rem', margin: 0 }}>
|
||||
{t('batchChangeArtist') || 'Change Artist'}
|
||||
</label>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipBatchArtistShort')}
|
||||
longText={tHelp('tooltipBatchArtistLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<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' }}>
|
||||
<table
|
||||
style={{
|
||||
@@ -1156,6 +1623,21 @@ export default function CuratorPageClient() {
|
||||
>
|
||||
<thead>
|
||||
<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
|
||||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||||
onClick={() => handleSort('id')}
|
||||
@@ -1214,8 +1696,26 @@ export default function CuratorPageClient() {
|
||||
? `${(song.averageRating || 0).toFixed(1)} (${song.ratingCount})`
|
||||
: '-';
|
||||
|
||||
const isSelected = selectedSongIds.has(song.id);
|
||||
|
||||
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' }}>
|
||||
<button
|
||||
@@ -1316,6 +1816,41 @@ export default function CuratorPageClient() {
|
||||
: genre.name?.de ?? genre.name?.en}
|
||||
</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 style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{song.genres
|
||||
@@ -1337,9 +1872,13 @@ export default function CuratorPageClient() {
|
||||
: g.name?.de ?? g.name?.en}
|
||||
</span>
|
||||
))}
|
||||
{song.specials.map(s => (
|
||||
{song.specials
|
||||
.filter(
|
||||
s => !curatorInfo?.specialIds?.includes(s.id)
|
||||
)
|
||||
.map(s => (
|
||||
<span
|
||||
key={`s-${s.id}`}
|
||||
key={`fixed-s-${s.id}`}
|
||||
style={{
|
||||
padding: '0.1rem 0.4rem',
|
||||
borderRadius: '999px',
|
||||
@@ -1347,6 +1886,7 @@ export default function CuratorPageClient() {
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
★{' '}
|
||||
{typeof s.name === 'string'
|
||||
? s.name
|
||||
: s.name?.de ?? s.name?.en}
|
||||
|
||||
171
app/curator/help/CuratorHelpClient.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Link } from '@/lib/navigation';
|
||||
|
||||
export default function CuratorHelpClient() {
|
||||
const t = useTranslations('CuratorHelp');
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
|
||||
<header style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>{t('title')}</h1>
|
||||
<Link
|
||||
href="/curator"
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{t('backToDashboard')}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{/* Einführung */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('introductionTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('introductionText')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('permissionsTitle')}</h3>
|
||||
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('permission1')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('permission2')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('permission3')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('permission4')}</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#fef3c7', borderRadius: '0.375rem', border: '1px solid #fbbf24' }}>
|
||||
<strong>{t('note')}:</strong> {t('permissionNote')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Song-Upload */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('uploadTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('uploadStepsTitle')}</h3>
|
||||
<ol style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep1')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep2')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep3')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep4')}</li>
|
||||
</ol>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('uploadBestPracticesTitle')}</h3>
|
||||
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice1')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice2')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice3')}</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#dbeafe', borderRadius: '0.375rem', border: '1px solid #3b82f6' }}>
|
||||
<strong>{t('tip')}:</strong> {t('uploadTip')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Song-Bearbeitung */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('editingTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('singleEditTitle')}</h3>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('singleEditText')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('batchEditTitle')}</h3>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('batchEditText')}</p>
|
||||
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature1')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature2')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature3')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature4')}</li>
|
||||
</ul>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('genreSpecialAssignmentTitle')}</h3>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('genreSpecialAssignmentText')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Specials kuratieren */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('curateSpecialsHelpTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('curateSpecialsHelpIntro')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>
|
||||
{t('curateSpecialsHelpStepsTitle')}
|
||||
</h3>
|
||||
<ol style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep1')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep2')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep3')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep4')}</li>
|
||||
</ol>
|
||||
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#fef3c7', borderRadius: '0.375rem', border: '1px solid #fbbf24' }}>
|
||||
<strong>{t('note')}:</strong> {t('curateSpecialsPermissionsNote')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Kommentar-Verwaltung */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('commentsTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('commentsText')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('commentsActionsTitle')}</h3>
|
||||
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}><strong>{t('markAsRead')}:</strong> {t('markAsReadText')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}><strong>{t('archive')}:</strong> {t('archiveText')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Best Practices */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('bestPracticesTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice1')}</li>
|
||||
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice2')}</li>
|
||||
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice3')}</li>
|
||||
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice4')}</li>
|
||||
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice5')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('troubleshootingTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ1')}</h3>
|
||||
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA1')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ2')}</h3>
|
||||
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA2')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ3')}</h3>
|
||||
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA3')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ4')}</h3>
|
||||
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA4')}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
8
app/curator/help/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import CuratorHelpClient from './CuratorHelpClient';
|
||||
|
||||
export default function CuratorHelpPage() {
|
||||
return <CuratorHelpClient />;
|
||||
}
|
||||
|
||||
156
app/curator/specials/CuratorSpecialsClient.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Link } from '@/lib/navigation';
|
||||
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
interface CuratorSpecial {
|
||||
id: number;
|
||||
name: string | { de?: string; en?: string };
|
||||
songCount: number;
|
||||
}
|
||||
|
||||
export default function CuratorSpecialsClient() {
|
||||
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 [specials, setSpecials] = useState<CuratorSpecial[]>([]);
|
||||
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) {
|
||||
if (res.status === 403) {
|
||||
setError(t('specialForbidden'));
|
||||
} else {
|
||||
setError('Failed to load specials');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setSpecials(data);
|
||||
} catch (e) {
|
||||
setError('Failed to load specials');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSpecials();
|
||||
}, [t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
|
||||
<p>{t('loading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
|
||||
<p style={{ color: 'red' }}>{error}</p>
|
||||
<Link
|
||||
href="/curator"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{t('backToDashboard') || 'Back to Dashboard'}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
|
||||
<header style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>
|
||||
{t('curateSpecialsTitle') || 'Curate Specials'}
|
||||
</h1>
|
||||
<Link
|
||||
href="/curator"
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{t('backToDashboard') || 'Back to Dashboard'}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{specials.length === 0 ? (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: '#666' }}>
|
||||
<p>{t('noSpecialsAssigned') || 'No specials assigned to you.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{specials.map((special) => (
|
||||
<Link
|
||||
key={special.id}
|
||||
href={`/curator/specials/${special.id}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '1.5rem',
|
||||
background: '#f9fafb',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f3f4f6';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.5rem', color: '#111827' }}>
|
||||
{getLocalizedValue(special.name, locale)}
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.875rem', color: '#6b7280' }}>
|
||||
{special.songCount} {special.songCount === 1 ? 'song' : 'songs'}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', color: '#10b981' }}>→</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
170
app/curator/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
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';
|
||||
import HelpTooltip from '@/components/HelpTooltip';
|
||||
|
||||
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 tHelp = useTranslations('CuratorHelp');
|
||||
|
||||
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 (
|
||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold' }}>
|
||||
{t('curateSpecialHeaderPrefix')}
|
||||
</h1>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipCurateSpecialEditorShort')}
|
||||
longText={tHelp('tooltipCurateSpecialEditorLong')}
|
||||
position="bottom"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/${locale}/curator/specials`)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{t('backToCuratorSpecials')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
13
app/curator/specials/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
// Root /curator/specials route without locale:
|
||||
// redirect users to the default English locale version.
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function CuratorSpecialsPage() {
|
||||
redirect('/en/curator/specials');
|
||||
}
|
||||
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const t = useTranslations('Game');
|
||||
const locale = useLocale();
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||
const [hasWon, setHasWon] = useState(false);
|
||||
const [hasLost, setHasLost] = useState(false);
|
||||
const [hasWon, setHasWon] = useState(gameState?.isSolved ?? false);
|
||||
const [hasLost, setHasLost] = useState(gameState?.isFailed ?? false);
|
||||
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
|
||||
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
|
||||
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
||||
@@ -65,6 +65,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const [commentSending, setCommentSending] = useState(false);
|
||||
const [commentSent, setCommentSent] = useState(false);
|
||||
const [commentError, setCommentError] = useState<string | null>(null);
|
||||
const [commentCollapsed, setCommentCollapsed] = useState(true);
|
||||
const [rewrittenMessage, setRewrittenMessage] = useState<string | null>(null);
|
||||
const [commentAIConsent, setCommentAIConsent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCountdown = () => {
|
||||
@@ -85,12 +88,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameState && dailyPuzzle) {
|
||||
if (gameState) {
|
||||
setHasWon(gameState.isSolved);
|
||||
setHasLost(gameState.isFailed);
|
||||
|
||||
// Show year modal if won but year not guessed yet and release year is available
|
||||
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle.releaseYear) {
|
||||
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle?.releaseYear) {
|
||||
setShowYearModal(true);
|
||||
}
|
||||
}
|
||||
@@ -315,12 +318,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
};
|
||||
|
||||
const handleCommentSubmit = async () => {
|
||||
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle) {
|
||||
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle || !commentAIConsent) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCommentSending(true);
|
||||
setCommentError(null);
|
||||
setRewrittenMessage(null);
|
||||
|
||||
try {
|
||||
const playerIdentifier = getOrCreatePlayerId();
|
||||
@@ -328,6 +332,33 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
throw new Error('Could not get player identifier');
|
||||
}
|
||||
|
||||
// 1. Rewrite message using AI
|
||||
const rewriteResponse = await fetch('/api/rewrite-message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: commentText.trim() })
|
||||
});
|
||||
|
||||
let finalMessage = commentText.trim();
|
||||
if (rewriteResponse.ok) {
|
||||
const rewriteData = await rewriteResponse.json();
|
||||
if (rewriteData.rewrittenMessage) {
|
||||
finalMessage = rewriteData.rewrittenMessage;
|
||||
// Only show rewritten message if it was actually changed
|
||||
// The API adds "(autocorrected by Polite-Bot)" suffix only if message was changed
|
||||
const wasChanged = finalMessage.includes('(autocorrected by Polite-Bot)');
|
||||
if (wasChanged) {
|
||||
// Remove the suffix for display
|
||||
const displayMessage = finalMessage.replace(/\s*\(autocorrected by Polite-Bot\)\s*/g, '').trim();
|
||||
setRewrittenMessage(displayMessage);
|
||||
} else {
|
||||
// Ensure rewrittenMessage is not set if message wasn't changed
|
||||
setRewrittenMessage(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Send comment
|
||||
// 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
|
||||
|
||||
@@ -339,7 +370,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
body: JSON.stringify({
|
||||
puzzleId: dailyPuzzle.id,
|
||||
genreId: genreId,
|
||||
message: commentText.trim(),
|
||||
message: finalMessage,
|
||||
originalMessage: commentText.trim() !== finalMessage ? commentText.trim() : undefined,
|
||||
playerIdentifier: playerIdentifier
|
||||
})
|
||||
});
|
||||
@@ -392,11 +424,22 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||
|
||||
// Use current domain from window.location to support both hoerdle.de and hördle.de
|
||||
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
||||
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
||||
|
||||
// For users on hördle.de, use Punycode domain (xn--hrdle-jua.de) in share message
|
||||
// to avoid rendering issues with Unicode domains
|
||||
let currentHost = rawHost;
|
||||
if (rawHost === 'hördle.de' || rawHost === 'xn--hrdle-jua.de') {
|
||||
currentHost = 'xn--hrdle-jua.de';
|
||||
}
|
||||
|
||||
// OLD CODE (commented out - may be needed again in the future):
|
||||
// Use current domain from window.location to support both hoerdle.de and hördle.de,
|
||||
// but always share the pretty Unicode-Domain "hördle.de" instead of the Punycode variant.
|
||||
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
||||
const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
|
||||
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
||||
// const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
|
||||
|
||||
let shareUrl = `${protocol}//${currentHost}`;
|
||||
// Add locale prefix if not default (en)
|
||||
if (locale !== 'en') {
|
||||
@@ -602,9 +645,24 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
{/* 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' }}>
|
||||
<div
|
||||
onClick={() => setCommentCollapsed(!commentCollapsed)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
marginBottom: commentCollapsed ? 0 : '1rem'
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', margin: 0 }}>
|
||||
{t('sendComment')}
|
||||
</h3>
|
||||
<span>{commentCollapsed ? '▼' : '▲'}</span>
|
||||
</div>
|
||||
|
||||
{!commentCollapsed && (
|
||||
<>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
|
||||
{t('commentHelp')}
|
||||
</p>
|
||||
@@ -612,7 +670,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder={t('commentPlaceholder')}
|
||||
maxLength={2000}
|
||||
maxLength={300}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '100px',
|
||||
@@ -622,13 +680,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical',
|
||||
marginBottom: '0.5rem'
|
||||
marginBottom: '0.5rem',
|
||||
display: 'block',
|
||||
boxSizing: 'border-box' // Ensure padding and border are included in width
|
||||
}}
|
||||
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
|
||||
{commentText.length}/300
|
||||
</span>
|
||||
{commentError && (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
|
||||
@@ -636,26 +696,52 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--foreground)', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={commentAIConsent}
|
||||
onChange={(e) => setCommentAIConsent(e.target.checked)}
|
||||
disabled={commentSending || commentSent}
|
||||
style={{ marginTop: '0.2rem', cursor: (commentSending || commentSent) ? 'not-allowed' : 'pointer' }}
|
||||
/>
|
||||
<span>{t('commentAIConsent')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCommentSubmit}
|
||||
disabled={!commentText.trim() || commentSending || commentSent}
|
||||
disabled={!commentText.trim() || commentSending || commentSent || !commentAIConsent}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
width: '100%',
|
||||
opacity: (!commentText.trim() || commentSending || commentSent) ? 0.5 : 1,
|
||||
cursor: (!commentText.trim() || commentSending || commentSent) ? 'not-allowed' : 'pointer'
|
||||
opacity: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? 0.5 : 1,
|
||||
cursor: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? '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' }}>
|
||||
{rewrittenMessage ? (
|
||||
<>
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: '0.5rem' }}>
|
||||
{t('commentSent')}
|
||||
</p>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', textAlign: 'center' }}>
|
||||
<p style={{ marginBottom: '0.25rem' }}>{t('commentRewritten')}</p>
|
||||
<p style={{ fontStyle: 'italic' }}>"{rewrittenMessage}"</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: 0 }}>
|
||||
{t('commentThankYou')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
175
components/HelpTooltip.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface HelpTooltipProps {
|
||||
shortText: string; // Text für Hover
|
||||
longText: string; // Text für Click/Modal
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export default function HelpTooltip({ shortText, longText, position = 'top' }: HelpTooltipProps) {
|
||||
const t = useTranslations('CuratorHelp');
|
||||
const [showHover, setShowHover] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
tooltipRef.current &&
|
||||
!tooltipRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowModal(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (showModal) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [showModal]);
|
||||
|
||||
const positionStyles = {
|
||||
top: { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: '0.5rem' },
|
||||
bottom: { top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: '0.5rem' },
|
||||
left: { right: '100%', top: '50%', transform: 'translateY(-50%)', marginRight: '0.5rem' },
|
||||
right: { left: '100%', top: '50%', transform: 'translateY(-50%)', marginLeft: '0.5rem' },
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setShowModal(!showModal)}
|
||||
onMouseEnter={() => setShowHover(true)}
|
||||
onMouseLeave={() => setShowHover(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#6b7280',
|
||||
fontSize: '1rem',
|
||||
padding: '0.25rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
width: '1.5rem',
|
||||
height: '1.5rem',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
aria-label="Help"
|
||||
>
|
||||
ℹ
|
||||
</button>
|
||||
|
||||
{/* Hover Tooltip */}
|
||||
{showHover && !showModal && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
...positionStyles[position],
|
||||
background: '#1f2937',
|
||||
color: 'white',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'normal',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
maxWidth: '250px',
|
||||
}}
|
||||
>
|
||||
{shortText}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
...(position === 'top' && { top: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderTop: '6px solid #1f2937' }),
|
||||
...(position === 'bottom' && { bottom: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderBottom: '6px solid #1f2937' }),
|
||||
...(position === 'left' && { left: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: '6px solid transparent', borderBottom: '6px solid transparent', borderLeft: '6px solid #1f2937' }),
|
||||
...(position === 'right' && { right: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: '6px solid transparent', borderBottom: '6px solid transparent', borderRight: '6px solid #1f2937' }),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal für detaillierte Informationen */}
|
||||
{showModal && (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 9998,
|
||||
}}
|
||||
onClick={() => setShowModal(false)}
|
||||
/>
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)',
|
||||
maxWidth: '500px',
|
||||
width: '90%',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 'bold' }}>{t('modalTitle')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '1.5rem',
|
||||
cursor: 'pointer',
|
||||
color: '#6b7280',
|
||||
padding: '0',
|
||||
lineHeight: '1',
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', lineHeight: '1.6', whiteSpace: 'pre-wrap' }}>
|
||||
{longText}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,7 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
|
||||
const allSongs = await prisma.song.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
puzzles: {
|
||||
where: { genreId: genreId }
|
||||
},
|
||||
puzzles: true, // Load ALL puzzles, not just for this genre (to use total activations)
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,28 +38,24 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate weights
|
||||
const weightedSongs = allSongs.map(song => ({
|
||||
// Find songs with the minimum number of activations (all puzzles, not just for this genre)
|
||||
// Only select from songs with the fewest activations to ensure fair distribution
|
||||
const songsWithActivations = allSongs.map(song => ({
|
||||
song,
|
||||
weight: 1.0 / (song.puzzles.length + 1),
|
||||
activations: song.puzzles.length,
|
||||
}));
|
||||
|
||||
// Calculate total weight
|
||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||
// Find minimum activations
|
||||
const minActivations = Math.min(...songsWithActivations.map(item => item.activations));
|
||||
|
||||
// Pick a random song based on weights using cumulative weights
|
||||
// This ensures proper distribution and handles edge cases
|
||||
let random = Math.random() * totalWeight;
|
||||
let selectedSong = weightedSongs[weightedSongs.length - 1].song; // Fallback to last song
|
||||
// Filter to only songs with minimum activations
|
||||
const songsWithMinActivations = songsWithActivations
|
||||
.filter(item => item.activations === minActivations)
|
||||
.map(item => item.song);
|
||||
|
||||
let cumulativeWeight = 0;
|
||||
for (const item of weightedSongs) {
|
||||
cumulativeWeight += item.weight;
|
||||
if (random <= cumulativeWeight) {
|
||||
selectedSong = item.song;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Randomly select from songs with minimum activations
|
||||
const randomIndex = Math.floor(Math.random() * songsWithMinActivations.length);
|
||||
const selectedSong = songsWithMinActivations[randomIndex];
|
||||
|
||||
// Create the daily puzzle
|
||||
try {
|
||||
@@ -141,7 +135,7 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
|
||||
song: {
|
||||
include: {
|
||||
puzzles: {
|
||||
where: { specialId: special.id }
|
||||
where: { specialId: special.id } // For specials, only count puzzles within this special
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,25 +144,25 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
|
||||
|
||||
if (specialSongs.length === 0) return null;
|
||||
|
||||
// Calculate weights
|
||||
const weightedSongs = specialSongs.map(specialSong => ({
|
||||
// Find songs with the minimum number of activations within this special
|
||||
// Note: For specials, we only count puzzles within the special (not all puzzles),
|
||||
// since specials are curated, separate lists
|
||||
const songsWithActivations = specialSongs.map(specialSong => ({
|
||||
specialSong,
|
||||
weight: 1.0 / (specialSong.song.puzzles.length + 1),
|
||||
activations: specialSong.song.puzzles.length,
|
||||
}));
|
||||
|
||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
let selectedSpecialSong = weightedSongs[weightedSongs.length - 1].specialSong; // Fallback to last song
|
||||
// Find minimum activations
|
||||
const minActivations = Math.min(...songsWithActivations.map(item => item.activations));
|
||||
|
||||
// Pick a random song based on weights using cumulative weights
|
||||
let cumulativeWeight = 0;
|
||||
for (const item of weightedSongs) {
|
||||
cumulativeWeight += item.weight;
|
||||
if (random <= cumulativeWeight) {
|
||||
selectedSpecialSong = item.specialSong;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Filter to only songs with minimum activations
|
||||
const songsWithMinActivations = songsWithActivations
|
||||
.filter(item => item.activations === minActivations)
|
||||
.map(item => item.specialSong);
|
||||
|
||||
// Randomly select from songs with minimum activations
|
||||
const randomIndex = Math.floor(Math.random() * songsWithMinActivations.length);
|
||||
const selectedSpecialSong = songsWithMinActivations[randomIndex];
|
||||
|
||||
try {
|
||||
dailyPuzzle = await prisma.dailyPuzzle.create({
|
||||
|
||||
145
messages/de.json
@@ -58,9 +58,13 @@
|
||||
"skipBonus": "Bonus überspringen",
|
||||
"notQuite": "Nicht ganz!",
|
||||
"sendComment": "Nachricht an Kurator senden",
|
||||
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres...",
|
||||
"sendCommentCollapsed": "Nachricht an Kurator senden",
|
||||
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres... Bitte bleibe freundlich und höflich.",
|
||||
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
|
||||
"commentAIConsent": "Ich bin damit einverstanden, dass diese Nachricht von einer KI verarbeitet wird, um unfreundliche Nachrichten zu filtern.",
|
||||
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
|
||||
"commentThankYou": "Vielen Dank für dein Feedback!",
|
||||
"commentRewritten": "Deine Nachricht wurde automatisch freundlicher formuliert:",
|
||||
"commentError": "Fehler beim Senden der Nachricht",
|
||||
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
|
||||
"sending": "Wird gesendet...",
|
||||
@@ -201,6 +205,7 @@
|
||||
"selectedFilesTitle": "Ausgewählte Dateien:",
|
||||
"uploadProgress": "Upload: {current} / {total}",
|
||||
"assignGenresLabel": "Genres zuordnen",
|
||||
"assignSpecialsLabel": "Specials zuordnen",
|
||||
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
|
||||
"uploadButtonIdle": "Upload starten",
|
||||
"uploadButtonUploading": "Lade hoch...",
|
||||
@@ -252,7 +257,143 @@
|
||||
"archiveComment": "Archivieren",
|
||||
"archiveCommentConfirm": "Möchtest du diesen Kommentar wirklich archivieren?",
|
||||
"archiveCommentError": "Fehler beim Archivieren des Kommentars.",
|
||||
"newComments": "neu"
|
||||
"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",
|
||||
"backToDashboard": "Zurück zum Dashboard",
|
||||
"loading": "Laden...",
|
||||
"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.",
|
||||
"noSpecialsAssigned": "Dir sind keine Specials zugeordnet.",
|
||||
"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",
|
||||
"backToDashboard": "Zurück zum Dashboard",
|
||||
"helpButton": "Hilfe",
|
||||
"modalTitle": "Hilfe",
|
||||
"introductionTitle": "Einführung",
|
||||
"introductionText": "Als Kurator bist du verantwortlich für die Verwaltung von Songs in deinen zugewiesenen Genres und Specials. Dieses Dashboard ermöglicht es dir, Musik für das Hördle-Spiel hochzuladen, zu bearbeiten und zu organisieren.",
|
||||
"permissionsTitle": "Deine Berechtigungen",
|
||||
"permission1": "MP3-Dateien hochladen und deinen Genres zuordnen",
|
||||
"permission2": "Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind",
|
||||
"permission3": "Songs löschen, die ausschließlich deinen Genres/Specials zugeordnet sind",
|
||||
"permission4": "Kommentare von Spielern zu deinen Rätseln einsehen und verwalten",
|
||||
"note": "Hinweis",
|
||||
"permissionNote": "Du kannst nur Songs bearbeiten oder löschen, die deinen Genres/Specials zugeordnet sind. Songs, die anderen Kuratoren zugeordnet sind, kannst du nicht ändern.",
|
||||
"uploadTitle": "Songs hochladen",
|
||||
"uploadStepsTitle": "Schritt-für-Schritt-Anleitung",
|
||||
"uploadStep1": "MP3-Dateien in den Upload-Bereich ziehen oder klicken, um Dateien auszuwählen",
|
||||
"uploadStep2": "Ein oder mehrere Genres und – falls passend – Specials auswählen, um sie den hochgeladenen Songs zuzuordnen",
|
||||
"uploadStep3": "Auf 'Upload starten' klicken, um den Upload-Prozess zu beginnen",
|
||||
"uploadStep4": "Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus den Dateien",
|
||||
"uploadBestPracticesTitle": "Best Practices",
|
||||
"uploadBestPractice1": "Stelle sicher, dass MP3-Dateien korrekte ID3-Tags (Titel, Artist) für die automatische Metadaten-Extraktion haben",
|
||||
"uploadBestPractice2": "Passende Genres (und Specials) vor dem Upload auswählen, um spätere manuelle Zuordnung zu vermeiden",
|
||||
"uploadBestPractice3": "Vor dem Upload auf Duplikate prüfen - das System warnt dich, wenn ein Song bereits existiert",
|
||||
"tip": "Tipp",
|
||||
"uploadTip": "Alle von Kuratoren hochgeladenen Songs werden automatisch von der globalen Playlist ausgeschlossen. Nur Admins können diese Einstellung ändern.",
|
||||
"editingTitle": "Songs bearbeiten",
|
||||
"singleEditTitle": "Einzelne Song-Bearbeitung",
|
||||
"singleEditText": "Klicke auf den Bearbeiten-Button (✏️) neben einem Song, um Titel, Artist, Erscheinungsjahr, Genres, Specials oder das Exclude-Global-Flag zu ändern. Nur Songs, die du bearbeiten kannst, haben einen aktiven Bearbeiten-Button.",
|
||||
"batchEditTitle": "Batch-Bearbeitung",
|
||||
"batchEditText": "Wähle mehrere Songs über die Checkboxen aus, dann nutze die Batch-Bearbeitungs-Toolbar, um Änderungen auf alle ausgewählten Songs gleichzeitig anzuwenden:",
|
||||
"batchEditFeature1": "Genre Toggle: Genres zu allen ausgewählten Songs hinzufügen oder entfernen",
|
||||
"batchEditFeature2": "Special Toggle: Specials zu allen ausgewählten Songs hinzufügen oder entfernen",
|
||||
"batchEditFeature3": "Artist ändern: Gleichen Artist-Namen für alle ausgewählten Songs setzen",
|
||||
"batchEditFeature4": "Exclude Global Flag: Exclude-Global-Flag setzen oder entfernen (nur Global-Kuratoren)",
|
||||
"genreSpecialAssignmentTitle": "Genre & Special Zuordnung",
|
||||
"genreSpecialAssignmentText": "Du kannst Songs nur Genres und Specials zuordnen, für die du verantwortlich bist. Songs können mehrere Genres und Specials haben. Beim Bearbeiten kannst du Zuordnungen umschalten - wenn ein Genre/Special bereits zugeordnet ist, wird es entfernt; wenn nicht, wird es hinzugefügt.",
|
||||
"commentsTitle": "Kommentare verwalten",
|
||||
"commentsText": "Spieler können dir Feedback zu Rätseln in deinen Genres oder Specials senden. Kommentare erscheinen in deinem Dashboard mit einem Badge für ungelesene Nachrichten.",
|
||||
"commentsActionsTitle": "Verfügbare Aktionen",
|
||||
"markAsRead": "Als gelesen markieren",
|
||||
"markAsReadText": "Klicke auf einen Kommentar, um ihn als gelesen zu markieren. Gelesene Kommentare werden mit einem grauen Rahmen angezeigt.",
|
||||
"archive": "Archivieren",
|
||||
"archiveText": "Archiviere Kommentare, die du nicht mehr benötigst. Archivierte Kommentare werden aus deiner Ansicht entfernt.",
|
||||
"bestPracticesTitle": "Best Practices für Kuratoren",
|
||||
"bestPractice1": "Metadaten korrekt halten: Stelle sicher, dass Song-Titel und Artist-Namen korrekt und konsistent sind",
|
||||
"bestPractice2": "Passende Genres verwenden: Ordne Songs den relevantesten Genres zu, um Spielern zu helfen, Musik zu entdecken",
|
||||
"bestPractice3": "Auf Kommentare reagieren: Prüfe Kommentare regelmäßig und berücksichtige Spieler-Feedback beim Kuratieren",
|
||||
"bestPractice4": "Qualität wahren: Überprüfe hochgeladene Songs auf Audio-Qualität und Metadaten-Genauigkeit",
|
||||
"bestPractice5": "Batch-Bearbeitung effizient nutzen: Wenn du ähnliche Änderungen an mehreren Songs vornehmen musst, nutze die Batch-Bearbeitung, um Zeit zu sparen",
|
||||
"troubleshootingTitle": "Troubleshooting",
|
||||
"troubleshootingQ1": "Warum kann ich einen Song nicht bearbeiten?",
|
||||
"troubleshootingA1": "Du kannst nur Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Wenn ein Song keine Genres/Specials zugeordnet hat, kannst du ihn bearbeiten. Wenn er nur anderen Kuratoren zugeordnet ist, kannst du ihn nicht bearbeiten.",
|
||||
"troubleshootingQ2": "Warum kann ich einen Song nicht löschen?",
|
||||
"troubleshootingA2": "Du kannst nur Songs löschen, die ausschließlich deinen Genres/Specials zugeordnet sind. Wenn ein Song Genres oder Specials hat, die anderen Kuratoren zugeordnet sind, kannst du ihn nicht löschen.",
|
||||
"troubleshootingQ3": "Warum kann ich ein Genre/Special nicht zuordnen?",
|
||||
"troubleshootingA3": "Du kannst Songs nur Genres und Specials zuordnen, für die du verantwortlich bist. Wende dich an den Admin, wenn du Zugriff auf zusätzliche Genres oder Specials benötigst.",
|
||||
"troubleshootingQ4": "Warum ist die Exclude-Global-Checkbox deaktiviert?",
|
||||
"troubleshootingA4": "Nur Global-Kuratoren können das Exclude-Global-Flag ändern. Wenn du diese Berechtigung benötigst, wende dich an den Admin.",
|
||||
"curateSpecialsHelpTitle": "Specials kuratieren",
|
||||
"curateSpecialsHelpIntro": "Im Bereich „Curate Specials\" kannst du den exakten Audio-Ausschnitt festlegen, den Spieler in deinen Specials hören. Es werden nur Specials angezeigt, die dir zugewiesen sind.",
|
||||
"curateSpecialsHelpStepsTitle": "So kuratierst du Specials",
|
||||
"curateSpecialsHelpStep1": "Öffne das Kuratoren-Dashboard und klicke auf „Curate Specials\", um alle dir zugewiesenen Specials zu sehen.",
|
||||
"curateSpecialsHelpStep2": "Wähle ein Special aus der Liste, um den Waveform-Editor für dieses Special zu öffnen.",
|
||||
"curateSpecialsHelpStep3": "Klicke auf die Waveform, um die Startzeit zu wählen. Der hervorgehobene Bereich zeigt genau das, was Spieler hören werden.",
|
||||
"curateSpecialsHelpStep4": "Nutze Zoom, Pan und Segment-Playback, um den Ausschnitt fein abzustimmen. Klicke auf „Änderungen speichern\", um die neue Startzeit zu übernehmen.",
|
||||
"curateSpecialsPermissionsNote": "Du kannst nur Specials kuratieren, die dir zugewiesen sind. Wenn du versuchst, ein fremdes Special zu öffnen oder zu speichern, blockiert das System die Aktion.",
|
||||
"tooltipDashboardShort": "Übersicht über dein Kuratoren-Dashboard",
|
||||
"tooltipDashboardLong": "Dies ist dein Haupt-Dashboard, wo du Songs hochladen, deine Track-Liste verwalten und Kommentare von Spielern einsehen kannst. Nutze den Hilfe-Button (❓), um auf das vollständige Handbuch zuzugreifen.",
|
||||
"tooltipUploadShort": "MP3-Dateien zu deinen Genres hochladen",
|
||||
"tooltipUploadLong": "Ziehe MP3-Dateien per Drag & Drop oder klicke, um sie auszuwählen. Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus ID3-Tags. Wähle Genres vor dem Upload aus, um Songs automatisch zuzuordnen. Alle Kuratoren-Uploads sind standardmäßig von der globalen Playlist ausgeschlossen.",
|
||||
"tooltipGenreAssignmentShort": "Genres zu hochgeladenen Songs zuordnen",
|
||||
"tooltipGenreAssignmentLong": "Wähle ein oder mehrere Genres vor dem Upload aus. Die ausgewählten Genres werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Genres zuordnen, für die du verantwortlich bist. Wenn du keine Genres auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.",
|
||||
"tooltipSpecialAssignmentShort": "Specials zu hochgeladenen Songs zuordnen",
|
||||
"tooltipSpecialAssignmentLong": "Wähle ein oder mehrere Specials vor dem Upload aus. Die ausgewählten Specials werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Specials zuordnen, für die du verantwortlich bist. Wenn du keine Specials auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.",
|
||||
"tooltipTracklistShort": "Deine Songs verwalten",
|
||||
"tooltipTracklistLong": "Diese Tabelle zeigt alle Songs in deinen Genres und Specials. Du kannst suchen, filtern, sortieren und Songs bearbeiten. Nutze die Checkboxen, um mehrere Songs für die Batch-Bearbeitung auszuwählen. Nur Songs, die du bearbeiten kannst, haben eine aktive Checkbox.",
|
||||
"tooltipSearchShort": "Nach Titel oder Artist suchen",
|
||||
"tooltipSearchLong": "Tippe in das Suchfeld, um Songs nach Titel oder Artist-Namen zu filtern. Die Suche ist nicht case-sensitive und findet Teiltexte. Leere das Suchfeld, um alle Songs wieder anzuzeigen.",
|
||||
"tooltipFilterShort": "Nach Genre, Special oder Global-Flag filtern",
|
||||
"tooltipFilterLong": "Nutze das Filter-Dropdown, um nur Songs aus einem bestimmten Genre, Special oder Songs, die von der globalen Playlist ausgeschlossen sind, anzuzeigen. Kombiniere mit der Suche für präzisere Filterung.",
|
||||
"tooltipBatchEditShort": "Mehrere Songs gleichzeitig bearbeiten",
|
||||
"tooltipBatchEditLong": "Wähle mehrere Songs über Checkboxen aus, dann nutze die Batch-Bearbeitungs-Toolbar, um Änderungen auf alle ausgewählten Songs gleichzeitig anzuwenden. Du kannst Genres/Specials umschalten, Artist-Namen ändern oder das Exclude-Global-Flag ändern (nur Global-Kuratoren).",
|
||||
"tooltipBatchGenreToggleShort": "Genres hinzufügen oder entfernen",
|
||||
"tooltipBatchGenreToggleLong": "Wähle Genres zum Umschalten aus. Wenn ein ausgewählter Song das Genre bereits hat, wird es entfernt. Wenn nicht, wird es hinzugefügt. Dies ermöglicht es dir, schnell Genres zu mehreren Songs hinzuzufügen oder zu entfernen.",
|
||||
"tooltipBatchSpecialToggleShort": "Specials hinzufügen oder entfernen",
|
||||
"tooltipBatchSpecialToggleLong": "Wähle Specials zum Umschalten aus. Wenn ein ausgewählter Song das Special bereits hat, wird es entfernt. Wenn nicht, wird es hinzugefügt. Du kannst nur Specials umschalten, für die du verantwortlich bist.",
|
||||
"tooltipBatchArtistShort": "Artist für alle ausgewählten Songs ändern",
|
||||
"tooltipBatchArtistLong": "Gib einen neuen Artist-Namen ein, um ihn für alle ausgewählten Songs zu setzen. Dies ist nützlich, um Artist-Namen zu korrigieren oder Namenskonventionen über mehrere Songs hinweg zu standardisieren.",
|
||||
"tooltipCommentsShort": "Spieler-Feedback und Kommentare",
|
||||
"tooltipCommentsLong": "Spieler können dir Nachrichten zu Rätseln in deinen Genres oder Specials senden. Ungelesene Kommentare sind mit einem blauen Rahmen und Badge hervorgehoben. Klicke auf einen Kommentar, um ihn als gelesen zu markieren, oder archiviere ihn, wenn du ihn nicht mehr benötigst.",
|
||||
"tooltipCurateSpecialsShort": "Startzeiten für deine Specials kuratieren",
|
||||
"tooltipCurateSpecialsLong": "In dieser Ansicht siehst du alle Specials, die dir zugewiesen sind. Öffne ein Special, um den Audio-Ausschnitt zu wählen, den die Spieler hören. Du kannst nur Specials sehen und bearbeiten, für die du zuständig bist.",
|
||||
"tooltipCurateSpecialEditorShort": "Mit dem Waveform-Editor den Puzzle-Ausschnitt wählen",
|
||||
"tooltipCurateSpecialEditorLong": "Klicke auf die Waveform, um zu bestimmen, wo das Rätsel startet. Nutze Zoom und Pan für Feineinstellungen und spiele einzelne Segmente ab, um sie zu testen. Beim Speichern wird nur dieser kuratierte Ausschnitt für Spieler in diesem Special verwendet."
|
||||
},
|
||||
"About": {
|
||||
"title": "Über Hördle & Impressum",
|
||||
|
||||
147
messages/en.json
@@ -58,9 +58,13 @@
|
||||
"skipBonus": "Skip Bonus",
|
||||
"notQuite": "Not quite!",
|
||||
"sendComment": "Send message to curator",
|
||||
"commentPlaceholder": "Write a message to the curators of this genre...",
|
||||
"commentHelp": "Share your thoughts about the puzzle with the curators. Your message will be displayed to them.",
|
||||
"sendCommentCollapsed": "Send message to curator",
|
||||
"commentPlaceholder": "Write a message to the curators of this genre... Please remain friendly and polite.",
|
||||
"commentHelp": "Share your thoughts on the puzzle with the curators. Your message will be shown to them.",
|
||||
"commentAIConsent": "I agree that this message will be processed by an AI to filter unfriendly messages.",
|
||||
"commentSent": "✓ Message sent! Thank you for your feedback.",
|
||||
"commentThankYou": "Thank you for your feedback!",
|
||||
"commentRewritten": "Your message was automatically rephrased to be more friendly:",
|
||||
"commentError": "Error sending message",
|
||||
"commentRateLimited": "You have already sent a message for this puzzle.",
|
||||
"sending": "Sending...",
|
||||
@@ -201,6 +205,7 @@
|
||||
"selectedFilesTitle": "Selected files:",
|
||||
"uploadProgress": "Upload: {current} / {total}",
|
||||
"assignGenresLabel": "Assign genres",
|
||||
"assignSpecialsLabel": "Assign specials",
|
||||
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
|
||||
"uploadButtonIdle": "Start upload",
|
||||
"uploadButtonUploading": "Uploading...",
|
||||
@@ -252,7 +257,143 @@
|
||||
"archiveComment": "Archive",
|
||||
"archiveCommentConfirm": "Do you really want to archive this comment?",
|
||||
"archiveCommentError": "Error archiving comment.",
|
||||
"newComments": "new"
|
||||
"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",
|
||||
"backToDashboard": "Back to dashboard",
|
||||
"loading": "Loading...",
|
||||
"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.",
|
||||
"noSpecialsAssigned": "No specials assigned to you.",
|
||||
"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",
|
||||
"backToDashboard": "Back to Dashboard",
|
||||
"helpButton": "Help",
|
||||
"modalTitle": "Help",
|
||||
"introductionTitle": "Introduction",
|
||||
"introductionText": "As a curator, you are responsible for managing songs within your assigned genres and specials. This dashboard allows you to upload, edit, and organize music for the Hördle game.",
|
||||
"permissionsTitle": "Your Permissions",
|
||||
"permission1": "Upload MP3 files and assign them to your genres",
|
||||
"permission2": "Edit songs that are assigned to at least one of your genres or specials",
|
||||
"permission3": "Delete songs that are exclusively assigned to your genres/specials",
|
||||
"permission4": "View and manage comments from players about your puzzles",
|
||||
"note": "Note",
|
||||
"permissionNote": "You can only edit or delete songs that are assigned to your genres/specials. Songs assigned to other curators' genres cannot be modified by you.",
|
||||
"uploadTitle": "Uploading Songs",
|
||||
"uploadStepsTitle": "Step-by-Step Guide",
|
||||
"uploadStep1": "Drag MP3 files into the upload area or click to select files",
|
||||
"uploadStep2": "Select one or more genres and, if applicable, specials to assign to the uploaded songs",
|
||||
"uploadStep3": "Click 'Start upload' to begin the upload process",
|
||||
"uploadStep4": "The system will automatically extract metadata (title, artist, release year) from the files",
|
||||
"uploadBestPracticesTitle": "Best Practices",
|
||||
"uploadBestPractice1": "Ensure MP3 files have correct ID3 tags (title, artist) for automatic metadata extraction",
|
||||
"uploadBestPractice2": "Select appropriate genres (and specials) before uploading to avoid manual assignment later",
|
||||
"uploadBestPractice3": "Check for duplicates before uploading - the system will warn you if a song already exists",
|
||||
"tip": "Tip",
|
||||
"uploadTip": "All songs uploaded by curators are automatically excluded from the global playlist. Only admins can change this setting.",
|
||||
"editingTitle": "Editing Songs",
|
||||
"singleEditTitle": "Single Song Editing",
|
||||
"singleEditText": "Click the edit button (✏️) next to a song to modify its title, artist, release year, genres, specials, or exclude-from-global flag. Only songs you can edit will have an active edit button.",
|
||||
"batchEditTitle": "Batch Editing",
|
||||
"batchEditText": "Select multiple songs using the checkboxes, then use the batch edit toolbar to apply changes to all selected songs at once:",
|
||||
"batchEditFeature1": "Genre Toggle: Add or remove genres from all selected songs",
|
||||
"batchEditFeature2": "Special Toggle: Add or remove specials from all selected songs",
|
||||
"batchEditFeature3": "Artist Change: Set the same artist name for all selected songs",
|
||||
"batchEditFeature4": "Exclude Global Flag: Set or remove the exclude-from-global flag (Global Curators only)",
|
||||
"genreSpecialAssignmentTitle": "Genre & Special Assignment",
|
||||
"genreSpecialAssignmentText": "You can only assign songs to genres and specials that you are responsible for. Songs can have multiple genres and specials. When editing, you can toggle assignments - if a genre/special is already assigned, it will be removed; if not, it will be added.",
|
||||
"commentsTitle": "Managing Comments",
|
||||
"commentsText": "Players can send you feedback about puzzles in your genres or specials. Comments appear in your dashboard with a badge showing unread messages.",
|
||||
"commentsActionsTitle": "Available Actions",
|
||||
"markAsRead": "Mark as Read",
|
||||
"markAsReadText": "Click on a comment to mark it as read. Read comments are displayed with a gray border.",
|
||||
"archive": "Archive",
|
||||
"archiveText": "Archive comments you no longer need. Archived comments are removed from your view.",
|
||||
"bestPracticesTitle": "Best Practices for Curators",
|
||||
"bestPractice1": "Keep metadata accurate: Ensure song titles and artist names are correct and consistent",
|
||||
"bestPractice2": "Use appropriate genres: Assign songs to the most relevant genres to help players discover music",
|
||||
"bestPractice3": "Respond to comments: Check comments regularly and consider player feedback when curating",
|
||||
"bestPractice4": "Maintain quality: Review uploaded songs for audio quality and metadata accuracy",
|
||||
"bestPractice5": "Use batch editing efficiently: When making similar changes to multiple songs, use batch edit to save time",
|
||||
"troubleshootingTitle": "Troubleshooting",
|
||||
"troubleshootingQ1": "Why can't I edit a song?",
|
||||
"troubleshootingA1": "You can only edit songs that are assigned to at least one of your genres or specials. If a song has no genres/specials assigned, you can edit it. If it's assigned to other curators' genres only, you cannot edit it.",
|
||||
"troubleshootingQ2": "Why can't I delete a song?",
|
||||
"troubleshootingA2": "You can only delete songs that are exclusively assigned to your genres/specials. If a song has any genres or specials assigned to other curators, you cannot delete it.",
|
||||
"troubleshootingQ3": "Why can't I assign a genre/special?",
|
||||
"troubleshootingA3": "You can only assign songs to genres and specials that you are responsible for. Contact the admin if you need access to additional genres or specials.",
|
||||
"troubleshootingQ4": "Why is the exclude-from-global checkbox disabled?",
|
||||
"troubleshootingA4": "Only Global Curators can change the exclude-from-global flag. If you need this permission, contact the admin.",
|
||||
"curateSpecialsHelpTitle": "Curating specials",
|
||||
"curateSpecialsHelpIntro": "In the \"Curate Specials\" area you can choose the exact audio snippet that players will hear in your specials. You only ever see specials that are assigned to you.",
|
||||
"curateSpecialsHelpStepsTitle": "How to curate specials",
|
||||
"curateSpecialsHelpStep1": "Open the curator dashboard and click on \"Curate Specials\" to see all specials assigned to you.",
|
||||
"curateSpecialsHelpStep2": "Select a special from the list to open the waveform editor for that special.",
|
||||
"curateSpecialsHelpStep3": "Click on the waveform to choose the start time. The highlighted region shows exactly what players will hear.",
|
||||
"curateSpecialsHelpStep4": "Use zoom, pan and segment playback to fine-tune the snippet. Click \"Save changes\" to apply the new start time.",
|
||||
"curateSpecialsPermissionsNote": "You can only curate specials that are assigned to you. If you try to open or save a special that is not yours, the system will block the action.",
|
||||
"tooltipDashboardShort": "Overview of your curator dashboard",
|
||||
"tooltipDashboardLong": "This is your main dashboard where you can upload songs, manage your track list, and view comments from players. Use the help button (❓) to access the full manual.",
|
||||
"tooltipUploadShort": "Upload MP3 files to your genres",
|
||||
"tooltipUploadLong": "Drag and drop MP3 files or click to select. The system will automatically extract metadata (title, artist, release year) from ID3 tags. Select genres before uploading to automatically assign songs. All curator uploads are excluded from the global playlist by default.",
|
||||
"tooltipGenreAssignmentShort": "Assign genres to uploaded songs",
|
||||
"tooltipGenreAssignmentLong": "Select one or more genres before uploading. The selected genres will be assigned to all successfully uploaded songs. You can only assign genres that you are responsible for. If you don't select any genres, you can assign them later by editing the songs.",
|
||||
"tooltipSpecialAssignmentShort": "Assign specials to uploaded songs",
|
||||
"tooltipSpecialAssignmentLong": "Select one or more specials before uploading. The selected specials will be assigned to all successfully uploaded songs. You can only assign specials that you are responsible for. If you don't select any specials, you can assign them later by editing the songs.",
|
||||
"tooltipTracklistShort": "Manage your songs",
|
||||
"tooltipTracklistLong": "This table shows all songs in your genres and specials. You can search, filter, sort, and edit songs. Use the checkboxes to select multiple songs for batch editing. Only songs you can edit will have an active checkbox.",
|
||||
"tooltipSearchShort": "Search by title or artist",
|
||||
"tooltipSearchLong": "Type in the search box to filter songs by title or artist name. The search is case-insensitive and matches partial text. Clear the search to show all songs again.",
|
||||
"tooltipFilterShort": "Filter by genre, special, or global flag",
|
||||
"tooltipFilterLong": "Use the filter dropdown to show only songs from a specific genre, special, or songs excluded from the global playlist. Combine with search for more precise filtering.",
|
||||
"tooltipBatchEditShort": "Edit multiple songs at once",
|
||||
"tooltipBatchEditLong": "Select multiple songs using checkboxes, then use the batch edit toolbar to apply changes to all selected songs simultaneously. You can toggle genres/specials, change artist names, or modify the exclude-from-global flag (Global Curators only).",
|
||||
"tooltipBatchGenreToggleShort": "Add or remove genres",
|
||||
"tooltipBatchGenreToggleLong": "Select genres to toggle. If a selected song already has the genre, it will be removed. If it doesn't have the genre, it will be added. This allows you to quickly add or remove genres from multiple songs at once.",
|
||||
"tooltipBatchSpecialToggleShort": "Add or remove specials",
|
||||
"tooltipBatchSpecialToggleLong": "Select specials to toggle. If a selected song already has the special, it will be removed. If it doesn't have the special, it will be added. You can only toggle specials you are responsible for.",
|
||||
"tooltipBatchArtistShort": "Change artist for all selected songs",
|
||||
"tooltipBatchArtistLong": "Enter a new artist name to set it for all selected songs. This is useful for correcting artist names or standardizing naming conventions across multiple songs.",
|
||||
"tooltipCommentsShort": "Player feedback and comments",
|
||||
"tooltipCommentsLong": "Players can send you messages about puzzles in your genres or specials. Unread comments are highlighted with a blue border and badge. Click on a comment to mark it as read, or archive it if you no longer need it.",
|
||||
"tooltipCurateSpecialsShort": "Curate the start times for your specials",
|
||||
"tooltipCurateSpecialsLong": "This view shows all specials that are assigned to you. Open a special to choose the audio snippet that players will hear. You can only see and edit specials for which you are responsible.",
|
||||
"tooltipCurateSpecialEditorShort": "Use the waveform editor to pick the puzzle snippet",
|
||||
"tooltipCurateSpecialEditorLong": "Click on the waveform to choose where the puzzle starts. Use zoom and pan for fine control, and play back individual segments to test them. When you save, only this curated snippet will be used for players in this special."
|
||||
},
|
||||
"About": {
|
||||
"title": "About Hördle & Imprint",
|
||||
|
||||
100
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.6.11",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"driver.js": "^1.4.0",
|
||||
"music-metadata": "^11.10.2",
|
||||
"next": "16.0.3",
|
||||
"next": "^16.0.7",
|
||||
"next-intl": "^4.5.6",
|
||||
"prisma": "^6.19.0",
|
||||
"react": "19.2.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"eslint-config-next": "^16.0.7",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
@@ -1101,15 +1101,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz",
|
||||
"integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
|
||||
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.3.tgz",
|
||||
"integrity": "sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.7.tgz",
|
||||
"integrity": "sha512-hFrTNZcMEG+k7qxVxZJq3F32Kms130FAhG8lvw2zkKBgAcNOJIxlljNiCjGygvBshvaGBdf88q2CqWtnqezDHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1117,9 +1117,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz",
|
||||
"integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
|
||||
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1133,9 +1133,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz",
|
||||
"integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
|
||||
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1149,9 +1149,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz",
|
||||
"integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1165,9 +1165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz",
|
||||
"integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1181,9 +1181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz",
|
||||
"integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1197,9 +1197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz",
|
||||
"integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1213,9 +1213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz",
|
||||
"integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1229,9 +1229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz",
|
||||
"integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3474,13 +3474,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.3.tgz",
|
||||
"integrity": "sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.7.tgz",
|
||||
"integrity": "sha512-WubFGLFHfk2KivkdRGfx6cGSFhaQqhERRfyO8BRx+qiGPGp7WLKcPvYC4mdx1z3VhVRcrfFzczjjTrbJZOpnEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.0.3",
|
||||
"@next/eslint-plugin-next": "16.0.7",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -5945,12 +5945,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
|
||||
"integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
|
||||
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.0.3",
|
||||
"@next/env": "16.0.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -5963,14 +5963,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.0.3",
|
||||
"@next/swc-darwin-x64": "16.0.3",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.3",
|
||||
"@next/swc-linux-arm64-musl": "16.0.3",
|
||||
"@next/swc-linux-x64-gnu": "16.0.3",
|
||||
"@next/swc-linux-x64-musl": "16.0.3",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.3",
|
||||
"@next/swc-win32-x64-msvc": "16.0.3",
|
||||
"@next/swc-darwin-arm64": "16.0.7",
|
||||
"@next/swc-darwin-x64": "16.0.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.7",
|
||||
"@next/swc-linux-arm64-musl": "16.0.7",
|
||||
"@next/swc-linux-x64-gnu": "16.0.7",
|
||||
"@next/swc-linux-x64-musl": "16.0.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.7",
|
||||
"@next/swc-win32-x64-msvc": "16.0.7",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.6.0",
|
||||
"version": "0.1.6.16",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -13,7 +13,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"driver.js": "^1.4.0",
|
||||
"music-metadata": "^11.10.2",
|
||||
"next": "16.0.3",
|
||||
"next": "^16.0.7",
|
||||
"next-intl": "^4.5.6",
|
||||
"prisma": "^6.19.0",
|
||||
"react": "19.2.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"eslint-config-next": "^16.0.7",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/favicon-base.png
Normal file
|
After Width: | Height: | Size: 563 KiB |
BIN
public/logo-1024.png
Normal file
|
After Width: | Height: | Size: 437 KiB |
BIN
public/logo-128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/logo-256.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/logo-512.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
19
public/logo-large.svg
Normal file
|
After Width: | Height: | Size: 507 KiB |
19
public/logo.svg
Normal file
|
After Width: | Height: | Size: 142 KiB |
@@ -4,6 +4,11 @@
|
||||
|
||||
set -e
|
||||
|
||||
if [ -f "$HOME/.restic-env" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$HOME/.restic-env"
|
||||
fi
|
||||
|
||||
echo "💾 Creating Restic backup..."
|
||||
|
||||
if ! command -v restic >/dev/null 2>&1; then
|
||||
|
||||
47
scripts/convert-logos-to-png.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const sharp = require('sharp');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function convertSvgToPng(svgPath, pngPath, size) {
|
||||
try {
|
||||
const svgBuffer = fs.readFileSync(svgPath);
|
||||
|
||||
await sharp(svgBuffer, {
|
||||
density: 300 // High DPI for better quality
|
||||
})
|
||||
.resize(size, size, {
|
||||
fit: 'contain',
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 } // Transparent background
|
||||
})
|
||||
.png()
|
||||
.toFile(pngPath);
|
||||
|
||||
console.log(`✅ Created ${pngPath} (${size}x${size})`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error converting ${svgPath}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const publicDir = path.join(__dirname, '..', 'public');
|
||||
|
||||
// Convert logo.svg to various PNG sizes
|
||||
const logoPath = path.join(publicDir, 'logo.svg');
|
||||
if (fs.existsSync(logoPath)) {
|
||||
await convertSvgToPng(logoPath, path.join(publicDir, 'logo-512.png'), 512);
|
||||
await convertSvgToPng(logoPath, path.join(publicDir, 'logo-256.png'), 256);
|
||||
await convertSvgToPng(logoPath, path.join(publicDir, 'logo-128.png'), 128);
|
||||
}
|
||||
|
||||
// Convert logo-large.svg to larger PNG sizes
|
||||
const logoLargePath = path.join(publicDir, 'logo-large.svg');
|
||||
if (fs.existsSync(logoLargePath)) {
|
||||
await convertSvgToPng(logoLargePath, path.join(publicDir, 'logo-1024.png'), 1024);
|
||||
await convertSvgToPng(logoLargePath, path.join(publicDir, 'logo-512.png'), 512);
|
||||
}
|
||||
|
||||
console.log('\n✨ Logo conversion complete!');
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
138
scripts/create-logo-from-favicon-v2.js
Normal file
@@ -0,0 +1,138 @@
|
||||
const sharp = require('sharp');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function createLogoWithText(faviconPath, outputPath, size) {
|
||||
try {
|
||||
// Load and resize favicon - smaller to leave room for text
|
||||
const faviconSize = Math.floor(size * 0.65);
|
||||
const faviconBuffer = await sharp(faviconPath)
|
||||
.resize(faviconSize, faviconSize, {
|
||||
fit: 'contain',
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 }
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
// Create SVG with favicon and text
|
||||
const textSize = Math.floor(size * 0.12);
|
||||
const iconY = Math.floor(size * 0.10); // Logo higher up
|
||||
const textY = Math.floor(size * 0.92); // Text further down
|
||||
const iconX = Math.floor((size - faviconSize) / 2);
|
||||
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- White background -->
|
||||
<rect width="${size}" height="${size}" fill="#ffffff"/>
|
||||
<image href="data:image/png;base64,${faviconBuffer.toString('base64')}"
|
||||
x="${iconX}"
|
||||
y="${iconY}"
|
||||
width="${faviconSize}"
|
||||
height="${faviconSize}"/>
|
||||
<text x="${size / 2}" y="${textY}"
|
||||
font-family="system-ui, -apple-system, sans-serif"
|
||||
font-size="${textSize}"
|
||||
font-weight="bold"
|
||||
fill="#000000"
|
||||
text-anchor="middle"
|
||||
letter-spacing="-0.5">
|
||||
hördle.de
|
||||
</text>
|
||||
</svg>`;
|
||||
|
||||
// Convert SVG to PNG with white background
|
||||
await sharp(Buffer.from(svg))
|
||||
.resize(size, size)
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
|
||||
console.log(`✅ Created ${path.basename(outputPath)} (${size}x${size})`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error creating ${outputPath}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSVGLogo(faviconPath, outputPath, size) {
|
||||
try {
|
||||
// Load and resize favicon - smaller to leave room for text
|
||||
const faviconSize = Math.floor(size * 0.65);
|
||||
const faviconBuffer = await sharp(faviconPath)
|
||||
.resize(faviconSize, faviconSize, {
|
||||
fit: 'contain',
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 }
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
// Create SVG with favicon and text
|
||||
const textSize = Math.floor(size * 0.12);
|
||||
const iconY = Math.floor(size * 0.10); // Logo higher up
|
||||
const textY = Math.floor(size * 0.92); // Text further down
|
||||
const iconX = Math.floor((size - faviconSize) / 2);
|
||||
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- White background covering entire image -->
|
||||
<rect width="${size}" height="${size}" fill="#ffffff"/>
|
||||
<image href="data:image/png;base64,${faviconBuffer.toString('base64')}"
|
||||
x="${iconX}"
|
||||
y="${iconY}"
|
||||
width="${faviconSize}"
|
||||
height="${faviconSize}"/>
|
||||
<text x="${size / 2}" y="${textY}"
|
||||
font-family="system-ui, -apple-system, sans-serif"
|
||||
font-size="${textSize}"
|
||||
font-weight="bold"
|
||||
fill="#000000"
|
||||
text-anchor="middle"
|
||||
letter-spacing="-0.5">
|
||||
hördle.de
|
||||
</text>
|
||||
</svg>`;
|
||||
|
||||
fs.writeFileSync(outputPath, svg);
|
||||
console.log(`✅ Created ${path.basename(outputPath)} (${size}x${size} SVG)`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error creating ${outputPath}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const faviconPath = path.join(__dirname, '..', 'app', 'favicon.ico');
|
||||
const publicDir = path.join(__dirname, '..', 'public');
|
||||
|
||||
if (!fs.existsSync(faviconPath)) {
|
||||
console.error('❌ Favicon not found at', faviconPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract favicon to PNG first for processing
|
||||
const tempFavicon = path.join(publicDir, 'favicon-temp.png');
|
||||
const faviconBuffer = fs.readFileSync(faviconPath);
|
||||
|
||||
// Convert ICO to PNG
|
||||
await sharp(faviconBuffer)
|
||||
.resize(1024, 1024, { fit: 'contain' })
|
||||
.png()
|
||||
.toFile(tempFavicon);
|
||||
|
||||
console.log('✅ Extracted favicon to PNG\n');
|
||||
|
||||
// Create SVG logo
|
||||
await createSVGLogo(tempFavicon, path.join(publicDir, 'logo.svg'), 512);
|
||||
await createSVGLogo(tempFavicon, path.join(publicDir, 'logo-large.svg'), 1024);
|
||||
|
||||
// Create PNG logos with text in various sizes
|
||||
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-128.png'), 128);
|
||||
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-256.png'), 256);
|
||||
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-512.png'), 512);
|
||||
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-1024.png'), 1024);
|
||||
|
||||
// Clean up temp file
|
||||
if (fs.existsSync(tempFavicon)) {
|
||||
fs.unlinkSync(tempFavicon);
|
||||
}
|
||||
|
||||
console.log('\n✨ Logo creation complete!');
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
120
scripts/create-logo-from-favicon.js
Normal file
@@ -0,0 +1,120 @@
|
||||
const sharp = require('sharp');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function createLogoWithText(faviconPath, outputPath, size, includeText = true) {
|
||||
try {
|
||||
const favicon = await sharp(faviconPath)
|
||||
.resize(size * 0.7, size * 0.7, {
|
||||
fit: 'contain',
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 }
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
// Create SVG with favicon and text
|
||||
const textSize = Math.floor(size * 0.15);
|
||||
const spacing = Math.floor(size * 0.05);
|
||||
const iconSize = Math.floor(size * 0.7);
|
||||
const iconY = Math.floor(includeText ? size * 0.25 : size * 0.5);
|
||||
const textY = Math.floor(size * 0.85);
|
||||
|
||||
// For now, we'll create a composite image
|
||||
// First, create the favicon part
|
||||
const svg = includeText ? `
|
||||
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="faviconPattern" x="0" y="0" width="1" height="1">
|
||||
<image href="data:image/png;base64,${favicon.toString('base64')}"
|
||||
x="${(size - iconSize) / 2}"
|
||||
y="${iconY - iconSize / 2}"
|
||||
width="${iconSize}"
|
||||
height="${iconSize}"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="${size}" height="${size}" fill="url(#faviconPattern)"/>
|
||||
<text x="${size / 2}" y="${textY}"
|
||||
font-family="system-ui, -apple-system, sans-serif"
|
||||
font-size="${textSize}"
|
||||
font-weight="bold"
|
||||
fill="#000000"
|
||||
text-anchor="middle"
|
||||
letter-spacing="-0.5">
|
||||
Hördle
|
||||
</text>
|
||||
</svg>
|
||||
` : `
|
||||
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<image href="data:image/png;base64,${favicon.toString('base64')}"
|
||||
x="${(size - iconSize) / 2}"
|
||||
y="${(size - iconSize) / 2}"
|
||||
width="${iconSize}"
|
||||
height="${iconSize}"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Convert SVG to PNG
|
||||
await sharp(Buffer.from(svg))
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
|
||||
console.log(`✅ Created ${outputPath} (${size}x${size})`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error creating ${outputPath}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const faviconPath = path.join(__dirname, '..', 'app', 'favicon.ico');
|
||||
const publicDir = path.join(__dirname, '..', 'public');
|
||||
|
||||
if (!fs.existsSync(faviconPath)) {
|
||||
console.error('❌ Favicon not found at', faviconPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract favicon to PNG first
|
||||
const tempFavicon = path.join(publicDir, 'favicon-temp.png');
|
||||
const faviconBuffer = fs.readFileSync(faviconPath);
|
||||
|
||||
// Convert ICO to PNG
|
||||
await sharp(faviconBuffer)
|
||||
.resize(1024, 1024, { fit: 'contain' })
|
||||
.png()
|
||||
.toFile(tempFavicon);
|
||||
|
||||
console.log('✅ Extracted favicon to PNG');
|
||||
|
||||
// Create logos with text in various sizes
|
||||
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-128.png'), 128);
|
||||
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-256.png'), 256);
|
||||
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-512.png'), 512);
|
||||
await createLogoWithText(tempFavicon, path.join(publicDir, 'logo-1024.png'), 1024);
|
||||
|
||||
// Create SVG version
|
||||
const faviconPng = await sharp(faviconBuffer)
|
||||
.resize(512, 512, { fit: 'contain' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const svgContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<image id="faviconImg" href="data:image/png;base64,${faviconPng.toString('base64')}" width="358" height="358" x="77" y="77"/>
|
||||
</defs>
|
||||
<use href="#faviconImg"/>
|
||||
<text x="256" y="430" font-family="system-ui, -apple-system, sans-serif" font-size="48" font-weight="bold" fill="#000000" text-anchor="middle" letter-spacing="-0.5">
|
||||
Hördle
|
||||
</text>
|
||||
</svg>`;
|
||||
|
||||
fs.writeFileSync(path.join(publicDir, 'logo.svg'), svgContent);
|
||||
console.log('✅ Created logo.svg');
|
||||
|
||||
// Clean up temp file
|
||||
fs.unlinkSync(tempFavicon);
|
||||
|
||||
console.log('\n✨ Logo creation complete!');
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -88,10 +88,13 @@ docker compose build
|
||||
echo "🔄 Restarting with new image (minimal downtime)..."
|
||||
docker compose up -d
|
||||
|
||||
# Clean up old images
|
||||
# Clean up old images and build cache
|
||||
echo "🧹 Cleaning up old images..."
|
||||
docker image prune -f
|
||||
|
||||
echo "🧹 Cleaning up build cache..."
|
||||
docker builder prune -f
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
echo ""
|
||||
echo "📊 Showing logs (Ctrl+C to exit)..."
|
||||
|
||||
104
scripts/restore-restic.sh
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/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
|
||||
|
||||
# Optional: Restic-Umgebungsvariablen aus ~/.restic-env laden
|
||||
if [ -f "$HOME/.restic-env" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$HOME/.restic-env"
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
|
||||