diff --git a/.agent/plans/add_subtitles.md b/.agent/plans/add_subtitles.md new file mode 100644 index 0000000..fba7888 --- /dev/null +++ b/.agent/plans/add_subtitles.md @@ -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. diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 9208c40..9d7351e 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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([]); const [genres, setGenres] = useState([]); const [newGenreName, setNewGenreName] = useState(''); + const [newGenreSubtitle, setNewGenreSubtitle] = useState(''); + const [editingGenreId, setEditingGenreId] = useState(null); + const [editGenreName, setEditGenreName] = useState(''); + const [editGenreSubtitle, setEditGenreSubtitle] = useState(''); // Specials state const [specials, setSpecials] = useState([]); 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(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() { setNewSpecialName(e.target.value)} className="form-input" required /> +
+ + setNewSpecialSubtitle(e.target.value)} className="form-input" /> +
setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} /> @@ -735,6 +777,7 @@ export default function AdminPage() { fontSize: '0.875rem' }}> {special.name} ({special._count?.songs || 0}) + {special.subtitle && - {special.subtitle}} Curate @@ -749,6 +792,10 @@ export default function AdminPage() { setEditSpecialName(e.target.value)} className="form-input" />
+
+ + setEditSpecialSubtitle(e.target.value)} className="form-input" /> +
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' }} /> + setNewGenreSubtitle(e.target.value)} + placeholder="Subtitle" + className="form-input" + style={{ maxWidth: '300px' }} + />
@@ -802,15 +857,29 @@ export default function AdminPage() { fontSize: '0.875rem' }}> {genre.name} ({genre._count?.songs || 0}) - + {genre.subtitle && - {genre.subtitle}} + +
))} + {editingGenreId !== null && ( +
+

Edit Genre

+
+
+ + setEditGenreName(e.target.value)} className="form-input" /> +
+
+ + setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} /> +
+ + +
+
+ )} {/* AI Categorization */}
diff --git a/app/admin/specials/[id]/page.tsx b/app/admin/specials/[id]/page.tsx index 493569e..1dc3cb2 100644 --- a/app/admin/specials/[id]/page.tsx +++ b/app/admin/specials/[id]/page.tsx @@ -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() {

Edit Special: {special.name}

+ {special.subtitle && ( +

+ {special.subtitle} +

+ )}

Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s

diff --git a/app/api/genres/route.ts b/app/api/genres/route.ts index de66d81..f7d588a 100644 --- a/app/api/genres/route.ts +++ b/app/api/genres/route.ts @@ -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 }); + } +} diff --git a/app/api/specials/route.ts b/app/api/specials/route.ts index 09a939f..0260f16 100644 --- a/app/api/specials/route.ts +++ b/app/api/specials/route.ts @@ -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, diff --git a/app/globals.css b/app/globals.css index d5c77cc..c2f1704 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 97ede4b..056e451 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -28,13 +28,19 @@ export default async function Home() { <>
- Global +
+ Global + A random song from the entire collection +
{/* Genres */} {genres.map(g => ( - - {g.name} - +
+ + {g.name} + + {g.subtitle && {g.subtitle}} +
))} {/* Separator if both exist */} @@ -45,16 +51,19 @@ export default async function Home() { {/* Active Specials */} {activeSpecials.map(s => (
- - ★ {s.name} - +
+ + ★ {s.name} + + {s.subtitle && {s.subtitle}} +
{s.curator && ( Curated by {s.curator} diff --git a/prisma/migrations/20251123140527_add_subtitles/migration.sql b/prisma/migrations/20251123140527_add_subtitles/migration.sql new file mode 100644 index 0000000..9dd4dd5 --- /dev/null +++ b/prisma/migrations/20251123140527_add_subtitles/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Genre" ADD COLUMN "subtitle" TEXT; + +-- AlterTable +ALTER TABLE "Special" ADD COLUMN "subtitle" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 75ed0ea..78b85f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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())