diff --git a/logs/err-0.log b/logs/err-0.log index 56e7e80..fe985c1 100644 --- a/logs/err-0.log +++ b/logs/err-0.log @@ -31,3 +31,36 @@ Error: Could not find a production build in the '.next' directory. Try building at setupFsCheck (/home/alex/git/warme/node_modules/next/dist/server/lib/router-utils/filesystem.js:151:19) at async initialize (/home/alex/git/warme/node_modules/next/dist/server/lib/router-server.js:64:23) at async NextCustomServer.prepare (/home/alex/git/warme/node_modules/next/dist/server/next.js:242:28) +2025-10-30T19:41:07: ⨯ Error: Input Buffer is empty + at Sharp._createInputDescriptor (/home/alex/git/warme/node_modules/sharp/lib/input.js:68:13) + at new Sharp (/home/alex/git/warme/node_modules/sharp/lib/constructor.js:403:29) + at Sharp (/home/alex/git/warme/node_modules/sharp/lib/constructor.js:212:12) + at detectContentType (/home/alex/git/warme/node_modules/next/dist/server/image-optimizer.js:359:28) + at imageOptimizer (/home/alex/git/warme/node_modules/next/dist/server/image-optimizer.js:767:32) + at NextNodeServer.imageOptimizer (/home/alex/git/warme/node_modules/next/dist/server/next-server.js:655:20) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + at async cacheEntry.imageResponseCache.get.incrementalCache (/home/alex/git/warme/node_modules/next/dist/server/next-server.js:182:65) + at async /home/alex/git/warme/node_modules/next/dist/server/response-cache/index.js:90:36 + at async /home/alex/git/warme/node_modules/next/dist/lib/batcher.js:45:32 +2025-10-30T19:41:29: ⨯ Error: Input Buffer is empty + at Sharp._createInputDescriptor (/home/alex/git/warme/node_modules/sharp/lib/input.js:68:13) + at new Sharp (/home/alex/git/warme/node_modules/sharp/lib/constructor.js:403:29) + at Sharp (/home/alex/git/warme/node_modules/sharp/lib/constructor.js:212:12) + at detectContentType (/home/alex/git/warme/node_modules/next/dist/server/image-optimizer.js:359:28) + at imageOptimizer (/home/alex/git/warme/node_modules/next/dist/server/image-optimizer.js:767:32) + at NextNodeServer.imageOptimizer (/home/alex/git/warme/node_modules/next/dist/server/next-server.js:655:20) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + at async cacheEntry.imageResponseCache.get.incrementalCache (/home/alex/git/warme/node_modules/next/dist/server/next-server.js:182:65) + at async /home/alex/git/warme/node_modules/next/dist/server/response-cache/index.js:90:36 + at async /home/alex/git/warme/node_modules/next/dist/lib/batcher.js:45:32 +2025-10-30T19:42:20: ⨯ Error: Input Buffer is empty + at Sharp._createInputDescriptor (/home/alex/git/warme/node_modules/sharp/lib/input.js:68:13) + at new Sharp (/home/alex/git/warme/node_modules/sharp/lib/constructor.js:403:29) + at Sharp (/home/alex/git/warme/node_modules/sharp/lib/constructor.js:212:12) + at detectContentType (/home/alex/git/warme/node_modules/next/dist/server/image-optimizer.js:359:28) + at imageOptimizer (/home/alex/git/warme/node_modules/next/dist/server/image-optimizer.js:767:32) + at NextNodeServer.imageOptimizer (/home/alex/git/warme/node_modules/next/dist/server/next-server.js:655:20) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + at async cacheEntry.imageResponseCache.get.incrementalCache (/home/alex/git/warme/node_modules/next/dist/server/next-server.js:182:65) + at async /home/alex/git/warme/node_modules/next/dist/server/response-cache/index.js:90:36 + at async /home/alex/git/warme/node_modules/next/dist/lib/batcher.js:45:32 diff --git a/logs/out-0.log b/logs/out-0.log index f0dbe7d..3243740 100644 --- a/logs/out-0.log +++ b/logs/out-0.log @@ -6,3 +6,10 @@ 2025-10-30T19:31:44: > Ready on http://localhost:3000 2025-10-30T19:34:15: > Ready on http://localhost:3000 2025-10-30T19:38:44: > Ready on http://localhost:3000 +2025-10-30T19:40:45: > Ready on http://localhost:3000 +2025-10-30T19:42:05: > Ready on http://localhost:3000 +2025-10-30T19:50:39: > Ready on http://localhost:3000 +2025-10-30T19:54:37: > Ready on http://localhost:3000 +2025-10-30T19:58:29: > Ready on http://localhost:3000 +2025-10-31T19:17:21: > Ready on http://localhost:3000 +2025-10-31T20:05:46: > Ready on http://localhost:3000 diff --git a/package.json b/package.json index add4a00..56d05db 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "pm2:logs": "pm2 logs warme --lines 100 --nostream", "pm2:delete": "pm2 delete warme" }, + "migration": { + "addFeatured": "node scripts/add-featured-column.mjs" + }, "dependencies": { "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..3b60156 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,48 @@ +Run database migrations in this repo + +This repository includes a small, idempotent migration to add a `featured` column to the `menus` table. + +Script +- `scripts/add-featured-column.mjs` — checks information_schema and adds `featured TINYINT(1) NOT NULL DEFAULT 0` if missing. + +How to run + +From the project root, set your DB connection environment variables and run the script. Example (zsh): + +```bash +export DB_HOST=localhost +export DB_USER=root +export DB_PASSWORD='your-password' +export DB_NAME=warme + +# run the migration +node scripts/add-featured-column.mjs +``` + +If the script can't connect, confirm your environment variables and that the MySQL/MariaDB server is reachable from this machine. + +SQL alternative + +If you prefer to run SQL directly in your DB client, run: + +```sql +SELECT COUNT(*) AS cnt +FROM information_schema.columns +WHERE table_schema = 'your_db_name' AND table_name = 'menus' AND column_name = 'featured'; + +-- If cnt = 0 +ALTER TABLE menus ADD COLUMN featured TINYINT(1) NOT NULL DEFAULT 0; +UPDATE menus SET featured = 0 WHERE featured IS NULL; +``` + +Verification + +After running, check with: + +```bash +mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" -D "$DB_NAME" -e "DESCRIBE menus;" +``` + +Notes +- The migration is idempotent — running it multiple times is safe. +- I can't run it from CI/this environment without database credentials. Run it locally on your machine or on a host that can reach the DB. diff --git a/scripts/add-featured-column.mjs b/scripts/add-featured-column.mjs new file mode 100644 index 0000000..9cea87e --- /dev/null +++ b/scripts/add-featured-column.mjs @@ -0,0 +1,38 @@ +import { pool } from '../src/lib/db.js'; + +// Idempotent migration using the project's DB pool: add `featured` TINYINT(1) to `menus` if missing +// Usage: node scripts/add-featured-column.mjs + +async function columnExists(connection) { + const dbName = process.env.DB_NAME || 'warme'; + const [rows] = await connection.query( + `SELECT COUNT(*) as cnt FROM information_schema.columns WHERE table_schema = ? AND table_name = 'menus' AND column_name = 'featured'`, + [dbName] + ); + return rows[0].cnt > 0; +} + +async function run() { + const connection = await pool.getConnection(); + try { + const exists = await columnExists(connection); + if (exists) { + console.log('Column `featured` already exists on `menus`. Nothing to do.'); + return; + } + + console.log('Adding `featured` column to `menus`...'); + await connection.query(`ALTER TABLE menus ADD COLUMN featured TINYINT(1) NOT NULL DEFAULT 0`); + console.log('Column added. Ensuring existing rows have 0...'); + await connection.query(`UPDATE menus SET featured = 0 WHERE featured IS NULL`); + console.log('Migration completed successfully.'); + } finally { + connection.release(); + await pool.end(); + } +} + +run().then(() => process.exit(0)).catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/src/app/admin/availability/page.tsx b/src/app/admin/availability/page.tsx index 37e3c93..5060d5c 100644 --- a/src/app/admin/availability/page.tsx +++ b/src/app/admin/availability/page.tsx @@ -3,14 +3,53 @@ import { useEffect, useState } from 'react' import { DayPicker } from 'react-day-picker' import { da } from 'date-fns/locale' +import { ToastProvider, useToast } from '@/components/ui/toast' type Weekly = { id: string; weekday: number; start_time: string; end_time: string; note?: string } type Blocked = { id: string; block_date: string; note?: string } +function BlockedList({ blocked, onDelete }: { blocked: Blocked[]; onDelete: (id: string) => void }) { + if (!blocked || blocked.length === 0) return

