Compare commits

...

32 Commits

Author SHA1 Message Date
Hördle Bot
71abb7c322 Bump version to v0.1.5.1 2025-12-03 17:34:40 +01:00
Hördle Bot
b730c6637a Fix random song selection bias in daily puzzle generation 2025-12-03 17:34:23 +01:00
Hördle Bot
6e93529bc3 Add backup metadata and restore script for full DB rollback 2025-12-03 16:25:50 +01:00
Hördle Bot
990e1927e9 Curator: Client-Komponente ausgelagert, Server-Wrapper für stabilen Build 2025-12-03 15:28:17 +01:00
Hördle Bot
d7fee047c2 Deploy: shallow fetch + dynamische /curator-Seite für Docker-Build 2025-12-03 15:16:38 +01:00
Hördle Bot
28d14ff099 chore: bump version to v0.1.5.0 2025-12-03 15:12:50 +01:00
Hördle Bot
b1493b44bf Game: Share-Button unter Rating platziert und kurz erläutert 2025-12-03 15:03:32 +01:00
Hördle Bot
b8a803b76e Songs-API: robuste Behandlung möglicher verwaister SpecialSong-Relationen 2025-12-03 14:56:40 +01:00
Hördle Bot
e2bdf0fc88 Game: Attempt-Anzeige nach Rätsel-Ende nicht auf nächsten Versuch springen lassen 2025-12-03 14:09:31 +01:00
Hördle Bot
2cb9af8d2b Game: öffentliche Song-Liste für GuessInput statt geschütztem /api/songs 2025-12-03 14:06:32 +01:00
Hördle Bot
d6ad01b00e Curator-UI: sichere Optional-Chains für Genre-Filter 2025-12-03 13:46:58 +01:00
Hördle Bot
693817b18c Curator-Song-Update: Genre-Zuordnungen auch bei leerem Array korrekt übernehmen 2025-12-03 13:42:02 +01:00
Hördle Bot
41336e3af3 Curators API: aussagekräftige Fehler bei doppelten Usernames (P2002) 2025-12-03 13:37:59 +01:00
Hördle Bot
d7ec691469 Curator: Optional Chaining für Genre/Special-Filter abgesichert 2025-12-03 13:31:38 +01:00
Hördle Bot
5e1700712e Fix: Kuratoren-Scope für Specials & Audio-Playback im Curator-Dashboard 2025-12-03 13:25:43 +01:00
Hördle Bot
f691384a34 API: Auth & Scope für Song-GET, Kommentar für Kurator-Wrapper 2025-12-03 13:17:31 +01:00
Hördle Bot
f0d75c591a Admin: Validierung für Kuratoren-Passwort bei Neuanlage 2025-12-03 13:13:02 +01:00
Hördle Bot
1f34d5813e Fix: Kuratoren-Berechtigungscheck für Specials vereinheitlicht 2025-12-03 13:11:12 +01:00
Hördle Bot
33f8080aa8 Curator: Lokalisierung und einstellbare Paginierung 2025-12-03 13:09:20 +01:00
Hördle Bot
8a102afc0e Admin: Song Library und Upload entfernt 2025-12-03 12:59:26 +01:00
Hördle Bot
38148ace8d Kuratoren-Accounts und Anpassungen im Admin- und Kuratoren-Dashboard 2025-12-03 12:52:38 +01:00
Hördle Bot
49e98ade3c Update credits text in configuration to include a heart emoji for improved sentiment 2025-12-02 19:36:47 +01:00
Hördle Bot
397839cc1f Update credits text in configuration to enhance clarity 2025-12-02 19:29:55 +01:00
Hördle Bot
3fe805129b Change deploy-remote.sh file permissions to make it executable 2025-12-02 15:03:03 +01:00
Hördle Bot
bf9a49a9ac Update SSH password environment variable in remote deployment script 2025-12-02 14:56:36 +01:00
Hördle Bot
9b89cbf8ed Handle temporary SQLite DB during Docker build to prevent errors in sitemap generation 2025-12-02 14:55:50 +01:00
Hördle Bot
7f33e98fb5 Update donation note in costs section and increment version to v0.1.4.12 2025-12-02 14:54:26 +01:00
Hördle Bot
72f8b99092 Adjust costs section donation note and bump version to v0.1.4.11 2025-12-02 14:50:02 +01:00
Hördle Bot
e60daa511b Add donation note for political beauty and bump version to v0.1.4.10 2025-12-02 14:42:46 +01:00
Hördle Bot
19706abacb Bump version to v0.1.4.9 2025-12-02 14:26:17 +01:00
Hördle Bot
170e7b5402 Store political statements in database 2025-12-02 14:14:53 +01:00
Hördle Bot
ade1043c3c chore: Update .gitignore to include new script and documentation files 2025-12-02 14:11:11 +01:00
28 changed files with 2782 additions and 1718 deletions

