Add subtitles to Genres and Specials

This commit is contained in:
Hördle Bot
2025-11-23 15:20:12 +01:00
parent b8321cef56
commit 80e6066c17
9 changed files with 237 additions and 25 deletions

View File

@@ -0,0 +1,47 @@
---
description: Add subtitles to Genres and Specials
---
# Implementation Plan - Add Subtitles to Genres and Specials
The goal is to add a `subtitle` field to both `Genre` and `Special` models, allowing administrators to provide descriptions. These subtitles will be displayed as tooltips on the homepage.
## 1. Database Schema Changes
- [ ] Modify `prisma/schema.prisma`:
- Add `subtitle String?` to the `Genre` model.
- Add `subtitle String?` to the `Special` model.
- [ ] Create a migration: `npx prisma migrate dev --name add_subtitles`
## 2. Backend API Updates
- [ ] Update `app/api/genres/route.ts`:
- Update `POST` to accept `subtitle`.
- Add `PUT` method to allow updating genre name and subtitle.
- [ ] Update `app/api/specials/route.ts`:
- Update `POST` to accept `subtitle`.
- Update `PUT` to accept `subtitle`.
## 3. Admin UI Updates
- [ ] Update `app/admin/page.tsx`:
- **Genres**:
- Update the "Add Genre" form to include an input for `subtitle`.
- Add an "Edit" button for each genre.
- Implement a form/modal to edit genre name and subtitle.
- Display the subtitle in the list of genres.
- **Specials**:
- Update the "Create Special" form to include an input for `subtitle`.
- Update the "Edit Special" form (in the conditional rendering) to include `subtitle`.
- [ ] Update `app/admin/specials/[id]/page.tsx`:
- Update the display to show the subtitle under the title.
## 4. Frontend Updates
- [ ] Update `app/page.tsx`:
- Fetch `subtitle` for genres and specials (already covered by `findMany`).
- Add a tooltip to the links.
- For `Link` components, we can use the `title` attribute for a native tooltip, or build a custom CSS tooltip. The user asked for "gut lesbarer Tooltip" (readable tooltip). Native `title` is often small and delayed. A custom CSS tooltip (using a group/hover pattern) would be better.
- I will implement a simple CSS-based tooltip component or style.
## 5. Verification
- [ ] Verify database migration.
- [ ] Verify creating a genre with a subtitle.
- [ ] Verify creating/editing a special with a subtitle.
- [ ] Verify tooltips on the homepage.

View File