Ingen blokeringer

+ const sorted = blocked.slice().sort((a, b) => a.block_date.localeCompare(b.block_date)) + const fmt = (d: string) => { + try { + const dt = new Date(d) + return dt.toLocaleDateString('da-DK', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }) + } catch (e) { + return d + } + } + return ( + + ) +} + export default function AvailabilityAdmin() { + return ( + + + + ) +} + +function AvailabilityAdminContent() { + const { addToast } = useToast() const [weekly, setWeekly] = useState([]) const [blocked, setBlocked] = useState([]) const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) // form state const [weekday, setWeekday] = useState(1) @@ -21,6 +60,11 @@ export default function AvailabilityAdmin() { const [blockDate, setBlockDate] = useState('') const [blockNote, setBlockNote] = useState('') + const [multiSelectMode, setMultiSelectMode] = useState(false) + const [selectedDates, setSelectedDates] = useState(undefined) + const [showOnlyBlocked, setShowOnlyBlocked] = useState(false) + const [lastClickedDate, setLastClickedDate] = useState(null) + const [pendingDates, setPendingDates] = useState([]) // for Ctrl/Cmd+click accumulation useEffect(() => { fetchAll() }, []) @@ -38,21 +82,38 @@ export default function AvailabilityAdmin() { async function addWeekly(e: React.FormEvent) { e.preventDefault() e.currentTarget && (e.currentTarget as HTMLFormElement).querySelectorAll('button') - if (editingId) { - // send PUT to update - await fetch('/api/availability/weekly', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id: editingId, weekday, start_time: startTime, end_time: endTime, note }) }) - setEditingId(null) - } else { - await fetch('/api/availability/weekly', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ weekday, start_time: startTime, end_time: endTime, note }) }) + setSaving(true) + try { + if (editingId) { + // send PUT to update + await fetch('/api/availability/weekly', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id: editingId, weekday, start_time: startTime, end_time: endTime, note }) }) + addToast('Skema opdateret', 'success') + setEditingId(null) + } else { + await fetch('/api/availability/weekly', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ weekday, start_time: startTime, end_time: endTime, note }) }) + addToast('Skema tilføjet', 'success') + } + setNote('') + fetchAll() + } catch (err) { + addToast('Kunne ikke gemme skema', 'error') + } finally { + setSaving(false) } - setNote('') - fetchAll() } async function deleteWeekly(id: string) { if (!confirm('Slet dette skema?')) return - await fetch('/api/availability/weekly', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id }) }) - fetchAll() + setSaving(true) + try { + await fetch('/api/availability/weekly', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id }) }) + addToast('Skema slettet', 'success') + fetchAll() + } catch (err) { + addToast('Kunne ikke slette skema', 'error') + } finally { + setSaving(false) + } } function startEdit(w: Weekly) { @@ -66,35 +127,137 @@ export default function AvailabilityAdmin() { async function addBlocked(e: React.FormEvent) { e.preventDefault() - if (!blockDate) return alert('Vælg en dato') - await fetch('/api/availability/blocked', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ block_date: blockDate, note: blockNote }) }) - setBlockDate('') - setBlockNote('') - fetchAll() + if (!blockDate) { + addToast('Vælg en dato', 'warning') + return + } + setSaving(true) + try { + await fetch('/api/availability/blocked', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ block_date: blockDate, note: blockNote }) }) + setBlockDate('') + setBlockNote('') + addToast('Dato blokeret', 'success') + fetchAll() + } catch (err) { + addToast('Kunne ikke blokere dato', 'error') + } finally { + setSaving(false) + } } // Toggle a single date's blocked state when clicked in the calendar async function toggleBlockedDate(d: Date) { - const y = d.getFullYear() - const m = String(d.getMonth() + 1).padStart(2, '0') - const dd = String(d.getDate()).padStart(2, '0') - const iso = `${y}-${m}-${dd}` + const iso = isoFromDate(d) const found = blocked.find(b => b.block_date === iso) + const weeklyForDay = weekly.find(w => w.weekday === d.getDay()) + // If this date falls on a working day, confirm user intention to block full day + if (!found && weeklyForDay) { + const proceed = confirm(`Der er allerede et arbejdsskema for denne ugedag (${['Søndag','Mandag','Tirsdag','Onsdag','Torsdag','Fredag','Lørdag'][weeklyForDay.weekday]} ${weeklyForDay.start_time}–${weeklyForDay.end_time}).\n\nVil du blokere hele dagen alligevel?`) + if (!proceed) return + } if (found) { - // delete - await fetch('/api/availability/blocked', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id: found.id }) }) + // optimistic UI: remove locally first + setBlocked(prev => prev.filter(b => b.block_date !== iso)) + try { + await fetch('/api/availability/blocked', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id: found.id }) }) + addToast('Blokering fjernet', 'success') + } catch (err) { + // revert on failure + fetchAll() + addToast('Kunne ikke fjerne blokering — prøv igen', 'error') + return + } } else { - // create - await fetch('/api/availability/blocked', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ block_date: iso, note: '' }) }) + // optimistic UI: add temporary entry so calendar turns red immediately + setBlocked(prev => [...prev, { id: `tmp-${iso}`, block_date: iso }]) + try { + await fetch('/api/availability/blocked', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ block_date: iso, note: '' }) }) + addToast('Dato blokeret', 'success') + // refresh to pick up real id + fetchAll() + return + } catch (err) { + // revert on failure + setBlocked(prev => prev.filter(b => b.block_date !== iso)) + addToast('Kunne ikke oprette blokering — prøv igen', 'error') + return + } } fetchAll() } + // Bulk delete of selected blocked dates (multi-select mode) + async function deleteSelectedBlocked() { + if (!selectedDates || selectedDates.length === 0) return + if (!confirm(`Slet ${selectedDates.length} valgte blokering(er)?`)) return + setSaving(true) + const isos = selectedDates.map(d => { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${dd}` + }) + // map isos to ids + const ids = blocked.filter(b => isos.includes(b.block_date)).map(b => b.id) + if (ids.length === 0) { + addToast('Ingen matcher blandt eksisterende blokeringer', 'warning') + setSaving(false) + return + } + // optimistic + setBlocked(prev => prev.filter(b => !isos.includes(b.block_date))) + try { + await Promise.all(ids.map(id => fetch('/api/availability/blocked', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id }) }))) + setSelectedDates(undefined) + setMultiSelectMode(false) + addToast(`${ids.length} blokering(er) slettet`, 'success') + } catch (err) { + addToast('Noget gik galt ved sletning — opdaterer listen', 'error') + fetchAll() + } finally { + setSaving(false) + } + } + async function deleteBlocked(id: string) { if (!confirm('Slet denne blokering?')) return - await fetch('/api/availability/blocked', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id }) }) - fetchAll() + setSaving(true) + try { + await fetch('/api/availability/blocked', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id }) }) + addToast('Blokering slettet', 'success') + fetchAll() + } catch (err) { + addToast('Kunne ikke slette blokering', 'error') + } finally { + setSaving(false) + } + } + + function sameDay(a: Date, b: Date) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() + } + + function isoFromDate(d: Date) { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${dd}` + } + + // Generate inclusive date range between a and b + function datesBetween(a: Date, b: Date) { + const start = new Date(a.getFullYear(), a.getMonth(), a.getDate()) + const end = new Date(b.getFullYear(), b.getMonth(), b.getDate()) + const out: Date[] = [] + const step = start <= end ? 1 : -1 + let cur = new Date(start) + while (true) { + out.push(new Date(cur)) + if (cur.getFullYear() === end.getFullYear() && cur.getMonth() === end.getMonth() && cur.getDate() === end.getDate()) break + cur.setDate(cur.getDate() + step) + } + return out } return ( @@ -108,13 +271,133 @@ export default function AvailabilityAdmin() {

