Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a15b7504 | ||
|
|
00160d9602 | ||
|
|
296a227d22 | ||
|
|
50ca51b143 | ||
|
|
afe6e12afc | ||
|
|
91b12ad859 | ||
|
|
d2548c2870 | ||
|
|
40d6ea75f0 | ||
|
|
0054facbe7 | ||
|
|
95bcf9ed1e | ||
|
|
08fedf9881 | ||
|
|
cd564b5d8c | ||
|
|
863539a5e9 | ||
|
|
2fa8aa0042 | ||
|
|
8ecf430bf5 | ||
|
|
71abb7c322 | ||
|
|
b730c6637a | ||
|
|
6e93529bc3 | ||
|
|
990e1927e9 | ||
|
|
d7fee047c2 | ||
|
|
28d14ff099 | ||
|
|
b1493b44bf | ||
|
|
b8a803b76e | ||
|
|
e2bdf0fc88 | ||
|
|
2cb9af8d2b | ||
|
|
d6ad01b00e | ||
|
|
693817b18c | ||
|
|
41336e3af3 | ||
|
|
d7ec691469 | ||
|
|
5e1700712e | ||
|
|
f691384a34 | ||
|
|
f0d75c591a | ||
|
|
1f34d5813e | ||
|
|
33f8080aa8 | ||
|
|
8a102afc0e | ||
|
|
38148ace8d | ||
|
|
49e98ade3c | ||
|
|
397839cc1f | ||
|
|
3fe805129b | ||
|
|
bf9a49a9ac | ||
|
|
9b89cbf8ed | ||
|
|
7f33e98fb5 | ||
|
|
72f8b99092 | ||
|
|
e60daa511b | ||
|
|
19706abacb | ||
|
|
170e7b5402 | ||
|
|
ade1043c3c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ next-env.d.ts
|
|||||||
.release-years-migrated
|
.release-years-migrated
|
||||||
.covers-migrated
|
.covers-migrated
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
scripts/scrape-bahn-expert-statements.js
|
||||||
|
docs/bahn-expert-statements.txt
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -15,6 +15,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- Bearbeitung von Metadaten.
|
- Bearbeitung von Metadaten.
|
||||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||||
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
||||||
|
- **Kuratoren-Verwaltung:** Erstellen und Verwalten von Kurator-Accounts mit Zuweisung zu Genres und Specials.
|
||||||
- **Cover Art:**
|
- **Cover Art:**
|
||||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||||
@@ -42,7 +43,6 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- Live-Vorschau beim Hovern über die Waveform.
|
- Live-Vorschau beim Hovern über die Waveform.
|
||||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
- Einzelne Segmente zum Testen abspielen.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
|
||||||
- Manuelle Speicherung mit visueller Bestätigung.
|
- Manuelle Speicherung mit visueller Bestätigung.
|
||||||
- **News & Announcements:**
|
- **News & Announcements:**
|
||||||
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||||
@@ -51,6 +51,24 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||||
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||||
- Verwaltung über das Admin-Dashboard.
|
- Verwaltung über das Admin-Dashboard.
|
||||||
|
- **Kurator-System:**
|
||||||
|
- **Kurator-Accounts:** Separate Login-Accounts für Kuratoren (nicht Admins).
|
||||||
|
- **Genre- & Special-Zuweisung:** Kuratoren können einzelnen Genres oder Specials zugewiesen werden.
|
||||||
|
- **Global-Kuratoren:** Optionale globale Kuratoren, die für alle Rätsel zuständig sind.
|
||||||
|
- **Kurator-Dashboard:** Eigene Dashboard-Seite (`/curator` oder `/de/curator`, `/en/curator`) für Kuratoren.
|
||||||
|
- **Song-Verwaltung:** Kuratoren können Songs hochladen, bearbeiten und Genres/Specials zuweisen.
|
||||||
|
- **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren.
|
||||||
|
- **Spieler-Kommentare:**
|
||||||
|
- **Feedback an Kuratoren:** Spieler können nach Abschluss eines Rätsels optional eine Nachricht an die Kuratoren senden.
|
||||||
|
- **Automatische Zuordnung:** Kommentare werden automatisch an relevante Kuratoren verteilt (Genre-Kuratoren, Special-Kuratoren, Global-Kuratoren).
|
||||||
|
- **Rate-Limiting:** Pro Spieler nur ein Kommentar pro Puzzle möglich.
|
||||||
|
- **Kontext-Informationen:** Kommentare enthalten vollständigen Rätsel-Kontext (Hördle #, Genre/Special, Titel/Artist).
|
||||||
|
- **Kommentar-Verwaltung:** Kuratoren sehen Kommentare in ihrem Dashboard mit Badge für neue/ungelesene Nachrichten.
|
||||||
|
- **Analytics:**
|
||||||
|
- **Plausible Analytics:** Integration mit Plausible Analytics für anonyme Nutzungsstatistiken.
|
||||||
|
- **Automatisches Domain-Tracking:** Unterstützt mehrere Domains mit automatischer Erkennung.
|
||||||
|
- **Privacy-First:** Keine Cookies, kein Cross-Site-Tracking.
|
||||||
|
- 👉 **[Plausible Setup-Dokumentation](docs/PLAUSIBLE_SETUP.md)**
|
||||||
|
|
||||||
## Internationalisierung (i18n)
|
## Internationalisierung (i18n)
|
||||||
|
|
||||||
@@ -139,6 +157,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
||||||
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||||
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
||||||
|
- `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`: URL zum Plausible Analytics Script (z.B. `https://plausible.example.com/js/script.js`, optional)
|
||||||
|
|
||||||
2. **Starten:**
|
2. **Starten:**
|
||||||
```bash
|
```bash
|
||||||
@@ -156,7 +175,12 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
- URL: `/de/admin` oder `/en/admin`
|
- URL: `/de/admin` oder `/en/admin`
|
||||||
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
||||||
|
|
||||||
5. **Special Curation & Scheduling verwenden:**
|
5. **Kurator-Zugang:**
|
||||||
|
- URL: `/de/curator` oder `/en/curator`
|
||||||
|
- Kurator-Accounts werden vom Admin erstellt und verwaltet.
|
||||||
|
- Kuratoren können Songs hochladen und verwalten, sowie Kommentare von Spielern einsehen.
|
||||||
|
|
||||||
|
6. **Special Curation & Scheduling verwenden:**
|
||||||
- Erstelle ein Special im Admin-Dashboard:
|
- Erstelle ein Special im Admin-Dashboard:
|
||||||
- Gib Name, Max Attempts und Unlock Steps ein.
|
- Gib Name, Max Attempts und Unlock Steps ein.
|
||||||
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
||||||
|
|||||||
@@ -98,13 +98,27 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "0.75rem",
|
marginBottom: "0.5rem",
|
||||||
fontSize: "0.9rem",
|
fontSize: "0.9rem",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("costsSheetPrivacyNote")}
|
{t("costsSheetPrivacyNote")}
|
||||||
</p>
|
</p>
|
||||||
|
<p style={{ marginBottom: "0.75rem" }}>
|
||||||
|
{t.rich("costsDonationNote", {
|
||||||
|
link: (chunks) => (
|
||||||
|
<a
|
||||||
|
href="https://politicalbeauty.de/ueber-das-ZPS.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
{chunks}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ marginBottom: "2rem" }}>
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ interface PoliticalStatement {
|
|||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Curator {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
isGlobalCurator: boolean;
|
||||||
|
genreIds: number[];
|
||||||
|
specialIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
@@ -159,14 +167,18 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
const [sortField, setSortField] = useState<SortField>('artist');
|
const [sortField, setSortField] = useState<SortField>('artist');
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
|
|
||||||
// Search and pagination state
|
// Search and pagination state (wird nur noch in Resten der alten Song Library verwendet, kann später entfernt werden)
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
|
const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
|
||||||
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
|
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 10;
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
// Audio state
|
// Legacy Song-Library-Helper (Liste selbst ist obsolet; wir halten diese Werte nur, damit altes JSX nicht crasht)
|
||||||
|
const paginatedSongs: Song[] = [];
|
||||||
|
const totalPages = 1;
|
||||||
|
|
||||||
|
// Audio state (für Daily Puzzles)
|
||||||
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
||||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
@@ -184,6 +196,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true);
|
const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Curators state
|
||||||
|
const [curators, setCurators] = useState<Curator[]>([]);
|
||||||
|
const [showCurators, setShowCurators] = useState(false);
|
||||||
|
const [editingCuratorId, setEditingCuratorId] = useState<number | null>(null);
|
||||||
|
const [curatorUsername, setCuratorUsername] = useState('');
|
||||||
|
const [curatorPassword, setCuratorPassword] = useState('');
|
||||||
|
const [curatorIsGlobal, setCuratorIsGlobal] = useState(false);
|
||||||
|
const [curatorGenreIds, setCuratorGenreIds] = useState<number[]>([]);
|
||||||
|
const [curatorSpecialIds, setCuratorSpecialIds] = useState<number[]>([]);
|
||||||
|
|
||||||
// Check for existing auth on mount
|
// Check for existing auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authToken = localStorage.getItem('hoerdle_admin_auth');
|
const authToken = localStorage.getItem('hoerdle_admin_auth');
|
||||||
@@ -194,6 +216,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
fetchDailyPuzzles();
|
fetchDailyPuzzles();
|
||||||
fetchSpecials();
|
fetchSpecials();
|
||||||
fetchNews();
|
fetchNews();
|
||||||
|
fetchCurators();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -210,6 +233,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
fetchDailyPuzzles();
|
fetchDailyPuzzles();
|
||||||
fetchSpecials();
|
fetchSpecials();
|
||||||
fetchNews();
|
fetchNews();
|
||||||
|
fetchCurators();
|
||||||
} else {
|
} else {
|
||||||
alert(t('wrongPassword'));
|
alert(t('wrongPassword'));
|
||||||
}
|
}
|
||||||
@@ -224,6 +248,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
setGenres([]);
|
setGenres([]);
|
||||||
setSpecials([]);
|
setSpecials([]);
|
||||||
setDailyPuzzles([]);
|
setDailyPuzzles([]);
|
||||||
|
setCurators([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to add auth headers to requests
|
// Helper function to add auth headers to requests
|
||||||
@@ -245,6 +270,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchCurators = async () => {
|
||||||
|
const res = await fetch('/api/curators', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setCurators(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchGenres = async () => {
|
const fetchGenres = async () => {
|
||||||
const res = await fetch('/api/genres', {
|
const res = await fetch('/api/genres', {
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
@@ -719,6 +754,94 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetCuratorForm = () => {
|
||||||
|
setEditingCuratorId(null);
|
||||||
|
setCuratorUsername('');
|
||||||
|
setCuratorPassword('');
|
||||||
|
setCuratorIsGlobal(false);
|
||||||
|
setCuratorGenreIds([]);
|
||||||
|
setCuratorSpecialIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditCurator = (curator: Curator) => {
|
||||||
|
setEditingCuratorId(curator.id);
|
||||||
|
setCuratorUsername(curator.username);
|
||||||
|
setCuratorPassword('');
|
||||||
|
setCuratorIsGlobal(curator.isGlobalCurator);
|
||||||
|
setCuratorGenreIds(curator.genreIds || []);
|
||||||
|
setCuratorSpecialIds(curator.specialIds || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCuratorGenre = (genreId: number) => {
|
||||||
|
setCuratorGenreIds(prev =>
|
||||||
|
prev.includes(genreId) ? prev.filter(id => id !== genreId) : [...prev, genreId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCuratorSpecial = (specialId: number) => {
|
||||||
|
setCuratorSpecialIds(prev =>
|
||||||
|
prev.includes(specialId) ? prev.filter(id => id !== specialId) : [...prev, specialId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCurator = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!curatorUsername.trim()) {
|
||||||
|
alert('Bitte einen Benutzernamen eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Anlegen eines neuen Kurators ist ein Passwort Pflicht.
|
||||||
|
if (!editingCuratorId && !curatorPassword.trim()) {
|
||||||
|
alert('Für neue Kuratoren muss ein Passwort gesetzt werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
username: curatorUsername.trim(),
|
||||||
|
isGlobalCurator: curatorIsGlobal,
|
||||||
|
genreIds: curatorGenreIds,
|
||||||
|
specialIds: curatorSpecialIds,
|
||||||
|
};
|
||||||
|
if (curatorPassword.trim()) {
|
||||||
|
payload.password = curatorPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = '/api/curators';
|
||||||
|
const method = editingCuratorId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
if (editingCuratorId) {
|
||||||
|
payload.id = editingCuratorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
resetCuratorForm();
|
||||||
|
fetchCurators();
|
||||||
|
} else {
|
||||||
|
alert('Failed to save curator');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCurator = async (id: number) => {
|
||||||
|
if (!confirm('Kurator wirklich löschen?')) return;
|
||||||
|
const res = await fetch('/api/curators', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchCurators();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete curator');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleBatchUpload = async (e: React.FormEvent) => {
|
const handleBatchUpload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
@@ -1019,15 +1142,6 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
|
||||||
if (sortField === field) {
|
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortDirection('asc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlayPause = (song: Song) => {
|
const handlePlayPause = (song: Song) => {
|
||||||
if (playingSongId === song.id) {
|
if (playingSongId === song.id) {
|
||||||
// Pause current song
|
// Pause current song
|
||||||
@@ -1067,70 +1181,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter and sort songs
|
// Song Library ist in das Kuratoren-Dashboard umgezogen, daher keine Song-Filter/Pagination mehr im Admin nötig.
|
||||||
const filteredSongs = songs.filter(song => {
|
|
||||||
// Text search filter
|
|
||||||
const matchesSearch = song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
song.artist.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
|
|
||||||
// Genre filter
|
|
||||||
// Unified Filter
|
|
||||||
let matchesFilter = true;
|
|
||||||
if (selectedGenreFilter) {
|
|
||||||
if (selectedGenreFilter.startsWith('genre:')) {
|
|
||||||
const genreId = Number(selectedGenreFilter.split(':')[1]);
|
|
||||||
matchesFilter = genreId === -1
|
|
||||||
? song.genres.length === 0
|
|
||||||
: song.genres.some(g => g.id === genreId);
|
|
||||||
} else if (selectedGenreFilter.startsWith('special:')) {
|
|
||||||
const specialId = Number(selectedGenreFilter.split(':')[1]);
|
|
||||||
matchesFilter = song.specials?.some(s => s.id === specialId) || false;
|
|
||||||
} else if (selectedGenreFilter === 'daily') {
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
|
|
||||||
} else if (selectedGenreFilter === 'no-global') {
|
|
||||||
matchesFilter = song.excludeFromGlobal === true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchesSearch && matchesFilter;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
|
||||||
// Handle numeric sorting for ID, Release Year, Activations, and Rating
|
|
||||||
if (sortField === 'id') {
|
|
||||||
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
|
|
||||||
}
|
|
||||||
if (sortField === 'releaseYear') {
|
|
||||||
const yearA = a.releaseYear || 0;
|
|
||||||
const yearB = b.releaseYear || 0;
|
|
||||||
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
|
|
||||||
}
|
|
||||||
if (sortField === 'activations') {
|
|
||||||
return sortDirection === 'asc' ? a.activations - b.activations : b.activations - a.activations;
|
|
||||||
}
|
|
||||||
if (sortField === 'averageRating') {
|
|
||||||
return sortDirection === 'asc' ? a.averageRating - b.averageRating : b.averageRating - a.averageRating;
|
|
||||||
}
|
|
||||||
|
|
||||||
// String sorting for other fields
|
|
||||||
const valA = String(a[sortField]).toLowerCase();
|
|
||||||
const valB = String(b[sortField]).toLowerCase();
|
|
||||||
|
|
||||||
if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
|
|
||||||
if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const totalPages = Math.ceil(sortedSongs.length / itemsPerPage);
|
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
||||||
const paginatedSongs = sortedSongs.slice(startIndex, startIndex + itemsPerPage);
|
|
||||||
|
|
||||||
// Reset to page 1 when search changes
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
@@ -1826,155 +1877,193 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Curator Management */}
|
||||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>{t('uploadSongs')}</h2>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
<form onSubmit={handleBatchUpload}>
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
|
||||||
{/* Drag & Drop Zone */}
|
{t('manageCurators')}
|
||||||
<div
|
</h2>
|
||||||
onDragEnter={handleDragEnter}
|
<button
|
||||||
onDragOver={handleDragOver}
|
onClick={() => setShowCurators(!showCurators)}
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
style={{
|
style={{
|
||||||
border: isDragging ? '2px solid #4f46e5' : '2px dashed #d1d5db',
|
padding: '0.5rem 1rem',
|
||||||
borderRadius: '0.5rem',
|
background: '#f3f4f6',
|
||||||
padding: '2rem',
|
border: '1px solid #d1d5db',
|
||||||
textAlign: 'center',
|
borderRadius: '0.25rem',
|
||||||
background: isDragging ? '#eef2ff' : '#f9fafb',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s'
|
fontSize: '0.875rem'
|
||||||
}}
|
}}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
|
{showCurators ? t('hide') : t('show')}
|
||||||
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
</button>
|
||||||
{files.length > 0 ? `${files.length} file(s) selected` : 'Drag & drop MP3 files here'}
|
</div>
|
||||||
</p>
|
{showCurators && (
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666' }}>
|
<>
|
||||||
or click to browse
|
<form onSubmit={handleSaveCurator} style={{ marginBottom: '1rem' }}>
|
||||||
</p>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
<input
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
ref={fileInputRef}
|
<input
|
||||||
type="file"
|
type="text"
|
||||||
accept="audio/mpeg"
|
value={curatorUsername}
|
||||||
multiple
|
onChange={e => setCuratorUsername(e.target.value)}
|
||||||
onChange={handleFileChange}
|
placeholder={t('curatorUsername')}
|
||||||
style={{ display: 'none' }}
|
className="form-input"
|
||||||
/>
|
style={{ minWidth: '200px', flex: '1 1 200px' }}
|
||||||
</div>
|
required
|
||||||
|
/>
|
||||||
{/* File List */}
|
<input
|
||||||
{files.length > 0 && (
|
type="password"
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
value={curatorPassword}
|
||||||
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>Selected Files:</p>
|
onChange={e => setCuratorPassword(e.target.value)}
|
||||||
<div style={{ maxHeight: '200px', overflowY: 'auto', background: '#f9fafb', padding: '0.5rem', borderRadius: '0.25rem' }}>
|
placeholder={t('curatorPassword')}
|
||||||
{files.map((file, index) => (
|
className="form-input"
|
||||||
<div key={index} style={{ padding: '0.25rem 0', fontSize: '0.875rem' }}>
|
style={{ minWidth: '200px', flex: '1 1 200px' }}
|
||||||
📄 {file.name}
|
/>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={curatorIsGlobal}
|
||||||
|
onChange={e => setCuratorIsGlobal(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('isGlobalCurator')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
|
||||||
|
<div style={{ flex: '1 1 200px' }}>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignedGenres')}</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{genres.map(genre => (
|
||||||
|
<label
|
||||||
|
key={genre.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: curatorGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={curatorGenreIds.includes(genre.id)}
|
||||||
|
onChange={() => toggleCuratorGenre(genre.id)}
|
||||||
|
/>
|
||||||
|
{typeof genre.name === 'string'
|
||||||
|
? genre.name
|
||||||
|
: getLocalizedValue(genre.name, activeTab)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div style={{ flex: '1 1 200px' }}>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignedSpecials')}</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{specials.map(special => (
|
||||||
|
<label
|
||||||
|
key={special.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: curatorSpecialIds.includes(special.id) ? '#fee2e2' : '#f3f4f6',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={curatorSpecialIds.includes(special.id)}
|
||||||
|
onChange={() => toggleCuratorSpecial(special.id)}
|
||||||
|
/>
|
||||||
|
{typeof special.name === 'string'
|
||||||
|
? special.name
|
||||||
|
: getLocalizedValue(special.name, activeTab)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
{editingCuratorId ? t('save') : t('addCurator')}
|
||||||
|
</button>
|
||||||
|
{editingCuratorId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={resetCuratorForm}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upload Progress */}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
{isUploading && (
|
{curators.length === 0 && (
|
||||||
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#eef2ff', borderRadius: '0.5rem' }}>
|
<p style={{ color: '#666', fontSize: '0.875rem' }}>{t('noCurators')}</p>
|
||||||
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
)}
|
||||||
Uploading: {uploadProgress.current} / {uploadProgress.total}
|
{curators.map(curator => (
|
||||||
</p>
|
<div
|
||||||
<div style={{ width: '100%', height: '8px', background: '#d1d5db', borderRadius: '4px', overflow: 'hidden' }}>
|
key={curator.id}
|
||||||
<div style={{
|
|
||||||
width: `${(uploadProgress.current / uploadProgress.total) * 100}%`,
|
|
||||||
height: '100%',
|
|
||||||
background: '#4f46e5',
|
|
||||||
transition: 'width 0.3s'
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>
|
|
||||||
Assign Genres (optional)
|
|
||||||
</label>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
|
||||||
{genres.map(genre => (
|
|
||||||
<label
|
|
||||||
key={genre.id}
|
|
||||||
style={{
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
background: curator.isGlobalCurator ? '#eff6ff' : '#f9fafb',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.25rem',
|
gap: '0.5rem',
|
||||||
padding: '0.25rem 0.5rem',
|
flexWrap: 'wrap'
|
||||||
background: batchUploadGenreIds.includes(genre.id) ? '#dbeafe' : '#f3f4f6',
|
|
||||||
border: batchUploadGenreIds.includes(genre.id) ? '2px solid #3b82f6' : '2px solid transparent',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
transition: 'all 0.2s'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
type="checkbox"
|
<div style={{ fontWeight: 600 }}>{curator.username}</div>
|
||||||
checked={batchUploadGenreIds.includes(genre.id)}
|
<div style={{ fontSize: '0.8rem', color: '#4b5563' }}>
|
||||||
onChange={e => {
|
{curator.isGlobalCurator && <span>Globaler Kurator · </span>}
|
||||||
if (e.target.checked) {
|
<span>
|
||||||
setBatchUploadGenreIds([...batchUploadGenreIds, genre.id]);
|
{t('assignedGenres')}: {curator.genreIds.length}
|
||||||
} else {
|
</span>
|
||||||
setBatchUploadGenreIds(batchUploadGenreIds.filter(id => id !== genre.id));
|
{' · '}
|
||||||
}
|
<span>
|
||||||
}}
|
{t('assignedSpecials')}: {curator.specialIds.length}
|
||||||
style={{ margin: 0 }}
|
</span>
|
||||||
/>
|
</div>
|
||||||
{getLocalizedValue(genre.name, activeTab)}
|
</div>
|
||||||
</label>
|
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{ padding: '0.25rem 0.6rem', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => startEditCurator(curator)}
|
||||||
|
>
|
||||||
|
{t('edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-danger"
|
||||||
|
style={{ padding: '0.25rem 0.6rem', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => handleDeleteCurator(curator.id)}
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
|
</>
|
||||||
Selected genres will be assigned to all uploaded songs.
|
)}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={uploadExcludeFromGlobal}
|
|
||||||
onChange={e => setUploadExcludeFromGlobal(e.target.checked)}
|
|
||||||
style={{ width: '1.25rem', height: '1.25rem' }}
|
|
||||||
/>
|
|
||||||
<span style={{ fontWeight: '500' }}>Exclude from Global Daily Puzzle</span>
|
|
||||||
</label>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', marginLeft: '1.75rem', marginTop: '0.25rem' }}>
|
|
||||||
If checked, these songs will only appear in Genre or Special puzzles.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary"
|
|
||||||
disabled={files.length === 0 || isUploading}
|
|
||||||
style={{ opacity: files.length === 0 || isUploading ? 0.5 : 1 }}
|
|
||||||
>
|
|
||||||
{isUploading ? 'Uploading...' : `Upload ${files.length} Song(s)`}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div style={{
|
|
||||||
marginTop: '1rem',
|
|
||||||
padding: '0.75rem',
|
|
||||||
background: '#d1fae5',
|
|
||||||
color: '#065f46',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Songs wurde in das Kuratoren-Dashboard verlagert */}
|
||||||
|
|
||||||
{/* Today's Daily Puzzles */}
|
{/* Today's Daily Puzzles */}
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
@@ -2049,397 +2138,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-card">
|
{/* Song Library wurde in das Kuratoren-Dashboard verlagert */}
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
|
||||||
Song Library ({songs.length} songs)
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Search and Filter */}
|
|
||||||
<div style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by title or artist..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
style={{ flex: '1', minWidth: '200px' }}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={selectedGenreFilter}
|
|
||||||
onChange={e => setSelectedGenreFilter(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
style={{ minWidth: '150px' }}
|
|
||||||
>
|
|
||||||
<option value="">All Content</option>
|
|
||||||
<option value="daily">📅 Song of the Day</option>
|
|
||||||
<option value="no-global">🚫 No Global</option>
|
|
||||||
<optgroup label="Genres">
|
|
||||||
<option value="genre:-1">No Genre</option>
|
|
||||||
{genres.map(genre => (
|
|
||||||
<option key={genre.id} value={`genre:${genre.id}`}>
|
|
||||||
{getLocalizedValue(genre.name, activeTab)} ({genre._count?.songs || 0})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Specials">
|
|
||||||
{specials.map(special => (
|
|
||||||
<option key={special.id} value={`special:${special.id}`}>
|
|
||||||
★ {getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
{(searchQuery || selectedGenreFilter) && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery('');
|
|
||||||
setSelectedGenreFilter('');
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
background: '#f3f4f6',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.875rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear Filters
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('id')}
|
|
||||||
>
|
|
||||||
ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('title')}
|
|
||||||
>
|
|
||||||
Song {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('releaseYear')}
|
|
||||||
>
|
|
||||||
Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th style={{ padding: '0.75rem' }}>Genres / Specials</th>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('createdAt')}
|
|
||||||
>
|
|
||||||
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('activations')}
|
|
||||||
>
|
|
||||||
Activations {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('averageRating')}
|
|
||||||
>
|
|
||||||
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th style={{ padding: '0.75rem' }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{paginatedSongs.map(song => (
|
|
||||||
<tr key={song.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
|
||||||
<td style={{ padding: '0.75rem' }}>{song.id}</td>
|
|
||||||
|
|
||||||
{editingId === song.id ? (
|
|
||||||
<>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editTitle}
|
|
||||||
onChange={e => setEditTitle(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
style={{ padding: '0.25rem', marginBottom: '0.5rem', width: '100%' }}
|
|
||||||
placeholder="Title"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editArtist}
|
|
||||||
onChange={e => setEditArtist(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
style={{ padding: '0.25rem', width: '100%' }}
|
|
||||||
placeholder="Artist"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editReleaseYear}
|
|
||||||
onChange={e => setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))}
|
|
||||||
className="form-input"
|
|
||||||
style={{ padding: '0.25rem', width: '80px' }}
|
|
||||||
placeholder="Year"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
|
||||||
{genres.map(genre => (
|
|
||||||
<label key={genre.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editGenreIds.includes(genre.id)}
|
|
||||||
onChange={e => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setEditGenreIds([...editGenreIds, genre.id]);
|
|
||||||
} else {
|
|
||||||
setEditGenreIds(editGenreIds.filter(id => id !== genre.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{getLocalizedValue(genre.name, activeTab)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.25rem' }}>
|
|
||||||
{specials.map(special => (
|
|
||||||
<label key={special.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem', color: '#4b5563' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editSpecialIds.includes(special.id)}
|
|
||||||
onChange={e => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setEditSpecialIds([...editSpecialIds, special.id]);
|
|
||||||
} else {
|
|
||||||
setEditSpecialIds(editSpecialIds.filter(id => id !== special.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{getLocalizedValue(special.name, activeTab)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.5rem' }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', cursor: 'pointer', color: '#b91c1c' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editExcludeFromGlobal}
|
|
||||||
onChange={e => setEditExcludeFromGlobal(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Exclude from Global
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
|
||||||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
|
||||||
{song.averageRating > 0 ? (
|
|
||||||
<span title={`${song.ratingCount} ratings`}>
|
|
||||||
{song.averageRating.toFixed(1)} ★ <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#ccc' }}>-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => saveEditing(song.id)}
|
|
||||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
|
||||||
title="Save"
|
|
||||||
>
|
|
||||||
✅
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={cancelEditing}
|
|
||||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
|
||||||
title="Cancel"
|
|
||||||
>
|
|
||||||
❌
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
|
|
||||||
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</div>
|
|
||||||
|
|
||||||
{song.excludeFromGlobal && (
|
|
||||||
<div style={{ marginTop: '0.25rem' }}>
|
|
||||||
<span style={{
|
|
||||||
background: '#fee2e2',
|
|
||||||
color: '#991b1b',
|
|
||||||
padding: '0.1rem 0.4rem',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
border: '1px solid #fecaca',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.25rem'
|
|
||||||
}}>
|
|
||||||
🚫 No Global
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Daily Puzzle Badges */}
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
|
||||||
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
|
|
||||||
if (!p.genreId && !p.specialId) {
|
|
||||||
return (
|
|
||||||
<span key={p.id} style={{ background: '#dbeafe', color: '#1e40af', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #93c5fd' }}>
|
|
||||||
🌍 Global Daily
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (p.genreId) {
|
|
||||||
const genreName = genres.find(g => g.id === p.genreId)?.name;
|
|
||||||
return (
|
|
||||||
<span key={p.id} style={{ background: '#f3f4f6', color: '#374151', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #d1d5db' }}>
|
|
||||||
🏷️ {getLocalizedValue(genreName, activeTab)} Daily
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (p.specialId) {
|
|
||||||
const specialName = specials.find(s => s.id === p.specialId)?.name;
|
|
||||||
return (
|
|
||||||
<span key={p.id} style={{ background: '#fce7f3', color: '#be185d', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #fbcfe8' }}>
|
|
||||||
★ {getLocalizedValue(specialName, activeTab)} Daily
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
|
||||||
{song.releaseYear || '-'}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
|
||||||
{song.genres?.map(g => (
|
|
||||||
<span key={g.id} style={{
|
|
||||||
background: '#e5e7eb',
|
|
||||||
padding: '0.1rem 0.4rem',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
fontSize: '0.7rem'
|
|
||||||
}}>
|
|
||||||
{getLocalizedValue(g.name, activeTab)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
|
||||||
{song.specials?.map(s => (
|
|
||||||
<span key={s.id} style={{
|
|
||||||
background: '#fce7f3',
|
|
||||||
color: '#9d174d',
|
|
||||||
padding: '0.1rem 0.4rem',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
fontSize: '0.7rem'
|
|
||||||
}}>
|
|
||||||
{getLocalizedValue(s.name, activeTab)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
|
||||||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
|
||||||
{song.averageRating > 0 ? (
|
|
||||||
<span title={`${song.ratingCount} ratings`}>
|
|
||||||
{song.averageRating.toFixed(1)} ★ <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#ccc' }}>-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => handlePlayPause(song)}
|
|
||||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
|
||||||
title={playingSongId === song.id ? "Pause" : "Play"}
|
|
||||||
>
|
|
||||||
{playingSongId === song.id ? '⏸️' : '▶️'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => startEditing(song)}
|
|
||||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(song.id, song.title)}
|
|
||||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
|
||||||
title={t('deletePuzzle')}
|
|
||||||
>
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{paginatedSongs.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
|
|
||||||
{searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', alignItems: 'center' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
background: currentPage === 1 ? '#f3f4f6' : '#fff',
|
|
||||||
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span style={{ color: '#666' }}>
|
|
||||||
Page {currentPage} of {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
background: currentPage === totalPages ? '#f3f4f6' : '#fff',
|
|
||||||
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-card" style={{ marginTop: '2rem', border: '1px solid #ef4444' }}>
|
<div className="admin-card" style={{ marginTop: '2rem', border: '1px solid #ef4444' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem', color: '#ef4444' }}>
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem', color: '#ef4444' }}>
|
||||||
|
|||||||
11
app/[locale]/curator/page.tsx
Normal file
11
app/[locale]/curator/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import CuratorPageInner from '../../curator/page';
|
||||||
|
|
||||||
|
export default function CuratorPage() {
|
||||||
|
// Wrapper für die lokalisierte Route /[locale]/curator
|
||||||
|
// Hinweis: Pfad '../../curator/page' zeigt von 'app/[locale]/curator' korrekt auf 'app/curator/page'.
|
||||||
|
return <CuratorPageInner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
194
app/api/curator-comment/route.ts
Normal file
194
app/api/curator-comment/route.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { rateLimit } from '@/lib/rateLimit';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// Rate limiting: 3 requests per minute
|
||||||
|
const rateLimitError = rateLimit(request, { windowMs: 60000, maxRequests: 3 });
|
||||||
|
if (rateLimitError) return rateLimitError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { puzzleId, genreId, message, playerIdentifier } = await request.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!puzzleId || !message || !playerIdentifier) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'puzzleId, message, and playerIdentifier are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message validation: max 2000 characters, no empty message
|
||||||
|
const trimmedMessage = message.trim();
|
||||||
|
if (trimmedMessage.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Message cannot be empty' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (trimmedMessage.length > 2000) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Message too long. Maximum 2000 characters allowed.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerIdentifier validation: Check if it exists in PlayerState
|
||||||
|
const playerState = await prisma.playerState.findFirst({
|
||||||
|
where: {
|
||||||
|
identifier: playerIdentifier
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!playerState) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid player identifier' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puzzle validation: Check if puzzle exists and matches genreId
|
||||||
|
const puzzle = await prisma.dailyPuzzle.findUnique({
|
||||||
|
where: { id: Number(puzzleId) },
|
||||||
|
include: {
|
||||||
|
song: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!puzzle) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Puzzle not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate genreId matches puzzle (if genreId is provided)
|
||||||
|
if (genreId !== null && genreId !== undefined) {
|
||||||
|
if (puzzle.genreId !== Number(genreId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Puzzle does not match the provided genre' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no genreId provided, use puzzle's genreId
|
||||||
|
// For global puzzles, genreId is null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit check: Check if comment already exists for this playerIdentifier + puzzleId
|
||||||
|
const existingComment = await prisma.curatorComment.findUnique({
|
||||||
|
where: {
|
||||||
|
playerIdentifier_puzzleId: {
|
||||||
|
playerIdentifier: playerIdentifier,
|
||||||
|
puzzleId: Number(puzzleId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingComment) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'You have already sent a comment for this puzzle' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine responsible curators
|
||||||
|
const finalGenreId = genreId !== null && genreId !== undefined ? Number(genreId) : puzzle.genreId;
|
||||||
|
const specialId = puzzle.specialId;
|
||||||
|
|
||||||
|
let curatorIds: number[] = [];
|
||||||
|
const allCuratorIds = new Set<number>();
|
||||||
|
|
||||||
|
// Get all global curators (always included)
|
||||||
|
const globalCurators = await prisma.curator.findMany({
|
||||||
|
where: {
|
||||||
|
isGlobalCurator: true
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
globalCurators.forEach(gc => allCuratorIds.add(gc.id));
|
||||||
|
|
||||||
|
// Check for special puzzle first (takes precedence)
|
||||||
|
if (specialId !== null) {
|
||||||
|
// Special puzzle: Get curators for this special + all global curators
|
||||||
|
const specialCurators = await prisma.curatorSpecial.findMany({
|
||||||
|
where: {
|
||||||
|
specialId: specialId
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
curatorId: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
specialCurators.forEach(cs => allCuratorIds.add(cs.curatorId));
|
||||||
|
} else if (finalGenreId !== null) {
|
||||||
|
// Genre puzzle: Get curators for this genre + all global curators
|
||||||
|
const genreCurators = await prisma.curatorGenre.findMany({
|
||||||
|
where: {
|
||||||
|
genreId: finalGenreId
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
curatorId: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
genreCurators.forEach(cg => allCuratorIds.add(cg.curatorId));
|
||||||
|
}
|
||||||
|
// else: Global puzzle - only global curators (already added above)
|
||||||
|
|
||||||
|
curatorIds = Array.from(allCuratorIds);
|
||||||
|
|
||||||
|
if (curatorIds.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No curators found for this puzzle' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create comment and recipients in a transaction
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Create the comment
|
||||||
|
const comment = await tx.curatorComment.create({
|
||||||
|
data: {
|
||||||
|
playerIdentifier: playerIdentifier,
|
||||||
|
puzzleId: Number(puzzleId),
|
||||||
|
genreId: finalGenreId,
|
||||||
|
message: trimmedMessage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create recipients for all curators
|
||||||
|
await tx.curatorCommentRecipient.createMany({
|
||||||
|
data: curatorIds.map(curatorId => ({
|
||||||
|
commentId: comment.id,
|
||||||
|
curatorId: curatorId
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
commentId: result.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating curator comment:', error);
|
||||||
|
|
||||||
|
// Handle unique constraint violation (shouldn't happen due to our check, but just in case)
|
||||||
|
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'You have already sent a comment for this puzzle' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
app/api/curator-comments/[id]/archive/route.ts
Normal file
66
app/api/curator-comments/[id]/archive/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
// Require curator authentication
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) {
|
||||||
|
return error!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only curators can archive comments
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can archive comments' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const commentId = Number(id);
|
||||||
|
const curatorId = context.curator.id;
|
||||||
|
|
||||||
|
// Verify that this comment belongs to this curator
|
||||||
|
const recipient = await prisma.curatorCommentRecipient.findUnique({
|
||||||
|
where: {
|
||||||
|
commentId_curatorId: {
|
||||||
|
commentId: commentId,
|
||||||
|
curatorId: curatorId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Comment not found or access denied' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update archived flag
|
||||||
|
await prisma.curatorCommentRecipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
archived: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error archiving comment:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
app/api/curator-comments/[id]/read/route.ts
Normal file
66
app/api/curator-comments/[id]/read/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
// Require curator authentication
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) {
|
||||||
|
return error!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only curators can mark comments as read
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can mark comments as read' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const commentId = Number(id);
|
||||||
|
const curatorId = context.curator.id;
|
||||||
|
|
||||||
|
// Verify that this comment belongs to this curator
|
||||||
|
const recipient = await prisma.curatorCommentRecipient.findUnique({
|
||||||
|
where: {
|
||||||
|
commentId_curatorId: {
|
||||||
|
commentId: commentId,
|
||||||
|
curatorId: curatorId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Comment not found or access denied' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update readAt timestamp
|
||||||
|
await prisma.curatorCommentRecipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
readAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking comment as read:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
139
app/api/curator-comments/route.ts
Normal file
139
app/api/curator-comments/route.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
// Require curator authentication
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) {
|
||||||
|
return error!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only curators can view comments (not admins directly)
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can view comments' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const curatorId = context.curator.id;
|
||||||
|
|
||||||
|
// Get all non-archived comments for this curator, ordered by creation date (newest first)
|
||||||
|
const comments = await prisma.curatorCommentRecipient.findMany({
|
||||||
|
where: {
|
||||||
|
curatorId: curatorId,
|
||||||
|
archived: false
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
comment: {
|
||||||
|
include: {
|
||||||
|
puzzle: {
|
||||||
|
include: {
|
||||||
|
song: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
artist: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
genre: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
special: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
comment: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format the response with puzzle context
|
||||||
|
const formattedComments = await Promise.all(comments.map(async (recipient) => {
|
||||||
|
const puzzle = recipient.comment.puzzle;
|
||||||
|
|
||||||
|
// Calculate puzzle number
|
||||||
|
let puzzleNumber = 0;
|
||||||
|
if (puzzle.specialId) {
|
||||||
|
// Special puzzle
|
||||||
|
puzzleNumber = await prisma.dailyPuzzle.count({
|
||||||
|
where: {
|
||||||
|
specialId: puzzle.specialId,
|
||||||
|
date: {
|
||||||
|
lte: puzzle.date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (puzzle.genreId) {
|
||||||
|
// Genre puzzle
|
||||||
|
puzzleNumber = await prisma.dailyPuzzle.count({
|
||||||
|
where: {
|
||||||
|
genreId: puzzle.genreId,
|
||||||
|
date: {
|
||||||
|
lte: puzzle.date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Global puzzle
|
||||||
|
puzzleNumber = await prisma.dailyPuzzle.count({
|
||||||
|
where: {
|
||||||
|
genreId: null,
|
||||||
|
specialId: null,
|
||||||
|
date: {
|
||||||
|
lte: puzzle.date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: recipient.comment.id,
|
||||||
|
message: recipient.comment.message,
|
||||||
|
createdAt: recipient.comment.createdAt,
|
||||||
|
readAt: recipient.readAt,
|
||||||
|
puzzle: {
|
||||||
|
id: puzzle.id,
|
||||||
|
date: puzzle.date,
|
||||||
|
puzzleNumber: puzzleNumber,
|
||||||
|
song: {
|
||||||
|
title: puzzle.song.title,
|
||||||
|
artist: puzzle.song.artist
|
||||||
|
},
|
||||||
|
genre: puzzle.genre ? {
|
||||||
|
id: puzzle.genre.id,
|
||||||
|
name: puzzle.genre.name
|
||||||
|
} : null,
|
||||||
|
special: puzzle.special ? {
|
||||||
|
id: puzzle.special.id,
|
||||||
|
name: puzzle.special.name
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(formattedComments);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching curator comments:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
42
app/api/curator/login/route.ts
Normal file
42
app/api/curator/login/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const curator = await prisma.curator.findUnique({
|
||||||
|
where: { username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!curator) {
|
||||||
|
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(password, curator.passwordHash);
|
||||||
|
if (!isValid) {
|
||||||
|
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
curator: {
|
||||||
|
id: curator.id,
|
||||||
|
username: curator.username,
|
||||||
|
isGlobalCurator: curator.isGlobalCurator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Curator login error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
38
app/api/curator/me/route.ts
Normal file
38
app/api/curator/me/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [genres, specials] = await Promise.all([
|
||||||
|
prisma.curatorGenre.findMany({
|
||||||
|
where: { curatorId: context.curator.id },
|
||||||
|
select: { genreId: true },
|
||||||
|
}),
|
||||||
|
prisma.curatorSpecial.findMany({
|
||||||
|
where: { curatorId: context.curator.id },
|
||||||
|
select: { specialId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: context.curator.id,
|
||||||
|
username: context.curator.username,
|
||||||
|
isGlobalCurator: context.curator.isGlobalCurator,
|
||||||
|
genreIds: genres.map(g => g.genreId),
|
||||||
|
specialIds: specials.map(s => s.specialId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
200
app/api/curators/route.ts
Normal file
200
app/api/curators/route.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient, Prisma } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
// Only admin may list and manage curators
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const curators = await prisma.curator.findMany({
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
orderBy: { username: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
curators.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
username: c.username,
|
||||||
|
isGlobalCurator: c.isGlobalCurator,
|
||||||
|
genreIds: c.genres.map(g => g.genreId),
|
||||||
|
specialIds: c.specials.map(s => s.specialId),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { username, password, isGlobalCurator = false, genreIds = [], specialIds = [] } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const curator = await prisma.curator.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
isGlobalCurator: Boolean(isGlobalCurator),
|
||||||
|
genres: {
|
||||||
|
create: (genreIds as number[]).map(id => ({ genreId: id })),
|
||||||
|
},
|
||||||
|
specials: {
|
||||||
|
create: (specialIds as number[]).map(id => ({ specialId: id })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: curator.id,
|
||||||
|
username: curator.username,
|
||||||
|
isGlobalCurator: curator.isGlobalCurator,
|
||||||
|
genreIds: curator.genres.map(g => g.genreId),
|
||||||
|
specialIds: curator.specials.map(s => s.specialId),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating curator:', error);
|
||||||
|
|
||||||
|
// Handle unique username constraint violation explicitly
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'A curator with this username already exists.' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { id, username, password, isGlobalCurator, genreIds, specialIds } = await request.json();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
if (username !== undefined) data.username = username;
|
||||||
|
if (isGlobalCurator !== undefined) data.isGlobalCurator = Boolean(isGlobalCurator);
|
||||||
|
if (password) {
|
||||||
|
data.passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
const curator = await tx.curator.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(genreIds)) {
|
||||||
|
await tx.curatorGenre.deleteMany({
|
||||||
|
where: { curatorId: curator.id },
|
||||||
|
});
|
||||||
|
if (genreIds.length > 0) {
|
||||||
|
await tx.curatorGenre.createMany({
|
||||||
|
data: (genreIds as number[]).map(gid => ({
|
||||||
|
curatorId: curator.id,
|
||||||
|
genreId: gid,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(specialIds)) {
|
||||||
|
await tx.curatorSpecial.deleteMany({
|
||||||
|
where: { curatorId: curator.id },
|
||||||
|
});
|
||||||
|
if (specialIds.length > 0) {
|
||||||
|
await tx.curatorSpecial.createMany({
|
||||||
|
data: (specialIds as number[]).map(sid => ({
|
||||||
|
curatorId: curator.id,
|
||||||
|
specialId: sid,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalCurator = await tx.curator.findUnique({
|
||||||
|
where: { id: curator.id },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finalCurator) {
|
||||||
|
throw new Error('Curator not found after update');
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalCurator;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: updated.id,
|
||||||
|
username: updated.username,
|
||||||
|
isGlobalCurator: updated.isGlobalCurator,
|
||||||
|
genreIds: updated.genres.map(g => g.genreId),
|
||||||
|
specialIds: updated.specials.map(s => s.specialId),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating curator:', error);
|
||||||
|
|
||||||
|
// Handle unique username constraint violation explicitly for updates
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'A curator with this username already exists.' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.curator.delete({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting curator:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
21
app/api/public-songs/route.ts
Normal file
21
app/api/public-songs/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Öffentliche, schreibgeschützte Song-Liste für das Spiel (GuessInput etc.).
|
||||||
|
// Kein Auth, nur Lesen der nötigsten Felder.
|
||||||
|
export async function GET() {
|
||||||
|
const songs = await prisma.song.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
artist: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(songs);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
265
app/api/songs/batch/route.ts
Normal file
265
app/api/songs/batch/route.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function getCuratorAssignments(curatorId: number) {
|
||||||
|
const [genres, specials] = await Promise.all([
|
||||||
|
prisma.curatorGenre.findMany({
|
||||||
|
where: { curatorId },
|
||||||
|
select: { genreId: true },
|
||||||
|
}),
|
||||||
|
prisma.curatorSpecial.findMany({
|
||||||
|
where: { curatorId },
|
||||||
|
select: { specialId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
genreIds: new Set(genres.map(g => g.genreId)),
|
||||||
|
specialIds: new Set(specials.map(s => s.specialId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||||
|
if (context.role === 'admin') return true;
|
||||||
|
|
||||||
|
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||||
|
const songSpecialIds = (song.specials || [])
|
||||||
|
.map((s: any) => {
|
||||||
|
if (s?.specialId != null) return s.specialId;
|
||||||
|
if (s?.special?.id != null) return s.special.id;
|
||||||
|
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.filter((id: any): id is number => typeof id === 'number');
|
||||||
|
|
||||||
|
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasGenre = songGenreIds.some((id: number) => assignments.genreIds.has(id));
|
||||||
|
const hasSpecial = songSpecialIds.some((id: number) => assignments.specialIds.has(id));
|
||||||
|
|
||||||
|
return hasGenre || hasSpecial;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
|
if (error || !context) return error!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { songIds, genreToggleIds, specialToggleIds, artist, excludeFromGlobal } = await request.json();
|
||||||
|
|
||||||
|
if (!songIds || !Array.isArray(songIds) || songIds.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Missing or invalid songIds array' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that at least one operation is requested
|
||||||
|
const hasGenreToggle = genreToggleIds && Array.isArray(genreToggleIds) && genreToggleIds.length > 0;
|
||||||
|
const hasSpecialToggle = specialToggleIds && Array.isArray(specialToggleIds) && specialToggleIds.length > 0;
|
||||||
|
const hasArtistChange = artist !== undefined && artist !== null && artist.trim() !== '';
|
||||||
|
const hasExcludeGlobalChange = excludeFromGlobal !== undefined;
|
||||||
|
|
||||||
|
if (!hasGenreToggle && !hasSpecialToggle && !hasArtistChange && !hasExcludeGlobalChange) {
|
||||||
|
return NextResponse.json({ error: 'No update operations specified' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate artist if provided
|
||||||
|
if (hasArtistChange && artist.trim() === '') {
|
||||||
|
return NextResponse.json({ error: 'Artist cannot be empty' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate excludeFromGlobal permission
|
||||||
|
if (hasExcludeGlobalChange && context.role === 'curator' && !context.curator.isGlobalCurator) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let assignments: { genreIds: Set<number>; specialIds: Set<number> } | null = null;
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
assignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
|
||||||
|
// Validate genre/special toggles are within curator's assignments
|
||||||
|
if (hasGenreToggle) {
|
||||||
|
const invalidGenre = genreToggleIds.some((id: number) => !assignments.genreIds.has(id));
|
||||||
|
if (invalidGenre) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Curators may only toggle their own genres' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSpecialToggle) {
|
||||||
|
const invalidSpecial = specialToggleIds.some((id: number) => !assignments.specialIds.has(id));
|
||||||
|
if (invalidSpecial) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Curators may only toggle their own specials' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all songs with relations for permission checks
|
||||||
|
const songs = await prisma.song.findMany({
|
||||||
|
where: { id: { in: songIds.map((id: any) => Number(id)) } },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: {
|
||||||
|
include: {
|
||||||
|
special: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (songs.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No songs found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter songs that can be edited
|
||||||
|
const editableSongs = context.role === 'admin'
|
||||||
|
? songs
|
||||||
|
: songs.filter(song => curatorCanEditSong(context, song, assignments!));
|
||||||
|
|
||||||
|
if (editableSongs.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No songs can be edited with current permissions' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
total: songIds.length,
|
||||||
|
processed: editableSongs.length,
|
||||||
|
skipped: songs.length - editableSongs.length,
|
||||||
|
success: 0,
|
||||||
|
errors: [] as Array<{ songId: number; error: string }>,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each song in a transaction
|
||||||
|
for (const song of editableSongs) {
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const updateData: any = {};
|
||||||
|
|
||||||
|
// Handle artist change
|
||||||
|
if (hasArtistChange) {
|
||||||
|
updateData.artist = artist.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle excludeFromGlobal change
|
||||||
|
if (hasExcludeGlobalChange) {
|
||||||
|
updateData.excludeFromGlobal = excludeFromGlobal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle genre toggles
|
||||||
|
if (hasGenreToggle) {
|
||||||
|
const currentGenreIds = song.genres.map(g => g.id);
|
||||||
|
const genreIdsToToggle = genreToggleIds as number[];
|
||||||
|
|
||||||
|
// Determine which genres to add/remove
|
||||||
|
const genresToAdd = genreIdsToToggle.filter(id => !currentGenreIds.includes(id));
|
||||||
|
const genresToRemove = genreIdsToToggle.filter(id => currentGenreIds.includes(id));
|
||||||
|
|
||||||
|
// For curators, preserve genres they can't manage
|
||||||
|
let finalGenreIds: number[];
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const fixedGenreIds = currentGenreIds.filter(gid => !assignments!.genreIds.has(gid));
|
||||||
|
const managedGenreIds = currentGenreIds
|
||||||
|
.filter(gid => assignments!.genreIds.has(gid) && !genresToRemove.includes(gid))
|
||||||
|
.concat(genresToAdd);
|
||||||
|
finalGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
|
||||||
|
} else {
|
||||||
|
const newGenreIds = currentGenreIds
|
||||||
|
.filter(id => !genresToRemove.includes(id))
|
||||||
|
.concat(genresToAdd);
|
||||||
|
finalGenreIds = Array.from(new Set(newGenreIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.genres = {
|
||||||
|
set: finalGenreIds.map(gId => ({ id: gId }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update song basic data
|
||||||
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
await tx.song.update({
|
||||||
|
where: { id: song.id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle special toggles
|
||||||
|
if (hasSpecialToggle) {
|
||||||
|
const currentSpecials = await tx.specialSong.findMany({
|
||||||
|
where: { songId: song.id }
|
||||||
|
});
|
||||||
|
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||||
|
const specialIdsToToggle = specialToggleIds as number[];
|
||||||
|
|
||||||
|
// Determine which specials to add/remove
|
||||||
|
const specialsToAdd = specialIdsToToggle.filter(id => !currentSpecialIds.includes(id));
|
||||||
|
const specialsToRemove = specialIdsToToggle.filter(id => currentSpecialIds.includes(id));
|
||||||
|
|
||||||
|
// For curators, preserve specials they can't manage
|
||||||
|
let finalSpecialIds: number[];
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const fixedSpecialIds = currentSpecialIds.filter(sid => !assignments!.specialIds.has(sid));
|
||||||
|
const managedSpecialIds = currentSpecialIds
|
||||||
|
.filter(sid => assignments!.specialIds.has(sid) && !specialsToRemove.includes(sid))
|
||||||
|
.concat(specialsToAdd);
|
||||||
|
finalSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
|
||||||
|
} else {
|
||||||
|
const newSpecialIds = currentSpecialIds
|
||||||
|
.filter(id => !specialsToRemove.includes(id))
|
||||||
|
.concat(specialsToAdd);
|
||||||
|
finalSpecialIds = Array.from(new Set(newSpecialIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removed specials
|
||||||
|
const toDelete = currentSpecialIds.filter(sid => !finalSpecialIds.includes(sid));
|
||||||
|
if (toDelete.length > 0) {
|
||||||
|
await tx.specialSong.deleteMany({
|
||||||
|
where: {
|
||||||
|
songId: song.id,
|
||||||
|
specialId: { in: toDelete }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new specials
|
||||||
|
const toAdd = finalSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
await tx.specialSong.createMany({
|
||||||
|
data: toAdd.map(specialId => ({
|
||||||
|
songId: song.id,
|
||||||
|
specialId,
|
||||||
|
startTime: 0
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
results.success++;
|
||||||
|
} catch (error: any) {
|
||||||
|
results.errors.push({
|
||||||
|
songId: song.id,
|
||||||
|
error: error.message || 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in batch update:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,18 +1,96 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { writeFile, unlink } from 'fs/promises';
|
import { writeFile, unlink } from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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);
|
||||||
|
// `song.specials` kann je nach Context entweder ein Array von
|
||||||
|
// - `Special` (mit `id`)
|
||||||
|
// - `SpecialSong` (mit `specialId`)
|
||||||
|
// - `SpecialSong` (mit Relation `special.id`)
|
||||||
|
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
|
||||||
|
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
|
||||||
|
// Daher zuerst specialId oder special.id prüfen.
|
||||||
|
const songSpecialIds = (song.specials || [])
|
||||||
|
.map((s: any) => {
|
||||||
|
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||||
|
if (s?.specialId != null) return s.specialId;
|
||||||
|
if (s?.special?.id != null) return s.special.id;
|
||||||
|
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||||
|
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.filter((id: any): id is number => typeof id === 'number');
|
||||||
|
|
||||||
|
// Songs ohne Genres/Specials sind für Kuratoren generell editierbar
|
||||||
|
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function curatorCanDeleteSong(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);
|
||||||
|
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
|
||||||
|
// Daher zuerst specialId oder special.id prüfen.
|
||||||
|
const songSpecialIds = (song.specials || [])
|
||||||
|
.map((s: any) => {
|
||||||
|
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||||
|
if (s?.specialId != null) return s.specialId;
|
||||||
|
if (s?.special?.id != null) return s.special.id;
|
||||||
|
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||||
|
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.filter((id: any): id is number => typeof id === 'number');
|
||||||
|
|
||||||
|
const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id));
|
||||||
|
const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id));
|
||||||
|
|
||||||
|
return allGenresAllowed && allSpecialsAllowed;
|
||||||
|
}
|
||||||
|
|
||||||
// Configure route to handle large file uploads
|
// Configure route to handle large file uploads
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
export const maxDuration = 60; // 60 seconds timeout for uploads
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
|
// Alle Zugriffe auf die Songliste erfordern Staff-Auth (Admin oder Kurator)
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) return error!;
|
||||||
|
|
||||||
const songs = await prisma.song.findMany({
|
const songs = await prisma.song.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
@@ -26,8 +104,33 @@ export async function GET() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let visibleSongs = songs;
|
||||||
|
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const assignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
|
||||||
|
visibleSongs = songs.filter(song => {
|
||||||
|
const songGenreIds = song.genres.map(g => g.id);
|
||||||
|
// `song.specials` ist hier ein Array von SpecialSong mit Relation `special`.
|
||||||
|
// Es kann theoretisch verwaiste Einträge ohne `special` geben → defensiv optional chainen.
|
||||||
|
const songSpecialIds = song.specials
|
||||||
|
.map(ss => ss.special?.id)
|
||||||
|
.filter((id): id is number => typeof id === 'number');
|
||||||
|
|
||||||
|
// Songs ohne Genres/Specials sind immer sichtbar
|
||||||
|
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasGenre = songGenreIds.some(id => assignments.genreIds.has(id));
|
||||||
|
const hasSpecial = songSpecialIds.some(id => assignments.specialIds.has(id));
|
||||||
|
|
||||||
|
return hasGenre || hasSpecial;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Map to include activation count and flatten specials
|
// Map to include activation count and flatten specials
|
||||||
const songsWithActivations = songs.map(song => ({
|
const songsWithActivations = visibleSongs.map(song => ({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.title,
|
title: song.title,
|
||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
@@ -38,7 +141,10 @@ export async function GET() {
|
|||||||
activations: song.puzzles.length,
|
activations: song.puzzles.length,
|
||||||
puzzles: song.puzzles,
|
puzzles: song.puzzles,
|
||||||
genres: song.genres,
|
genres: song.genres,
|
||||||
specials: song.specials.map(ss => ss.special),
|
// Nur Specials mit existierender Relation durchreichen, um undefinierte Einträge zu vermeiden.
|
||||||
|
specials: song.specials
|
||||||
|
.map(ss => ss.special)
|
||||||
|
.filter((s): s is any => !!s),
|
||||||
averageRating: song.averageRating,
|
averageRating: song.averageRating,
|
||||||
ratingCount: song.ratingCount,
|
ratingCount: song.ratingCount,
|
||||||
excludeFromGlobal: song.excludeFromGlobal,
|
excludeFromGlobal: song.excludeFromGlobal,
|
||||||
@@ -50,11 +156,11 @@ export async function GET() {
|
|||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
console.log('[UPLOAD] Starting song upload request');
|
console.log('[UPLOAD] Starting song upload request');
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) {
|
if (error || !context) {
|
||||||
console.log('[UPLOAD] Authentication failed');
|
console.log('[UPLOAD] Authentication failed');
|
||||||
return authError;
|
return error!;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -63,10 +169,17 @@ export async function POST(request: Request) {
|
|||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
let title = '';
|
let title = '';
|
||||||
let artist = '';
|
let artist = '';
|
||||||
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
let excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||||
|
|
||||||
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
||||||
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
|
console.log('[UPLOAD] excludeFromGlobal (raw):', excludeFromGlobal);
|
||||||
|
|
||||||
|
// Apply global playlist rules:
|
||||||
|
// - Admin: may control the flag via form data
|
||||||
|
// - Curator: uploads are always excluded from global by default
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
excludeFromGlobal = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
console.error('[UPLOAD] No file provided');
|
console.error('[UPLOAD] No file provided');
|
||||||
@@ -261,9 +374,9 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) return authError;
|
if (error || !context) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||||
@@ -272,6 +385,73 @@ export async function PUT(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load current song with relations for permission checks
|
||||||
|
const existingSong = await prisma.song.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: {
|
||||||
|
include: {
|
||||||
|
special: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSong) {
|
||||||
|
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let effectiveGenreIds = genreIds as number[] | undefined;
|
||||||
|
let effectiveSpecialIds = specialIds as number[] | undefined;
|
||||||
|
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const assignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
|
||||||
|
if (!curatorCanEditSong(context, existingSong, assignments)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden: You are not allowed to edit this song' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curators may assign genres, but only within their own assignments.
|
||||||
|
// Genres außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
|
||||||
|
if (effectiveGenreIds !== undefined) {
|
||||||
|
const invalidGenre = effectiveGenreIds.some(id => !assignments.genreIds.has(id));
|
||||||
|
if (invalidGenre) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Curators may only assign their own genres' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixedGenreIds = existingSong.genres
|
||||||
|
.filter(g => !assignments.genreIds.has(g.id))
|
||||||
|
.map(g => g.id);
|
||||||
|
const managedGenreIds = effectiveGenreIds.filter(id => assignments.genreIds.has(id));
|
||||||
|
effectiveGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curators may assign specials, but only within their own assignments.
|
||||||
|
// Specials außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
|
||||||
|
if (effectiveSpecialIds !== undefined) {
|
||||||
|
const invalidSpecial = effectiveSpecialIds.some(id => !assignments.specialIds.has(id));
|
||||||
|
if (invalidSpecial) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Curators may only assign their own specials' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSpecials = await prisma.specialSong.findMany({
|
||||||
|
where: { songId: Number(id) }
|
||||||
|
});
|
||||||
|
const fixedSpecialIds = currentSpecials
|
||||||
|
.map(ss => ss.specialId)
|
||||||
|
.filter(sid => !assignments.specialIds.has(sid));
|
||||||
|
const managedSpecialIds = effectiveSpecialIds.filter(id => assignments.specialIds.has(id));
|
||||||
|
effectiveSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data: any = { title, artist };
|
const data: any = { title, artist };
|
||||||
|
|
||||||
// Update releaseYear if provided (can be null to clear it)
|
// Update releaseYear if provided (can be null to clear it)
|
||||||
@@ -280,60 +460,76 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (excludeFromGlobal !== undefined) {
|
if (excludeFromGlobal !== undefined) {
|
||||||
data.excludeFromGlobal = excludeFromGlobal;
|
if (context.role === 'admin') {
|
||||||
|
data.excludeFromGlobal = excludeFromGlobal;
|
||||||
|
} else {
|
||||||
|
// Curators may only change the flag if they are global curators
|
||||||
|
if (!context.curator.isGlobalCurator) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
data.excludeFromGlobal = excludeFromGlobal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (genreIds) {
|
// Wenn effectiveGenreIds definiert ist, auch leere Arrays übernehmen (löscht alle Zuordnungen).
|
||||||
|
if (effectiveGenreIds !== undefined) {
|
||||||
data.genres = {
|
data.genres = {
|
||||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle SpecialSong relations separately
|
// Execute all database write operations in a transaction to ensure consistency
|
||||||
if (specialIds !== undefined) {
|
const updatedSong = await prisma.$transaction(async (tx) => {
|
||||||
// First, get current special assignments
|
// Handle SpecialSong relations separately
|
||||||
const currentSpecials = await prisma.specialSong.findMany({
|
if (effectiveSpecialIds !== undefined) {
|
||||||
where: { songId: Number(id) }
|
// First, get current special assignments (within transaction)
|
||||||
});
|
const currentSpecials = await tx.specialSong.findMany({
|
||||||
|
where: { songId: Number(id) }
|
||||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
|
||||||
const newSpecialIds = specialIds as number[];
|
|
||||||
|
|
||||||
// Delete removed specials
|
|
||||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
|
||||||
if (toDelete.length > 0) {
|
|
||||||
await prisma.specialSong.deleteMany({
|
|
||||||
where: {
|
|
||||||
songId: Number(id),
|
|
||||||
specialId: { in: toDelete }
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Add new specials
|
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||||
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
const newSpecialIds = effectiveSpecialIds as number[];
|
||||||
if (toAdd.length > 0) {
|
|
||||||
await prisma.specialSong.createMany({
|
|
||||||
data: toAdd.map(specialId => ({
|
|
||||||
songId: Number(id),
|
|
||||||
specialId,
|
|
||||||
startTime: 0
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedSong = await prisma.song.update({
|
// Delete removed specials
|
||||||
where: { id: Number(id) },
|
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||||
data,
|
if (toDelete.length > 0) {
|
||||||
include: {
|
await tx.specialSong.deleteMany({
|
||||||
genres: true,
|
where: {
|
||||||
specials: {
|
songId: Number(id),
|
||||||
include: {
|
specialId: { in: toDelete }
|
||||||
special: true
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new specials
|
||||||
|
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
await tx.specialSong.createMany({
|
||||||
|
data: toAdd.map(specialId => ({
|
||||||
|
songId: Number(id),
|
||||||
|
specialId,
|
||||||
|
startTime: 0
|
||||||
|
}))
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update song (this also handles genre relations via Prisma's set operation)
|
||||||
|
return await tx.song.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: {
|
||||||
|
include: {
|
||||||
|
special: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(updatedSong);
|
return NextResponse.json(updatedSong);
|
||||||
@@ -344,9 +540,9 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) return authError;
|
if (error || !context) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await request.json();
|
const { id } = await request.json();
|
||||||
@@ -355,16 +551,31 @@ export async function DELETE(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get song to find filename
|
// Get song to find filename and relations for permission checks
|
||||||
const song = await prisma.song.findUnique({
|
const song = await prisma.song.findUnique({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!song) {
|
if (!song) {
|
||||||
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete file
|
if (context.role === 'curator') {
|
||||||
|
const assignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
|
||||||
|
if (!curatorCanDeleteSong(context, song, assignments)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden: You are not allowed to delete this song' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete files first (outside transaction, as file system operations can't be rolled back)
|
||||||
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||||
try {
|
try {
|
||||||
await unlink(filePath);
|
await unlink(filePath);
|
||||||
@@ -383,9 +594,11 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from database (will cascade delete related puzzles)
|
// Delete from database in transaction (will cascade delete related puzzles, SpecialSong, etc.)
|
||||||
await prisma.song.delete({
|
await prisma.$transaction(async (tx) => {
|
||||||
where: { id: Number(id) },
|
await tx.song.delete({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
|
|||||||
1941
app/curator/CuratorPageClient.tsx
Normal file
1941
app/curator/CuratorPageClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
11
app/curator/page.tsx
Normal file
11
app/curator/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Server-Wrapper für die Kuratoren-Seite.
|
||||||
|
// Markiert die Route als dynamisch und rendert die eigentliche Client-Komponente.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
import CuratorPageClient from './CuratorPageClient';
|
||||||
|
|
||||||
|
export default function CuratorPage() {
|
||||||
|
return <CuratorPageClient />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +70,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
|
|
||||||
// Dynamic genre pages
|
// Dynamic genre pages
|
||||||
try {
|
try {
|
||||||
|
// Während des Docker-Builds wird häufig eine temporäre SQLite-DB (file:./dev.db)
|
||||||
|
// ohne migrierte Tabellen verwendet. In diesem Fall überspringen wir die
|
||||||
|
// Datenbankabfrage und liefern nur die statischen Seiten, um Build-Fehler zu vermeiden.
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
if (dbUrl && dbUrl.startsWith('file:./')) {
|
||||||
|
return staticPages;
|
||||||
|
}
|
||||||
|
|
||||||
const genres = await prisma.genre.findMany({
|
const genres = await prisma.genre.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { ExternalPuzzle } from '@/lib/externalPuzzles';
|
|||||||
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
|
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
|
||||||
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
|
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
|
||||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
import { getOrCreatePlayerId } from '@/lib/playerId';
|
||||||
|
|
||||||
// Plausible Analytics
|
// Plausible Analytics
|
||||||
declare global {
|
declare global {
|
||||||
@@ -60,6 +61,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||||
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||||
|
const [commentText, setCommentText] = useState('');
|
||||||
|
const [commentSending, setCommentSending] = useState(false);
|
||||||
|
const [commentSent, setCommentSent] = useState(false);
|
||||||
|
const [commentError, setCommentError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateCountdown = () => {
|
const updateCountdown = () => {
|
||||||
@@ -134,6 +139,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
} else {
|
} else {
|
||||||
setHasRated(false);
|
setHasRated(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if comment already sent for this puzzle
|
||||||
|
const playerIdentifier = getOrCreatePlayerId();
|
||||||
|
if (playerIdentifier) {
|
||||||
|
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
|
||||||
|
if (commentedPuzzles.includes(dailyPuzzle.id)) {
|
||||||
|
setCommentSent(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [dailyPuzzle]);
|
}, [dailyPuzzle]);
|
||||||
|
|
||||||
@@ -300,6 +314,59 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCommentSubmit = async () => {
|
||||||
|
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommentSending(true);
|
||||||
|
setCommentError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const playerIdentifier = getOrCreatePlayerId();
|
||||||
|
if (!playerIdentifier) {
|
||||||
|
throw new Error('Could not get player identifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle
|
||||||
|
const genreId = isSpecial ? null : null; // API will determine from puzzle
|
||||||
|
|
||||||
|
const response = await fetch('/api/curator-comment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
puzzleId: dailyPuzzle.id,
|
||||||
|
genreId: genreId,
|
||||||
|
message: commentText.trim(),
|
||||||
|
playerIdentifier: playerIdentifier
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to send comment');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommentSent(true);
|
||||||
|
setCommentText('');
|
||||||
|
|
||||||
|
// Store in localStorage that comment was sent
|
||||||
|
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
|
||||||
|
if (!commentedPuzzles.includes(dailyPuzzle.id)) {
|
||||||
|
commentedPuzzles.push(dailyPuzzle.id);
|
||||||
|
localStorage.setItem(`${config.appName.toLowerCase()}_commented_puzzles`, JSON.stringify(commentedPuzzles));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending comment:', error);
|
||||||
|
setCommentError(error instanceof Error ? error.message : 'Failed to send comment');
|
||||||
|
} finally {
|
||||||
|
setCommentSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
@@ -391,6 +458,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Aktuelle Attempt-Anzeige:
|
||||||
|
// - Während des Spiels: nächster Versuch = guesses.length + 1
|
||||||
|
// - Nach Spielende (gelöst oder verloren): letzter Versuch = guesses.length
|
||||||
|
const currentAttempt = (gameState.isSolved || gameState.isFailed)
|
||||||
|
? gameState.guesses.length
|
||||||
|
: gameState.guesses.length + 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
@@ -403,7 +477,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
<main className="game-board">
|
<main className="game-board">
|
||||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||||
<div id="tour-status" className="status-bar">
|
<div id="tour-status" className="status-bar">
|
||||||
<span>{t('attempt')} {gameState.guesses.length + 1} / {maxAttempts}</span>
|
<span>{t('attempt')} {currentAttempt} / {maxAttempts}</span>
|
||||||
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -512,14 +586,80 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.25rem', textAlign: 'center' }}>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>
|
||||||
|
{t('shareExplanation')}
|
||||||
|
</p>
|
||||||
|
<button onClick={handleShare} className="btn-primary">
|
||||||
|
{shareText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Form */}
|
||||||
|
{!commentSent && (
|
||||||
|
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}>
|
||||||
|
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||||
|
{t('sendComment')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
|
||||||
|
{t('commentHelp')}
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
placeholder={t('commentPlaceholder')}
|
||||||
|
maxLength={2000}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '100px',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
resize: 'vertical',
|
||||||
|
marginBottom: '0.5rem'
|
||||||
|
}}
|
||||||
|
disabled={commentSending}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
|
||||||
|
{commentText.length}/2000
|
||||||
|
</span>
|
||||||
|
{commentError && (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
|
||||||
|
{commentError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCommentSubmit}
|
||||||
|
disabled={!commentText.trim() || commentSending || commentSent}
|
||||||
|
className="btn-primary"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
opacity: (!commentText.trim() || commentSending || commentSent) ? 0.5 : 1,
|
||||||
|
cursor: (!commentText.trim() || commentSending || commentSent) ? 'not-allowed' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{commentSending ? t('sending') : t('sendComment')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{commentSent && (
|
||||||
|
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
|
||||||
|
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center' }}>
|
||||||
|
{t('commentSent')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{statistics && <Statistics statistics={statistics} />}
|
{statistics && <Statistics statistics={statistics} />}
|
||||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
|
||||||
{shareText}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -22,9 +22,25 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/songs')
|
fetch('/api/public-songs')
|
||||||
.then(res => res.json())
|
.then(res => {
|
||||||
.then(data => setSongs(data));
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load songs: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setSongs(data);
|
||||||
|
} else {
|
||||||
|
console.error('Unexpected songs payload in GuessInput:', data);
|
||||||
|
setSongs([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Error loading songs for GuessInput:', err);
|
||||||
|
setSongs([]);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
58
lib/auth.ts
58
lib/auth.ts
@@ -1,4 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient, Curator } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export type StaffContext =
|
||||||
|
| { role: 'admin' }
|
||||||
|
| { role: 'curator'; curator: Curator };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication middleware for admin API routes
|
* Authentication middleware for admin API routes
|
||||||
@@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise<NextRespon
|
|||||||
return null; // Auth successful
|
return null; // Auth successful
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve current staff (admin or curator) from headers.
|
||||||
|
*
|
||||||
|
* Admin:
|
||||||
|
* - x-admin-auth: 'authenticated'
|
||||||
|
*
|
||||||
|
* Curator:
|
||||||
|
* - x-curator-auth: 'authenticated'
|
||||||
|
* - x-curator-username: <username>
|
||||||
|
*/
|
||||||
|
export async function getStaffContext(request: NextRequest): Promise<StaffContext | null> {
|
||||||
|
const adminHeader = request.headers.get('x-admin-auth');
|
||||||
|
if (adminHeader === 'authenticated') {
|
||||||
|
return { role: 'admin' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const curatorAuth = request.headers.get('x-curator-auth');
|
||||||
|
const curatorUsername = request.headers.get('x-curator-username');
|
||||||
|
|
||||||
|
if (curatorAuth === 'authenticated' && curatorUsername) {
|
||||||
|
const curator = await prisma.curator.findUnique({
|
||||||
|
where: { username: curatorUsername },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (curator) {
|
||||||
|
return { role: 'curator', curator };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require that the current request is authenticated as staff (admin or curator).
|
||||||
|
* Returns either an error response or a resolved context.
|
||||||
|
*/
|
||||||
|
export async function requireStaffAuth(request: NextRequest): Promise<{ error?: NextResponse; context?: StaffContext }> {
|
||||||
|
const context = await getStaffContext(request);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'Unauthorized - Staff authentication required' },
|
||||||
|
{ status: 401 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { context };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to verify admin password
|
* Helper to verify admin password
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const config = {
|
|||||||
},
|
},
|
||||||
credits: {
|
credits: {
|
||||||
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
|
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
|
||||||
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Made with 💚, ☕ and 🍺 by',
|
||||||
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,13 +49,15 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
|
|||||||
// Calculate total weight
|
// Calculate total weight
|
||||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||||
|
|
||||||
// Pick a random song based on weights
|
// Pick a random song based on weights using cumulative weights
|
||||||
|
// This ensures proper distribution and handles edge cases
|
||||||
let random = Math.random() * totalWeight;
|
let random = Math.random() * totalWeight;
|
||||||
let selectedSong = weightedSongs[0].song;
|
let selectedSong = weightedSongs[weightedSongs.length - 1].song; // Fallback to last song
|
||||||
|
|
||||||
|
let cumulativeWeight = 0;
|
||||||
for (const item of weightedSongs) {
|
for (const item of weightedSongs) {
|
||||||
random -= item.weight;
|
cumulativeWeight += item.weight;
|
||||||
if (random <= 0) {
|
if (random <= cumulativeWeight) {
|
||||||
selectedSong = item.song;
|
selectedSong = item.song;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -156,11 +158,13 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
|
|||||||
|
|
||||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||||
let random = Math.random() * totalWeight;
|
let random = Math.random() * totalWeight;
|
||||||
let selectedSpecialSong = weightedSongs[0].specialSong;
|
let selectedSpecialSong = weightedSongs[weightedSongs.length - 1].specialSong; // Fallback to last song
|
||||||
|
|
||||||
|
// Pick a random song based on weights using cumulative weights
|
||||||
|
let cumulativeWeight = 0;
|
||||||
for (const item of weightedSongs) {
|
for (const item of weightedSongs) {
|
||||||
random -= item.weight;
|
cumulativeWeight += item.weight;
|
||||||
if (random <= 0) {
|
if (random <= cumulativeWeight) {
|
||||||
selectedSpecialSong = item.specialSong;
|
selectedSpecialSong = item.specialSong;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,94 @@
|
|||||||
import { promises as fs } from 'fs';
|
import { PrismaClient, PoliticalStatement as PrismaPoliticalStatement } from '@prisma/client';
|
||||||
import path from 'path';
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export type PoliticalStatement = {
|
export type PoliticalStatement = {
|
||||||
id: number;
|
id: number;
|
||||||
|
locale: string;
|
||||||
text: string;
|
text: string;
|
||||||
active?: boolean;
|
active: boolean;
|
||||||
source?: string;
|
source?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getFilePath(locale: string): string {
|
function mapFromPrisma(stmt: PrismaPoliticalStatement): PoliticalStatement {
|
||||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
return {
|
||||||
return path.join(process.cwd(), 'data', `political-statements.${safeLocale}.json`);
|
id: stmt.id,
|
||||||
}
|
locale: stmt.locale,
|
||||||
|
text: stmt.text,
|
||||||
async function readStatementsFile(locale: string): Promise<PoliticalStatement[]> {
|
active: stmt.active,
|
||||||
const filePath = getFilePath(locale);
|
source: stmt.source,
|
||||||
try {
|
};
|
||||||
const raw = await fs.readFile(filePath, 'utf-8');
|
|
||||||
const data = JSON.parse(raw);
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
// File does not exist yet
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
console.error('[politicalStatements] Failed to read file', filePath, err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeStatementsFile(locale: string, statements: PoliticalStatement[]): Promise<void> {
|
|
||||||
const filePath = getFilePath(locale);
|
|
||||||
const dir = path.dirname(filePath);
|
|
||||||
try {
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
|
||||||
await fs.writeFile(filePath, JSON.stringify(statements, null, 2), 'utf-8');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[politicalStatements] Failed to write file', filePath, err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
|
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
|
||||||
const statements = await readStatementsFile(locale);
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
const active = statements.filter((s) => s.active !== false);
|
const all = await prisma.politicalStatement.findMany({
|
||||||
if (active.length === 0) {
|
where: {
|
||||||
|
locale: safeLocale,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (all.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const index = Math.floor(Math.random() * active.length);
|
|
||||||
return active[index] ?? null;
|
const index = Math.floor(Math.random() * all.length);
|
||||||
|
return mapFromPrisma(all[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllStatements(locale: string): Promise<PoliticalStatement[]> {
|
export async function getAllStatements(locale: string): Promise<PoliticalStatement[]> {
|
||||||
return readStatementsFile(locale);
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
|
const all = await prisma.politicalStatement.findMany({
|
||||||
|
where: { locale: safeLocale },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
return all.map(mapFromPrisma);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createStatement(locale: string, input: Omit<PoliticalStatement, 'id'>): Promise<PoliticalStatement> {
|
export async function createStatement(locale: string, input: Omit<PoliticalStatement, 'id' | 'locale'>): Promise<PoliticalStatement> {
|
||||||
const statements = await readStatementsFile(locale);
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
const nextId = statements.length > 0 ? Math.max(...statements.map((s) => s.id)) + 1 : 1;
|
const created = await prisma.politicalStatement.create({
|
||||||
const newStatement: PoliticalStatement = {
|
data: {
|
||||||
id: nextId,
|
locale: safeLocale,
|
||||||
active: true,
|
text: input.text,
|
||||||
...input,
|
active: input.active ?? true,
|
||||||
};
|
source: input.source ?? null,
|
||||||
statements.push(newStatement);
|
},
|
||||||
await writeStatementsFile(locale, statements);
|
});
|
||||||
return newStatement;
|
return mapFromPrisma(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id'>>): Promise<PoliticalStatement | null> {
|
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id' | 'locale'>>): Promise<PoliticalStatement | null> {
|
||||||
const statements = await readStatementsFile(locale);
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
const index = statements.findIndex((s) => s.id === id);
|
|
||||||
if (index === -1) return null;
|
|
||||||
|
|
||||||
const updated: PoliticalStatement = {
|
// Optional: sicherstellen, dass das Statement zu dieser Locale gehört
|
||||||
...statements[index],
|
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
|
||||||
...input,
|
if (!existing || existing.locale !== safeLocale) {
|
||||||
id,
|
return null;
|
||||||
};
|
}
|
||||||
statements[index] = updated;
|
|
||||||
await writeStatementsFile(locale, statements);
|
const updated = await prisma.politicalStatement.update({
|
||||||
return updated;
|
where: { id },
|
||||||
|
data: {
|
||||||
|
text: input.text ?? existing.text,
|
||||||
|
active: input.active ?? existing.active,
|
||||||
|
source: input.source !== undefined ? input.source : existing.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapFromPrisma(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteStatement(locale: string, id: number): Promise<boolean> {
|
export async function deleteStatement(locale: string, id: number): Promise<boolean> {
|
||||||
const statements = await readStatementsFile(locale);
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
const filtered = statements.filter((s) => s.id !== id);
|
|
||||||
if (filtered.length === statements.length) {
|
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.locale !== safeLocale) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await writeStatementsFile(locale, filtered);
|
|
||||||
|
await prisma.politicalStatement.delete({ where: { id } });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
122
messages/de.json
122
messages/de.json
@@ -41,6 +41,7 @@
|
|||||||
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
|
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
|
||||||
"theSongWas": "Das Lied war:",
|
"theSongWas": "Das Lied war:",
|
||||||
"score": "Punkte",
|
"score": "Punkte",
|
||||||
|
"shareExplanation": "Teile dein Ergebnis mit Freund:innen – so hilfst du, Hördle bekannter zu machen.",
|
||||||
"scoreBreakdown": "Punkteaufschlüsselung",
|
"scoreBreakdown": "Punkteaufschlüsselung",
|
||||||
"albumCover": "Album-Cover",
|
"albumCover": "Album-Cover",
|
||||||
"released": "Veröffentlicht",
|
"released": "Veröffentlicht",
|
||||||
@@ -56,6 +57,13 @@
|
|||||||
"points": "Punkte",
|
"points": "Punkte",
|
||||||
"skipBonus": "Bonus überspringen",
|
"skipBonus": "Bonus überspringen",
|
||||||
"notQuite": "Nicht ganz!",
|
"notQuite": "Nicht ganz!",
|
||||||
|
"sendComment": "Nachricht an Kurator senden",
|
||||||
|
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres...",
|
||||||
|
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
|
||||||
|
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
|
||||||
|
"commentError": "Fehler beim Senden der Nachricht",
|
||||||
|
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
|
||||||
|
"sending": "Wird gesendet...",
|
||||||
"youGuessed": "Du hast geraten",
|
"youGuessed": "Du hast geraten",
|
||||||
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
||||||
"skipped": "Übersprungen",
|
"skipped": "Übersprungen",
|
||||||
@@ -157,9 +165,116 @@
|
|||||||
"artist": "Interpret",
|
"artist": "Interpret",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"deletePuzzle": "Löschen",
|
"deletePuzzle": "Löschen",
|
||||||
"wrongPassword": "Falsches Passwort"
|
"wrongPassword": "Falsches Passwort",
|
||||||
|
"manageCurators": "Kuratoren verwalten",
|
||||||
|
"addCurator": "Kurator hinzufügen",
|
||||||
|
"curatorUsername": "Benutzername",
|
||||||
|
"curatorPassword": "Passwort (bei Leer lassen: nicht ändern)",
|
||||||
|
"isGlobalCurator": "Globaler Kurator (darf Global-Flag ändern)",
|
||||||
|
"assignedGenres": "Zugeordnete Genres",
|
||||||
|
"assignedSpecials": "Zugeordnete Specials",
|
||||||
|
"noCurators": "Noch keine Kuratoren angelegt."
|
||||||
},
|
},
|
||||||
"About": {
|
"Curator": {
|
||||||
|
"loginTitle": "Kuratoren-Login",
|
||||||
|
"loginUsername": "Benutzername",
|
||||||
|
"loginPassword": "Passwort",
|
||||||
|
"loginButton": "Einloggen",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"loginFailed": "Login fehlgeschlagen.",
|
||||||
|
"loginNetworkError": "Netzwerkfehler beim Login.",
|
||||||
|
"loadCuratorError": "Fehler beim Laden der Kuratoren-Informationen.",
|
||||||
|
"loadSongsError": "Fehler beim Laden der Songs.",
|
||||||
|
"songUpdated": "Song erfolgreich aktualisiert.",
|
||||||
|
"saveError": "Fehler beim Speichern: {error}",
|
||||||
|
"saveNetworkError": "Netzwerkfehler beim Speichern.",
|
||||||
|
"noDeletePermission": "Du darfst diesen Song nicht löschen.",
|
||||||
|
"deleteConfirm": "Möchtest du \"{title}\" wirklich löschen?",
|
||||||
|
"songDeleted": "Song gelöscht.",
|
||||||
|
"deleteError": "Fehler beim Löschen: {error}",
|
||||||
|
"deleteNetworkError": "Netzwerkfehler beim Löschen.",
|
||||||
|
"uploadSectionTitle": "Titel hochladen",
|
||||||
|
"uploadSectionDescription": "Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert (inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens eines deiner Genres aus, um die Titel zuzuordnen.",
|
||||||
|
"dropzoneTitleEmpty": "MP3-Dateien hierher ziehen",
|
||||||
|
"dropzoneTitleWithFiles": "{count} Datei(en) ausgewählt",
|
||||||
|
"dropzoneSubtitle": "oder klicken, um Dateien auszuwählen",
|
||||||
|
"selectedFilesTitle": "Ausgewählte Dateien:",
|
||||||
|
"uploadProgress": "Upload: {current} / {total}",
|
||||||
|
"assignGenresLabel": "Genres zuordnen",
|
||||||
|
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
|
||||||
|
"uploadButtonIdle": "Upload starten",
|
||||||
|
"uploadButtonUploading": "Lade hoch...",
|
||||||
|
"uploadSummary": "✅ {success}/{total} Uploads erfolgreich.",
|
||||||
|
"uploadSummaryDuplicates": "⚠️ {count} Duplikat(e) übersprungen.",
|
||||||
|
"uploadSummaryFailed": "❌ {count} fehlgeschlagen.",
|
||||||
|
"uploadResultSuccess": "✅ erfolgreich",
|
||||||
|
"uploadResultDuplicate": "⚠️ Duplikat: {error}",
|
||||||
|
"uploadResultError": "❌ Fehler: {error}",
|
||||||
|
"tracklistTitle": "Titel in deinen Genres & Specials ({count} Titel)",
|
||||||
|
"tracklistDescription": "Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist. Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden.",
|
||||||
|
"searchPlaceholder": "Nach Titel oder Artist suchen...",
|
||||||
|
"filterAll": "Alle Inhalte",
|
||||||
|
"filterNoGlobal": "🚫 Ohne Global",
|
||||||
|
"filterReset": "Filter zurücksetzen",
|
||||||
|
"noSongsInScope": "Keine passenden Songs in deinen Genres/Specials gefunden.",
|
||||||
|
"columnId": "ID",
|
||||||
|
"columnPlay": "Play",
|
||||||
|
"columnTitle": "Titel",
|
||||||
|
"columnArtist": "Artist",
|
||||||
|
"columnYear": "Jahr",
|
||||||
|
"columnGenresSpecials": "Genres / Specials",
|
||||||
|
"columnAdded": "Hinzugefügt",
|
||||||
|
"columnActivations": "Aktivierungen",
|
||||||
|
"columnRating": "Rating",
|
||||||
|
"columnExcludeGlobal": "Exclude Global",
|
||||||
|
"columnActions": "Aktionen",
|
||||||
|
"play": "Abspielen",
|
||||||
|
"pause": "Pause",
|
||||||
|
"excludeGlobalYes": "Ja",
|
||||||
|
"excludeGlobalNo": "Nein",
|
||||||
|
"excludeGlobalInfo": "Nur globale Kuratoren dürfen dieses Flag ändern.",
|
||||||
|
"paginationPrev": "Zurück",
|
||||||
|
"paginationNext": "Weiter",
|
||||||
|
"paginationLabel": "Seite {page} von {total}",
|
||||||
|
"loadingData": "Lade Daten...",
|
||||||
|
"loggedInAs": "Eingeloggt als {username}",
|
||||||
|
"globalCuratorSuffix": " (Globaler Kurator)",
|
||||||
|
"pageSizeLabel": "Pro Seite:",
|
||||||
|
"commentsTitle": "Kommentare",
|
||||||
|
"showComments": "Kommentare anzeigen",
|
||||||
|
"hideComments": "Kommentare ausblenden",
|
||||||
|
"loadingComments": "Kommentare werden geladen...",
|
||||||
|
"noComments": "Keine Kommentare vorhanden.",
|
||||||
|
"loadCommentsError": "Fehler beim Laden der Kommentare.",
|
||||||
|
"commentFromPuzzle": "Kommentar zu Puzzle",
|
||||||
|
"commentGenre": "Genre",
|
||||||
|
"unreadComment": "Ungelesen",
|
||||||
|
"archiveComment": "Archivieren",
|
||||||
|
"archiveCommentConfirm": "Möchtest du diesen Kommentar wirklich archivieren?",
|
||||||
|
"archiveCommentError": "Fehler beim Archivieren des Kommentars.",
|
||||||
|
"newComments": "neu",
|
||||||
|
"batchEditTitle": "Batch-Bearbeitung",
|
||||||
|
"clearSelection": "Auswahl aufheben",
|
||||||
|
"batchToggleGenres": "Genres umschalten",
|
||||||
|
"batchToggleSpecials": "Specials umschalten",
|
||||||
|
"batchChangeArtist": "Artist ändern",
|
||||||
|
"batchArtistPlaceholder": "Neuen Artist-Namen eingeben",
|
||||||
|
"batchExcludeGlobal": "Von Global ausschließen",
|
||||||
|
"batchNoChange": "Keine Änderung",
|
||||||
|
"batchExclude": "Ausschließen",
|
||||||
|
"batchInclude": "Einschließen",
|
||||||
|
"batchUpdating": "Aktualisiere...",
|
||||||
|
"batchApply": "Änderungen anwenden",
|
||||||
|
"selectAll": "Alle auswählen",
|
||||||
|
"selectSong": "Titel auswählen",
|
||||||
|
"cannotEditSong": "Dieser Titel kann nicht bearbeitet werden",
|
||||||
|
"noSongsSelected": "Keine Titel ausgewählt",
|
||||||
|
"noBatchOperations": "Keine Batch-Operationen angegeben",
|
||||||
|
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
|
||||||
|
"batchUpdateError": "Fehler: {error}",
|
||||||
|
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung"
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
"title": "Über Hördle & Impressum",
|
"title": "Über Hördle & Impressum",
|
||||||
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
|
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
|
||||||
"projectTitle": "Über dieses Projekt",
|
"projectTitle": "Über dieses Projekt",
|
||||||
@@ -171,12 +286,13 @@
|
|||||||
"imprintEmailLabel": "E-Mail:",
|
"imprintEmailLabel": "E-Mail:",
|
||||||
"costsTitle": "Laufende Kosten des Projekts",
|
"costsTitle": "Laufende Kosten des Projekts",
|
||||||
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
||||||
|
"costsDonationNote": "Alle Einnahmen, die die Betriebskosten des Projekts übersteigen, werden am Jahresende an die Aktion <link>Zentrum für politische Schönheit</link> gespendet.",
|
||||||
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
||||||
"costsServer": "Server / vServer für App und Tracking",
|
"costsServer": "Server / vServer für App und Tracking",
|
||||||
"costsEmail": "E-Mail-Hosting",
|
"costsEmail": "E-Mail-Hosting",
|
||||||
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
|
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
|
||||||
"costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.",
|
"costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.",
|
||||||
"costsSheetPrivacyNote": "Beim Aufruf oder Einbetten der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
|
"costsSheetPrivacyNote": "Beim Aufruf der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
|
||||||
"supportTitle": "Hördle unterstützen",
|
"supportTitle": "Hördle unterstützen",
|
||||||
"supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:",
|
"supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:",
|
||||||
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",
|
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",
|
||||||
|
|||||||
120
messages/en.json
120
messages/en.json
@@ -41,6 +41,7 @@
|
|||||||
"comeBackTomorrow": "Come back tomorrow for a new song.",
|
"comeBackTomorrow": "Come back tomorrow for a new song.",
|
||||||
"theSongWas": "The song was:",
|
"theSongWas": "The song was:",
|
||||||
"score": "Score",
|
"score": "Score",
|
||||||
|
"shareExplanation": "Share your result with friends – your support helps Hördle grow.",
|
||||||
"scoreBreakdown": "Score Breakdown",
|
"scoreBreakdown": "Score Breakdown",
|
||||||
"albumCover": "Album Cover",
|
"albumCover": "Album Cover",
|
||||||
"released": "Released",
|
"released": "Released",
|
||||||
@@ -56,6 +57,13 @@
|
|||||||
"points": "points",
|
"points": "points",
|
||||||
"skipBonus": "Skip Bonus",
|
"skipBonus": "Skip Bonus",
|
||||||
"notQuite": "Not quite!",
|
"notQuite": "Not quite!",
|
||||||
|
"sendComment": "Send message to curator",
|
||||||
|
"commentPlaceholder": "Write a message to the curators of this genre...",
|
||||||
|
"commentHelp": "Share your thoughts about the puzzle with the curators. Your message will be displayed to them.",
|
||||||
|
"commentSent": "✓ Message sent! Thank you for your feedback.",
|
||||||
|
"commentError": "Error sending message",
|
||||||
|
"commentRateLimited": "You have already sent a message for this puzzle.",
|
||||||
|
"sending": "Sending...",
|
||||||
"youGuessed": "You guessed",
|
"youGuessed": "You guessed",
|
||||||
"actuallyReleasedIn": "Actually released in",
|
"actuallyReleasedIn": "Actually released in",
|
||||||
"skipped": "Skipped",
|
"skipped": "Skipped",
|
||||||
@@ -157,8 +165,115 @@
|
|||||||
"artist": "Artist",
|
"artist": "Artist",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"deletePuzzle": "Delete",
|
"deletePuzzle": "Delete",
|
||||||
"wrongPassword": "Wrong password"
|
"wrongPassword": "Wrong password",
|
||||||
|
"manageCurators": "Manage Curators",
|
||||||
|
"addCurator": "Add Curator",
|
||||||
|
"curatorUsername": "Username",
|
||||||
|
"curatorPassword": "Password (leave empty to keep)",
|
||||||
|
"isGlobalCurator": "Global curator (may change global flag)",
|
||||||
|
"assignedGenres": "Assigned genres",
|
||||||
|
"assignedSpecials": "Assigned specials",
|
||||||
|
"noCurators": "No curators created yet."
|
||||||
},
|
},
|
||||||
|
"Curator": {
|
||||||
|
"loginTitle": "Curator Login",
|
||||||
|
"loginUsername": "Username",
|
||||||
|
"loginPassword": "Password",
|
||||||
|
"loginButton": "Log in",
|
||||||
|
"logout": "Logout",
|
||||||
|
"loginFailed": "Login failed.",
|
||||||
|
"loginNetworkError": "Network error during login.",
|
||||||
|
"loadCuratorError": "Failed to load curator information.",
|
||||||
|
"loadSongsError": "Failed to load songs.",
|
||||||
|
"songUpdated": "Song updated successfully.",
|
||||||
|
"saveError": "Error while saving: {error}",
|
||||||
|
"saveNetworkError": "Network error while saving.",
|
||||||
|
"noDeletePermission": "You are not allowed to delete this song.",
|
||||||
|
"deleteConfirm": "Do you really want to delete \"{title}\"?",
|
||||||
|
"songDeleted": "Song deleted.",
|
||||||
|
"deleteError": "Error while deleting: {error}",
|
||||||
|
"deleteNetworkError": "Network error while deleting.",
|
||||||
|
"uploadSectionTitle": "Upload titles",
|
||||||
|
"uploadSectionDescription": "Drag one or more MP3 files here or select them. The titles will be analysed automatically (including detection of the release year) and excluded from the global playlist. Select at least one of your genres to assign the titles.",
|
||||||
|
"dropzoneTitleEmpty": "Drag MP3 files here",
|
||||||
|
"dropzoneTitleWithFiles": "{count} file(s) selected",
|
||||||
|
"dropzoneSubtitle": "or click to select files",
|
||||||
|
"selectedFilesTitle": "Selected files:",
|
||||||
|
"uploadProgress": "Upload: {current} / {total}",
|
||||||
|
"assignGenresLabel": "Assign genres",
|
||||||
|
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
|
||||||
|
"uploadButtonIdle": "Start upload",
|
||||||
|
"uploadButtonUploading": "Uploading...",
|
||||||
|
"uploadSummary": "✅ {success}/{total} uploads successful.",
|
||||||
|
"uploadSummaryDuplicates": "⚠️ {count} duplicate(s) skipped.",
|
||||||
|
"uploadSummaryFailed": "❌ {count} failed.",
|
||||||
|
"uploadResultSuccess": "✅ successful",
|
||||||
|
"uploadResultDuplicate": "⚠️ Duplicate: {error}",
|
||||||
|
"uploadResultError": "❌ Error: {error}",
|
||||||
|
"tracklistTitle": "Titles in your genres & specials ({count} titles)",
|
||||||
|
"tracklistDescription": "You can edit songs that are assigned to at least one of your genres or specials. Deletion is only allowed if a song is assigned exclusively to your genres/specials. Genres, specials, news and political statements can only be managed by the admin.",
|
||||||
|
"searchPlaceholder": "Search by title or artist...",
|
||||||
|
"filterAll": "All content",
|
||||||
|
"filterNoGlobal": "🚫 No global",
|
||||||
|
"filterReset": "Reset filters",
|
||||||
|
"noSongsInScope": "No matching songs in your genres/specials.",
|
||||||
|
"columnId": "ID",
|
||||||
|
"columnPlay": "Play",
|
||||||
|
"columnTitle": "Title",
|
||||||
|
"columnArtist": "Artist",
|
||||||
|
"columnYear": "Year",
|
||||||
|
"columnGenresSpecials": "Genres / Specials",
|
||||||
|
"columnAdded": "Added",
|
||||||
|
"columnActivations": "Activations",
|
||||||
|
"columnRating": "Rating",
|
||||||
|
"columnExcludeGlobal": "Exclude global",
|
||||||
|
"columnActions": "Actions",
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"excludeGlobalYes": "Yes",
|
||||||
|
"excludeGlobalNo": "No",
|
||||||
|
"excludeGlobalInfo": "Only global curators may change this flag.",
|
||||||
|
"paginationPrev": "Previous",
|
||||||
|
"paginationNext": "Next",
|
||||||
|
"paginationLabel": "Page {page} of {total}",
|
||||||
|
"loadingData": "Loading data...",
|
||||||
|
"loggedInAs": "Logged in as {username}",
|
||||||
|
"globalCuratorSuffix": " (Global curator)",
|
||||||
|
"pageSizeLabel": "Per page:",
|
||||||
|
"commentsTitle": "Comments",
|
||||||
|
"showComments": "Show comments",
|
||||||
|
"hideComments": "Hide comments",
|
||||||
|
"loadingComments": "Loading comments...",
|
||||||
|
"noComments": "No comments available.",
|
||||||
|
"loadCommentsError": "Error loading comments.",
|
||||||
|
"commentFromPuzzle": "Comment from puzzle",
|
||||||
|
"commentGenre": "Genre",
|
||||||
|
"unreadComment": "Unread",
|
||||||
|
"archiveComment": "Archive",
|
||||||
|
"archiveCommentConfirm": "Do you really want to archive this comment?",
|
||||||
|
"archiveCommentError": "Error archiving comment.",
|
||||||
|
"newComments": "new",
|
||||||
|
"batchEditTitle": "Batch Edit",
|
||||||
|
"clearSelection": "Clear Selection",
|
||||||
|
"batchToggleGenres": "Toggle Genres",
|
||||||
|
"batchToggleSpecials": "Toggle Specials",
|
||||||
|
"batchChangeArtist": "Change Artist",
|
||||||
|
"batchArtistPlaceholder": "Enter new artist name",
|
||||||
|
"batchExcludeGlobal": "Exclude from Global",
|
||||||
|
"batchNoChange": "No change",
|
||||||
|
"batchExclude": "Exclude",
|
||||||
|
"batchInclude": "Include",
|
||||||
|
"batchUpdating": "Updating...",
|
||||||
|
"batchApply": "Apply Changes",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"selectSong": "Select song",
|
||||||
|
"cannotEditSong": "Cannot edit this song",
|
||||||
|
"noSongsSelected": "No songs selected",
|
||||||
|
"noBatchOperations": "No batch operations specified",
|
||||||
|
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
|
||||||
|
"batchUpdateError": "Error: {error}",
|
||||||
|
"batchUpdateNetworkError": "Network error during batch update"
|
||||||
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "About Hördle & Imprint",
|
"title": "About Hördle & Imprint",
|
||||||
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",
|
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",
|
||||||
@@ -171,12 +286,13 @@
|
|||||||
"imprintEmailLabel": "Email:",
|
"imprintEmailLabel": "Email:",
|
||||||
"costsTitle": "Ongoing costs of the project",
|
"costsTitle": "Ongoing costs of the project",
|
||||||
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
||||||
|
"costsDonationNote": "All income that exceeds the operating costs of the project will be donated at the end of the year to the campaign <link>Zentrum für politische Schönheit</link>.",
|
||||||
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
||||||
"costsServer": "Servers / vServers for the app and tracking",
|
"costsServer": "Servers / vServers for the app and tracking",
|
||||||
"costsEmail": "Email hosting",
|
"costsEmail": "Email hosting",
|
||||||
"costsLicenses": "Possible fees for copyrights or other licenses",
|
"costsLicenses": "Possible fees for copyrights or other licenses",
|
||||||
"costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.",
|
"costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.",
|
||||||
"costsSheetPrivacyNote": "When accessing or embedding the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
|
"costsSheetPrivacyNote": "When accessing the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
|
||||||
"supportTitle": "Support Hördle",
|
"supportTitle": "Support Hördle",
|
||||||
"supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:",
|
"supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:",
|
||||||
"supportSepaTitle": "SEPA Bank Transfer (preferred)",
|
"supportSepaTitle": "SEPA Bank Transfer (preferred)",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.4.8",
|
"version": "0.1.6.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PoliticalStatement" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"locale" TEXT NOT NULL,
|
||||||
|
"text" TEXT NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"source" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PoliticalStatement_locale_active_idx" ON "PoliticalStatement"("locale", "active");
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Curator" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"isGlobalCurator" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CuratorGenre" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"curatorId" INTEGER NOT NULL,
|
||||||
|
"genreId" INTEGER NOT NULL,
|
||||||
|
CONSTRAINT "CuratorGenre_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "CuratorGenre_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CuratorSpecial" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"curatorId" INTEGER NOT NULL,
|
||||||
|
"specialId" INTEGER NOT NULL,
|
||||||
|
CONSTRAINT "CuratorSpecial_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "CuratorSpecial_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Curator_username_key" ON "Curator"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CuratorGenre_curatorId_genreId_key" ON "CuratorGenre"("curatorId", "genreId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CuratorSpecial_curatorId_specialId_key" ON "CuratorSpecial"("curatorId", "specialId");
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CuratorComment" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"playerIdentifier" TEXT NOT NULL,
|
||||||
|
"puzzleId" INTEGER NOT NULL,
|
||||||
|
"genreId" INTEGER,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "CuratorComment_puzzleId_fkey" FOREIGN KEY ("puzzleId") REFERENCES "DailyPuzzle" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "CuratorComment_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CuratorCommentRecipient" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"commentId" INTEGER NOT NULL,
|
||||||
|
"curatorId" INTEGER NOT NULL,
|
||||||
|
"readAt" DATETIME,
|
||||||
|
CONSTRAINT "CuratorCommentRecipient_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "CuratorComment" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "CuratorCommentRecipient_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CuratorComment_playerIdentifier_puzzleId_key" ON "CuratorComment"("playerIdentifier", "puzzleId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CuratorComment_genreId_idx" ON "CuratorComment"("genreId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CuratorCommentRecipient_commentId_curatorId_key" ON "CuratorCommentRecipient"("commentId", "curatorId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CuratorCommentRecipient_curatorId_idx" ON "CuratorCommentRecipient"("curatorId");
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CuratorCommentRecipient" ADD COLUMN "archived" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@ model Genre {
|
|||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
songs Song[]
|
songs Song[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
|
curatorGenres CuratorGenre[]
|
||||||
|
comments CuratorComment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Special {
|
model Special {
|
||||||
@@ -48,6 +50,7 @@ model Special {
|
|||||||
songs SpecialSong[]
|
songs SpecialSong[]
|
||||||
puzzles DailyPuzzle[]
|
puzzles DailyPuzzle[]
|
||||||
news News[]
|
news News[]
|
||||||
|
curatorSpecials CuratorSpecial[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model SpecialSong {
|
model SpecialSong {
|
||||||
@@ -71,6 +74,7 @@ model DailyPuzzle {
|
|||||||
genre Genre? @relation(fields: [genreId], references: [id])
|
genre Genre? @relation(fields: [genreId], references: [id])
|
||||||
specialId Int?
|
specialId Int?
|
||||||
special Special? @relation(fields: [specialId], references: [id])
|
special Special? @relation(fields: [specialId], references: [id])
|
||||||
|
comments CuratorComment[]
|
||||||
|
|
||||||
@@unique([date, genreId, specialId])
|
@@unique([date, genreId, specialId])
|
||||||
}
|
}
|
||||||
@@ -101,3 +105,79 @@ model PlayerState {
|
|||||||
@@unique([identifier, genreKey])
|
@@unique([identifier, genreKey])
|
||||||
@@index([identifier])
|
@@index([identifier])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Curator {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String
|
||||||
|
isGlobalCurator Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
genres CuratorGenre[]
|
||||||
|
specials CuratorSpecial[]
|
||||||
|
commentRecipients CuratorCommentRecipient[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model CuratorGenre {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
curatorId Int
|
||||||
|
genreId Int
|
||||||
|
|
||||||
|
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
|
||||||
|
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([curatorId, genreId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CuratorSpecial {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
curatorId Int
|
||||||
|
specialId Int
|
||||||
|
|
||||||
|
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
|
||||||
|
special Special @relation(fields: [specialId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([curatorId, specialId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PoliticalStatement {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
locale String
|
||||||
|
text String
|
||||||
|
active Boolean @default(true)
|
||||||
|
source String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([locale, active])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CuratorComment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
playerIdentifier String
|
||||||
|
puzzleId Int
|
||||||
|
puzzle DailyPuzzle @relation(fields: [puzzleId], references: [id], onDelete: Cascade)
|
||||||
|
genreId Int?
|
||||||
|
genre Genre? @relation(fields: [genreId], references: [id], onDelete: SetNull)
|
||||||
|
message String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
recipients CuratorCommentRecipient[]
|
||||||
|
|
||||||
|
@@unique([playerIdentifier, puzzleId])
|
||||||
|
@@index([genreId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CuratorCommentRecipient {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
commentId Int
|
||||||
|
comment CuratorComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||||
|
curatorId Int
|
||||||
|
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
|
||||||
|
readAt DateTime?
|
||||||
|
archived Boolean @default(false)
|
||||||
|
|
||||||
|
@@unique([commentId, curatorId])
|
||||||
|
@@index([curatorId])
|
||||||
|
}
|
||||||
|
|||||||
77
scripts/backup-restic.sh
Executable file
77
scripts/backup-restic.sh
Executable file
@@ -0,0 +1,77 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Restic backup script for Hördle deployment
|
||||||
|
# Creates a backup snapshot with tags and handles errors gracefully
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "💾 Creating Restic backup..."
|
||||||
|
|
||||||
|
if ! command -v restic >/dev/null 2>&1; then
|
||||||
|
echo "⚠️ restic not found in PATH, skipping Restic backup"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check required environment variables
|
||||||
|
if [ -z "$RESTIC_PASSWORD" ]; then
|
||||||
|
echo "⚠️ RESTIC_PASSWORD not set, skipping Restic backup"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$RESTIC_AUTH_USER" ] || [ -z "$RESTIC_AUTH_PASSWORD" ]; then
|
||||||
|
echo "⚠️ RESTIC_AUTH_USER or RESTIC_AUTH_PASSWORD not set, skipping Restic backup"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build repository URL
|
||||||
|
RESTIC_REPO="rest:https://${RESTIC_AUTH_USER}:${RESTIC_AUTH_PASSWORD}@restic.elpatron.me/"
|
||||||
|
|
||||||
|
# Get current commit hash for tagging
|
||||||
|
CURRENT_COMMIT_SHORT="$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')"
|
||||||
|
CURRENT_DATE="$(date +%Y-%m-%d)"
|
||||||
|
|
||||||
|
# Export password for restic
|
||||||
|
export RESTIC_PASSWORD
|
||||||
|
|
||||||
|
# Check if repository exists, initialize if not
|
||||||
|
if ! restic -r "$RESTIC_REPO" snapshots >/dev/null 2>&1; then
|
||||||
|
echo " Initializing Restic repository..."
|
||||||
|
if ! restic -r "$RESTIC_REPO" init >/dev/null 2>&1; then
|
||||||
|
echo "⚠️ Failed to initialize Restic repository, skipping backup"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create backup with tags
|
||||||
|
# Backup important directories: backups, config files, but exclude node_modules, .git, etc.
|
||||||
|
echo " Creating Restic snapshot..."
|
||||||
|
RESTIC_EXIT_CODE=0
|
||||||
|
restic -r "$RESTIC_REPO" backup \
|
||||||
|
--tag deployment \
|
||||||
|
--tag hoerdle \
|
||||||
|
--tag "date:${CURRENT_DATE}" \
|
||||||
|
--tag "commit:${CURRENT_COMMIT_SHORT}" \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='.next' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
./backups \
|
||||||
|
./data \
|
||||||
|
./public/uploads \
|
||||||
|
docker-compose.yml \
|
||||||
|
.env \
|
||||||
|
package.json \
|
||||||
|
prisma/schema.prisma \
|
||||||
|
prisma/migrations \
|
||||||
|
scripts/ || RESTIC_EXIT_CODE=$?
|
||||||
|
|
||||||
|
if [ $RESTIC_EXIT_CODE -eq 0 ]; then
|
||||||
|
echo "✅ Restic backup completed successfully"
|
||||||
|
exit 0
|
||||||
|
elif [ $RESTIC_EXIT_CODE -eq 3 ]; then
|
||||||
|
echo "⚠️ Restic backup completed with warnings (some files could not be read), continuing..."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "⚠️ Restic backup failed (exit code: $RESTIC_EXIT_CODE), continuing deployment..."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
38
scripts/deploy-remote.sh
Executable file
38
scripts/deploy-remote.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Remote-Deployment-Skript für Hördle
|
||||||
|
# Führt auf dem entfernten Host den Befehl
|
||||||
|
# ssh docker@100.116.245.76 "cd ~/hoerdle && ./scripts/deploy.sh"
|
||||||
|
# aus und liest das SSH-Passwort aus der Umgebungsvariablen PROD_SSH_PASSWORD.
|
||||||
|
#
|
||||||
|
# Voraussetzungen:
|
||||||
|
# - sshpass ist lokal installiert (z.B. `sudo apt-get install sshpass`)
|
||||||
|
# - PROD_SSH_PASSWORD ist im Environment gesetzt
|
||||||
|
# 1) Passwort im Environment setzen (nur für diese Session)
|
||||||
|
# export PROD_SSH_PASSWORD='dein-sehr-geheimes-passwort'
|
||||||
|
# 2) Skript ausführen: ./scripts/deploy-remote.sh
|
||||||
|
|
||||||
|
REMOTE_USER="docker"
|
||||||
|
REMOTE_HOST="100.116.245.76"
|
||||||
|
REMOTE_CMD='cd ~/hoerdle && ./scripts/deploy.sh'
|
||||||
|
|
||||||
|
if ! command -v sshpass >/dev/null 2>&1; then
|
||||||
|
echo "Fehler: sshpass ist nicht installiert. Bitte mit z.B. 'sudo apt-get install sshpass' nachinstallieren." >&2
|
||||||
|
exit 1;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${PROD_SSH_PASSWORD:-}" ]]; then
|
||||||
|
echo "Fehler: Umgebungsvariable PROD_SSH_PASSWORD ist nicht gesetzt." >&2
|
||||||
|
echo "Bitte setze sie z.B.: export PROD_SSH_PASSWORD='dein-passwort'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Starte Remote-Deployment auf ${REMOTE_USER}@${REMOTE_HOST} ..."
|
||||||
|
|
||||||
|
sshpass -p "${PROD_SSH_PASSWORD}" \
|
||||||
|
ssh -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "${REMOTE_CMD}"
|
||||||
|
|
||||||
|
echo "✅ Remote-Deployment abgeschlossen."
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "🚀 Starting optimized deployment..."
|
echo "🚀 Starting optimized deployment with full rollback support..."
|
||||||
|
|
||||||
# Backup database
|
# Backup database (per Deployment, inkl. Metadaten für Rollback)
|
||||||
echo "💾 Creating database backup..."
|
echo "💾 Creating database backup for this deployment..."
|
||||||
|
|
||||||
# Try to find database path from docker-compose.yml or .env
|
# Try to find database path from docker-compose.yml or .env
|
||||||
DB_PATH=""
|
DB_PATH=""
|
||||||
@@ -26,16 +26,29 @@ if [ -n "$DB_PATH" ]; then
|
|||||||
# Convert container path to host path if needed
|
# Convert container path to host path if needed
|
||||||
# /app/data/prod.db -> ./data/prod.db
|
# /app/data/prod.db -> ./data/prod.db
|
||||||
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
|
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
|
||||||
|
|
||||||
if [ -f "$DB_PATH" ]; then
|
if [ -f "$DB_PATH" ]; then
|
||||||
# Create backups directory
|
# Create backups directory
|
||||||
mkdir -p ./backups
|
mkdir -p ./backups
|
||||||
|
|
||||||
# Create timestamped backup
|
# Create timestamped backup
|
||||||
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_$(date +%Y%m%d_%H%M%S).db"
|
DEPLOY_TS="$(date +%Y%m%d_%H%M%S)"
|
||||||
|
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_${DEPLOY_TS}.db"
|
||||||
cp "$DB_PATH" "$BACKUP_FILE"
|
cp "$DB_PATH" "$BACKUP_FILE"
|
||||||
echo "✅ Database backed up to: $BACKUP_FILE"
|
echo "✅ Database backed up to: $BACKUP_FILE"
|
||||||
|
|
||||||
|
# Store metadata for restore (Timestamp, DB-Path, Git-Commit)
|
||||||
|
CURRENT_COMMIT="$(git rev-parse HEAD || echo 'unknown')"
|
||||||
|
{
|
||||||
|
echo "timestamp=${DEPLOY_TS}"
|
||||||
|
echo "db_path=${DB_PATH}"
|
||||||
|
echo "backup_file=${BACKUP_FILE}"
|
||||||
|
echo "git_commit=${CURRENT_COMMIT}"
|
||||||
|
} > "./backups/last_deploy.meta"
|
||||||
|
|
||||||
|
# Append to history manifest (eine Zeile pro Deployment)
|
||||||
|
echo "${DEPLOY_TS}|${DB_PATH}|${BACKUP_FILE}|${CURRENT_COMMIT}" >> "./backups/deploy_history.log"
|
||||||
|
|
||||||
# Keep only last 10 backups
|
# Keep only last 10 backups
|
||||||
ls -t ./backups/*.db | tail -n +11 | xargs -r rm
|
ls -t ./backups/*.db | tail -n +11 | xargs -r rm
|
||||||
echo "🧹 Cleaned old backups (keeping last 10)"
|
echo "🧹 Cleaned old backups (keeping last 10)"
|
||||||
@@ -46,13 +59,13 @@ else
|
|||||||
echo "⚠️ Could not determine database path from config files"
|
echo "⚠️ Could not determine database path from config files"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pull latest changes
|
# Restic backup to remote repository
|
||||||
echo "📥 Pulling latest changes from git..."
|
./scripts/backup-restic.sh
|
||||||
git pull
|
|
||||||
|
|
||||||
# Fetch all tags
|
# Nur neueste Version holen (shallow fetch), vollständiges Repo ist im Deployment nicht nötig
|
||||||
echo "🏷️ Fetching git tags..."
|
echo "📥 Fetching latest commit (shallow clone) from git..."
|
||||||
git fetch --tags
|
git fetch --prune --tags --depth=1 origin master
|
||||||
|
git reset --hard origin/master
|
||||||
|
|
||||||
# Prüfe und erstelle/repariere Netzwerk falls nötig
|
# Prüfe und erstelle/repariere Netzwerk falls nötig
|
||||||
echo "🌐 Prüfe Docker-Netzwerk..."
|
echo "🌐 Prüfe Docker-Netzwerk..."
|
||||||
|
|||||||
93
scripts/restore.sh
Normal file
93
scripts/restore.sh
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🧯 Hördle restore script – Rollback auf früheres Datenbank-Backup"
|
||||||
|
|
||||||
|
# Hilfsfunktion für Fehlerausgabe
|
||||||
|
die() {
|
||||||
|
echo "❌ $1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backup-Verzeichnis
|
||||||
|
BACKUP_DIR="./backups"
|
||||||
|
|
||||||
|
if [ ! -d "$BACKUP_DIR" ]; then
|
||||||
|
die "Kein Backup-Verzeichnis gefunden (${BACKUP_DIR}). Es scheint noch kein Deployment-Backup erstellt worden zu sein."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Argument: gewünschter Backup-Timestamp oder 'latest'
|
||||||
|
TARGET="$1"
|
||||||
|
|
||||||
|
if [ -z "$TARGET" ]; then
|
||||||
|
echo "⚙️ Nutzung:"
|
||||||
|
echo " ./scripts/restore.sh latest # neuestes Backup zurückspielen"
|
||||||
|
echo " ./scripts/restore.sh 20250101_120000 # bestimmtes Backup (Timestamp aus Dateiname)"
|
||||||
|
echo ""
|
||||||
|
echo "Verfügbare Backups:"
|
||||||
|
ls -1 "${BACKUP_DIR}"/*.db 2>/dev/null || echo " (keine .db-Backups gefunden)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# DB-Pfad wie in deploy.sh bestimmen
|
||||||
|
DB_PATH=""
|
||||||
|
|
||||||
|
if [ -f "docker-compose.yml" ]; then
|
||||||
|
DB_PATH=$(grep -oP 'DATABASE_URL=file:\K[^\s]+' docker-compose.yml | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$DB_PATH" ] && [ -f ".env" ]; then
|
||||||
|
DB_PATH=$(grep -oP '^DATABASE_URL=file:\K.+' .env | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
DB_PATH=$(echo "$DB_PATH" | tr -d '"' | tr -d "'")
|
||||||
|
|
||||||
|
if [ -z "$DB_PATH" ]; then
|
||||||
|
die "Konnte den Datenbank-Pfad aus docker-compose.yml oder .env nicht ermitteln."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Containerpfad zu Hostpfad umbauen (/app/... -> ./...)
|
||||||
|
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
|
||||||
|
|
||||||
|
echo "📁 Ziel-Datenbank-Datei: $DB_PATH"
|
||||||
|
|
||||||
|
# Backup-Datei bestimmen
|
||||||
|
if [ "$TARGET" = "latest" ]; then
|
||||||
|
BACKUP_FILE=$(ls -t "${BACKUP_DIR}"/*.db 2>/dev/null | head -1 || true)
|
||||||
|
[ -z "$BACKUP_FILE" ] && die "Kein Backup gefunden."
|
||||||
|
else
|
||||||
|
# Versuchen, exakten Dateinamen zu finden
|
||||||
|
if [ -f "${BACKUP_DIR}/${TARGET}" ]; then
|
||||||
|
BACKUP_FILE="${BACKUP_DIR}/${TARGET}"
|
||||||
|
else
|
||||||
|
# Versuchen, anhand des Timestamps ein Backup zu finden
|
||||||
|
BACKUP_FILE=$(ls "${BACKUP_DIR}"/*"${TARGET}"*.db 2>/dev/null | head -1 || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -z "$BACKUP_FILE" ] && die "Kein Backup für '${TARGET}' gefunden."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "⏪ Verwende Backup-Datei: $BACKUP_FILE"
|
||||||
|
|
||||||
|
if [ ! -f "$BACKUP_FILE" ]; then
|
||||||
|
die "Backup-Datei existiert nicht: $BACKUP_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "❗ Dies überschreibt die aktuelle Datenbank-Datei. Fortfahren? [y/N] " CONFIRM
|
||||||
|
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Abgebrochen."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 Kopiere Backup nach: $DB_PATH"
|
||||||
|
cp "$BACKUP_FILE" "$DB_PATH"
|
||||||
|
|
||||||
|
echo "🔄 Starte Docker-Container neu..."
|
||||||
|
docker compose restart hoerdle
|
||||||
|
|
||||||
|
echo "✅ Restore abgeschlossen."
|
||||||
|
echo "ℹ️ Hinweis: Der Code-Stand (Git-Commit) ist nicht automatisch zurückgedreht."
|
||||||
|
echo " Falls du auch die App-Version zurückrollen möchtest, checke lokal den passenden Commit/Tag aus"
|
||||||
|
echo " und führe anschließend wieder ./scripts/deploy.sh aus."
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user