Compare commits

...

6 Commits

Author SHA1 Message Date
Hördle Bot
8262a96213 feat(home): format upcoming special dates with TZ support 2025-11-23 02:33:22 +01:00
Hördle Bot
b294a3a8e6 feat(home): display curator in upcoming specials 2025-11-23 02:29:23 +01:00
Hördle Bot
2a38bce02c feat(specials): add curator field 2025-11-23 02:25:45 +01:00
Hördle Bot
6fd5f8ed0c style(admin): fix button height consistency 2025-11-23 02:16:42 +01:00
Hördle Bot
c05ead4493 fix(api): include song count in specials list 2025-11-23 02:15:12 +01:00
Hördle Bot
5fb450d37e style(admin): unify button styles for edit, delete, and cancel 2025-11-23 02:13:11 +01:00
7 changed files with 100 additions and 15 deletions

View File

@@ -10,6 +10,7 @@ interface Special {
unlockSteps: string; unlockSteps: string;
launchDate?: string; launchDate?: string;
endDate?: string; endDate?: string;
curator?: string;
_count?: { _count?: {
songs: number; songs: number;
}; };
@@ -66,6 +67,7 @@ export default function AdminPage() {
const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]'); const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
const [newSpecialLaunchDate, setNewSpecialLaunchDate] = useState(''); const [newSpecialLaunchDate, setNewSpecialLaunchDate] = useState('');
const [newSpecialEndDate, setNewSpecialEndDate] = useState(''); const [newSpecialEndDate, setNewSpecialEndDate] = useState('');
const [newSpecialCurator, setNewSpecialCurator] = useState('');
const [editingSpecialId, setEditingSpecialId] = useState<number | null>(null); const [editingSpecialId, setEditingSpecialId] = useState<number | null>(null);
const [editSpecialName, setEditSpecialName] = useState(''); const [editSpecialName, setEditSpecialName] = useState('');
@@ -73,6 +75,7 @@ export default function AdminPage() {
const [editSpecialUnlockSteps, setEditSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]'); const [editSpecialUnlockSteps, setEditSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
const [editSpecialLaunchDate, setEditSpecialLaunchDate] = useState(''); const [editSpecialLaunchDate, setEditSpecialLaunchDate] = useState('');
const [editSpecialEndDate, setEditSpecialEndDate] = useState(''); const [editSpecialEndDate, setEditSpecialEndDate] = useState('');
const [editSpecialCurator, setEditSpecialCurator] = useState('');
// Edit state // Edit state
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
@@ -186,6 +189,7 @@ export default function AdminPage() {
unlockSteps: newSpecialUnlockSteps, unlockSteps: newSpecialUnlockSteps,
launchDate: newSpecialLaunchDate || null, launchDate: newSpecialLaunchDate || null,
endDate: newSpecialEndDate || null, endDate: newSpecialEndDate || null,
curator: newSpecialCurator || null,
}), }),
}); });
if (res.ok) { if (res.ok) {
@@ -194,6 +198,7 @@ export default function AdminPage() {
setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]'); setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]');
setNewSpecialLaunchDate(''); setNewSpecialLaunchDate('');
setNewSpecialEndDate(''); setNewSpecialEndDate('');
setNewSpecialCurator('');
fetchSpecials(); fetchSpecials();
} else { } else {
alert('Failed to create special'); alert('Failed to create special');
@@ -275,6 +280,7 @@ export default function AdminPage() {
setEditSpecialUnlockSteps(special.unlockSteps); setEditSpecialUnlockSteps(special.unlockSteps);
setEditSpecialLaunchDate(special.launchDate ? new Date(special.launchDate).toISOString().split('T')[0] : ''); setEditSpecialLaunchDate(special.launchDate ? new Date(special.launchDate).toISOString().split('T')[0] : '');
setEditSpecialEndDate(special.endDate ? new Date(special.endDate).toISOString().split('T')[0] : ''); setEditSpecialEndDate(special.endDate ? new Date(special.endDate).toISOString().split('T')[0] : '');
setEditSpecialCurator(special.curator || '');
}; };
const saveEditedSpecial = async () => { const saveEditedSpecial = async () => {
@@ -289,6 +295,7 @@ export default function AdminPage() {
unlockSteps: editSpecialUnlockSteps, unlockSteps: editSpecialUnlockSteps,
launchDate: editSpecialLaunchDate || null, launchDate: editSpecialLaunchDate || null,
endDate: editSpecialEndDate || null, endDate: editSpecialEndDate || null,
curator: editSpecialCurator || null,
}), }),
}); });
if (res.ok) { if (res.ok) {
@@ -707,6 +714,10 @@ export default function AdminPage() {
<label style={{ fontSize: '0.75rem', color: '#666' }}>End Date</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>End Date</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' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Curator</label>
<input type="text" placeholder="Curator name" value={newSpecialCurator} onChange={e => setNewSpecialCurator(e.target.value)} className="form-input" />
</div>
<button type="submit" className="btn-primary" style={{ height: '38px' }}>Add Special</button> <button type="submit" className="btn-primary" style={{ height: '38px' }}>Add Special</button>
</div> </div>
</form> </form>
@@ -752,6 +763,10 @@ export default function AdminPage() {
<label style={{ fontSize: '0.75rem', color: '#666' }}>End Date</label> <label style={{ fontSize: '0.75rem', color: '#666' }}>End Date</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' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Curator</label>
<input type="text" value={editSpecialCurator} onChange={e => setEditSpecialCurator(e.target.value)} className="form-input" />
</div>
<button onClick={saveEditedSpecial} className="btn-primary" style={{ height: '38px' }}>Save</button> <button onClick={saveEditedSpecial} className="btn-primary" style={{ height: '38px' }}>Save</button>
<button onClick={() => setEditingSpecialId(null)} className="btn-secondary" style={{ height: '38px' }}>Cancel</button> <button onClick={() => setEditingSpecialId(null)} className="btn-secondary" style={{ height: '38px' }}>Cancel</button>
</div> </div>

View File

@@ -43,7 +43,7 @@ export async function PUT(
try { try {
const { id } = await params; const { id } = await params;
const specialId = parseInt(id); const specialId = parseInt(id);
const { name, maxAttempts, unlockSteps, launchDate, endDate } = await request.json(); const { name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
const special = await prisma.special.update({ const special = await prisma.special.update({
where: { id: specialId }, where: { id: specialId },
@@ -53,6 +53,7 @@ export async function PUT(
unlockSteps: typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(unlockSteps), unlockSteps: typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(unlockSteps),
launchDate: launchDate ? new Date(launchDate) : null, launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null, endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
} }
}); });

View File

@@ -6,12 +6,17 @@ const prisma = new PrismaClient();
export async function GET() { export async function GET() {
const specials = await prisma.special.findMany({ const specials = await prisma.special.findMany({
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
include: {
_count: {
select: { songs: true }
}
}
}); });
return NextResponse.json(specials); return NextResponse.json(specials);
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const { name, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate } = await request.json(); const { name, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
if (!name) { if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 }); return NextResponse.json({ error: 'Name is required' }, { status: 400 });
} }
@@ -22,6 +27,7 @@ export async function POST(request: Request) {
unlockSteps, unlockSteps,
launchDate: launchDate ? new Date(launchDate) : null, launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null, endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
}, },
}); });
return NextResponse.json(special); return NextResponse.json(special);
@@ -37,7 +43,7 @@ export async function DELETE(request: Request) {
} }
export async function PUT(request: Request) { export async function PUT(request: Request) {
const { id, name, maxAttempts, unlockSteps, launchDate, endDate } = await request.json(); const { id, name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
if (!id) { if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 }); return NextResponse.json({ error: 'ID required' }, { status: 400 });
} }
@@ -49,6 +55,7 @@ export async function PUT(request: Request) {
...(unlockSteps && { unlockSteps }), ...(unlockSteps && { unlockSteps }),
launchDate: launchDate ? new Date(launchDate) : null, launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null, endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
}, },
}); });
return NextResponse.json(updated); return NextResponse.json(updated);