2
.gitignore vendored
View File

@@ -52,3 +52,5 @@ next-env.d.ts
.release-years-migrated
.covers-migrated
docker-compose.yml
scripts/scrape-bahn-expert-statements.js
docs/bahn-expert-statements.txt

View File

@@ -98,13 +98,27 @@ export default async function AboutPage({ params }: AboutPageProps) {
</p>
<p
style={{
marginBottom: "0.75rem",
marginBottom: "0.5rem",
fontSize: "0.9rem",
color: "#6b7280",
}}
>
{t("costsSheetPrivacyNote")}
</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 style={{ marginBottom: "2rem" }}>

View File

@@ -76,6 +76,14 @@ interface PoliticalStatement {
locale: string;
}
interface Curator {
id: number;
username: string;
isGlobalCurator: boolean;
genreIds: number[];
specialIds: number[];
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc';
@@ -159,14 +167,18 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const [sortField, setSortField] = useState<SortField>('artist');
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 [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(1);
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 [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
@@ -184,6 +196,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true);
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
useEffect(() => {
const authToken = localStorage.getItem('hoerdle_admin_auth');
@@ -194,6 +216,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
fetchDailyPuzzles();
fetchSpecials();
fetchNews();
fetchCurators();
}
}, []);
@@ -210,6 +233,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
fetchDailyPuzzles();
fetchSpecials();
fetchNews();
fetchCurators();
} else {
alert(t('wrongPassword'));
}
@@ -224,6 +248,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
setGenres([]);
setSpecials([]);
setDailyPuzzles([]);
setCurators([]);
};
// 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 res = await fetch('/api/genres', {
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) => {
e.preventDefault();
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) => {
if (playingSongId === song.id) {
// Pause current song
@@ -1067,70 +1181,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}
};
// Filter and sort songs
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]);
// Song Library ist in das Kuratoren-Dashboard umgezogen, daher keine Song-Filter/Pagination mehr im Admin nötig.
if (!isAuthenticated) {
return (
@@ -1826,155 +1877,193 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
)}
</div>
{/* Curator Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>{t('uploadSongs')}</h2>
<form onSubmit={handleBatchUpload}>
{/* Drag & Drop Zone */}
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{t('manageCurators')}
</h2>
<button
onClick={() => setShowCurators(!showCurators)}
style={{
border: isDragging ? '2px solid #4f46e5' : '2px dashed #d1d5db',
borderRadius: '0.5rem',
padding: '2rem',
textAlign: 'center',
background: isDragging ? '#eef2ff' : '#f9fafb',
marginBottom: '1rem',
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
transition: 'all 0.2s'
fontSize: '0.875rem'
}}
onClick={() => fileInputRef.current?.click()}
>
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
{files.length > 0 ? `${files.length} file(s) selected` : 'Drag & drop MP3 files here'}
</p>
<p style={{ fontSize: '0.875rem', color: '#666' }}>
or click to browse
</p>
<input
ref={fileInputRef}
type="file"
accept="audio/mpeg"
multiple
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
{/* File List */}
{files.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>Selected Files:</p>
<div style={{ maxHeight: '200px', overflowY: 'auto', background: '#f9fafb', padding: '0.5rem', borderRadius: '0.25rem' }}>
{files.map((file, index) => (
<div key={index} style={{ padding: '0.25rem 0', fontSize: '0.875rem' }}>
📄 {file.name}
{showCurators ? t('hide') : t('show')}
</button>
</div>
{showCurators && (
<>
<form onSubmit={handleSaveCurator} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<input
type="text"
value={curatorUsername}
onChange={e => setCuratorUsername(e.target.value)}
placeholder={t('curatorUsername')}
className="form-input"
style={{ minWidth: '200px', flex: '1 1 200px' }}
required
/>
<input
type="password"
value={curatorPassword}
onChange={e => setCuratorPassword(e.target.value)}
placeholder={t('curatorPassword')}
className="form-input"
style={{ minWidth: '200px', flex: '1 1 200px' }}
/>
<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 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>
)}
</form>
{/* Upload Progress */}
{isUploading && (
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#eef2ff', borderRadius: '0.5rem' }}>
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>
Uploading: {uploadProgress.current} / {uploadProgress.total}
</p>
<div style={{ width: '100%', height: '8px', background: '#d1d5db', borderRadius: '4px', overflow: 'hidden' }}>
<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}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{curators.length === 0 && (
<p style={{ color: '#666', fontSize: '0.875rem' }}>{t('noCurators')}</p>
)}
{curators.map(curator => (
<div
key={curator.id}
style={{
padding: '0.75rem',
borderRadius: '0.5rem',
border: '1px solid #e5e7eb',
background: curator.isGlobalCurator ? '#eff6ff' : '#f9fafb',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
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'
gap: '0.5rem',
flexWrap: 'wrap'
}}
>
<input
type="checkbox"
checked={batchUploadGenreIds.includes(genre.id)}
onChange={e => {
if (e.target.checked) {
setBatchUploadGenreIds([...batchUploadGenreIds, genre.id]);
} else {
setBatchUploadGenreIds(batchUploadGenreIds.filter(id => id !== genre.id));
}
}}
style={{ margin: 0 }}
/>
{getLocalizedValue(genre.name, activeTab)}
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div style={{ fontWeight: 600 }}>{curator.username}</div>
<div style={{ fontSize: '0.8rem', color: '#4b5563' }}>
{curator.isGlobalCurator && <span>Globaler Kurator · </span>}
<span>
{t('assignedGenres')}: {curator.genreIds.length}
</span>
{' · '}
<span>
{t('assignedSpecials')}: {curator.specialIds.length}
</span>
</div>
</div>
<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>
<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>
{/* Upload Songs wurde in das Kuratoren-Dashboard verlagert */}
{/* Today's Daily Puzzles */}
<div className="admin-card">
<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 className="admin-card">
<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>
{/* Song Library wurde in das Kuratoren-Dashboard verlagert */}
<div className="admin-card" style={{ marginTop: '2rem', border: '1px solid #ef4444' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem', color: '#ef4444' }}>

View 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

View 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 });
}
}

View 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
View 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 });
}
}

View 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);
}