Kalender — klik for at skifte tilgængelighed

Klik på en dato for at markere den som ikke-tilgængelig (klik igen for at åbne).

- { if (d) toggleBlockedDate(d as Date) }} - locale={da} - modifiers={{ blocked: blocked.map(b => new Date(b.block_date)) }} - /> +
+ + + {multiSelectMode && ( + + )} + {!multiSelectMode && pendingDates.length > 0 && ( + + )} +
+ + {multiSelectMode ? ( + setSelectedDates(d as Date[] | undefined)} + locale={da} + modifiers={{ + blocked: blocked.map(b => new Date(b.block_date)), + nonWorking: (day: Date) => !weekly.some(w => w.weekday === day.getDay()) + }} + disabled={showOnlyBlocked ? ((day: Date) => !blocked.some(b => sameDay(new Date(b.block_date), day))) : undefined} + /> + ) : ( + { + if (!d) return + + // Ctrl/Cmd+click: accumulate dates for batch blocking + if (e && (e.ctrlKey || e.metaKey)) { + const iso = isoFromDate(d) + const alreadyPending = pendingDates.some(pd => isoFromDate(pd) === iso) + if (alreadyPending) { + setPendingDates(prev => prev.filter(pd => isoFromDate(pd) !== iso)) + } else { + setPendingDates(prev => [...prev, d]) + } + setLastClickedDate(d) + return + } + + // Shift+click range selection + if (e && e.shiftKey && lastClickedDate) { + setSaving(true) + const range = datesBetween(lastClickedDate, d) + // confirm if any date in range falls on a working weekly schedule + const hasWorking = range.some(rd => weekly.some(w => w.weekday === rd.getDay())) + if (hasWorking) { + const proceed = confirm('Nogle dage i det valgte interval har et arbejdsskema. Vil du blokere alle dagene alligevel?') + if (!proceed) { + setLastClickedDate(d) + setSaving(false) + return + } + } + // optimistic add + const isos = range.map(r => isoFromDate(r)) + setBlocked(prev => [...prev, ...isos.map(i => ({ id: `tmp-${i}`, block_date: i }))]) + try { + await Promise.all(range.map(r => fetch('/api/availability/blocked', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ block_date: isoFromDate(r), note: '' }) }))) + addToast(`${range.length} dage blokeret`, 'success') + } catch (err) { + addToast('Kunne ikke oprette alle blokeringer — opdaterer listen', 'error') + } finally { + fetchAll() + setSaving(false) + } + setLastClickedDate(d) + return + } + + // normal single toggle + await toggleBlockedDate(d) + setLastClickedDate(d) + }} + locale={da} + modifiers={{ + blocked: blocked.map(b => new Date(b.block_date)), + nonWorking: (day: Date) => !weekly.some(w => w.weekday === day.getDay()), + pending: pendingDates, + }} + modifiersClassNames={{ + pending: 'rdp-day_pending', + }} + disabled={showOnlyBlocked ? ((day: Date) => !blocked.some(b => sameDay(new Date(b.block_date), day))) : undefined} + /> + )}
@@ -128,7 +411,16 @@ export default function AvailabilityAdmin() { Valgt + + + Venter (Ctrl+klik) +
+ {!multiSelectMode && ( +
+

Tips: Hold Shift og klik for at vælge interval. Hold Ctrl/⌘ og klik for at vælge flere enkeltdage.

+
+ )}
@@ -155,7 +447,9 @@ export default function AvailabilityAdmin() { setNote(e.target.value)} placeholder="Noter (valgfri)" className="border px-2 py-1 rounded" /> - +

Eksisterende skemaer

@@ -177,18 +471,12 @@ export default function AvailabilityAdmin() {
setBlockDate(e.target.value)} className="border px-2 py-1 rounded" /> setBlockNote(e.target.value)} placeholder="Noter (fx ferie)" className="border px-2 py-1 rounded" /> - +
-

Eksisterende blokeringer

-
    - {blocked.map(b => ( -
  • -
    {b.block_date} {b.note ? `· ${b.note}` : ''}
    - -
  • - ))} -
+ )} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index febb3ba..6b589cc 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -36,6 +36,7 @@ export default function AdminPage() { courses: ['', '', ''], tags: [], image: '', + featured: false, baseIngredientsPerCover: 65, ingredientsCostIndex: 1.0, baseLabourHours: 4, @@ -145,7 +146,7 @@ export default function AdminPage() { }} /> ) : ( -
+
{menus.map(menu => (

{menu.title}

+ {menu.featured && ( +
Feature på forsiden
+ )}

{menu.description}

{menu.tags.map(tag => ( @@ -226,7 +230,14 @@ function MenuForm({ menu, onSave, onCancel }: { required />
-
+ +
+ +
+
(m as any).featured) as Menu[]) + .slice(0, 4) + .length > 0 ? (menus.filter((m: Menu) => (m as any).featured) as Menu[]).slice(0, 4) : menus.slice(0, 4) return (
diff --git a/src/components/ui/image-placeholder.tsx b/src/components/ui/image-placeholder.tsx index 6a37e71..e6cf3e0 100644 --- a/src/components/ui/image-placeholder.tsx +++ b/src/components/ui/image-placeholder.tsx @@ -19,13 +19,23 @@ export function ImagePlaceholder({ label, src, className }: ImagePlaceholderProp return (
{src ? ( - {label} + // If the image is a local static asset under /img we render a plain + // tag to avoid triggering Next.js' image optimizer which has + // caused errors in some environments. For external images we keep + // using the optimized component. + src.startsWith('/img/') ? ( + // fill the container using absolute positioning when parent is + // positioned, otherwise let w-full/h-full handle sizing + {label} + ) : ( + {label} + ) ) : (
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..72389ef --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,100 @@ +"use client" + +import { useEffect, useState } from 'react' + +export type ToastType = 'success' | 'error' | 'info' | 'warning' + +export interface Toast { + id: string + message: string + type: ToastType + duration?: number +} + +interface ToastContextValue { + toasts: Toast[] + addToast: (message: string, type?: ToastType, duration?: number) => void + removeToast: (id: string) => void +} + +let toastContext: ToastContextValue | null = null + +export function useToast() { + if (!toastContext) { + throw new Error('useToast must be used within ToastProvider') + } + return toastContext +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]) + + const addToast = (message: string, type: ToastType = 'info', duration = 4000) => { + const id = `toast-${Date.now()}-${Math.random()}` + const toast: Toast = { id, message, type, duration } + setToasts(prev => [...prev, toast]) + + if (duration > 0) { + setTimeout(() => { + removeToast(id) + }, duration) + } + } + + const removeToast = (id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)) + } + + toastContext = { toasts, addToast, removeToast } + + return ( + <> + {children} + + + ) +} + +function ToastContainer({ toasts, onRemove }: { toasts: Toast[]; onRemove: (id: string) => void }) { + if (toasts.length === 0) return null + + return ( +
+ {toasts.map(toast => ( + + ))} +
+ ) +} + +function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) => void }) { + const [exiting, setExiting] = useState(false) + + const handleClose = () => { + setExiting(true) + setTimeout(() => onRemove(toast.id), 200) + } + + const colors = { + success: 'bg-green-500 text-white', + error: 'bg-red-500 text-white', + warning: 'bg-amber-500 text-white', + info: 'bg-blue-500 text-white', + } + + return ( +
+
{toast.message}
+ +
+ ) +} diff --git a/src/config/sample-data.js b/src/config/sample-data.js index 0a722fa..7871702 100644 --- a/src/config/sample-data.js +++ b/src/config/sample-data.js @@ -54,7 +54,8 @@ exports.sampleData = { 'Chokoladekage med bær og vaniljeis' ], tags: ['Nordisk', 'Klassisk', 'Populær'], - image: '/img/klassisk.png', + featured: true, + image: '/img/klassisk.png', baseIngredientsPerCover: 65, ingredientsCostIndex: 1.0, baseLabourHours: 4, @@ -73,7 +74,8 @@ exports.sampleData = { 'Pocherede pærer med vaniljeis og hasselnødder' ], tags: ['Vegetarisk', 'Sæson', 'Populær'], - image: '/img/veg.png', + featured: true, + image: '/img/veg.png', baseIngredientsPerCover: 55, ingredientsCostIndex: 0.95, baseLabourHours: 4, @@ -92,7 +94,8 @@ exports.sampleData = { 'Tiramisu med mascarpone' ], tags: ['Italiensk', 'Pasta', 'Afslappet'], - image: '/img/italiensk.png', + featured: true, + image: '/img/italiensk.png', baseIngredientsPerCover: 55, ingredientsCostIndex: 0.9, baseLabourHours: 4, @@ -188,7 +191,8 @@ exports.sampleData = { 'Chokoladefondant med is' ], tags: ['Gourmet', 'Special', 'Luksus'], - image: '/img/gourmet.png', + featured: true, + image: '/img/gourmet.png', baseIngredientsPerCover: 95, ingredientsCostIndex: 1.2, baseLabourHours: 5, @@ -292,7 +296,7 @@ exports.sampleData = { 'Ris a la mande med kirsebærsauce og sprød mandel' ], tags: ['Jul', 'Dansk', 'Traditionel', 'Luksus'], - image: '/img/jul.jpg', + image: '/img/jul.png', baseIngredientsPerCover: 95, ingredientsCostIndex: 1.15, baseLabourHours: 5, diff --git a/src/config/sample-data.ts b/src/config/sample-data.ts index c520ac6..b45b975 100644 --- a/src/config/sample-data.ts +++ b/src/config/sample-data.ts @@ -52,7 +52,8 @@ export const sampleData = { ], tags: ['Nordisk', 'Klassisk', 'Populær'], image: '/img/klassisk.png', - baseIngredientsPerCover: 65, + featured: true, + baseIngredientsPerCover: 65, ingredientsCostIndex: 1.0, baseLabourHours: 4, incrementalLabourPerCover: 0.05, @@ -71,7 +72,8 @@ export const sampleData = { ], tags: ['Vegetarisk', 'Sæson', 'Populær'], image: '/img/veg.png', - baseIngredientsPerCover: 55, + featured: true, + baseIngredientsPerCover: 55, ingredientsCostIndex: 0.95, baseLabourHours: 4, incrementalLabourPerCover: 0.05, @@ -90,7 +92,8 @@ export const sampleData = { ], tags: ['Italiensk', 'Pasta', 'Afslappet'], image: '/img/italiensk.png', - baseIngredientsPerCover: 55, + featured: true, + baseIngredientsPerCover: 55, ingredientsCostIndex: 0.9, baseLabourHours: 4, incrementalLabourPerCover: 0.05, @@ -186,7 +189,8 @@ export const sampleData = { ], tags: ['Gourmet', 'Special', 'Luksus'], image: '/img/gourmet.png', - baseIngredientsPerCover: 95, + featured: true, + baseIngredientsPerCover: 95, ingredientsCostIndex: 1.2, baseLabourHours: 5, incrementalLabourPerCover: 0.06, @@ -289,7 +293,7 @@ export const sampleData = { 'Ris a la mande med kirsebærsauce og sprød mandel' ], tags: ['Jul', 'Dansk', 'Traditionel', 'Luksus'], - image: '/img/jul.jpg', + image: '/img/jul.png', baseIngredientsPerCover: 95, ingredientsCostIndex: 1.15, baseLabourHours: 5, diff --git a/src/lib/db.ts b/src/lib/db.ts index 318b8bd..4f1e2d1 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -22,6 +22,7 @@ export function rowToMenu(row: any) { courses: JSON.parse(row.courses), tags: JSON.parse(row.tags), image: row.image, + featured: !!row.featured, baseIngredientsPerCover: row.base_ingredients_per_cover, ingredientsCostIndex: row.ingredients_cost_index, baseLabourHours: row.base_labour_hours, @@ -41,6 +42,7 @@ export function menuToRow(menu: any) { courses: JSON.stringify(menu.courses), tags: JSON.stringify(menu.tags), image: menu.image, + featured: menu.featured ? 1 : 0, base_ingredients_per_cover: menu.baseIngredientsPerCover, ingredients_cost_index: menu.ingredientsCostIndex, base_labour_hours: menu.baseLabourHours, diff --git a/src/types/models.ts b/src/types/models.ts index 6942be1..928f342 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -36,6 +36,7 @@ export interface Menu { courses: readonly string[] tags: readonly string[] image: string + featured?: boolean baseIngredientsPerCover: number ingredientsCostIndex: number baseLabourHours: number