Add subtitles to Genres and Specials
This commit is contained in:
47
.agent/plans/add_subtitles.md
Normal file
47
.agent/plans/add_subtitles.md
Normal 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.
|
||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -411,3 +411,49 @@ body {
|
|||||||
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;
|
||||||
|
}
|
||||||
37
app/page.tsx
37
app/page.tsx
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Genre" ADD COLUMN "subtitle" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Special" ADD COLUMN "subtitle" TEXT;
|
||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user