Compare commits

..

3 Commits

Author SHA1 Message Date
Hördle Bot
25a79230a8 Admin-Seite lokalisiert: Übersetzungen hinzugefügt und URLs angepasst
- Admin-Namespace zu de.json und en.json hinzugefügt
- Alle UI-Texte in der Admin-Seite mit useTranslations lokalisiert
- Link-Komponente von next-intl verwendet für korrekte Locale-URLs
- Buttons, Labels, Formulare und Tabellen-Header übersetzt
2025-11-28 18:39:52 +01:00
Hördle Bot
0182db69b5 Füge Collapse-Funktionalität zu Admin-Management-Abschnitten hinzu 2025-11-28 18:07:09 +01:00
Hördle Bot
794e3fd74a Verbessere Docker-Migration: Entrypoint mit Baseline-Fallback und aktualisiere baseline-migrations.sh 2025-11-28 15:53:01 +01:00
6 changed files with 286 additions and 91 deletions

View File

@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Link } from '@/lib/navigation';
import { getLocalizedValue } from '@/lib/i18n'; import { getLocalizedValue } from '@/lib/i18n';
@@ -70,6 +72,8 @@ type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'acti
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
export default function AdminPage({ params }: { params: { locale: string } }) { export default function AdminPage({ params }: { params: { locale: string } }) {
const t = useTranslations('Admin');
const locale = useLocale();
const [activeTab, setActiveTab] = useState<'de' | 'en'>('de'); const [activeTab, setActiveTab] = useState<'de' | 'en'>('de');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -162,6 +166,9 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]); const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null); const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false); const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
const [showSpecials, setShowSpecials] = useState(false);
const [showGenres, setShowGenres] = useState(false);
const [showNews, setShowNews] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Check for existing auth on mount // Check for existing auth on mount
@@ -191,7 +198,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
fetchSpecials(); fetchSpecials();
fetchNews(); fetchNews();
} else { } else {
alert('Wrong password'); alert(t('wrongPassword'));
} }
}; };
@@ -1017,16 +1024,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<div className="container" style={{ justifyContent: 'center' }}> <div className="container" style={{ justifyContent: 'center' }}>
<h1 className="title" style={{ marginBottom: '1rem', fontSize: '1.5rem' }}>Admin Login</h1> <h1 className="title" style={{ marginBottom: '1rem', fontSize: '1.5rem' }}>{t('login')}</h1>
<input <input
type="password" type="password"
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
className="form-input" className="form-input"
style={{ marginBottom: '1rem', maxWidth: '300px' }} style={{ marginBottom: '1rem', maxWidth: '300px' }}
placeholder="Password" placeholder={t('password')}
/> />
<button onClick={handleLogin} className="btn-primary">Login</button> <button onClick={handleLogin} className="btn-primary">{t('loginButton')}</button>
</div> </div>
); );
} }
@@ -1034,11 +1041,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
return ( return (
<div className="admin-container"> <div className="admin-container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h1 className="title" style={{ margin: 0 }}>Hördle Admin Dashboard</h1> <h1 className="title" style={{ margin: 0 }}>{t('title')}</h1>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div style={{ display: 'flex', background: '#e5e7eb', borderRadius: '0.25rem', padding: '0.25rem' }}> <div style={{ display: 'flex', background: '#e5e7eb', borderRadius: '0.25rem', padding: '0.25rem' }}>
<button <button
onClick={() => setActiveTab('de')} type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setActiveTab('de');
}}
style={{ style={{
padding: '0.25rem 0.75rem', padding: '0.25rem 0.75rem',
background: activeTab === 'de' ? 'white' : 'transparent', background: activeTab === 'de' ? 'white' : 'transparent',
@@ -1052,7 +1064,12 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
DE DE
</button> </button>
<button <button
onClick={() => setActiveTab('en')} type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setActiveTab('en');
}}
style={{ style={{
padding: '0.25rem 0.75rem', padding: '0.25rem 0.75rem',
background: activeTab === 'en' ? 'white' : 'transparent', background: activeTab === 'en' ? 'white' : 'transparent',
@@ -1079,45 +1096,64 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
fontSize: '0.9rem' fontSize: '0.9rem'
}} }}
> >
🚪 Logout 🚪 {t('logout')}
</button> </button>
</div> </div>
</div> </div>
{/* Special Management */} {/* Special Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}> <div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Specials</h2> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<form onSubmit={handleCreateSpecial} style={{ marginBottom: '1rem' }}> <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{t('manageSpecials')}
</h2>
<button
onClick={() => setShowSpecials(!showSpecials)}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
{showSpecials ? t('hide') : t('show')}
</button>
</div>
{showSpecials && (
<>
<form onSubmit={handleCreateSpecial} style={{ marginBottom: '1rem' }} key={`special-form-${activeTab}`}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}> <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('name')}</label>
<input type="text" placeholder="Special name" value={newSpecialName[activeTab]} onChange={e => setNewSpecialName({ ...newSpecialName, [activeTab]: e.target.value })} className="form-input" required /> <input type="text" placeholder={t('name')} value={newSpecialName[activeTab] || ''} onChange={e => setNewSpecialName({ ...newSpecialName, [activeTab]: e.target.value })} className="form-input" required key={`special-name-${activeTab}`} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('subtitle')}</label>
<input type="text" placeholder="Subtitle" value={newSpecialSubtitle[activeTab]} onChange={e => setNewSpecialSubtitle({ ...newSpecialSubtitle, [activeTab]: e.target.value })} className="form-input" /> <input type="text" placeholder={t('subtitle')} value={newSpecialSubtitle[activeTab] || ''} onChange={e => setNewSpecialSubtitle({ ...newSpecialSubtitle, [activeTab]: e.target.value })} className="form-input" key={`special-subtitle-${activeTab}`} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Max Attempts</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('maxAttempts')}</label>
<input type="number" placeholder="Max attempts" value={newSpecialMaxAttempts} onChange={e => setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} /> <input type="number" placeholder={t('maxAttempts')} value={newSpecialMaxAttempts} onChange={e => setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Unlock Steps</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('unlockSteps')}</label>
<input type="text" placeholder="Unlock steps JSON" value={newSpecialUnlockSteps} onChange={e => setNewSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} /> <input type="text" placeholder={t('unlockSteps')} value={newSpecialUnlockSteps} onChange={e => setNewSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Launch Date</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('launchDate')}</label>
<input type="date" value={newSpecialLaunchDate} onChange={e => setNewSpecialLaunchDate(e.target.value)} className="form-input" /> <input type="date" value={newSpecialLaunchDate} onChange={e => setNewSpecialLaunchDate(e.target.value)} className="form-input" />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>End Date</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('endDate')}</label>
<input type="date" value={newSpecialEndDate} onChange={e => setNewSpecialEndDate(e.target.value)} className="form-input" /> <input type="date" value={newSpecialEndDate} onChange={e => setNewSpecialEndDate(e.target.value)} className="form-input" />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Curator</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('curator')}</label>
<input type="text" placeholder="Curator name" value={newSpecialCurator} onChange={e => setNewSpecialCurator(e.target.value)} className="form-input" /> <input type="text" placeholder={t('curator')} value={newSpecialCurator} onChange={e => setNewSpecialCurator(e.target.value)} className="form-input" />
</div> </div>
<button type="submit" className="btn-primary" style={{ height: '38px' }}>Add Special</button> <button type="submit" className="btn-primary" style={{ height: '38px' }}>{t('addSpecial')}</button>
</div> </div>
</form> </form>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
@@ -1133,70 +1169,93 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}}> }}>
<span>{getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})</span> <span>{getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})</span>
{special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {getLocalizedValue(special.subtitle, activeTab)}</span>} {special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {getLocalizedValue(special.subtitle, activeTab)}</span>}
<a href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>Curate</a> <Link href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>{t('curate')}</Link>
<button onClick={() => startEditSpecial(special)} className="btn-secondary" style={{ marginRight: '0.5rem' }}>Edit</button> <button onClick={() => startEditSpecial(special)} className="btn-secondary" style={{ marginRight: '0.5rem' }}>{t('edit')}</button>
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button> <button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">{t('delete')}</button>
</div> </div>
))} ))}
</div> </div>
{editingSpecialId !== null && ( {editingSpecialId !== null && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}> <div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}>
<h3>Edit Special</h3> <h3>{t('editSpecial')}</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}> <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('name')}</label>
<input type="text" value={editSpecialName[activeTab]} onChange={e => setEditSpecialName({ ...editSpecialName, [activeTab]: e.target.value })} className="form-input" /> <input type="text" value={editSpecialName[activeTab] || ''} onChange={e => setEditSpecialName({ ...editSpecialName, [activeTab]: e.target.value })} className="form-input" key={`edit-special-name-${activeTab}`} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('subtitle')}</label>
<input type="text" value={editSpecialSubtitle[activeTab]} onChange={e => setEditSpecialSubtitle({ ...editSpecialSubtitle, [activeTab]: e.target.value })} className="form-input" /> <input type="text" value={editSpecialSubtitle[activeTab] || ''} onChange={e => setEditSpecialSubtitle({ ...editSpecialSubtitle, [activeTab]: e.target.value })} className="form-input" key={`edit-special-subtitle-${activeTab}`} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Max Attempts</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('maxAttempts')}</label>
<input type="number" value={editSpecialMaxAttempts} onChange={e => setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} /> <input type="number" value={editSpecialMaxAttempts} onChange={e => setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Unlock Steps</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('unlockSteps')}</label>
<input type="text" value={editSpecialUnlockSteps} onChange={e => setEditSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} /> <input type="text" value={editSpecialUnlockSteps} onChange={e => setEditSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Launch Date</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('launchDate')}</label>
<input type="date" value={editSpecialLaunchDate} onChange={e => setEditSpecialLaunchDate(e.target.value)} className="form-input" /> <input type="date" value={editSpecialLaunchDate} onChange={e => setEditSpecialLaunchDate(e.target.value)} className="form-input" />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>End Date</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('endDate')}</label>
<input type="date" value={editSpecialEndDate} onChange={e => setEditSpecialEndDate(e.target.value)} className="form-input" /> <input type="date" value={editSpecialEndDate} onChange={e => setEditSpecialEndDate(e.target.value)} className="form-input" />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Curator</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('curator')}</label>
<input type="text" value={editSpecialCurator} onChange={e => setEditSpecialCurator(e.target.value)} className="form-input" /> <input type="text" value={editSpecialCurator} onChange={e => setEditSpecialCurator(e.target.value)} className="form-input" />
</div> </div>
<button onClick={saveEditedSpecial} className="btn-primary" style={{ height: '38px' }}>Save</button> <button onClick={saveEditedSpecial} className="btn-primary" style={{ height: '38px' }}>{t('save')}</button>
<button onClick={() => setEditingSpecialId(null)} className="btn-secondary" style={{ height: '38px' }}>Cancel</button> <button onClick={() => setEditingSpecialId(null)} className="btn-secondary" style={{ height: '38px' }}>{t('cancel')}</button>
</div> </div>
</div> </div>
)} )}
</>
)}
</div> </div>
{/* Genre Management */} {/* Genre Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}> <div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}> <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{t('manageGenres')}
</h2>
<button
onClick={() => setShowGenres(!showGenres)}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
{showGenres ? t('hide') : t('show')}
</button>
</div>
{showGenres && (
<>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
<input <input
type="text" type="text"
value={newGenreName[activeTab]} value={newGenreName[activeTab] || ''}
onChange={e => setNewGenreName({ ...newGenreName, [activeTab]: e.target.value })} onChange={e => setNewGenreName({ ...newGenreName, [activeTab]: e.target.value })}
placeholder="New Genre Name" placeholder={t('newGenreName')}
className="form-input" className="form-input"
style={{ maxWidth: '200px' }} style={{ maxWidth: '200px' }}
key={`genre-name-${activeTab}`}
/> />
<input <input
type="text" type="text"
value={newGenreSubtitle[activeTab]} value={newGenreSubtitle[activeTab] || ''}
onChange={e => setNewGenreSubtitle({ ...newGenreSubtitle, [activeTab]: e.target.value })} onChange={e => setNewGenreSubtitle({ ...newGenreSubtitle, [activeTab]: e.target.value })}
placeholder="Subtitle" placeholder={t('subtitle')}
className="form-input" className="form-input"
style={{ maxWidth: '300px' }} style={{ maxWidth: '300px' }}
key={`genre-subtitle-${activeTab}`}
/> />
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
<input <input
@@ -1204,9 +1263,9 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
checked={newGenreActive} checked={newGenreActive}
onChange={e => setNewGenreActive(e.target.checked)} onChange={e => setNewGenreActive(e.target.checked)}
/> />
Active {t('active')}
</label> </label>
<button onClick={createGenre} className="btn-primary">Add Genre</button> <button onClick={createGenre} className="btn-primary">{t('addGenre')}</button>
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{genres.map(genre => ( {genres.map(genre => (
@@ -1222,22 +1281,22 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}}> }}>
<span>{getLocalizedValue(genre.name, activeTab)} ({genre._count?.songs || 0})</span> <span>{getLocalizedValue(genre.name, activeTab)} ({genre._count?.songs || 0})</span>
{genre.subtitle && <span style={{ fontSize: '0.75rem', color: '#666' }}>- {getLocalizedValue(genre.subtitle, activeTab)}</span>} {genre.subtitle && <span style={{ fontSize: '0.75rem', color: '#666' }}>- {getLocalizedValue(genre.subtitle, activeTab)}</span>}
<button onClick={() => startEditGenre(genre)} className="btn-secondary" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>Edit</button> <button onClick={() => startEditGenre(genre)} className="btn-secondary" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>{t('edit')}</button>
<button onClick={() => deleteGenre(genre.id)} className="btn-danger" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>×</button> <button onClick={() => deleteGenre(genre.id)} className="btn-danger" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>×</button>
</div> </div>
))} ))}
</div> </div>
{editingGenreId !== null && ( {editingGenreId !== null && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}> <div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}>
<h3>Edit Genre</h3> <h3>{t('editGenre')}</h3>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('name')}</label>
<input type="text" value={editGenreName[activeTab]} onChange={e => setEditGenreName({ ...editGenreName, [activeTab]: e.target.value })} className="form-input" /> <input type="text" value={editGenreName[activeTab] || ''} onChange={e => setEditGenreName({ ...editGenreName, [activeTab]: e.target.value })} className="form-input" key={`edit-genre-name-${activeTab}`} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>{t('subtitle')}</label>
<input type="text" value={editGenreSubtitle[activeTab]} onChange={e => setEditGenreSubtitle({ ...editGenreSubtitle, [activeTab]: e.target.value })} className="form-input" style={{ width: '300px' }} /> <input type="text" value={editGenreSubtitle[activeTab] || ''} onChange={e => setEditGenreSubtitle({ ...editGenreSubtitle, [activeTab]: e.target.value })} className="form-input" style={{ width: '300px' }} key={`edit-genre-subtitle-${activeTab}`} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', paddingBottom: '0.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', paddingBottom: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
@@ -1246,11 +1305,11 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
checked={editGenreActive} checked={editGenreActive}
onChange={e => setEditGenreActive(e.target.checked)} onChange={e => setEditGenreActive(e.target.checked)}
/> />
Active {t('active')}
</label> </label>
</div> </div>
<button onClick={saveEditedGenre} className="btn-primary">Save</button> <button onClick={saveEditedGenre} className="btn-primary">{t('save')}</button>
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button> <button onClick={() => setEditingGenreId(null)} className="btn-secondary">{t('cancel')}</button>
</div> </div>
</div> </div>
)} )}
@@ -1335,36 +1394,59 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
</button> </button>
</div> </div>
)} )}
</>
)}
</div> </div>
{/* News Management */} {/* News Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}> <div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage News & Announcements</h2> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<form onSubmit={handleCreateNews} style={{ marginBottom: '1rem' }}> <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{t('manageNews')}
</h2>
<button
onClick={() => setShowNews(!showNews)}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
{showNews ? t('hide') : t('show')}
</button>
</div>
{showNews && (
<>
<form onSubmit={handleCreateNews} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<input <input
type="text" type="text"
value={newNewsTitle[activeTab]} value={newNewsTitle[activeTab] || ''}
onChange={e => setNewNewsTitle({ ...newNewsTitle, [activeTab]: e.target.value })} onChange={e => setNewNewsTitle({ ...newNewsTitle, [activeTab]: e.target.value })}
placeholder="News Title" placeholder={t('newsTitle')}
className="form-input" className="form-input"
required required
key={`news-title-${activeTab}`}
/> />
<textarea <textarea
value={newNewsContent[activeTab]} value={newNewsContent[activeTab] || ''}
onChange={e => setNewNewsContent({ ...newNewsContent, [activeTab]: e.target.value })} onChange={e => setNewNewsContent({ ...newNewsContent, [activeTab]: e.target.value })}
placeholder="Content (Markdown supported)" placeholder={t('content')}
className="form-input" className="form-input"
rows={4} rows={4}
required required
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }} style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
key={`news-content-${activeTab}`}
/> />
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input <input
type="text" type="text"
value={newNewsAuthor} value={newNewsAuthor}
onChange={e => setNewNewsAuthor(e.target.value)} onChange={e => setNewNewsAuthor(e.target.value)}
placeholder="Author (optional)" placeholder={t('author')}
className="form-input" className="form-input"
style={{ maxWidth: '200px' }} style={{ maxWidth: '200px' }}
/> />
@@ -1374,7 +1456,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
className="form-input" className="form-input"
style={{ maxWidth: '200px' }} style={{ maxWidth: '200px' }}
> >
<option value="">No Special Link</option> <option value="">{t('noSpecialLink')}</option>
{specials.map(s => ( {specials.map(s => (
<option key={s.id} value={s.id}>{getLocalizedValue(s.name, activeTab)}</option> <option key={s.id} value={s.id}>{getLocalizedValue(s.name, activeTab)}</option>
))} ))}
@@ -1385,9 +1467,9 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
checked={newNewsFeatured} checked={newNewsFeatured}
onChange={e => setNewNewsFeatured(e.target.checked)} onChange={e => setNewNewsFeatured(e.target.checked)}
/> />
Featured {t('featured')}
</label> </label>
<button type="submit" className="btn-primary">Add News</button> <button type="submit" className="btn-primary">{t('addNews')}</button>
</div> </div>
</div> </div>
</form> </form>
@@ -1404,23 +1486,25 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<input <input
type="text" type="text"
value={editNewsTitle[activeTab]} value={editNewsTitle[activeTab] || ''}
onChange={e => setEditNewsTitle({ ...editNewsTitle, [activeTab]: e.target.value })} onChange={e => setEditNewsTitle({ ...editNewsTitle, [activeTab]: e.target.value })}
className="form-input" className="form-input"
key={`edit-news-title-${activeTab}`}
/> />
<textarea <textarea
value={editNewsContent[activeTab]} value={editNewsContent[activeTab] || ''}
onChange={e => setEditNewsContent({ ...editNewsContent, [activeTab]: e.target.value })} onChange={e => setEditNewsContent({ ...editNewsContent, [activeTab]: e.target.value })}
className="form-input" className="form-input"
rows={4} rows={4}
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }} style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
key={`edit-news-content-${activeTab}`}
/> />
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input <input
type="text" type="text"
value={editNewsAuthor} value={editNewsAuthor}
onChange={e => setEditNewsAuthor(e.target.value)} onChange={e => setEditNewsAuthor(e.target.value)}
placeholder="Author" placeholder={t('author')}
className="form-input" className="form-input"
style={{ maxWidth: '200px' }} style={{ maxWidth: '200px' }}
/> />
@@ -1430,7 +1514,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
className="form-input" className="form-input"
style={{ maxWidth: '200px' }} style={{ maxWidth: '200px' }}
> >
<option value="">No Special Link</option> <option value="">{t('noSpecialLink')}</option>
{specials.map(s => ( {specials.map(s => (
<option key={s.id} value={s.id}>{getLocalizedValue(s.name, activeTab)}</option> <option key={s.id} value={s.id}>{getLocalizedValue(s.name, activeTab)}</option>
))} ))}
@@ -1441,10 +1525,10 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
checked={editNewsFeatured} checked={editNewsFeatured}
onChange={e => setEditNewsFeatured(e.target.checked)} onChange={e => setEditNewsFeatured(e.target.checked)}
/> />
Featured {t('featured')}
</label> </label>
<button onClick={saveEditedNews} className="btn-primary">Save</button> <button onClick={saveEditedNews} className="btn-primary">{t('save')}</button>
<button onClick={() => setEditingNewsId(null)} className="btn-secondary">Cancel</button> <button onClick={() => setEditingNewsId(null)} className="btn-secondary">{t('cancel')}</button>
</div> </div>
</div> </div>
) : ( ) : (
@@ -1479,7 +1563,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
</div> </div>
<div style={{ display: 'flex', gap: '0.25rem', marginLeft: '1rem' }}> <div style={{ display: 'flex', gap: '0.25rem', marginLeft: '1rem' }}>
<button onClick={() => startEditNews(newsItem)} className="btn-secondary" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>Edit</button> <button onClick={() => startEditNews(newsItem)} className="btn-secondary" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>Edit</button>
<button onClick={() => handleDeleteNews(newsItem.id)} className="btn-danger" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>Delete</button> <button onClick={() => handleDeleteNews(newsItem.id)} className="btn-danger" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>{t('delete')}</button>
</div> </div>
</div> </div>
</> </>
@@ -1488,14 +1572,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
))} ))}
{news.length === 0 && ( {news.length === 0 && (
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', padding: '1rem' }}> <p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', padding: '1rem' }}>
No news items yet. Create one above! {t('noNewsItems')}
</p> </p>
)} )}
</div> </div>
</>
)}
</div> </div>
<div className="admin-card" style={{ marginBottom: '2rem' }}> <div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload Songs</h2> <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>{t('uploadSongs')}</h2>
<form onSubmit={handleBatchUpload}> <form onSubmit={handleBatchUpload}>
{/* Drag & Drop Zone */} {/* Drag & Drop Zone */}
<div <div
@@ -1647,7 +1733,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
<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' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}> <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
Today's Daily Puzzles {t('todaysPuzzles')}
</h2> </h2>
<button <button
onClick={() => setShowDailyPuzzles(!showDailyPuzzles)} onClick={() => setShowDailyPuzzles(!showDailyPuzzles)}
@@ -1664,15 +1750,15 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
</button> </button>
</div> </div>
{showDailyPuzzles && (dailyPuzzles.length === 0 ? ( {showDailyPuzzles && (dailyPuzzles.length === 0 ? (
<p style={{ color: '#6b7280' }}>No daily puzzles found for today.</p> <p style={{ color: '#6b7280' }}>{t('noPuzzlesToday')}</p>
) : ( ) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead> <thead>
<tr style={{ borderBottom: '2px solid #e5e7eb' }}> <tr style={{ borderBottom: '2px solid #e5e7eb' }}>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Category</th> <th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>{t('category')}</th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Song</th> <th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>{t('song')}</th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Artist</th> <th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>{t('artist')}</th>
<th style={{ padding: '0.75rem', textAlign: 'center', fontWeight: 'bold' }}>Actions</th> <th style={{ padding: '0.75rem', textAlign: 'center', fontWeight: 'bold' }}>{t('actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1704,7 +1790,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
<button <button
onClick={() => handleDeletePuzzle(puzzle.id)} onClick={() => handleDeletePuzzle(puzzle.id)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }} style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title="Delete" title={t('deletePuzzle')}
> >
🗑 🗑
</button> </button>
@@ -2052,7 +2138,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
<button <button
onClick={() => handleDelete(song.id, song.title)} onClick={() => handleDelete(song.id, song.title)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }} style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title="Delete" title={t('deletePuzzle')}
> >
🗑 🗑
</button> </button>

View File

@@ -37,6 +37,4 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 40s
# Run migrations and start server (auto-baseline on first run if needed) # docker-entrypoint.sh handles migrations and server startup (with baseline fallback)
command: >
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node server.js"

View File

@@ -104,5 +104,52 @@
"globalTooltip": "Ein zufälliger Song aus der gesamten Sammlung", "globalTooltip": "Ein zufälliger Song aus der gesamten Sammlung",
"comingSoon": "Demnächst", "comingSoon": "Demnächst",
"curatedBy": "Kuratiert von" "curatedBy": "Kuratiert von"
},
"Admin": {
"title": "Hördle Admin Dashboard",
"login": "Admin Login",
"password": "Passwort",
"loginButton": "Login",
"logout": "Abmelden",
"manageSpecials": "Specials verwalten",
"manageGenres": "Genres verwalten",
"manageNews": "News & Ankündigungen verwalten",
"uploadSongs": "Songs hochladen",
"todaysPuzzles": "Heutige tägliche Rätsel",
"show": "▶ Anzeigen",
"hide": "▼ Ausblenden",
"addSpecial": "Special hinzufügen",
"addGenre": "Genre hinzufügen",
"addNews": "News hinzufügen",
"edit": "Bearbeiten",
"delete": "Löschen",
"save": "Speichern",
"cancel": "Abbrechen",
"curate": "Kurieren",
"name": "Name",
"subtitle": "Untertitel",
"maxAttempts": "Max. Versuche",
"unlockSteps": "Freischalt-Schritte",
"launchDate": "Startdatum",
"endDate": "Enddatum",
"curator": "Kurator",
"active": "Aktiv",
"newGenreName": "Neuer Genre-Name",
"editSpecial": "Special bearbeiten",
"editGenre": "Genre bearbeiten",
"editNews": "News bearbeiten",
"newsTitle": "News-Titel",
"content": "Inhalt (Markdown unterstützt)",
"author": "Autor (optional)",
"featured": "Hervorgehoben",
"noSpecialLink": "Kein Special-Link",
"noNewsItems": "Noch keine News-Einträge. Erstelle einen oben!",
"noPuzzlesToday": "Keine täglichen Rätsel für heute gefunden.",
"category": "Kategorie",
"song": "Song",
"artist": "Interpret",
"actions": "Aktionen",
"deletePuzzle": "Löschen",
"wrongPassword": "Falsches Passwort"
} }
} }

View File

@@ -104,5 +104,52 @@
"globalTooltip": "A random song from the entire collection", "globalTooltip": "A random song from the entire collection",
"comingSoon": "Coming soon", "comingSoon": "Coming soon",
"curatedBy": "Curated by" "curatedBy": "Curated by"
},
"Admin": {
"title": "Hördle Admin Dashboard",
"login": "Admin Login",
"password": "Password",
"loginButton": "Login",
"logout": "Logout",
"manageSpecials": "Manage Specials",
"manageGenres": "Manage Genres",
"manageNews": "Manage News & Announcements",
"uploadSongs": "Upload Songs",
"todaysPuzzles": "Today's Daily Puzzles",
"show": "▶ Show",
"hide": "▼ Hide",
"addSpecial": "Add Special",
"addGenre": "Add Genre",
"addNews": "Add News",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"curate": "Curate",
"name": "Name",
"subtitle": "Subtitle",
"maxAttempts": "Max Attempts",
"unlockSteps": "Unlock Steps",
"launchDate": "Launch Date",
"endDate": "End Date",
"curator": "Curator",
"active": "Active",
"newGenreName": "New Genre Name",
"editSpecial": "Edit Special",
"editGenre": "Edit Genre",
"editNews": "Edit News",
"newsTitle": "News Title",
"content": "Content (Markdown supported)",
"author": "Author (optional)",
"featured": "Featured",
"noSpecialLink": "No Special Link",
"noNewsItems": "No news items yet. Create one above!",
"noPuzzlesToday": "No daily puzzles found for today.",
"category": "Category",
"song": "Song",
"artist": "Artist",
"actions": "Actions",
"deletePuzzle": "Delete",
"wrongPassword": "Wrong password"
} }
} }

