Compare commits

...

7 Commits

Author SHA1 Message Date
Hördle Bot
8262a96213 feat(home): format upcoming special dates with TZ support 2025-11-23 02:33:22 +01:00
Hördle Bot
b294a3a8e6 feat(home): display curator in upcoming specials 2025-11-23 02:29:23 +01:00
Hördle Bot
2a38bce02c feat(specials): add curator field 2025-11-23 02:25:45 +01:00
Hördle Bot
6fd5f8ed0c style(admin): fix button height consistency 2025-11-23 02:16:42 +01:00
Hördle Bot
c05ead4493 fix(api): include song count in specials list 2025-11-23 02:15:12 +01:00
Hördle Bot
5fb450d37e style(admin): unify button styles for edit, delete, and cancel 2025-11-23 02:13:11 +01:00
Hördle Bot
e9526918e1 feat(specials): add launch and end dates for scheduling 2025-11-23 02:09:49 +01:00
9 changed files with 256 additions and 37 deletions

View File

@@ -8,6 +8,9 @@ interface Special {
name: string;
maxAttempts: number;
unlockSteps: string;
launchDate?: string;
endDate?: string;
curator?: string;
_count?: {
songs: number;
};
@@ -62,10 +65,17 @@ export default function AdminPage() {
const [newSpecialName, setNewSpecialName] = useState('');
const [newSpecialMaxAttempts, setNewSpecialMaxAttempts] = useState(7);
const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
const [newSpecialLaunchDate, setNewSpecialLaunchDate] = useState('');
const [newSpecialEndDate, setNewSpecialEndDate] = useState('');
const [newSpecialCurator, setNewSpecialCurator] = useState('');
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]');
const [editSpecialLaunchDate, setEditSpecialLaunchDate] = useState('');
const [editSpecialEndDate, setEditSpecialEndDate] = useState('');
const [editSpecialCurator, setEditSpecialCurator] = useState('');
// Edit state
const [editingId, setEditingId] = useState<number | null>(null);
@@ -177,12 +187,18 @@ export default function AdminPage() {
name: newSpecialName,
maxAttempts: newSpecialMaxAttempts,
unlockSteps: newSpecialUnlockSteps,
launchDate: newSpecialLaunchDate || null,
endDate: newSpecialEndDate || null,
curator: newSpecialCurator || null,
}),
});
if (res.ok) {
setNewSpecialName('');
setNewSpecialMaxAttempts(7);
setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]');
setNewSpecialLaunchDate('');
setNewSpecialEndDate('');
setNewSpecialCurator('');
fetchSpecials();
} else {
alert('Failed to create special');
@@ -262,6 +278,9 @@ export default function AdminPage() {
setEditSpecialName(special.name);
setEditSpecialMaxAttempts(special.maxAttempts);
setEditSpecialUnlockSteps(special.unlockSteps);
setEditSpecialLaunchDate(special.launchDate ? new Date(special.launchDate).toISOString().split('T')[0] : '');
setEditSpecialEndDate(special.endDate ? new Date(special.endDate).toISOString().split('T')[0] : '');
setEditSpecialCurator(special.curator || '');
};
const saveEditedSpecial = async () => {
@@ -274,6 +293,9 @@ export default function AdminPage() {
name: editSpecialName,
maxAttempts: editSpecialMaxAttempts,
unlockSteps: editSpecialUnlockSteps,
launchDate: editSpecialLaunchDate || null,
endDate: editSpecialEndDate || null,
curator: editSpecialCurator || null,
}),
});
if (res.ok) {
@@ -671,11 +693,32 @@ export default function AdminPage() {
<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 style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<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' }}>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' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Unlock Steps</label>
<input type="text" placeholder="Unlock steps JSON" value={newSpecialUnlockSteps} onChange={e => setNewSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Launch Date</label>
<input type="date" value={newSpecialLaunchDate} onChange={e => setNewSpecialLaunchDate(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>End Date</label>
<input type="date" value={newSpecialEndDate} onChange={e => setNewSpecialEndDate(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Curator</label>
<input type="text" placeholder="Curator name" value={newSpecialCurator} onChange={e => setNewSpecialCurator(e.target.value)} className="form-input" />
</div>
<button type="submit" className="btn-primary" style={{ height: '38px' }}>Add Special</button>
</div>
</form>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
@@ -691,6 +734,7 @@ export default function AdminPage() {
}}>
<span>{special.name} ({special._count?.songs || 0})</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>
</div>
))}
@@ -698,12 +742,33 @@ export default function AdminPage() {
{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 style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<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' }}>Max Attempts</label>
<input type="number" value={editSpecialMaxAttempts} onChange={e => setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Unlock Steps</label>
<input type="text" value={editSpecialUnlockSteps} onChange={e => setEditSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Launch Date</label>
<input type="date" value={editSpecialLaunchDate} onChange={e => setEditSpecialLaunchDate(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>End Date</label>
<input type="date" value={editSpecialEndDate} onChange={e => setEditSpecialEndDate(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Curator</label>
<input type="text" value={editSpecialCurator} onChange={e => setEditSpecialCurator(e.target.value)} className="form-input" />
</div>
<button onClick={saveEditedSpecial} className="btn-primary" style={{ height: '38px' }}>Save</button>
<button onClick={() => setEditingSpecialId(null)} className="btn-secondary" style={{ height: '38px' }}>Cancel</button>
</div>
</div>
)}

View File

@@ -43,14 +43,17 @@ export async function PUT(
try {
const { id } = await params;
const specialId = parseInt(id);
const { name, maxAttempts, unlockSteps } = await request.json();
const { name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
const special = await prisma.special.update({
where: { id: specialId },
data: {
name,
maxAttempts,
unlockSteps: JSON.stringify(unlockSteps)
unlockSteps: typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(unlockSteps),
launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
}
});

View File

@@ -6,12 +6,17 @@ const prisma = new PrismaClient();
export async function GET() {
const specials = await prisma.special.findMany({
orderBy: { name: 'asc' },
include: {
_count: {
select: { songs: true }
}
}
});
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();
const { name, 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 });
}
@@ -20,6 +25,9 @@ export async function POST(request: Request) {
name,
maxAttempts: Number(maxAttempts),
unlockSteps,
launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
},
});
return NextResponse.json(special);
@@ -35,7 +43,7 @@ export async function DELETE(request: Request) {
}
export async function PUT(request: Request) {
const { id, name, maxAttempts, unlockSteps } = await request.json();
const { id, name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
}
@@ -45,6 +53,9 @@ export async function PUT(request: Request) {
...(name && { name }),
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
...(unlockSteps && { unlockSteps }),
launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
},
});
return NextResponse.json(updated);

View File

@@ -278,12 +278,59 @@ body {
border-radius: 0.25rem;
cursor: pointer;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.5rem;
box-sizing: border-box;
text-decoration: none;
font-size: 0.875rem;
}
.btn-primary:hover {
background: #333;
}
.btn-secondary {
background: #4b5563;
color: #fff;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.5rem;
box-sizing: border-box;
font-size: 0.875rem;
}
.btn-secondary:hover {
background: #374151;
}
.btn-danger {
background: #ef4444;
color: #fff;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.5rem;
box-sizing: border-box;
font-size: 0.875rem;
}
.btn-danger:hover {
background: #dc2626;
}
/* Footer */
.app-footer {
margin-top: auto;

View File

@@ -12,6 +12,18 @@ export default async function Home() {
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const now = new Date();
const activeSpecials = specials.filter(s => {
const isStarted = !s.launchDate || s.launchDate <= now;
const isEnded = s.endDate && s.endDate < now;
return isStarted && !isEnded;
});
const upcomingSpecials = specials.filter(s => {
return s.launchDate && s.launchDate > now;
});
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
@@ -26,25 +38,48 @@ export default async function Home() {
))}
{/* Separator if both exist */}
{genres.length > 0 && specials.length > 0 && (
{genres.length > 0 && activeSpecials.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>
{/* 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>
{s.curator && (
<span style={{ fontSize: '0.75rem', color: '#666' }}>
Curated by {s.curator}
</span>
)}
</div>
))}
</div>
{/* Upcoming Specials */}
{upcomingSpecials.length > 0 && (
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Coming soon: {upcomingSpecials.map(s => (
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
{s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: process.env.TZ
}) : ''})
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
</span>
))}
</div>
)}
</div>
<Game dailyPuzzle={dailyPuzzle} genre={null} />
</>

View File

@@ -14,10 +14,45 @@ interface PageProps {
export default async function SpecialPage({ params }: PageProps) {
const { name } = await params;
const decodedName = decodeURIComponent(name);
const currentSpecial = await prisma.special.findUnique({
where: { name: decodedName }
});
const now = new Date();
const isStarted = currentSpecial && (!currentSpecial.launchDate || currentSpecial.launchDate <= now);
const isEnded = currentSpecial && (currentSpecial.endDate && currentSpecial.endDate < now);
if (!currentSpecial || !isStarted) {
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h1>Special Not Available</h1>
<p>This special has not launched yet or does not exist.</p>
<Link href="/">Go Home</Link>
</div>
);
}
if (isEnded) {
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h1>Special Ended</h1>
<p>This special event has ended.</p>
<Link href="/">Go Home</Link>
</div>
);
}
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const activeSpecials = specials.filter(s => {
const sStarted = !s.launchDate || s.launchDate <= now;
const sEnded = s.endDate && s.endDate < now;
return sStarted && !sEnded;
});
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
@@ -39,12 +74,12 @@ export default async function SpecialPage({ params }: PageProps) {
))}
{/* Separator if both exist */}
{genres.length > 0 && specials.length > 0 && (
{genres.length > 0 && activeSpecials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{specials.map(s => (
{activeSpecials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Special" ADD COLUMN "curator" TEXT;

View File

@@ -0,0 +1,18 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Special" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"maxAttempts" INTEGER NOT NULL DEFAULT 7,
"unlockSteps" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"launchDate" DATETIME,
"endDate" DATETIME
);
INSERT INTO "new_Special" ("createdAt", "id", "maxAttempts", "name", "unlockSteps") SELECT "createdAt", "id", "maxAttempts", "name", "unlockSteps" FROM "Special";
DROP TABLE "Special";
ALTER TABLE "new_Special" RENAME TO "Special";
CREATE UNIQUE INDEX "Special_name_key" ON "Special"("name");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -30,13 +30,16 @@ model Genre {
}
model Special {
id Int @id @default(autoincrement())
name String @unique
maxAttempts Int @default(7)
unlockSteps String // JSON array: "[2,4,7,11,16,30,60]"
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
name String @unique
maxAttempts Int @default(7)
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
createdAt DateTime @default(now())
launchDate DateTime?
endDate DateTime?
curator String?
songs SpecialSong[]
dailyPuzzles DailyPuzzle[]
puzzles DailyPuzzle[]
}
model SpecialSong {