diff --git a/.gitignore b/.gitignore index def6982f2b7f282d733a774b381cd45776a96e07..7aaa03b77830a1627282c2fd70f21db90771b14e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* .devcontainer static/flyer -static/stundenplaene/* +static/stundenplaene static/images static/uploads -!static/stundenplaene/branding.png -#src/lib/server/fonts +#!static/stundenplaene/branding.png +src/lib/server/fonts diff --git a/src/lib/components/AdminLayout.svelte b/src/lib/components/AdminLayout.svelte index dd96c6b7b6f2d1e9d0a2009ec03a59742db03d47..da8fa9101456fe11cb8bed0a409097d15088685e 100644 --- a/src/lib/components/AdminLayout.svelte +++ b/src/lib/components/AdminLayout.svelte @@ -44,6 +44,7 @@ <DropdownItem href="/admin/rallye/station">Stationen</DropdownItem> <DropdownItem href="/admin/rallye/points">Punkte</DropdownItem> </Dropdown> + <NavLi href="/admin/mr-x">Mr. X</NavLi> <NavLi href="/admin/rabatte">Rabatte</NavLi> <NavLi href="/admin/flyer">Flyer</NavLi> <NavLi href="/admin/uploads">Uploads</NavLi> diff --git a/src/lib/components/H1.svelte b/src/lib/components/H1.svelte new file mode 100644 index 0000000000000000000000000000000000000000..63db73a53cd0cf192deb6ff5cfaf350f6a242051 --- /dev/null +++ b/src/lib/components/H1.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + import { Heading } from "flowbite-svelte"; + import type { Snippet } from "svelte"; + + let { children, ...restProps }: { children: Snippet } = $props(); +</script> + +<Heading tag="h1" customSize="text-4xl font-bold" class="mb-6 text-center" {...restProps}> + {@render children()} +</Heading> diff --git a/src/lib/components/H2.svelte b/src/lib/components/H2.svelte new file mode 100644 index 0000000000000000000000000000000000000000..13081e72125ed9307c825f36a66fd69e5ab2fd88 --- /dev/null +++ b/src/lib/components/H2.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + import { Heading } from "flowbite-svelte"; + import type { Snippet } from "svelte"; + + let { children, ...restProps }: { children: Snippet } = $props(); +</script> + +<Heading tag="h2" customSize="text-3xl font-bold" class="mb-3 mt-6" {...restProps}> + {@render children()} +</Heading> diff --git a/src/lib/components/H3.svelte b/src/lib/components/H3.svelte new file mode 100644 index 0000000000000000000000000000000000000000..195f898eb10672307e9ed06e49eaa229053d61a9 --- /dev/null +++ b/src/lib/components/H3.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + import { Heading } from "flowbite-svelte"; + import type { Snippet } from "svelte"; + + let { children, ...restProps }: { children: Snippet } = $props(); +</script> + +<Heading tag="h3" customSize="text-xl font-bold" class="mb-2 mt-5" {...restProps}> + {@render children()} +</Heading> diff --git a/src/lib/components/Image.svelte b/src/lib/components/Image.svelte index 856c5ebd5986bc1879019b8cf26a76804803d31e..db5e2ad8c2319746018974f9358ec65895bf51e3 100644 --- a/src/lib/components/Image.svelte +++ b/src/lib/components/Image.svelte @@ -2,12 +2,13 @@ import type { ImageMetadata } from "$lib/server/images"; import { locale } from "$lib/i18n/i18n"; import { onMount } from "svelte"; + import type { HTMLAttributes } from "svelte/elements"; let { sizes, src, autosize = false, ...restProps }: { sizes?: string, src: ImageMetadata<any>, autosize?: boolean, - } = $props(); + } & Partial<HTMLAttributes<HTMLImageElement>> = $props(); function groupBy<T>(arr: T[], propExtractor: (t: T)=>string): Record<string, T[]> { let result = {} as Record<string, T[]>; diff --git a/src/lib/server/database/entities/MrX.entity.ts b/src/lib/server/database/entities/MrX.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..2095160a611fd3af1d86ae5d1b4675232853655c --- /dev/null +++ b/src/lib/server/database/entities/MrX.entity.ts @@ -0,0 +1,51 @@ +import { sql } from "../db"; + +export class MrXEntry { + id!: number; + mrx!: number; + time!: Date; + text!: string|null; + image!: string|null; + + static parse(data: any): MrXEntry|undefined { + if(!data) return; + return Object.assign(new MrXEntry(), data, { time: new Date(data.time) }); + } + static async create(entry: Omit<MrXEntry, "id">): Promise<number> { + return (await sql`INSERT INTO mrx_entries ${sql(entry)} RETURNING id`)[0].id; + } + static async update(id: number, entry: Partial<MrXEntry>): Promise<void> { + await sql`UPDATE mrx_entries SET ${sql(entry)} WHERE id = ${id}`; + } + static async delete(id: number): Promise<void> { + await sql`DELETE FROM mrx_entries WHERE id = ${id}`; + } +} + +export class MrX { + id!: number; + name!: string; + entries!: MrXEntry[]; + + static parse(data: any): MrX|undefined { + if(!data) return; + const m = Object.assign(new MrX(), data); + m.entries = (data.entries as any[] || []) + .map(e => MrXEntry.parse(e)) + .filter(e=>!!e) + .sort((a,b)=>a.time.getTime()-b.time.getTime()); + return m; + } + static async getById(id: number): Promise<MrX|undefined> { + const row = (await sql`SELECT row_to_json(mrx.*) AS mr_x, json_agg(row_to_json(mrx_entries.*)) AS entries FROM mrx LEFT JOIN mrx_entries ON mrx.id = mrx_entries.mrx WHERE mrx.id = ${id} GROUP BY mrx.id`)[0]; + if(!row) return; + return MrX.parse(Object.assign(row.mrX, { entries: row.entries })); + } + static async getAll(): Promise<MrX[]> { + const rows = await sql`SELECT row_to_json(mrx.*) AS mr_x, json_agg(row_to_json(mrx_entries.*)) AS entries FROM mrx LEFT JOIN mrx_entries ON mrx.id = mrx_entries.mrx GROUP BY mrx.id`; + return rows.map((row: any) => MrX.parse(Object.assign(row.mrX, { entries: row.entries }))!); + } + static async create(mrX: Omit<MrX, "id"|"entries">): Promise<number> { + return (await sql`INSERT INTO mrx ${sql(mrX)} RETURNING id`)[0].id; + } +} diff --git a/src/lib/server/database/migrations/0_init.sql b/src/lib/server/database/migrations/0_init.sql index e29e563b6ecadc07951c2b5d94a48f7c3fbfef8e..30662e5ce123ee59e9bf943b62499d8190632d66 100644 --- a/src/lib/server/database/migrations/0_init.sql +++ b/src/lib/server/database/migrations/0_init.sql @@ -179,18 +179,14 @@ CREATE TABLE IF NOT EXISTS master_freshers ( ); CREATE TABLE IF NOT EXISTS mrx ( - "id" SERIAL PRIMARY KEY + "id" SERIAL PRIMARY KEY, + "name" TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS mrx_entries ( "id" SERIAL PRIMARY KEY, "mrx" INT NOT NULL REFERENCES mrx(id), - "date" DATE NOT NULL, - "text" TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS mrx_attachments ( - "id" SERIAL PRIMARY KEY, - "mrx_entry" INT NOT NULL REFERENCES mrx_entries(id), - "path" TEXT NOT NULL + "time" TIMESTAMP NOT NULL, + "text" TEXT, + "image" TEXT ); diff --git a/src/routes/(non-admin)/mr-x/+page.server.ts b/src/routes/(non-admin)/mr-x/+page.server.ts index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..919a8a259514f14893e10d0b33bb4caafa32d67a 100644 --- a/src/routes/(non-admin)/mr-x/+page.server.ts +++ b/src/routes/(non-admin)/mr-x/+page.server.ts @@ -0,0 +1,8 @@ +import { MrX } from '$lib/server/database/entities/MrX.entity.js'; + +export async function load(event){ + const mrXs = await MrX.getAll(); + return { + mrXs: mrXs.map(mrX => ({ id: mrX.id, name: mrX.name })), + }; +} diff --git a/src/routes/(non-admin)/mr-x/+page.svelte b/src/routes/(non-admin)/mr-x/+page.svelte index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..bdb80dd5a2079baf3b56e8adf168c5a08c01e20c 100644 --- a/src/routes/(non-admin)/mr-x/+page.svelte +++ b/src/routes/(non-admin)/mr-x/+page.svelte @@ -0,0 +1,14 @@ +<script lang="ts"> + import H1 from "$lib/components/H1.svelte"; + import { Button } from "flowbite-svelte"; + + let { data } = $props(); +</script> + +<H1>Mr. X</H1> + +<div class="flex flex-col items-center gap-3"> + {#each data.mrXs as mrX} + <Button href="/mr-x/{mrX.id}" class="w-full sm:max-w-96 md:max-w-xl" size="xl">{mrX.name}</Button> + {/each} +</div> diff --git a/src/routes/(non-admin)/mr-x/[id=number]/+page.server.ts b/src/routes/(non-admin)/mr-x/[id=number]/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..bba948954ab39cefbf20390366815a345914df68 --- /dev/null +++ b/src/routes/(non-admin)/mr-x/[id=number]/+page.server.ts @@ -0,0 +1,11 @@ +import type { MrXData } from './data/+server.js'; + +export async function load(event){ + const data = await event.fetch(`/mr-x/${event.params.id}/data`).then(r => r.json()) as MrXData; + return { + mrX: { + ...data.mrX, + entries: data.mrX.entries.map(e=>({...e, time: new Date(e.time)})), + } + } +} diff --git a/src/routes/(non-admin)/mr-x/[id=number]/+page.svelte b/src/routes/(non-admin)/mr-x/[id=number]/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..3041d586be6da8e3b24c4d0442595967b806fb91 --- /dev/null +++ b/src/routes/(non-admin)/mr-x/[id=number]/+page.svelte @@ -0,0 +1,135 @@ +<script lang="ts"> + import H1 from "$lib/components/H1.svelte"; + import { onMount } from "svelte"; + import type { MrXData } from "./data/+server.js"; + import { Card, P, Span } from "flowbite-svelte"; + import Image from "$lib/components/Image.svelte"; + import MarkdownIt from "markdown-it"; + + let { data } = $props(); + + let mrX = $state(data.mrX); + + onMount(()=>{ + const interval = setInterval(async ()=>{ + const response = await fetch(`/mr-x/${mrX.id}/data`); + if(response.ok){ + const json = await response.json() as MrXData; + mrX = json.mrX; + mrX.entries = mrX.entries.map(entry=>({...entry, time: new Date(entry.time)})); + } + }, 30_000); + return ()=>{ + clearInterval(interval); + }; + }); + + const md = new MarkdownIt({ + breaks: true, + linkify: true, + quotes: "“”‘’", + html: true, + }); +</script> + +<style> + /* add styles again bc tailwind removes them all :( */ + :global(.md-wrapper h1) { + font-size: 2rem; + font-weight: 700; + padding-bottom: .3em; + } + :global(.md-wrapper h2) { + font-size: 1.75rem; + font-weight: 600; + padding-bottom: .3em; + } + :global(.md-wrapper h3) { + font-size: 1.5rem; + font-weight: 600; + padding-bottom: .3em; + } + :global(.md-wrapper h4) { + font-size: 1.25rem; + font-weight: 600; + padding-bottom: .3em; + } + :global(.md-wrapper h5) { + font-size: 1rem; + font-weight: 600; + padding-bottom: .3em; + } + :global(.md-wrapper h6) { + font-size: .875rem; + font-weight: 600; + padding-bottom: .3em; + } + :global(.md-wrapper p) { + padding-bottom: .75em; + } + :global(.md-wrapper blockquote) { + margin: 0 0 .5em 0; + padding: 0 .5em; + border-left: 4px solid #d2d6dc; + } + :global(.dark .md-wrapper blockquote) { + border-left-color: #4b5563; + } + :global(.md-wrapper blockquote p) { + padding: .2em 0; + } + :global(.md-wrapper blockquote blockquote){ + margin-bottom: 0; + } + :global(.md-wrapper pre) { + padding: 1em; + background-color: #f3f4f6; + border-radius: .5em; + margin-bottom: .5em; + overflow-x: auto; + } + :global(.dark .md-wrapper pre) { + background-color: #31384f; + } + :global(.md-wrapper code) { + background-color: #f3f4f6; + padding: .1em .3em; + border-radius: .3em; + } + :global(.dark .md-wrapper code) { + background-color: #31384f; + } + :global(.md-wrapper pre code){ + background-color: transparent; + padding: 0; + border-radius: 0; + } + :global(.md-wrapper a) { + color: #2563EB; + text-decoration: underline; + } + :global(.dark .md-wrapper a) { + color: #90CDF4; + } +</style> + +<H1>{mrX.name}</H1> + +<div class="w-full flex flex-col items-center gap-3"> + {#each mrX.entries as entry (entry.id)} + <Card class="w-full max-w-2xl relative"> + <Span class="relative -top-3 -left-2 -mb-2 text-sm font-normal text-gray-600 dark:text-gray-400">{entry.time.toLocaleString()}</Span> + {#if entry.text} + <div class="text-base text-gray-900 dark:text-white leading-normal font-normal text-left whitespace-normal {entry.image ? "mb-2" : "-mb-4"} md-wrapper"> + {@html md.render(entry.text)} + </div> + {/if} + {#if entry.image} + <div class="flex justify-center max-h-svh"> + <Image src={entry.image} autosize class="max-h-full object-contain" /> + </div> + {/if} + </Card> + {/each} +</div> + diff --git a/src/routes/(non-admin)/mr-x/[id=number]/data/+server.ts b/src/routes/(non-admin)/mr-x/[id=number]/data/+server.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce7ee21c1f9f2f341ccbc4dd352f30a0bd14c9dc --- /dev/null +++ b/src/routes/(non-admin)/mr-x/[id=number]/data/+server.ts @@ -0,0 +1,23 @@ +import { MrX } from '$lib/server/database/entities/MrX.entity.js'; +import { getImages, type ImageMetadata } from '$lib/server/images'; +import { error } from '@sveltejs/kit'; +import path from 'path'; + +export type MrXData = { + mrX: Omit<MrX, "entries"> & { entries: Array<Omit<MrX['entries'][number], "image"> & { image?: ImageMetadata<never> }> }; +}; + +export async function GET(event){ + const id = Number(event.params.id); + const mrX = await MrX.getById(id); + if(!mrX) error(404, 'Mr. X not found'); + const images = await getImages<never>(path.join('mrx', String(id))); + const now = new Date(); + const firstFutureEntryIndex = mrX.entries.findLastIndex(e => e.time <= now) + 1; + // TODO maybe add next entry time? + const entries = mrX.entries.slice(0, firstFutureEntryIndex).map(e => ({ + ...e, + image: e.image ? images.find(i => i.identifier === e.image) : undefined, + })); + return new Response(JSON.stringify({ mrX: { ...mrX, entries } } satisfies MrXData)); +} diff --git a/src/routes/admin/mr-x/+page.server.ts b/src/routes/admin/mr-x/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..70cc323b5ed29b90412ae03cdaadfa101c7d4a33 --- /dev/null +++ b/src/routes/admin/mr-x/+page.server.ts @@ -0,0 +1,19 @@ +import { MrX } from '$lib/server/database/entities/MrX.entity.js'; +import { error } from '@sveltejs/kit'; + +export async function load(){ + const mrXs = await MrX.getAll(); + return { + mrXs: mrXs.map(m => ({id: m.id, name: m.name})), + }; +} + +export const actions = { + new: async function(event){ + const data = await event.request.formData(); + const name = data.get('name'); + if(!name) error(400, 'Name is required'); + if(typeof name !== 'string') error(400, 'Invalid name'); + return await MrX.create({name}); + }, +}; diff --git a/src/routes/admin/mr-x/+page.svelte b/src/routes/admin/mr-x/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..547bc80520a5569e790f1f96ba09f2b83d888e41 --- /dev/null +++ b/src/routes/admin/mr-x/+page.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import { enhance } from "$app/forms"; + import { Breadcrumb, BreadcrumbItem, Button, Input, Li, List } from "flowbite-svelte"; + + let { data } = $props(); +</script> + +<Breadcrumb> + <BreadcrumbItem href="/admin">Admin</BreadcrumbItem> + <BreadcrumbItem href="/admin/mr-x">Mr. X</BreadcrumbItem> +</Breadcrumb> + +<List list="none"> + {#each data.mrXs as item} + <Li class="mb-2"><Button href="/admin/mr-x/{item.id}" class="min-w-28">{item.name}</Button></Li> + {/each} + <Li> + <form method="post" action="?/new" class="flex gap-3" use:enhance> + <Input type="text" placeholder="Name" name="name" /> + <Button type="submit">Erstellen</Button> + </form> + </Li> +</List> diff --git a/src/routes/admin/mr-x/[id=number]/+page.server.ts b/src/routes/admin/mr-x/[id=number]/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..615929a63fa6d20e7ac395c49b0a999d0b9575bb --- /dev/null +++ b/src/routes/admin/mr-x/[id=number]/+page.server.ts @@ -0,0 +1,92 @@ +import { locales } from '$lib/i18n/i18n.js'; +import { MrX, MrXEntry } from '$lib/server/database/entities/MrX.entity.js'; +import { deleteImage, getImages, uploadImage } from '$lib/server/images'; +import type { Localized } from '$lib/utils.js'; +import { error, redirect } from '@sveltejs/kit'; +import path from 'path'; + +export async function load(event){ + const id = Number(event.params.id); + const mrX = await MrX.getById(id); + if(!mrX) redirect(307, '/admin/mr-x'); + const images = await getImages<never>(path.join('mrx', String(id))); + const entries = mrX.entries.map(e => ({ + ...e, + image: e.image ? images.find(i => i.identifier === e.image) : undefined, + })); + return { + mrX: { + ...mrX, + entries, + }, + } +} + +export const actions = { + new: async event => { + const mrX = await MrX.getById(Number(event.params.id)); + if(!mrX) error(404, 'Mr. X not found'); + const data = await event.request.formData(); + const datetime = data.get('datetime'); + const text = data.get('text'); + const image = data.get('image'); + if(!datetime || typeof datetime !== 'string') error(400, 'Invalid datetime'); + const time = new Date(datetime); + if(isNaN(time.getTime())) error(400, 'Invalid datetime'); + if(text && typeof text !== 'string') error(400, 'Invalid text'); + if(image !== null && !(image instanceof File)) error(400, 'Invalid image'); + let imageIdentifier: string|null = null; + if(image && image.size > 0){ + const buffer = await image.arrayBuffer(); + const description = Object.fromEntries(locales.map(l=>[l, text ?? ""])) as Localized; + const img = await uploadImage(buffer, description, path.join('mrx', String(mrX.id))); + imageIdentifier = img.identifier; + } + return await MrXEntry.create({ mrx: mrX.id, time, text, image: imageIdentifier }); + }, + update: async event => { + const mrX = await MrX.getById(Number(event.params.id)); + if(!mrX) error(404, 'Mr. X not found'); + const data = await event.request.formData(); + const id = data.get('entry'); + const time = data.get('time'); + const text = data.get('text'); + const image = data.get('image'); + const removeImage = data.get('removeImage') === 'true'; + if(!id || typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) error(400, 'Invalid entry'); + const entryId = Number(id); + const entry = mrX.entries.find(e => e.id === entryId); + if(!entry) error(404, 'Entry not found'); + if(!time || typeof time !== 'string') error(400, 'Invalid datetime'); + const newTime = new Date(time); + if(isNaN(newTime.getTime())) error(400, 'Invalid datetime'); + if(text && typeof text !== 'string') error(400, 'Invalid text'); + if(image !== null && !(image instanceof File)) error(400, 'Invalid image'); + let imageIdentifier: string|null = entry.image; + if(imageIdentifier && (removeImage || (image && image.size > 0))){ + await deleteImage(path.join("mrx", String(mrX.id)), imageIdentifier); + imageIdentifier = null; + } + if(image && image.size > 0){ + const buffer = await image.arrayBuffer(); + const description = Object.fromEntries(locales.map(l=>[l, text ?? ""])) as Localized; + const img = await uploadImage(buffer, description, path.join('mrx', String(mrX.id))); + imageIdentifier = img.identifier; + } + await MrXEntry.update(entryId, { time: newTime, text, image: imageIdentifier }); + }, + delete: async event => { + const mrX = await MrX.getById(Number(event.params.id)); + if(!mrX) error(404, 'Mr. X not found'); + const data = await event.request.formData(); + const id = data.get('entry'); + if(!id || typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) error(400, 'Invalid entry'); + const entryId = Number(id); + const entry = mrX.entries.find(e => e.id === entryId); + if(!entry) error(404, 'Entry not found'); + if(entry.image){ + await deleteImage(path.join("mrx", String(mrX.id)), entry.image); + } + await MrXEntry.delete(entryId); + }, +}; diff --git a/src/routes/admin/mr-x/[id=number]/+page.svelte b/src/routes/admin/mr-x/[id=number]/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..37091d68bc297646e498f7ecced3fd3515abc400 --- /dev/null +++ b/src/routes/admin/mr-x/[id=number]/+page.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + import H1 from "$lib/components/H1.svelte"; + import { Breadcrumb, BreadcrumbItem, Button, Card, Input, Textarea } from "flowbite-svelte"; + import Entry from "./Entry.svelte"; + import { enhance } from "$app/forms"; + import { invalidateAll } from "$app/navigation"; + + let { data } = $props(); +</script> + +<Breadcrumb> + <BreadcrumbItem href="/admin">Admin</BreadcrumbItem> + <BreadcrumbItem href="/admin/mr-x">Mr. X</BreadcrumbItem> + <BreadcrumbItem>{data.mrX.name}</BreadcrumbItem> +</Breadcrumb> + +<H1>{data.mrX.name}</H1> + +<div class="w-full flex flex-col items-center gap-3"> + {#each data.mrX.entries as entry (entry.id)} + <Entry {entry} /> + {/each} + <Card class="w-full max-w-2xl"> + <form method="post" action="?/new" enctype="multipart/form-data" use:enhance={({formData})=>{ + const datetime = new Date(formData.get("datetime") as string); + formData.set("datetime", datetime.toISOString()); // Convert to ISO string to add timezone information + return ()=>invalidateAll(); + }}> + <Input name="time" type="datetime-local" class="mb-2" required /> + <Textarea name="text" class="mb-2" /> + <Input name="image" type="file" accept="image/png, image/jpeg" class="mb-2" /> + <Button type="submit" class="w-full">Erstellen</Button> + </form> + </Card> +</div> diff --git a/src/routes/admin/mr-x/[id=number]/Entry.svelte b/src/routes/admin/mr-x/[id=number]/Entry.svelte new file mode 100644 index 0000000000000000000000000000000000000000..69ae9a2917d25b8fd65a2de5bee7846903781570 --- /dev/null +++ b/src/routes/admin/mr-x/[id=number]/Entry.svelte @@ -0,0 +1,49 @@ +<script lang="ts"> + import { Card, Textarea, Button, ButtonGroup, Input } from "flowbite-svelte"; + import type { MrXData } from "../../../(non-admin)/mr-x/[id=number]/data/+server"; + import { enhance } from "$app/forms"; + import Image from "$lib/components/Image.svelte"; + + type Props = { + entry: MrXData["mrX"]["entries"][number]; + }; + + let { entry }: Props = $props(); + + let removeImage = $state(false); + let imageInput = $state<HTMLInputElement | null>(null); +</script> + +<Card class="w-full max-w-2xl"> + <form method="post" action="?/update" enctype="multipart/form-data" use:enhance={({formData})=>{ + const datetime = new Date(formData.get("time") as string); + formData.set("time", datetime.toISOString()); // Convert to ISO string to add timezone information + return ({update})=>update(); + }}> + <input type="hidden" name="entry" value={entry.id} /> + <Input name="time" type="datetime-local" class="mb-2" required value="{entry.time.getFullYear()}-{String(entry.time.getMonth()+1).padStart(2, "0")}-{String(entry.time.getDate()).padStart(2, "0")}T{String(entry.time.getHours()).padStart(2, "0")}:{String(entry.time.getMinutes()).padStart(2, "0")}" /> + <Textarea name="text" class="mb-2" value={entry.text} /> + {#if entry.image} + {#if !removeImage} + <div class="flex justify-center max-h-svh mb-2"> + <Image src={entry.image} autosize class="max-h-full object-contain" /> + </div> + <ButtonGroup class="mb-2"> + <Button on:click={()=>{ + removeImage = true; + if(imageInput) imageInput.value = ""; + }}>Bild entfernen</Button> + <Button on:click={()=>imageInput?.click()}>Bild ersetzen</Button> + </ButtonGroup> + {/if} + <input bind:this={imageInput} name="image" type="file" accept="image/png, image/jpeg" class={removeImage ? "mb-2" : "hidden"} onchange={()=>removeImage=true} /> + <input type="hidden" name="removeImage" value={removeImage ? "true" : null} /> + {:else} + <Input name="image" type="file" accept="image/png, image/jpeg" class="mb-2" /> + {/if} + <div class="flex gap-2"> + <Button type="submit" class="w-full">Speichern</Button> + <Button type="submit" formaction="?/delete" color="red">Löschen</Button> + </div> + </form> +</Card>