View File

@@ -14,5 +14,10 @@ npx prisma migrate resolve --applied "20251123083856_add_rating_system"
npx prisma migrate resolve --applied "20251123140527_add_subtitles" npx prisma migrate resolve --applied "20251123140527_add_subtitles"
npx prisma migrate resolve --applied "20251123181922_add_release_year" npx prisma migrate resolve --applied "20251123181922_add_release_year"
npx prisma migrate resolve --applied "20251123204000_fix_cascade_delete" npx prisma migrate resolve --applied "20251123204000_fix_cascade_delete"
npx prisma migrate resolve --applied "20251124182259_add_exclude_from_global"
npx prisma migrate resolve --applied "20251124231438_add_genre_active_field"
npx prisma migrate resolve --applied "20251125101602_add_news_model"
npx prisma migrate resolve --applied "20251128131405_add_i18n_columns"
npx prisma migrate resolve --applied "20251128132806_switch_to_json_columns"
echo "✅ Baseline complete! Restart the container to apply migrations normally." echo "✅ Baseline complete! Restart the container to apply migrations normally."

View File

@@ -9,11 +9,23 @@ fi
echo "Starting deployment..." echo "Starting deployment..."
# Run migrations # Run migrations with fallback to baseline if needed
echo "Running database migrations..." echo "Running database migrations..."
npx prisma migrate deploy if ! npx prisma migrate deploy; then
echo "⚠️ Migration failed, attempting to baseline existing database..."
if [ -f /app/scripts/baseline-migrations.sh ]; then
sh /app/scripts/baseline-migrations.sh
echo "Retrying migrations after baseline..."
npx prisma migrate deploy || {
echo "❌ Migration failed even after baseline. Please check your database."
exit 1
}
else
echo "❌ ERROR: Migration failed and baseline script not found at /app/scripts/baseline-migrations.sh"
exit 1
fi
fi
echo "✅ Migrations completed successfully"
# Start the application # Start the application
echo "Starting application..." echo "Starting application..."