View File

@@ -1,18 +1,88 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { writeFile, unlink } from 'fs/promises';
import path from 'path';
import { parseBuffer } from 'music-metadata';
import { isDuplicateSong } from '@/lib/fuzzyMatch';
import { requireAdminAuth } from '@/lib/auth';
import { getStaffContext, 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);
// `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.
const songSpecialIds = (song.specials || [])
.map((s: any) => {
if (s?.id != null) return s.id;
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.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);
const songSpecialIds = (song.specials || [])
.map((s: any) => {
if (s?.id != null) return s.id;
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.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
export const runtime = 'nodejs';
export const maxDuration = 60; // 60 seconds timeout for uploads
export async function GET() {
export async function GET(request: NextRequest) {
// Alle Zugriffe auf die Songliste erfordern Staff-Auth (Admin oder Kurator)
const { error, context } = await requireStaffAuth(request);
if (error || !context) return error!;
const songs = await prisma.song.findMany({
orderBy: { createdAt: 'desc' },
include: {
@@ -26,8 +96,33 @@ export async function GET() {
},
});
let visibleSongs = songs;
if (context.role === 'curator') {
const assignments = await getCuratorAssignments(context.curator.id);
visibleSongs = songs.filter(song => {
const songGenreIds = song.genres.map(g => g.id);
// `song.specials` ist hier ein Array von SpecialSong mit Relation `special`.
// Es kann theoretisch verwaiste Einträge ohne `special` geben → defensiv optional chainen.
const songSpecialIds = song.specials
.map(ss => ss.special?.id)
.filter((id): id is number => typeof id === 'number');
// Songs ohne Genres/Specials sind immer sichtbar
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
return true;
}
const hasGenre = songGenreIds.some(id => assignments.genreIds.has(id));
const hasSpecial = songSpecialIds.some(id => assignments.specialIds.has(id));
return hasGenre || hasSpecial;
});
}
// Map to include activation count and flatten specials
const songsWithActivations = songs.map(song => ({
const songsWithActivations = visibleSongs.map(song => ({
id: song.id,
title: song.title,
artist: song.artist,
@@ -38,7 +133,10 @@ export async function GET() {
activations: song.puzzles.length,
puzzles: song.puzzles,
genres: song.genres,
specials: song.specials.map(ss => ss.special),
// Nur Specials mit existierender Relation durchreichen, um undefinierte Einträge zu vermeiden.
specials: song.specials
.map(ss => ss.special)
.filter((s): s is any => !!s),
averageRating: song.averageRating,
ratingCount: song.ratingCount,
excludeFromGlobal: song.excludeFromGlobal,
@@ -50,11 +148,11 @@ export async function GET() {
export async function POST(request: Request) {
console.log('[UPLOAD] Starting song upload request');
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) {
// Check authentication (admin or curator)
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (error || !context) {
console.log('[UPLOAD] Authentication failed');
return authError;
return error!;
}
try {
@@ -63,10 +161,17 @@ export async function POST(request: Request) {
const file = formData.get('file') as File;
let title = '';
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] 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) {
console.error('[UPLOAD] No file provided');
@@ -261,9 +366,9 @@ export async function POST(request: Request) {
}
export async function PUT(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
// Check authentication (admin or curator)
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (error || !context) return error!;
try {
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
@@ -272,6 +377,69 @@ export async function PUT(request: Request) {
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: 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 };
// Update releaseYear if provided (can be null to clear it)
@@ -280,24 +448,36 @@ export async function PUT(request: Request) {
}
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 = {
set: genreIds.map((gId: number) => ({ id: gId }))
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
};
}
// Handle SpecialSong relations separately
if (specialIds !== undefined) {
if (effectiveSpecialIds !== undefined) {
// First, get current special assignments
const currentSpecials = await prisma.specialSong.findMany({
where: { songId: Number(id) }
});
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
const newSpecialIds = specialIds as number[];
const newSpecialIds = effectiveSpecialIds as number[];
// Delete removed specials
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
@@ -344,9 +524,9 @@ export async function PUT(request: Request) {
}
export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
// Check authentication (admin or curator)
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (error || !context) return error!;
try {
const { id } = await request.json();
@@ -355,15 +535,30 @@ export async function DELETE(request: Request) {
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({
where: { id: Number(id) },
include: {
genres: true,
specials: true,
},
});
if (!song) {
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
}
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 file
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
try {

File diff suppressed because it is too large Load Diff

11
app/curator/page.tsx Normal file
View 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 />;
}

View File

@@ -70,6 +70,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Dynamic genre pages
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({
where: { active: true },
});

View File

@@ -391,6 +391,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
};
// Aktuelle Attempt-Anzeige:
// - Während des Spiels: nächster Versuch = guesses.length + 1
// - Nach Spielende (gelöst oder verloren): letzter Versuch = guesses.length
const currentAttempt = (gameState.isSolved || gameState.isFailed)
? gameState.guesses.length
: gameState.guesses.length + 1;
return (
<div className="container">
<header className="header">
@@ -403,7 +410,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div id="tour-status" className="status-bar">
<span>{t('attempt')} {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{t('attempt')} {currentAttempt} / {maxAttempts}</span>
<span>{unlockedSeconds}s {t('unlocked')}</span>
</div>
@@ -512,14 +519,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</audio>
</div>
<div style={{ marginBottom: '1rem' }}>
<div style={{ marginBottom: '1.25rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</div>
<div style={{ marginBottom: '1.25rem', textAlign: 'center' }}>
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>
{t('shareExplanation')}
</p>
<button onClick={handleShare} className="btn-primary">
{shareText}
</button>
</div>
{statistics && <Statistics statistics={statistics} />}
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText}
</button>
</div>
)}
</main>

View File

@@ -22,9 +22,25 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
fetch('/api/songs')
.then(res => res.json())
.then(data => setSongs(data));
fetch('/api/public-songs')
.then(res => {
if (!res.ok) {
throw new Error(`Failed to load songs: ${res.status}`);
}
return res.json();
})
.then(data => {
if (Array.isArray(data)) {
setSongs(data);
} else {
console.error('Unexpected songs payload in GuessInput:', data);
setSongs([]);
}
})
.catch(err => {
console.error('Error loading songs for GuessInput:', err);
setSongs([]);
});
}, []);
useEffect(() => {

View File

@@ -1,4 +1,11 @@
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
@@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise<NextRespon
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
*/

View File

@@ -9,7 +9,7 @@ export const config = {
},
credits: {
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',
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
},

View File

@@ -49,13 +49,15 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
// Calculate total weight
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
// Pick a random song based on weights
// Pick a random song based on weights using cumulative weights
// This ensures proper distribution and handles edge cases
let random = Math.random() * totalWeight;
let selectedSong = weightedSongs[0].song;
let selectedSong = weightedSongs[weightedSongs.length - 1].song; // Fallback to last song
let cumulativeWeight = 0;
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
cumulativeWeight += item.weight;
if (random <= cumulativeWeight) {
selectedSong = item.song;
break;
}
@@ -156,11 +158,13 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
let selectedSpecialSong = weightedSongs[0].specialSong;
let selectedSpecialSong = weightedSongs[weightedSongs.length - 1].specialSong; // Fallback to last song
// Pick a random song based on weights using cumulative weights
let cumulativeWeight = 0;
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
cumulativeWeight += item.weight;
if (random <= cumulativeWeight) {
selectedSpecialSong = item.specialSong;
break;
}

View File

@@ -1,99 +1,94 @@
import { promises as fs } from 'fs';
import path from 'path';
import { PrismaClient, PoliticalStatement as PrismaPoliticalStatement } from '@prisma/client';
const prisma = new PrismaClient();
export type PoliticalStatement = {
id: number;
locale: string;
text: string;
active?: boolean;
source?: string;
active: boolean;
source?: string | null;
};
function getFilePath(locale: string): string {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
return path.join(process.cwd(), 'data', `political-statements.${safeLocale}.json`);
}
async function readStatementsFile(locale: string): Promise<PoliticalStatement[]> {
const filePath = getFilePath(locale);
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;
}
function mapFromPrisma(stmt: PrismaPoliticalStatement): PoliticalStatement {
return {
id: stmt.id,
locale: stmt.locale,
text: stmt.text,
active: stmt.active,
source: stmt.source,
};
}
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
const statements = await readStatementsFile(locale);
const active = statements.filter((s) => s.active !== false);
if (active.length === 0) {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const all = await prisma.politicalStatement.findMany({
where: {
locale: safeLocale,
active: true,
},
});
if (all.length === 0) {
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[]> {
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> {
const statements = await readStatementsFile(locale);
const nextId = statements.length > 0 ? Math.max(...statements.map((s) => s.id)) + 1 : 1;
const newStatement: PoliticalStatement = {
id: nextId,
active: true,
...input,
};
statements.push(newStatement);
await writeStatementsFile(locale, statements);
return newStatement;
export async function createStatement(locale: string, input: Omit<PoliticalStatement, 'id' | 'locale'>): Promise<PoliticalStatement> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const created = await prisma.politicalStatement.create({
data: {
locale: safeLocale,
text: input.text,
active: input.active ?? true,
source: input.source ?? null,
},
});
return mapFromPrisma(created);
}
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id'>>): Promise<PoliticalStatement | null> {
const statements = await readStatementsFile(locale);
const index = statements.findIndex((s) => s.id === id);
if (index === -1) return null;
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id' | 'locale'>>): Promise<PoliticalStatement | null> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const updated: PoliticalStatement = {
...statements[index],
...input,
id,
};
statements[index] = updated;
await writeStatementsFile(locale, statements);
return updated;
// Optional: sicherstellen, dass das Statement zu dieser Locale gehört
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
if (!existing || existing.locale !== safeLocale) {
return null;
}
const updated = await prisma.politicalStatement.update({
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> {
const statements = await readStatementsFile(locale);
const filtered = statements.filter((s) => s.id !== id);
if (filtered.length === statements.length) {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
if (!existing || existing.locale !== safeLocale) {
return false;
}
await writeStatementsFile(locale, filtered);
await prisma.politicalStatement.delete({ where: { id } });
return true;
}

View File

@@ -41,6 +41,7 @@
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
"theSongWas": "Das Lied war:",
"score": "Punkte",
"shareExplanation": "Teile dein Ergebnis mit Freund:innen so hilfst du, Hördle bekannter zu machen.",
"scoreBreakdown": "Punkteaufschlüsselung",
"albumCover": "Album-Cover",
"released": "Veröffentlicht",
@@ -157,9 +158,83 @@
"artist": "Interpret",
"actions": "Aktionen",
"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:"
},
"About": {
"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.",
"projectTitle": "Über dieses Projekt",
@@ -171,12 +246,13 @@
"imprintEmailLabel": "E-Mail:",
"costsTitle": "Laufende Kosten des Projekts",
"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)",
"costsServer": "Server / vServer für App und Tracking",
"costsEmail": "E-Mail-Hosting",
"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>.",
"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",
"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)",

View File

@@ -41,6 +41,7 @@
"comeBackTomorrow": "Come back tomorrow for a new song.",
"theSongWas": "The song was:",
"score": "Score",
"shareExplanation": "Share your result with friends your support helps Hördle grow.",
"scoreBreakdown": "Score Breakdown",
"albumCover": "Album Cover",
"released": "Released",
@@ -157,8 +158,82 @@
"artist": "Artist",
"actions": "Actions",
"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:"
},
"About": {
"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.",
@@ -171,12 +246,13 @@
"imprintEmailLabel": "Email:",
"costsTitle": "Ongoing costs of the project",
"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)",
"costsServer": "Servers / vServers for the app and tracking",
"costsEmail": "Email hosting",
"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>.",
"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",
"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)",

View File

@@ -1,6 +1,6 @@
{
"name": "hoerdle",
"version": "0.1.4.8",
"version": "0.1.5.1",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -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");

View File

@@ -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");

View File

@@ -33,6 +33,7 @@ model Genre {
active Boolean @default(true)
songs Song[]
dailyPuzzles DailyPuzzle[]
curatorGenres CuratorGenre[]
}
model Special {
@@ -48,6 +49,7 @@ model Special {
songs SpecialSong[]
puzzles DailyPuzzle[]
news News[]
curatorSpecials CuratorSpecial[]
}
model SpecialSong {
@@ -101,3 +103,49 @@ model PlayerState {
@@unique([identifier, genreKey])
@@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[]
}
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])
}

38
scripts/deploy-remote.sh Executable file
View 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."

View File

@@ -1,10 +1,10 @@
#!/bin/bash
set -e
echo "🚀 Starting optimized deployment..."
echo "🚀 Starting optimized deployment with full rollback support..."
# Backup database
echo "💾 Creating database backup..."
# Backup database (per Deployment, inkl. Metadaten für Rollback)
echo "💾 Creating database backup for this deployment..."
# Try to find database path from docker-compose.yml or .env
DB_PATH=""
@@ -26,16 +26,29 @@ if [ -n "$DB_PATH" ]; then
# Convert container path to host path if needed
# /app/data/prod.db -> ./data/prod.db
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
if [ -f "$DB_PATH" ]; then
# Create backups directory
mkdir -p ./backups
# Create timestamped backup
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_$(date +%Y%m%d_%H%M%S).db"
DEPLOY_TS="$(date +%Y%m%d_%H%M%S)"
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_${DEPLOY_TS}.db"
cp "$DB_PATH" "$BACKUP_FILE"
echo "✅ Database backed up to: $BACKUP_FILE"
# Store metadata for restore (Timestamp, DB-Path, Git-Commit)
CURRENT_COMMIT="$(git rev-parse HEAD || echo 'unknown')"
{
echo "timestamp=${DEPLOY_TS}"
echo "db_path=${DB_PATH}"
echo "backup_file=${BACKUP_FILE}"
echo "git_commit=${CURRENT_COMMIT}"
} > "./backups/last_deploy.meta"
# Append to history manifest (eine Zeile pro Deployment)
echo "${DEPLOY_TS}|${DB_PATH}|${BACKUP_FILE}|${CURRENT_COMMIT}" >> "./backups/deploy_history.log"
# Keep only last 10 backups
ls -t ./backups/*.db | tail -n +11 | xargs -r rm
echo "🧹 Cleaned old backups (keeping last 10)"
@@ -46,13 +59,10 @@ else
echo "⚠️ Could not determine database path from config files"
fi
# Pull latest changes
echo "📥 Pulling latest changes from git..."
git pull
# Fetch all tags
echo "🏷️ Fetching git tags..."
git fetch --tags
# Nur neueste Version holen (shallow fetch), vollständiges Repo ist im Deployment nicht nötig
echo "📥 Fetching latest commit (shallow clone) from git..."
git fetch --prune --tags --depth=1 origin master
git reset --hard origin/master
# Prüfe und erstelle/repariere Netzwerk falls nötig
echo "🌐 Prüfe Docker-Netzwerk..."

93
scripts/restore.sh Normal file
View 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."