View File

@@ -278,12 +278,59 @@ body {
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.5rem;
box-sizing: border-box;
text-decoration: none;
font-size: 0.875rem;
} }
.btn-primary:hover { .btn-primary:hover {
background: #333; background: #333;
} }
.btn-secondary {
background: #4b5563;
color: #fff;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.5rem;
box-sizing: border-box;
font-size: 0.875rem;
}
.btn-secondary:hover {
background: #374151;
}
.btn-danger {
background: #ef4444;
color: #fff;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.5rem;
box-sizing: border-box;
font-size: 0.875rem;
}
.btn-danger:hover {
background: #dc2626;
}
/* Footer */ /* Footer */
.app-footer { .app-footer {
margin-top: auto; margin-top: auto;

View File

@@ -44,8 +44,8 @@ export default async function Home() {
{/* Active Specials */} {/* Active Specials */}
{activeSpecials.map(s => ( {activeSpecials.map(s => (
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Link <Link
key={s.id}
href={`/special/${s.name}`} href={`/special/${s.name}`}
style={{ style={{
color: '#be185d', // Pink-700 color: '#be185d', // Pink-700
@@ -55,6 +55,12 @@ export default async function Home() {
> >
{s.name} {s.name}
</Link> </Link>
{s.curator && (
<span style={{ fontSize: '0.75rem', color: '#666' }}>
Curated by {s.curator}
</span>
)}
</div>
))} ))}
</div> </div>
@@ -63,7 +69,13 @@ export default async function Home() {
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}> <div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Coming soon: {upcomingSpecials.map(s => ( Coming soon: {upcomingSpecials.map(s => (
<span key={s.id} style={{ marginLeft: '0.5rem' }}> <span key={s.id} style={{ marginLeft: '0.5rem' }}>
{s.name} ({s.launchDate?.toLocaleDateString()}) {s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: process.env.TZ
}) : ''})
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
</span> </span>
))} ))}
</div> </div>

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Special" ADD COLUMN "curator" TEXT;

View File

@@ -37,6 +37,7 @@ model Special {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
launchDate DateTime? launchDate DateTime?
endDate DateTime? endDate DateTime?
curator String?
songs SpecialSong[] songs SpecialSong[]
puzzles DailyPuzzle[] puzzles DailyPuzzle[]
} }