Implement Specials feature, Admin UI enhancements, and Database Rebuild tool

This commit is contained in:
Hördle Bot
2025-11-22 16:09:45 +01:00
parent c270f2098f
commit 903d626699
16 changed files with 816 additions and 37 deletions

View File

@@ -16,12 +16,15 @@ export default async function GenrePage({ params }: PageProps) {
const decodedGenre = decodeURIComponent(genre);
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
{/* Genres */}
{genres.map(g => (
<Link
key={g.id}
@@ -35,6 +38,26 @@ export default async function GenrePage({ params }: PageProps) {
{g.name}
</Link>
))}
{/* Separator if both exist */}
{genres.length > 0 && specials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{specials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
))}
</div>
</div>
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />

View File

@@ -3,12 +3,13 @@
const GOTIFY_URL = process.env.GOTIFY_URL;
const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN;
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number) {
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null) {
try {
const title = `Hördle #${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
const genreText = genre ? `[${genre}] ` : '';
const title = `Hördle ${genreText}#${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
const message = status === 'won'
? `Puzzle #${puzzleId} was solved in ${attempts} attempt(s).`
: `Puzzle #${puzzleId} was failed after ${attempts} attempt(s).`;
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s).`
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s).`;
const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
method: 'POST',

View File

@@ -2,6 +2,17 @@
import { useState, useEffect } from 'react';
interface Special {
id: number;
name: string;
maxAttempts: number;
unlockSteps: string;
_count?: {
songs: number;
};
}
interface Genre {
id: number;
name: string;
@@ -18,6 +29,7 @@ interface Song {
createdAt: string;
activations: number;
genres: Genre[];
specials: Special[];
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt';
@@ -36,11 +48,22 @@ export default function AdminPage() {
const [genres, setGenres] = useState<Genre[]>([]);
const [newGenreName, setNewGenreName] = useState('');
// Specials state
const [specials, setSpecials] = useState<Special[]>([]);
const [newSpecialName, setNewSpecialName] = useState('');
const [newSpecialMaxAttempts, setNewSpecialMaxAttempts] = useState(7);
const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
const [editingSpecialId, setEditingSpecialId] = useState<number | null>(null);
const [editSpecialName, setEditSpecialName] = useState('');
const [editSpecialMaxAttempts, setEditSpecialMaxAttempts] = useState(7);
const [editSpecialUnlockSteps, setEditSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
// Edit state
const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editArtist, setEditArtist] = useState('');
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
// Post-upload state
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
@@ -56,7 +79,8 @@ export default function AdminPage() {
// Search and pagination state
const [searchQuery, setSearchQuery] = useState('');
const [selectedGenreFilter, setSelectedGenreFilter] = useState<number | null>(null);
const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
@@ -119,6 +143,79 @@ export default function AdminPage() {
}
};
// Specials functions
const fetchSpecials = async () => {
const res = await fetch('/api/specials');
if (res.ok) {
const data = await res.json();
setSpecials(data);
}
};
const handleCreateSpecial = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/specials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newSpecialName,
maxAttempts: newSpecialMaxAttempts,
unlockSteps: newSpecialUnlockSteps,
}),
});
if (res.ok) {
setNewSpecialName('');
setNewSpecialMaxAttempts(7);
setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]');
fetchSpecials();
} else {
alert('Failed to create special');
}
};
const handleDeleteSpecial = async (id: number) => {
if (!confirm('Delete this special?')) return;
const res = await fetch('/api/specials', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) fetchSpecials();
else alert('Failed to delete special');
};
const startEditSpecial = (special: Special) => {
setEditingSpecialId(special.id);
setEditSpecialName(special.name);
setEditSpecialMaxAttempts(special.maxAttempts);
setEditSpecialUnlockSteps(special.unlockSteps);
};
const saveEditedSpecial = async () => {
if (editingSpecialId === null) return;
const res = await fetch('/api/specials', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingSpecialId,
name: editSpecialName,
maxAttempts: editSpecialMaxAttempts,
unlockSteps: editSpecialUnlockSteps,
}),
});
if (res.ok) {
setEditingSpecialId(null);
fetchSpecials();
} else {
alert('Failed to update special');
}
};
// Load specials after auth
useEffect(() => {
if (isAuthenticated) fetchSpecials();
}, [isAuthenticated]);
const deleteGenre = async (id: number) => {
if (!confirm('Delete this genre?')) return;
const res = await fetch('/api/genres', {
@@ -322,6 +419,7 @@ export default function AdminPage() {
setEditTitle(song.title);
setEditArtist(song.artist);
setEditGenreIds(song.genres.map(g => g.id));
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
};
const cancelEditing = () => {
@@ -329,6 +427,7 @@ export default function AdminPage() {
setEditTitle('');
setEditArtist('');
setEditGenreIds([]);
setEditSpecialIds([]);
};
const saveEditing = async (id: number) => {
@@ -339,7 +438,8 @@ export default function AdminPage() {
id,
title: editTitle,
artist: editArtist,
genreIds: editGenreIds
genreIds: editGenreIds,
specialIds: editSpecialIds
}),
});
@@ -424,10 +524,21 @@ export default function AdminPage() {
song.artist.toLowerCase().includes(searchQuery.toLowerCase());
// Genre filter
const matchesGenre = selectedGenreFilter === null ||
song.genres.some(g => g.id === selectedGenreFilter);
// Unified Filter
let matchesFilter = true;
if (selectedGenreFilter) {
if (selectedGenreFilter.startsWith('genre:')) {
const genreId = Number(selectedGenreFilter.split(':')[1]);
matchesFilter = genreId === -1
? song.genres.length === 0
: song.genres.some(g => g.id === genreId);
} else if (selectedGenreFilter.startsWith('special:')) {
const specialId = Number(selectedGenreFilter.split(':')[1]);
matchesFilter = song.specials?.some(s => s.id === specialId) || false;
}
}
return matchesSearch && matchesGenre;
return matchesSearch && matchesFilter;
});
const sortedSongs = [...filteredSongs].sort((a, b) => {
@@ -476,6 +587,48 @@ export default function AdminPage() {
<div className="admin-container">
<h1 className="title" style={{ marginBottom: '2rem' }}>Hördle Admin Dashboard</h1>
{/* Special Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Specials</h2>
<form onSubmit={handleCreateSpecial} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input type="text" placeholder="Special name" value={newSpecialName} onChange={e => setNewSpecialName(e.target.value)} className="form-input" required />
<input type="number" placeholder="Max attempts" value={newSpecialMaxAttempts} onChange={e => setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} />
<input type="text" placeholder="Unlock steps JSON" value={newSpecialUnlockSteps} onChange={e => setNewSpecialUnlockSteps(e.target.value)} className="form-input" />
<button type="submit" className="btn-primary">Add Special</button>
</div>
</form>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{specials.map(special => (
<div key={special.id} style={{
background: '#f3f4f6',
padding: '0.25rem 0.75rem',
borderRadius: '999px',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.875rem'
}}>
<span>{special.name} ({special._count?.songs || 0})</span>
<button onClick={() => startEditSpecial(special)} className="btn-primary" style={{ marginRight: '0.5rem' }}>Edit</button>
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button>
</div>
))}
</div>
{editingSpecialId !== null && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}>
<h3>Edit Special</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input type="text" value={editSpecialName} onChange={e => setEditSpecialName(e.target.value)} className="form-input" />
<input type="number" value={editSpecialMaxAttempts} onChange={e => setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} />
<input type="text" value={editSpecialUnlockSteps} onChange={e => setEditSpecialUnlockSteps(e.target.value)} className="form-input" />
<button onClick={saveEditedSpecial} className="btn-primary">Save</button>
<button onClick={() => setEditingSpecialId(null)} className="btn-secondary">Cancel</button>
</div>
</div>
)}
</div>
{/* Genre Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
@@ -704,23 +857,33 @@ export default function AdminPage() {
style={{ flex: '1', minWidth: '200px' }}
/>
<select
value={selectedGenreFilter || ''}
onChange={e => setSelectedGenreFilter(e.target.value ? Number(e.target.value) : null)}
value={selectedGenreFilter}
onChange={e => setSelectedGenreFilter(e.target.value)}
className="form-input"
style={{ minWidth: '150px' }}
>
<option value="">All Genres</option>
{genres.map(genre => (
<option key={genre.id} value={genre.id}>
{genre.name} ({genre._count?.songs || 0})
</option>
))}
<option value="">All Content</option>
<optgroup label="Genres">
<option value="genre:-1">No Genre</option>
{genres.map(genre => (
<option key={genre.id} value={`genre:${genre.id}`}>
{genre.name} ({genre._count?.songs || 0})
</option>
))}
</optgroup>
<optgroup label="Specials">
{specials.map(special => (
<option key={special.id} value={`special:${special.id}`}>
{special.name} ({special._count?.songs || 0})
</option>
))}
</optgroup>
</select>
{(searchQuery || selectedGenreFilter) && (
<button
onClick={() => {
setSearchQuery('');
setSelectedGenreFilter(null);
setSelectedGenreFilter('');
}}
style={{
padding: '0.5rem 1rem',
@@ -813,6 +976,24 @@ export default function AdminPage() {
</label>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.25rem' }}>
{specials.map(special => (
<label key={special.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem', color: '#4b5563' }}>
<input
type="checkbox"
checked={editSpecialIds.includes(special.id)}
onChange={e => {
if (e.target.checked) {
setEditSpecialIds([...editSpecialIds, special.id]);
} else {
setEditSpecialIds(editSpecialIds.filter(id => id !== special.id));
}
}}
/>
{special.name}
</label>
))}
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')}
@@ -854,6 +1035,19 @@ export default function AdminPage() {
</span>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{song.specials?.map(s => (
<span key={s.id} style={{
background: '#fce7f3',
color: '#9d174d',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem'
}}>
{s.name}
</span>
))}
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')}
@@ -934,6 +1128,47 @@ export default function AdminPage() {
</div>
)}
</div>
<div className="admin-card" style={{ marginTop: '2rem', border: '1px solid #ef4444' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem', color: '#ef4444' }}>
Danger Zone
</h2>
<p style={{ marginBottom: '1rem', color: '#666' }}>
These actions are destructive and cannot be undone.
</p>
<button
onClick={async () => {
if (window.confirm('⚠️ WARNING: This will delete ALL data from the database (Songs, Genres, Specials, Puzzles) and re-import songs from the uploads folder.\n\nExisting genres and specials will be LOST and must be recreated manually.\n\nAre you sure you want to proceed?')) {
try {
setMessage('Rebuilding database... this may take a while.');
const res = await fetch('/api/admin/rebuild', { method: 'POST' });
if (res.ok) {
const data = await res.json();
alert(data.message + '\n\nPlease recreate your Genres and Specials now.');
window.location.reload();
} else {
alert('Rebuild failed. Check server logs.');
setMessage('Rebuild failed.');
}
} catch (e) {
console.error(e);
alert('Rebuild failed due to network error.');
}
}
}}
style={{
padding: '0.75rem 1.5rem',
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
Rebuild Database
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { parseFile } from 'music-metadata';
import path from 'path';
import fs from 'fs/promises';
const prisma = new PrismaClient();
export async function POST() {
try {
console.log('[Rebuild] Starting database rebuild...');
// 1. Clear Database
// Delete in order to respect foreign keys
await prisma.dailyPuzzle.deleteMany();
// We need to clear the many-to-many relations first implicitly by deleting songs/genres/specials
// But explicit deletion of join tables isn't needed with Prisma's cascading deletes usually,
// but let's be safe and delete main entities.
await prisma.song.deleteMany();
await prisma.genre.deleteMany();
await prisma.special.deleteMany();
console.log('[Rebuild] Database cleared.');
// 2. Clear Covers Directory
const coversDir = path.join(process.cwd(), 'public/uploads/covers');
try {
const coverFiles = await fs.readdir(coversDir);
for (const file of coverFiles) {
if (file !== '.gitkeep') { // Preserve .gitkeep if it exists
await fs.unlink(path.join(coversDir, file));
}
}
console.log('[Rebuild] Covers directory cleared.');
} catch (e) {
console.log('[Rebuild] Covers directory might not exist or empty, creating it.');
await fs.mkdir(coversDir, { recursive: true });
}
// 3. Re-import Songs
const uploadsDir = path.join(process.cwd(), 'public/uploads');
const files = await fs.readdir(uploadsDir);
const mp3Files = files.filter(f => f.endsWith('.mp3'));
console.log(`[Rebuild] Found ${mp3Files.length} MP3 files to import.`);
let importedCount = 0;
for (const filename of mp3Files) {
const filePath = path.join(uploadsDir, filename);
try {
const metadata = await parseFile(filePath);
const title = metadata.common.title || 'Unknown Title';
const artist = metadata.common.artist || 'Unknown Artist';
let coverImage = null;
const picture = metadata.common.picture?.[0];
if (picture) {
const extension = picture.format.split('/')[1] || 'jpg';
const coverFilename = `cover-${Date.now()}-${Math.random().toString(36).substring(7)}.${extension}`;
const coverPath = path.join(coversDir, coverFilename);
await fs.writeFile(coverPath, picture.data);
coverImage = coverFilename;
}
await prisma.song.create({
data: {
title,
artist,
filename,
coverImage
}
});
importedCount++;
} catch (e) {
console.error(`[Rebuild] Failed to process ${filename}:`, e);
}
}
console.log(`[Rebuild] Successfully imported ${importedCount} songs.`);
return NextResponse.json({
success: true,
message: `Database rebuilt. Imported ${importedCount} songs.`
});
} catch (error) {
console.error('[Rebuild] Error:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -12,6 +12,7 @@ export async function GET() {
include: {
puzzles: true,
genres: true,
specials: true,
},
});
@@ -25,6 +26,7 @@ export async function GET() {
coverImage: song.coverImage,
activations: song.puzzles.length,
genres: song.genres,
specials: song.specials,
}));
return NextResponse.json(songsWithActivations);
@@ -146,7 +148,7 @@ export async function POST(request: Request) {
filename,
coverImage,
},
include: { genres: true }
include: { genres: true, specials: true }
});
return NextResponse.json({
@@ -161,7 +163,7 @@ export async function POST(request: Request) {
export async function PUT(request: Request) {
try {
const { id, title, artist, genreIds } = await request.json();
const { id, title, artist, genreIds, specialIds } = await request.json();
if (!id || !title || !artist) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
@@ -175,10 +177,16 @@ export async function PUT(request: Request) {
};
}
if (specialIds) {
data.specials = {
set: specialIds.map((sId: number) => ({ id: sId }))
};
}
const updatedSong = await prisma.song.update({
where: { id: Number(id) },
data,
include: { genres: true }
include: { genres: true, specials: true }
});
return NextResponse.json(updatedSong);

51
app/api/specials/route.ts Normal file
View File

@@ -0,0 +1,51 @@
import { PrismaClient, Special } from '@prisma/client';
import { NextResponse } from 'next/server';
const prisma = new PrismaClient();
export async function GET() {
const specials = await prisma.special.findMany({
orderBy: { name: 'asc' },
});
return NextResponse.json(specials);
}
export async function POST(request: Request) {
const { name, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]' } = await request.json();
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
const special = await prisma.special.create({
data: {
name,
maxAttempts: Number(maxAttempts),
unlockSteps,
},
});
return NextResponse.json(special);
}
export async function DELETE(request: Request) {
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
}
await prisma.special.delete({ where: { id: Number(id) } });
return NextResponse.json({ success: true });
}
export async function PUT(request: Request) {
const { id, name, maxAttempts, unlockSteps } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
}
const updated = await prisma.special.update({
where: { id: Number(id) },
data: {
...(name && { name }),
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
...(unlockSteps && { unlockSteps }),
},
});
return NextResponse.json(updated);
}

View File

@@ -10,17 +10,40 @@ const prisma = new PrismaClient();
export default async function Home() {
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
{/* Genres */}
{genres.map(g => (
<Link key={g.id} href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
{g.name}
</Link>
))}
{/* Separator if both exist */}
{genres.length > 0 && specials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{specials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
))}
</div>
</div>
<Game dailyPuzzle={dailyPuzzle} genre={null} />

View File

@@ -0,0 +1,70 @@
import Game from '@/components/Game';
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
interface PageProps {
params: Promise<{ name: string }>;
}
export default async function SpecialPage({ params }: PageProps) {
const { name } = await params;
const decodedName = decodeURIComponent(name);
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
{/* Genres */}
{genres.map(g => (
<Link
key={g.id}
href={`/${g.name}`}
style={{
color: '#4b5563',
textDecoration: 'none'
}}
>
{g.name}
</Link>
))}
{/* Separator if both exist */}
{genres.length > 0 && specials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{specials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}
style={{
fontWeight: s.name === decodedName ? 'bold' : 'normal',
textDecoration: s.name === decodedName ? 'underline' : 'none',
color: s.name === decodedName ? '#9d174d' : '#be185d'
}}
>
{s.name}
</Link>
))}
</div>
</div>
<Game
dailyPuzzle={dailyPuzzle}
genre={decodedName}
maxAttempts={dailyPuzzle?.maxAttempts}
unlockSteps={dailyPuzzle?.unlockSteps}
/>
</>
);
}