Skip to content
Snippets Groups Projects
Commit 41dc7970 authored by Aaron Dötsch's avatar Aaron Dötsch
Browse files

update

parent bb8bfd52
No related branches found
No related tags found
No related merge requests found
Showing
with 501 additions and 13 deletions
...@@ -10,8 +10,8 @@ vite.config.js.timestamp-* ...@@ -10,8 +10,8 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
.devcontainer .devcontainer
static/flyer static/flyer
static/stundenplaene/* static/stundenplaene
static/images static/images
static/uploads static/uploads
!static/stundenplaene/branding.png #!static/stundenplaene/branding.png
#src/lib/server/fonts src/lib/server/fonts
...@@ -44,6 +44,7 @@ ...@@ -44,6 +44,7 @@
<DropdownItem href="/admin/rallye/station">Stationen</DropdownItem> <DropdownItem href="/admin/rallye/station">Stationen</DropdownItem>
<DropdownItem href="/admin/rallye/points">Punkte</DropdownItem> <DropdownItem href="/admin/rallye/points">Punkte</DropdownItem>
</Dropdown> </Dropdown>
<NavLi href="/admin/mr-x">Mr. X</NavLi>
<NavLi href="/admin/rabatte">Rabatte</NavLi> <NavLi href="/admin/rabatte">Rabatte</NavLi>
<NavLi href="/admin/flyer">Flyer</NavLi> <NavLi href="/admin/flyer">Flyer</NavLi>
<NavLi href="/admin/uploads">Uploads</NavLi> <NavLi href="/admin/uploads">Uploads</NavLi>
......
<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>
<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>
<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>
...@@ -2,12 +2,13 @@ ...@@ -2,12 +2,13 @@
import type { ImageMetadata } from "$lib/server/images"; import type { ImageMetadata } from "$lib/server/images";
import { locale } from "$lib/i18n/i18n"; import { locale } from "$lib/i18n/i18n";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let { sizes, src, autosize = false, ...restProps }: { let { sizes, src, autosize = false, ...restProps }: {
sizes?: string, sizes?: string,
src: ImageMetadata<any>, src: ImageMetadata<any>,
autosize?: boolean, autosize?: boolean,
} = $props(); } & Partial<HTMLAttributes<HTMLImageElement>> = $props();
function groupBy<T>(arr: T[], propExtractor: (t: T)=>string): Record<string, T[]> { function groupBy<T>(arr: T[], propExtractor: (t: T)=>string): Record<string, T[]> {
let result = {} as Record<string, T[]>; let result = {} as Record<string, T[]>;
......
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;
}
}
...@@ -179,18 +179,14 @@ CREATE TABLE IF NOT EXISTS master_freshers ( ...@@ -179,18 +179,14 @@ CREATE TABLE IF NOT EXISTS master_freshers (
); );
CREATE TABLE IF NOT EXISTS mrx ( 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 ( CREATE TABLE IF NOT EXISTS mrx_entries (
"id" SERIAL PRIMARY KEY, "id" SERIAL PRIMARY KEY,
"mrx" INT NOT NULL REFERENCES mrx(id), "mrx" INT NOT NULL REFERENCES mrx(id),
"date" DATE NOT NULL, "time" TIMESTAMP NOT NULL,
"text" TEXT NOT NULL "text" TEXT,
); "image" TEXT
CREATE TABLE IF NOT EXISTS mrx_attachments (
"id" SERIAL PRIMARY KEY,
"mrx_entry" INT NOT NULL REFERENCES mrx_entries(id),
"path" TEXT NOT NULL
); );
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 })),
};
}
<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>
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)})),
}
}
}
<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>
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));
}
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});
},
};
<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>
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);
},
};
<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>
<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>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment