From 41dc797007e38eb34000c26eac5d8d2e1df1f785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aaron=20D=C3=B6tsch?= <aaron@fsmpi.rwth-aachen.de> Date: Fri, 20 Sep 2024 17:47:24 +0200 Subject: [PATCH] update --- .gitignore | 6 +- src/lib/components/AdminLayout.svelte | 1 + src/lib/components/H1.svelte | 10 ++ src/lib/components/H2.svelte | 10 ++ src/lib/components/H3.svelte | 10 ++ src/lib/components/Image.svelte | 3 +- .../server/database/entities/MrX.entity.ts | 51 +++++++ src/lib/server/database/migrations/0_init.sql | 14 +- src/routes/(non-admin)/mr-x/+page.server.ts | 8 ++ src/routes/(non-admin)/mr-x/+page.svelte | 14 ++ .../mr-x/[id=number]/+page.server.ts | 11 ++ .../(non-admin)/mr-x/[id=number]/+page.svelte | 135 ++++++++++++++++++ .../mr-x/[id=number]/data/+server.ts | 23 +++ src/routes/admin/mr-x/+page.server.ts | 19 +++ src/routes/admin/mr-x/+page.svelte | 23 +++ .../admin/mr-x/[id=number]/+page.server.ts | 92 ++++++++++++ .../admin/mr-x/[id=number]/+page.svelte | 35 +++++ .../admin/mr-x/[id=number]/Entry.svelte | 49 +++++++ 18 files changed, 501 insertions(+), 13 deletions(-) create mode 100644 src/lib/components/H1.svelte create mode 100644 src/lib/components/H2.svelte create mode 100644 src/lib/components/H3.svelte create mode 100644 src/lib/server/database/entities/MrX.entity.ts create mode 100644 src/routes/(non-admin)/mr-x/[id=number]/+page.server.ts create mode 100644 src/routes/(non-admin)/mr-x/[id=number]/+page.svelte create mode 100644 src/routes/(non-admin)/mr-x/[id=number]/data/+server.ts create mode 100644 src/routes/admin/mr-x/+page.server.ts create mode 100644 src/routes/admin/mr-x/+page.svelte create mode 100644 src/routes/admin/mr-x/[id=number]/+page.server.ts create mode 100644 src/routes/admin/mr-x/[id=number]/+page.svelte create mode 100644 src/routes/admin/mr-x/[id=number]/Entry.svelte diff --git a/.gitignore b/.gitignore index def6982..7aaa03b 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 dd96c6b..da8fa91 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 0000000..63db73a --- /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 0000000..13081e7 --- /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 0000000..195f898 --- /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 856c5eb..db5e2ad 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 0000000..2095160 --- /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 e29e563..30662e5 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 e69de29..919a8a2 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 e69de29..bdb80dd 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 0000000..bba9489 --- /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 0000000..3041d58 --- /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 0000000..ce7ee21 --- /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 0000000..70cc323 --- /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 0000000..547bc80 --- /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 0000000..615929a --- /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 0000000..37091d6 --- /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 0000000..69ae9a2 --- /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> -- GitLab