@@ -6,6 +6,7 @@ import { useState, useEffect } from 'react';
interface Special {
id: number;
name: string;
subtitle?: string;
maxAttempts: number;
unlockSteps: string;
launchDate?: string;
@@ -19,6 +20,7 @@ interface Special {
interface Genre {
id: number;
name: string;
subtitle?: string;
_count?: {
songs: number;
};
@@ -61,10 +63,15 @@ export default function AdminPage() {
const [songs, setSongs] = useState<Song[]>([]);
const [genres, setGenres] = useState<Genre[]>([]);
const [newGenreName, setNewGenreName] = useState('');
const [newGenreSubtitle, setNewGenreSubtitle] = useState('');
const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
const [editGenreName, setEditGenreName] = useState('');
const [editGenreSubtitle, setEditGenreSubtitle] = useState('');
// Specials state
const [specials, setSpecials] = useState<Special[]>([]);
const [newSpecialName, setNewSpecialName] = useState('');
const [newSpecialSubtitle, setNewSpecialSubtitle] = useState('');
const [newSpecialMaxAttempts, setNewSpecialMaxAttempts] = useState(7);
const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
const [newSpecialLaunchDate, setNewSpecialLaunchDate] = useState('');
@@ -73,6 +80,7 @@ export default function AdminPage() {
const [editingSpecialId, setEditingSpecialId] = useState<number | null>(null);
const [editSpecialName, setEditSpecialName] = useState('');
const [editSpecialSubtitle, setEditSpecialSubtitle] = useState('');
const [editSpecialMaxAttempts, setEditSpecialMaxAttempts] = useState(7);
const [editSpecialUnlockSteps, setEditSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
const [editSpecialLaunchDate, setEditSpecialLaunchDate] = useState('');
@@ -161,16 +169,42 @@ export default function AdminPage() {
if (!newGenreName.trim()) return;
const res = await fetch('/api/genres', {
method: 'POST',
body: JSON.stringify({ name: newGenreName }),
body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }),
});
if (res.ok) {
setNewGenreName('');
setNewGenreSubtitle('');
fetchGenres();
} else {
alert('Failed to create genre');
}
};
const startEditGenre = (genre: Genre) => {
setEditingGenreId(genre.id);
setEditGenreName(genre.name);
setEditGenreSubtitle(genre.subtitle || '');
};
const saveEditedGenre = async () => {
if (editingGenreId === null) return;
const res = await fetch('/api/genres', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingGenreId,
name: editGenreName,
subtitle: editGenreSubtitle
}),
});
if (res.ok) {
setEditingGenreId(null);
fetchGenres();
} else {
alert('Failed to update genre');
}
};
// Specials functions
const fetchSpecials = async () => {
const res = await fetch('/api/specials');
@@ -187,6 +221,7 @@ export default function AdminPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newSpecialName,
subtitle: newSpecialSubtitle,
maxAttempts: newSpecialMaxAttempts,
unlockSteps: newSpecialUnlockSteps,
launchDate: newSpecialLaunchDate || null,
@@ -196,6 +231,7 @@ export default function AdminPage() {
});
if (res.ok) {
setNewSpecialName('');
setNewSpecialSubtitle('');
setNewSpecialMaxAttempts(7);
setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]');
setNewSpecialLaunchDate('');
@@ -278,6 +314,7 @@ export default function AdminPage() {
const startEditSpecial = (special: Special) => {
setEditingSpecialId(special.id);
setEditSpecialName(special.name);
setEditSpecialSubtitle(special.subtitle || '');
setEditSpecialMaxAttempts(special.maxAttempts);
setEditSpecialUnlockSteps(special.unlockSteps);
setEditSpecialLaunchDate(special.launchDate ? new Date(special.launchDate).toISOString().split('T')[0] : '');
@@ -293,6 +330,7 @@ export default function AdminPage() {
body: JSON.stringify({
id: editingSpecialId,
name: editSpecialName,
subtitle: editSpecialSubtitle,
maxAttempts: editSpecialMaxAttempts,
unlockSteps: editSpecialUnlockSteps,
launchDate: editSpecialLaunchDate || null,
@@ -700,6 +738,10 @@ export default function AdminPage() {
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label>
<input type="text" placeholder="Special name" value={newSpecialName} onChange={e => setNewSpecialName(e.target.value)} className="form-input" required />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
<input type="text" placeholder="Subtitle" value={newSpecialSubtitle} onChange={e => setNewSpecialSubtitle(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Max Attempts</label>
<input type="number" placeholder="Max attempts" value={newSpecialMaxAttempts} onChange={e => setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
@@ -735,6 +777,7 @@ export default function AdminPage() {
fontSize: '0.875rem'
}}>
<span>{special.name} ({special._count?.songs || 0})</span>
{special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {special.subtitle}</span>}
<a href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>Curate</a>
<button onClick={() => startEditSpecial(special)} className="btn-secondary" style={{ marginRight: '0.5rem' }}>Edit</button>
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button>
@@ -749,6 +792,10 @@ export default function AdminPage() {
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label>
<input type="text" value={editSpecialName} onChange={e => setEditSpecialName(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
<input type="text" value={editSpecialSubtitle} onChange={e => setEditSpecialSubtitle(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Max Attempts</label>
<input type="number" value={editSpecialMaxAttempts} onChange={e => setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
@@ -788,6 +835,14 @@ export default function AdminPage() {
className="form-input"
style={{ maxWidth: '200px' }}
/>
<input
type="text"
value={newGenreSubtitle}
onChange={e => setNewGenreSubtitle(e.target.value)}
placeholder="Subtitle"
className="form-input"
style={{ maxWidth: '300px' }}
/>
<button onClick={createGenre} className="btn-primary">Add Genre</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
@@ -802,15 +857,29 @@ export default function AdminPage() {
fontSize: '0.875rem'
}}>
<span>{genre.name} ({genre._count?.songs || 0})</span>
<button
onClick={() => deleteGenre(genre.id)}
style={{ border: 'none', background: 'none', cursor: 'pointer', color: '#ef4444', fontWeight: 'bold' }}
>
×
</button>
{genre.subtitle && <span style={{ fontSize: '0.75rem', color: '#666' }}>- {genre.subtitle}</span>}
<button onClick={() => startEditGenre(genre)} className="btn-secondary" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>Edit</button>
<button onClick={() => deleteGenre(genre.id)} className="btn-danger" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>×</button>
</div>
))}
</div>
{editingGenreId !== null && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}>
<h3>Edit Genre</h3>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label>
<input type="text" value={editGenreName} onChange={e => setEditGenreName(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
<input type="text" value={editGenreSubtitle} onChange={e => setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} />
</div>
<button onClick={saveEditedGenre} className="btn-primary">Save</button>
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button>
</div>
</div>
)}
{/* AI Categorization */}
<div style={{ marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid #e5e7eb' }}>

View File

@@ -22,6 +22,7 @@ interface SpecialSong {
interface Special {
id: number;
name: string;
subtitle?: string;
maxAttempts: number;
unlockSteps: string;
songs: SpecialSong[];
@@ -139,6 +140,11 @@ export default function SpecialEditorPage() {
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
Edit Special: {special.name}
</h1>
{special.subtitle && (
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
{special.subtitle}
</p>
)}
<p style={{ color: '#666', marginTop: '0.5rem' }}>
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
</p>

View File

@@ -22,14 +22,17 @@ export async function GET() {
export async function POST(request: Request) {
try {
const { name } = await request.json();
const { name, subtitle } = await request.json();
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
}
const genre = await prisma.genre.create({
data: { name: name.trim() },
data: {
name: name.trim(),
subtitle: subtitle ? subtitle.trim() : null
},
});
return NextResponse.json(genre);
@@ -57,3 +60,26 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function PUT(request: Request) {
try {
const { id, name, subtitle } = await request.json();
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
const genre = await prisma.genre.update({
where: { id: Number(id) },
data: {
...(name && { name: name.trim() }),
subtitle: subtitle ? subtitle.trim() : null // Allow clearing subtitle if empty string passed? Or just update if provided? Let's assume null/empty string clears it.
},
});
return NextResponse.json(genre);
} catch (error) {
console.error('Error updating genre:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -16,13 +16,14 @@ export async function GET() {
}
export async function POST(request: Request) {
const { name, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
const special = await prisma.special.create({
data: {
name,
subtitle: subtitle || null,
maxAttempts: Number(maxAttempts),
unlockSteps,
launchDate: launchDate ? new Date(launchDate) : null,
@@ -43,7 +44,7 @@ export async function DELETE(request: Request) {
}
export async function PUT(request: Request) {
const { id, name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
}
@@ -51,6 +52,7 @@ export async function PUT(request: Request) {
where: { id: Number(id) },
data: {
...(name && { name }),
subtitle: subtitle || null, // Allow clearing or setting
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
...(unlockSteps && { unlockSteps }),
launchDate: launchDate ? new Date(launchDate) : null,

View File

@@ -410,4 +410,50 @@ body {
font-size: 1.25rem;
font-weight: bold;
color: #000;
}
/* Tooltip */
.tooltip {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
}
.tooltip .tooltip-text {
visibility: hidden;
width: 200px;
background-color: #333;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px;
position: absolute;
z-index: 100;
top: 100%;
left: 50%;
margin-left: -100px;
margin-top: 5px;
opacity: 0;
transition: opacity 0.3s;
font-size: 0.75rem;
font-weight: normal;
pointer-events: none;
line-height: 1.2;
}
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
.tooltip .tooltip-text::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent transparent #333 transparent;
}

View File

@@ -28,13 +28,19 @@ export default async function Home() {
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
<div className="tooltip">
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
<span className="tooltip-text">A random song from the entire collection</span>
</div>
{/* Genres */}
{genres.map(g => (
<Link key={g.id} href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
{g.name}
</Link>
<div key={g.id} className="tooltip">
<Link href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
{g.name}
</Link>
{g.subtitle && <span className="tooltip-text">{g.subtitle}</span>}
</div>
))}
{/* Separator if both exist */}
@@ -45,16 +51,19 @@ export default async function Home() {
{/* Active Specials */}
{activeSpecials.map(s => (
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Link
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
<div className="tooltip">
<Link
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
{s.subtitle && <span className="tooltip-text">{s.subtitle}</span>}
</div>
{s.curator && (
<span style={{ fontSize: '0.75rem', color: '#666' }}>
Curated by {s.curator}

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Genre" ADD COLUMN "subtitle" TEXT;
-- AlterTable
ALTER TABLE "Special" ADD COLUMN "subtitle" TEXT;

View File

@@ -27,6 +27,7 @@ model Song {
model Genre {
id Int @id @default(autoincrement())
name String @unique
subtitle String?
songs Song[]
dailyPuzzles DailyPuzzle[]
}
@@ -34,6 +35,7 @@ model Genre {
model Special {
id Int @id @default(autoincrement())
name String @unique
subtitle String?
maxAttempts Int @default(7)
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
createdAt DateTime @default(now())