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

View File

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

View File

@@ -22,14 +22,17 @@ export async function GET() {
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { name } = await request.json(); const { name, subtitle } = await request.json();
if (!name || typeof name !== 'string') { if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 }); return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
} }
const genre = await prisma.genre.create({ const genre = await prisma.genre.create({
data: { name: name.trim() }, data: {
name: name.trim(),
subtitle: subtitle ? subtitle.trim() : null
},
}); });
return NextResponse.json(genre); return NextResponse.json(genre);
@@ -57,3 +60,26 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); 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) { 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) { if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 }); return NextResponse.json({ error: 'Name is required' }, { status: 400 });
} }
const special = await prisma.special.create({ const special = await prisma.special.create({
data: { data: {
name, name,
subtitle: subtitle || null,
maxAttempts: Number(maxAttempts), maxAttempts: Number(maxAttempts),
unlockSteps, unlockSteps,
launchDate: launchDate ? new Date(launchDate) : null, launchDate: launchDate ? new Date(launchDate) : null,
@@ -43,7 +44,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, curator } = await request.json(); const { id, name, subtitle, 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 });
} }
@@ -51,6 +52,7 @@ export async function PUT(request: Request) {
where: { id: Number(id) }, where: { id: Number(id) },
data: { data: {
...(name && { name }), ...(name && { name }),
subtitle: subtitle || null, // Allow clearing or setting
...(maxAttempts && { maxAttempts: Number(maxAttempts) }), ...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
...(unlockSteps && { unlockSteps }), ...(unlockSteps && { unlockSteps }),
launchDate: launchDate ? new Date(launchDate) : null, launchDate: launchDate ? new Date(launchDate) : null,

View File

@@ -410,4 +410,50 @@ body {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: bold; font-weight: bold;
color: #000; 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={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}> <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 */}
{genres.map(g => ( {genres.map(g => (
<Link key={g.id} href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}> <div key={g.id} className="tooltip">
{g.name} <Link href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
</Link> {g.name}
</Link>
{g.subtitle && <span className="tooltip-text">{g.subtitle}</span>}
</div>
))} ))}
{/* Separator if both exist */} {/* Separator if both exist */}
@@ -45,16 +51,19 @@ 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' }}> <div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Link <div className="tooltip">
href={`/special/${s.name}`} <Link
style={{ href={`/special/${s.name}`}
color: '#be185d', // Pink-700 style={{
textDecoration: 'none', color: '#be185d', // Pink-700
fontWeight: '500' textDecoration: 'none',
}} fontWeight: '500'
> }}
{s.name} >
</Link> {s.name}
</Link>
{s.subtitle && <span className="tooltip-text">{s.subtitle}</span>}
</div>
{s.curator && ( {s.curator && (
<span style={{ fontSize: '0.75rem', color: '#666' }}> <span style={{ fontSize: '0.75rem', color: '#666' }}>
Curated by {s.curator} 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 { model Genre {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
subtitle String?
songs Song[] songs Song[]
dailyPuzzles DailyPuzzle[] dailyPuzzles DailyPuzzle[]
} }
@@ -34,6 +35,7 @@ model Genre {
model Special { model Special {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
subtitle String?
maxAttempts Int @default(7) maxAttempts Int @default(7)
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]" unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
createdAt DateTime @default(now()) createdAt DateTime @default(now())