diff --git a/.gitignore b/.gitignore index 31bc78031052e6b3dd0acecbc5fe5a0c877665de..7aaa03b77830a1627282c2fd70f21db90771b14e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,6 @@ vite.config.ts.timestamp-* static/flyer static/stundenplaene static/images +static/uploads #!static/stundenplaene/branding.png src/lib/server/fonts diff --git a/src/lib/components/AdminLayout.svelte b/src/lib/components/AdminLayout.svelte index 5810cece1defe5b7fbf3166a07e06a2cdcf2659f..dd96c6b7b6f2d1e9d0a2009ec03a59742db03d47 100644 --- a/src/lib/components/AdminLayout.svelte +++ b/src/lib/components/AdminLayout.svelte @@ -1,16 +1,23 @@ <script lang="ts"> + import { page } from "$app/stores"; import { signOut } from "@auth/sveltekit/client"; import { DarkMode, Dropdown, DropdownDivider, DropdownItem, NavBrand, NavHamburger, NavLi, NavUl, Navbar } from "flowbite-svelte"; import { ChevronDownOutline } from "flowbite-svelte-icons"; import type { Snippet } from "svelte"; let { children }: {children: Snippet} = $props(); + + function processActiveUrl(url: string) { + return url; + } + + const activeUrl = $derived(processActiveUrl($page.url.pathname)); </script> <Navbar> <NavBrand href="/admin">Fachschaft I/1</NavBrand> <NavHamburger /> - <NavUl> + <NavUl {activeUrl}> <NavLi class="cursor-pointer">Einstellungen<ChevronDownOutline class="w-4 h-4 ms-1 text-primary-800 dark:text-white inline" /></NavLi> <Dropdown class="w-44 z-20"> <DropdownItem href="/admin">Allgemein</DropdownItem> @@ -39,6 +46,7 @@ </Dropdown> <NavLi href="/admin/rabatte">Rabatte</NavLi> <NavLi href="/admin/flyer">Flyer</NavLi> + <NavLi href="/admin/uploads">Uploads</NavLi> <NavLi href="javascript:void 0" onclick={()=>signOut({callbackUrl: "/"})}>Logout</NavLi> <li><DarkMode btnClass="block py-2 pr-4 pl-3 md:p-0 rounded text-gray-700 hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-primary-700 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent cursor-pointer w-full" /></li> </NavUl> diff --git a/src/lib/components/Image.svelte b/src/lib/components/Image.svelte index 0ebef914c6d4238a0e333eea841e1df1ccb5cf9d..856c5ebd5986bc1879019b8cf26a76804803d31e 100644 --- a/src/lib/components/Image.svelte +++ b/src/lib/components/Image.svelte @@ -3,9 +3,11 @@ import { locale } from "$lib/i18n/i18n"; import { onMount } from "svelte"; - export let sizes: string | undefined = undefined; - export let src: ImageMetadata<any>; - export let autosize = false; + let { sizes, src, autosize = false, ...restProps }: { + sizes?: string, + src: ImageMetadata<any>, + autosize?: boolean, + } = $props(); function groupBy<T>(arr: T[], propExtractor: (t: T)=>string): Record<string, T[]> { let result = {} as Record<string, T[]>; @@ -46,5 +48,5 @@ {#each sources as {type, sizes: srcset}} <source {type} {srcset} /> {/each} - <img bind:this={imgElement} src={defaultImage.url} {sizes} width="{src.width}" height="{src.height}" alt={src.description?.[$locale]} {...$$restProps} /> + <img bind:this={imgElement} src={defaultImage.url} {sizes} width="{src.width}" height="{src.height}" alt={src.description?.[$locale]} {...restProps} /> </picture> diff --git a/src/lib/components/MailInput.svelte b/src/lib/components/MailInput.svelte index aa4de07fdad46165f875b8b69f44546eb719b9d6..d339c6b1e2fc074aa5f61e6e3ce639b4ad7e36d3 100644 --- a/src/lib/components/MailInput.svelte +++ b/src/lib/components/MailInput.svelte @@ -2,7 +2,7 @@ import Degree from "$lib/degrees"; import Gender from "$lib/genders"; import { locale, locales } from "$lib/i18n/i18n"; - import { variables, conditions, renderTemplate, parseTemplate, compileTemplate } from "$lib/mail"; + import { variables, conditions, renderTemplate, parseTemplate, compileTemplate, formatters, subjectFormatter, type PartialConfig, type TemplateType } from "$lib/mail"; import type { StudyProgram } from "$lib/server/database/entities/StudyProgram.entity"; import type { Tutor } from "$lib/server/database/entities/Tutor.entity"; import type { TutorTraining } from "$lib/server/database/entities/TutorTraining.entity"; @@ -22,7 +22,9 @@ buttonType = "button", disabled = false, buttonDisabled = disabled, - }: {text: Localized, subject: Localized, replyTo?: string|null, replyToOptions?: string[], allowCustomReplyTo?: boolean, studyPrograms: StudyProgram[], trainings: TutorTraining[], onsubmit: ()=>void, buttonText?: string, buttonType: HTMLButtonElement["type"], buttonDisabled?: boolean, disabled?: boolean} = $props(); + config, + type, + }: {text: Localized, subject: Localized, replyTo?: string|null, replyToOptions?: string[], allowCustomReplyTo?: boolean, studyPrograms: StudyProgram[], trainings: TutorTraining[], onsubmit: ()=>void, buttonText?: string, buttonType: HTMLButtonElement["type"], buttonDisabled?: boolean, disabled?: boolean, config: PartialConfig, type: TemplateType} = $props(); let customReplyTo = $state(false); @@ -53,18 +55,19 @@ trained: false, dietaryRestriction: "vegan, Eiweißallergie", training: trainings[0], + sentTrainingMail: false, }); let [mailHtml, compilationError]: [string, string|null] = $derived.by(()=>{ try { - return [renderTemplate(compileTemplate(parseTemplate(text), exampleTutor)), null] as const; + return [renderTemplate(compileTemplate(type, parseTemplate(type, text), exampleTutor, config)), null] as const; } catch(ex) { return ["", (ex as Error).message] as const; } }); let [compiledSubject, subjectError]: [string, string|null] = $derived.by(()=>{ try { - return [compileTemplate(parseTemplate(subject), exampleTutor, ({de, en})=>`${de} / ${en}`), null] as const; + return [compileTemplate(type, parseTemplate(type, subject), exampleTutor, config, subjectFormatter), null] as const; } catch(ex) { return ["", (ex as Error).message] as const; } @@ -97,7 +100,7 @@ {#each locales as locale} <Label class="w-full"> Betreff ({locale}) - <Input name="subject[{locale}]" bind:value={subject[locale]} oninput={e=>subject=Object.assign(subject,{[locale]:e.target.value})} {disabled} /> + <Input name="subject[{locale}]" bind:value={subject[locale]} oninput={e=>subject=Object.assign(subject,{[locale]:e.target.value})} {disabled} required={locale==="de"} /> </Label> {/each} </div> @@ -107,7 +110,7 @@ {#each locales as locale} <Label class="mb-1"> Text ({locale}) - <Textarea name="text[{locale}]" bind:value={text[locale]} class="min-h-36" {disabled} /> + <Textarea name="text[{locale}]" bind:value={text[locale]} class="min-h-36" {disabled} required /> </Label> {/each} {#if compilationError} @@ -171,11 +174,19 @@ <AccordionItem> <Span slot="header">Variablen</Span> <List tag="ul" position="outside" class="ml-4"> - {#each variables as variable} + {#each variables[type] as variable} <Li><code>{"{{"}{variable.name}{"}}"}</code> - {variable.description}</Li> {/each} </List> </AccordionItem> + <AccordionItem> + <Span slot="header">Formatierer</Span> + <List tag="ul" position="outside" class="ml-4"> + {#each Object.entries(formatters) as [name, {description}]} + <Li><code>{name}</code> - {description}</Li> + {/each} + </List> + </AccordionItem> <AccordionItem> <Span slot="header">Bedingungen</Span> <List tag="ul" position="outside" class="ml-4"> @@ -200,6 +211,12 @@ <Li>Variablen werden durch doppelte geschweifte Klammern gekennzeichnet: <code>{"{{"}variablenname{"}}"}</code>.</Li> </List> </Li> + <Li> + <b>Formatierung</b> + <List tag="ul" position="outside" class="ml-6"> + <Li>Werte von Variablen können formatiert werden, indem die Formatierer getrennt durch <code>|</code> nach dem Variablennamen gelistet werden, z.B. <code>{"{{"}variablenname|formatierer1|formatierer2{"}}"}</code>. Die Formatierer werden in der angegebenen Reihenfolge angewandt.</Li> + </List> + </Li> <Li> <b>Bedingte Anweisungen</b> <List tag="ul" position="outside" class="ml-6"> diff --git a/src/lib/components/MainLayout.svelte b/src/lib/components/MainLayout.svelte index 648f75e4bedbe611d15a0ef290690f0919509701..7fa7e2ce866e4caf6297db889daa027d9651c224 100644 --- a/src/lib/components/MainLayout.svelte +++ b/src/lib/components/MainLayout.svelte @@ -43,7 +43,6 @@ {#each headerLinks as link} <NavLi href={link}>{$LL.Navbar.Links[link as keyof Translation["Navbar"]["Links"]]()}</NavLi> {/each} - <NavLi href="/upload">Momente teilen</NavLi> <!-- TODO i18n --> <NavLi class="cursor-pointer"> {$LL.Navbar.Language()}<ChevronDownOutline class="w-4 h-4 ms-1 text-primary-800 dark:text-white inline" /> </NavLi> diff --git a/src/lib/components/TutorFAQ.svelte b/src/lib/components/TutorFAQ.svelte index 6e0c834dfc801fca9dc2e7ef98316795bbd362dd..7edf09bf2ad0afe3e371130ba40d41f7ccade459 100644 --- a/src/lib/components/TutorFAQ.svelte +++ b/src/lib/components/TutorFAQ.svelte @@ -1,9 +1,11 @@ -<script> +<script lang="ts"> import { Accordion, AccordionItem, Span, P, A, Heading } from "flowbite-svelte"; import { LL } from "$lib/i18n/i18n"; import LocalizedText from "./LocalizedText.svelte"; - export let title = $LL.Tutor.FAQ.Headline(); + let { title }: { title?: string } = $props(); + + if(!title) title = $LL.Tutor.FAQ.Headline(); </script> {#if title} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 971425166710efbd158b8b7699522bf14d7aa4fd..a378edefa593984ab96d1a9df58151e9cf473fd3 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -32,6 +32,7 @@ export default { "/admin/tutor/training": "Tutschulungen verwalten", "/admin/tutor/training/[id=number]": "Tutschulung bearbeiten: {training.date}", "/admin/tutor/training/new": "Neue Tutschulung erstellen", + "/admin/uploads": "Hochgeladene Bilder", "/admin/user": "Berechtigungen verwalten", }, Navbar: { @@ -42,6 +43,7 @@ export default { "/eswe": "Erstsemesterwochenende", "/flyer": "Flyer", "/tutor": "Tutor", + "/upload": "Momente teilen", }, Language: "Sprache", Languages: { diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 48d0b60ed6ee0ca93fa9951c8a91abe21e9e01fa..b308da7c0d211534a02216acd645c528bd629778 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -34,6 +34,7 @@ export default { "/admin/tutor/training": "Manage Tutor Trainings", "/admin/tutor/training/[id=number]": "Edit tutor training: {training.date}", "/admin/tutor/training/new": "Create new tutor training", + "/admin/uploads": "Uploaded photos", "/admin/user": "Manage Permissions", }, Navbar: { @@ -44,6 +45,7 @@ export default { "/eswe": "Freshers' Weekend", "/flyer": "Flyer", "/tutor": "Tutor", + "/upload": "Share Moments", }, Language: "Language", Languages: { diff --git a/src/lib/mail.ts b/src/lib/mail.ts index 17b9b8d45ee9e654472f48112bbd5de2e0cfedcd..4ca86b645afc068287f8a0272602f2f9ffd0ec90 100644 --- a/src/lib/mail.ts +++ b/src/lib/mail.ts @@ -4,6 +4,7 @@ import { locales, type Locale } from "./i18n/i18n"; import type { Tutor } from "./server/database/entities/Tutor.entity"; import markdownit from "markdown-it"; import type { Localized } from "./utils"; +import type { Config } from "./server/database/entities/Config.entity"; const md = markdownit({ breaks: true, @@ -17,65 +18,178 @@ export type MailTemplate = { replyTo: string; subject: Localized; text: Localized; + type: TemplateType; }; -export type Variable = { - name: string; - description: string; - replacement: (t: Tutor, l: Locale)=>string; -}; -export const variables: Variable[] = [ - { - name: "vorname", - description: "Vorname des Empfängers", - replacement: t=>t.firstname, +export const formatters: Record<string, { description: string, format: (l: Locale, arg: unknown)=>string }> = { + dateLong: { + description: "Formatiert ein Datum, z.B. 01. Januar 1970", + format: (locale, arg)=>new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "2-digit" }).format(arg as Date), }, - { - name: "nachname", - description: "Nachname des Empfängers", - replacement: t=>t.lastname, + dateShort: { + description: "Formatiert ein Datum, z.B. 1. Januar", + format: (locale, arg)=>new Intl.DateTimeFormat(locale, { month: "long", day: "numeric" }).format(arg as Date), }, - { - name: "spitzname", - description: "Spitzname des Empfängers", - replacement: t=>t.nickname ?? "", + dateWeekday: { + description: "Gibt den Wochentag eines Datums zurück, z.B. Montag", + format: (locale, arg)=>new Intl.DateTimeFormat(locale, { weekday: "long" }).format(arg as Date), }, - { - name: "email", - description: "E-Mail-Adresse des Empfängers", - replacement: t=>t.email, + dateWeekdayShort: { + description: "Gibt den Wochentag eines Datums zurück, z.B. Mo", + format: (locale, arg)=>new Intl.DateTimeFormat(locale, { weekday: "short" }).format(arg as Date), }, - { - name: "telefon", - description: "Telefonnummer des Empfängers", - replacement: t=>t.phone, + dateRangeLong: { + description: "Formatiert einen Datumsbereich, z.B. 1. - 2. Januar 1970", + format: (locale, arg)=>new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "2-digit" }).formatRange((arg as [Date, Date])[0], (arg as [Date, Date])[1]), }, - { - name: "studiengang", - description: "Studiengang des Empfängers", - replacement: (t, l)=>t.studyProgram.name[l], + dateRangeShort: { + description: "Formatiert einen Datumsbereich, z.B. 1. - 2. Januar", + format: (locale, arg)=>new Intl.DateTimeFormat(locale, { month: "long", day: "numeric" }).formatRange((arg as [Date, Date])[0], (arg as [Date, Date])[1]), }, - { - name: "abschluss", - description: "Abschluss des Empfängers", - replacement: (t, l)=>Degree[t.degree][l], + time: { + description: "Formatiert eine Uhrzeit, z.B. 12:34", + format: (locale, arg)=>new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(arg as Date), }, - { - name: "schulung.datum", - description: "Datum der Schulung des Empfängers", - replacement: (t, l) => t.training ? new Intl.DateTimeFormat(l, { year: "numeric", month: "long", day: "2-digit" }).format(Date.parse(t?.training?.date)) : {de: "keine Schulung", en: "no training"}[l], + timeRange: { + description: "Formatiert einen Zeitbereich, z.B. 12:34 - 13:45 Uhr", + format: (locale, arg)=>new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).formatRange((arg as [Date, Date])[0], (arg as [Date, Date])[1]), }, - { - name: "schulung.ort", - description: "Ort der Schulung des Empfängers", - replacement: (t, l)=>t?.training?.location ?? {de: "keine Schulung", en: "no training"}[l], + lowercase: { + description: "Konvertiert den Text in Kleinbuchstaben", + format: (_, arg)=>String(arg).toLowerCase(), }, - { - name: "cotutorwunsch", - description: "Name des gewünschten Co-Tutors", - replacement: t=>t.coTutorWish, + uppercase: { + description: "Konvertiert den Text in Großbuchstaben", + format: (_, arg)=>String(arg).toUpperCase(), }, -]; + titlecase: { + description: "Konvertiert den ersten Buchstaben jedes Wortes in Großbuchstaben", + format: (_, arg)=>String(arg).replace(/\b\w/g, l=>l.toUpperCase()), + }, +}; + +export const types = ["tutor"] as const; +export type TemplateType = typeof types[number]; + +export const configProps = ["currentSemester", "fresherWeekStart", "fresherWeekEnd", "rallyDate", "trainingsStart", "trainingsEnd"] as const satisfies (keyof Config)[]; +export type PartialConfig = Pick<Config, typeof configProps[number]>; +export type Variable = { + name: string; + description: string; + replacement: (t: Tutor, l: Locale, c: PartialConfig)=>unknown; +}; +export const variables: Record<TemplateType, Variable[]> = { + tutor: [ + { + name: "vorname", + description: "Vorname des Empfängers", + replacement: t=>t.firstname, + }, + { + name: "nachname", + description: "Nachname des Empfängers", + replacement: t=>t.lastname, + }, + { + name: "spitzname", + description: "Spitzname des Empfängers", + replacement: t=>t.nickname ?? "", + }, + { + name: "email", + description: "E-Mail-Adresse des Empfängers", + replacement: t=>t.email, + }, + { + name: "telefon", + description: "Telefonnummer des Empfängers", + replacement: t=>t.phone, + }, + { + name: "studiengang", + description: "Studiengang des Empfängers", + replacement: (t, l)=>t.studyProgram.name[l], + }, + { + name: "abschluss", + description: "Abschluss des Empfängers", + replacement: (t, l)=>Degree[t.degree][l], + }, + { + name: "schulung.datum", + description: "Datum der Schulung des Empfängers", + replacement: (t) => new Date(t.training?.date ?? 0), + }, + { + name: "schulung.ort", + description: "Ort der Schulung des Empfängers", + replacement: (t, l)=>t?.training?.location ?? {de: "keine Schulung", en: "no training"}[l], + }, + { + name: "cotutorwunsch", + description: "Name des gewünschten Co-Tutors", + replacement: t=>t.coTutorWish, + }, + { + name: "semester", + description: "Semester der Erstiwoche", + replacement: (t, l, c)=>c.currentSemester, + }, + { + name: "erstiwoche.start", + description: "Startdatum der Erstiwoche", + replacement: (t, l, c)=>new Date(c.fresherWeekStart), + }, + { + name: "erstiwoche.ende", + description: "Enddatum der Erstiwoche", + replacement: (t, l, c)=>new Date(c.fresherWeekEnd), + }, + { + name: "erstiwoche.zeitraum", + description: "Zeitraum der Erstiwoche", + replacement: (t, l, c)=>[new Date(c.fresherWeekStart), new Date(c.fresherWeekEnd)], + }, + { + name: "schulung.zeit", + description: "Zeitraum der Schulung", + replacement: (t, l, c)=>{ + const [startHour, startMinute] = c.trainingsStart.split(":").map(v=>Number(v)); + const [endHour, endMinute] = c.trainingsEnd.split(":").map(v=>Number(v)); + const start = new Date(t.training?.date ?? 0); + start.setHours(startHour, startMinute); + const end = new Date(start); + end.setHours(endHour, endMinute); + return [start, end]; + }, + }, + { + name: "schulung.start", + description: "Startzeit der Schulungen", + replacement: (t, l, c)=>{ + const [startHour, startMinute] = c.trainingsStart.split(":").map(v=>Number(v)); + const date = new Date(t.training?.date ?? 0); + date.setHours(startHour, startMinute); + return date; + }, + }, + { + name: "schulung.ende", + description: "Endzeit der Schulungen", + replacement: (t, l, c)=>{ + const [endHour, endMinute] = c.trainingsEnd.split(":").map(v=>Number(v)); + const date = new Date(t.training?.date ?? 0); + date.setHours(endHour, endMinute); + return date; + }, + }, + { + name: "rallye.datum", + description: "Datum der Rallye", + replacement: (t, l, c)=>new Date(c.rallyDate), + }, + ], +}; export type Condition = { name: string; @@ -83,91 +197,96 @@ export type Condition = { arguments?: string; condition: (t: Tutor, args: string[])=>boolean; }; -export const conditions: Condition[] = [ - { - name: "hatCotutorWunsch", - description: "Der Tutor hat einen Co-Tutor-Wunsch", - condition: t=>!!t.coTutorWish, - }, - { - name: "geschult", - description: "Der Tutor wurde geschult", - condition: t=>t.trained, - }, - { - name: "hatSchulung", - description: "Der Tutor hat eine Schulung", - condition: t=>!!t.training, - }, - { - name: "schulung", - description: "Der Tutor hat eine bestimmte Schulung", - arguments: "Datum der Schulungen (YYYY-MM-DD)", - condition: (t, args)=>!!t.training && args.includes(t.training.date), - }, - { - name: "abschluss", - description: `Der Tutor strebt einen bestimmten Abschluss an (${Object.keys(Degree).join("/")})`, - condition: (t, args)=>args.includes(t.degree), - }, - { - name: "hatSpitzname", - description: "Der Tutor hat einen Spitznamen angegeben", - condition: t=>!!t.nickname, - }, - { - name: "mentor", - description: "Der Tutor ist Mentor", - condition: t=>t.mentor, - }, - { - name: "studiengang", - description: "Der Tutor ist in einem Studiengang", - arguments: `Liste aller zu prüfenden Studiengänge (deutscher Name)`, - condition: (t, args)=>args.includes(t.studyProgram.name.de), - }, - { - name: "essenseinschränkung", - description: "Der Tutor hat eine Essenseinschränkung", - condition: t=>!!t.dietaryRestriction, - }, - { - name: "geschlecht", - description: "Der Tutor hat ein bestimmtes Geschlecht", - arguments: `Geschlecht(er) (${Object.keys(Gender).join("/")})`, - condition: (t, args)=>args.includes(t.gender), - }, - { - name: "not", - description: "Negiert eine Bedingung", - arguments: "Bedingung, Argumente falls notwendig", - condition: (t, [name, ...args])=>{ - const condition = conditions.find(c=>c.name === name); - if(!condition) throw new Error(`Unknown condition ${name}`); - return !condition.condition(t, args); +export const conditions: Record<TemplateType, Condition[]> = { + tutor: [ + { + name: "hatCotutorWunsch", + description: "Der Tutor hat einen Co-Tutor-Wunsch", + condition: t=>!!t.coTutorWish, }, - }, -]; + { + name: "geschult", + description: "Der Tutor wurde geschult", + condition: t=>t.trained, + }, + { + name: "hatSchulung", + description: "Der Tutor hat eine Schulung", + condition: t=>!!t.training, + }, + { + name: "schulung", + description: "Der Tutor hat eine bestimmte Schulung", + arguments: "Datum der Schulungen (YYYY-MM-DD)", + condition: (t, args)=>!!t.training && args.includes(t.training.date), + }, + { + name: "abschluss", + description: `Der Tutor strebt einen bestimmten Abschluss an (${Object.keys(Degree).join("/")})`, + condition: (t, args)=>args.includes(t.degree), + }, + { + name: "hatSpitzname", + description: "Der Tutor hat einen Spitznamen angegeben", + condition: t=>!!t.nickname, + }, + { + name: "mentor", + description: "Der Tutor ist Mentor", + condition: t=>t.mentor, + }, + { + name: "studiengang", + description: "Der Tutor ist in einem Studiengang", + arguments: `Liste aller zu prüfenden Studiengänge (deutscher Name)`, + condition: (t, args)=>args.includes(t.studyProgram.name.de), + }, + { + name: "essenseinschränkung", + description: "Der Tutor hat eine Essenseinschränkung", + condition: t=>!!t.dietaryRestriction, + }, + { + name: "geschlecht", + description: "Der Tutor hat ein bestimmtes Geschlecht", + arguments: `Geschlecht(er) (${Object.keys(Gender).join("/")})`, + condition: (t, args)=>args.includes(t.gender), + }, + { + name: "not", + description: "Negiert eine Bedingung", + arguments: "Bedingung, Argumente falls notwendig", + condition: (t, [name, ...args])=>{ + const condition = conditions.tutor.find(c=>c.name === name); + if(!condition) throw new Error(`Unknown condition ${name}`); + return !condition.condition(t, args); + }, + }, + ], +}; + +export const bodyFormatter = ({ de, en }: Localized) => `-------- english version below --------\n\n${de}\n\n-------- english version --------\n\n${en}`; +export const subjectFormatter = ({ de, en }: Localized) => [de, en].filter(s=>s).join(" / "); -export function compileTemplate(parts: Localized<Part[]>, tutor: Tutor, formatter: (l: Localized)=>string = ({de, en})=>`-------- english version below --------\n\n${de}\n\n-------- english version --------\n\n${en}`): string { +export function compileTemplate(type: TemplateType, parts: Localized<Part[]>, tutor: Tutor, config: PartialConfig, formatter: (l: Localized)=>string = bodyFormatter): string { const result = {} as Localized<string>; for(const locale of locales){ - result[locale] = parts[locale].map(p=>compilePart(p, tutor, locale)).join(""); + result[locale] = parts[locale].map(p=>compilePart(type, p, tutor, locale, config)).join(""); } return formatter(result); } -function compilePart(part: Part, tutor: Tutor, locale: Locale): string { +function compilePart(type: TemplateType, part: Part, tutor: Tutor, locale: Locale, config: PartialConfig): string { switch(part.type){ case "text": return part.text; case "variable": - return part.replacement(tutor, locale); + return String(part.replacement(tutor, locale, config)); case "condition": if(part.condition(tutor, part.args)){ - return part.then.map(p=>compilePart(p, tutor, locale)).join(""); + return part.then.map(p=>compilePart(type, p, tutor, locale, config)).join(""); }else{ - return part.else.map(p=>compilePart(p, tutor, locale)).join(""); + return part.else.map(p=>compilePart(type, p, tutor, locale, config)).join(""); } } } @@ -213,13 +332,13 @@ function tokenize(input: string): Token[] { } type Part = {type: "text", text: string} | {type: "variable", replacement: Variable["replacement"]} | {type: "condition", condition: Condition["condition"], args: string[], then: Part[], else: Part[]}; -export function parseTemplate(template: Localized): Localized<Part[]>; -export function parseTemplate(template: string): Part[]; -export function parseTemplate(template: Localized|string){ +export function parseTemplate(type: TemplateType, template: Localized): Localized<Part[]>; +export function parseTemplate(type: TemplateType, template: string): Part[]; +export function parseTemplate(type: TemplateType, template: Localized|string){ if(typeof template === "object"){ const result = {} as Localized<Part[]>; for(const locale of locales){ - result[locale] = parseTemplate(template[locale]); + result[locale] = parseTemplate(type, template[locale]); } return result; } @@ -233,21 +352,27 @@ export function parseTemplate(template: Localized|string){ const [name, ...args] = token.value.split(" "); if(name === "if" && args.length > 0){ const conditionName = args.shift(); - const condition = conditions.find(c=>c.name === conditionName); + const condition = conditions[type].find(c=>c.name === conditionName); if(!condition) throw new Error(`Unknown condition ${conditionName}`); - const [then, els] = consumeCondition(tokens); + const [then, els] = consumeCondition(type, tokens); parts.push({type: "condition", condition: condition.condition, args, then, else: els}); }else{ - const variable = variables.find(v=>v.name === name); + const [variableName, ...formatterNames] = name.split("|"); + const variable = variables[type].find(v=>v.name === variableName); if(!variable) throw new Error(`Unknown variable ${name}`); - parts.push({type: "variable", replacement: variable.replacement}); + const fs = formatterNames.map(f=>formatters[f]); + if(fs.some(f=>!f)) throw new Error(`Unknown formatter ${formatterNames.find((_, i)=>!fs[i])}`); + const replacement = (t: Tutor, l: Locale, c: PartialConfig)=>{ + return fs.reduce((v, f)=>f.format(l, v), variable.replacement(t, l, c)); + }; + parts.push({type: "variable", replacement}); } } } return parts; } -function consumeCondition(tokens: Token[]): [Part[], Part[]] { +function consumeCondition(type: TemplateType, tokens: Token[]): [Part[], Part[]] { const thenParts: Part[] = []; const elseParts: Part[] = []; let inElse = false; @@ -260,9 +385,9 @@ function consumeCondition(tokens: Token[]): [Part[], Part[]] { const [name, ...args] = token.value.split(" "); if(name === "if" && args.length > 0){ const conditionName = args.shift(); - const condition = conditions.find(c=>c.name === conditionName); + const condition = conditions[type].find(c=>c.name === conditionName); if(!condition) throw new Error(`Unknown condition ${conditionName}`); - const [then, els] = consumeCondition(tokens); + const [then, els] = consumeCondition(type, tokens); const part: Part = {type: "condition", condition: condition.condition, args, then, else: els}; if(inElse) elseParts.push(part); else thenParts.push(part); @@ -271,9 +396,9 @@ function consumeCondition(tokens: Token[]): [Part[], Part[]] { if(args.length > 0) { if(args[0] !== "if") throw new Error(`Unexpected argument "${args[0]}" to else`); const [, name, ...args2] = args; - const condition = conditions.find(c=>c.name === name); + const condition = conditions[type].find(c=>c.name === name); if(!condition) throw new Error(`Unknown condition ${name}`); - const [then, els] = consumeCondition(tokens); + const [then, els] = consumeCondition(type, tokens); const part: Part = {type: "condition", condition: condition.condition, args: args2, then, else: els}; elseParts.push(part); return [thenParts, elseParts]; @@ -282,10 +407,16 @@ function consumeCondition(tokens: Token[]): [Part[], Part[]] { }else if(name === "/if"){ return [thenParts, elseParts]; }else{ - const variable = variables.find(v=>v.name === name); + const [variableName, ...formatterNames] = name.split("|"); + const variable = variables[type].find(v=>v.name === variableName); if(!variable) throw new Error(`Unknown variable ${name}`); - if(inElse) elseParts.push({type: "variable", replacement: variable.replacement}); - else thenParts.push({type: "variable", replacement: variable.replacement}); + const fs = formatterNames.map(f=>formatters[f]); + if(fs.some(f=>!f)) throw new Error(`Unknown formatter ${formatterNames.find((_, i)=>!fs[i])}`); + const replacement = (t: Tutor, l: Locale, c: PartialConfig)=>{ + return fs.reduce((v, f)=>f.format(l, v), variable.replacement(t, l, c)); + }; + if(inElse) elseParts.push({type: "variable", replacement}); + else thenParts.push({type: "variable", replacement}); } } } diff --git a/src/lib/server/database/entities/TutorTraining.entity.ts b/src/lib/server/database/entities/TutorTraining.entity.ts index 45f05d805b0330a92c162772f392d976c731f42e..5a5c9f831ac67422ed44077e6fc341819475501c 100644 --- a/src/lib/server/database/entities/TutorTraining.entity.ts +++ b/src/lib/server/database/entities/TutorTraining.entity.ts @@ -14,7 +14,7 @@ export class TutorTraining { if(!training) return undefined; const t: TutorTraining = Object.assign(new TutorTraining(), training); t.participants = ((training.participants || []) as any[]) - .filter(tutor=>tutor) + .filter(tutor=>tutor?.tutor) .map(tutor=>Object.assign(Tutor.parse({...tutor.tutor, studyProgram: tutor.studyProgram})!, {training: t})); return t; } @@ -23,7 +23,7 @@ export class TutorTraining { return TutorTraining.parse((await sql`SELECT tutor_trainings.*, array_agg(json_build_object('tutor', row_to_json(tutors.*), 'studyProgram', row_to_json(study_programs.*))) AS participants FROM tutor_trainings LEFT JOIN tutors ON tutor_trainings.id=tutors.training JOIN study_programs ON tutors.study_program=study_programs.id WHERE tutor_trainings.id=${id} GROUP BY tutor_trainings.id`)[0]); } static async getAll(): Promise<TutorTraining[]> { - return (await sql`SELECT tutor_trainings.*, array_agg(json_build_object('tutor', row_to_json(tutors.*), 'studyProgram', row_to_json(study_programs.*))) AS participants FROM tutor_trainings LEFT JOIN tutors ON tutor_trainings.id=tutors.training JOIN study_programs ON tutors.study_program=study_programs.id GROUP BY tutor_trainings.id`).map(row=>TutorTraining.parse(row)!); + return (await sql`SELECT tutor_trainings.*, array_agg(json_build_object('tutor', row_to_json(tutors.*), 'studyProgram', row_to_json(study_programs.*))) AS participants FROM tutor_trainings LEFT JOIN tutors ON tutor_trainings.id=tutors.training LEFT JOIN study_programs ON tutors.study_program=study_programs.id GROUP BY tutor_trainings.id`).map(row=>TutorTraining.parse(row)!); } static async create(training: Omit<TutorTraining, "id"|"participants">): Promise<number> { return (await sql`INSERT INTO tutor_trainings ${sql(training)} 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 75fbd5f64ad86cb79dc7b9813e0b76b4188b0863..4fa987a18824ab7f2f9960f77d85377bbb9c15a2 100644 --- a/src/lib/server/database/migrations/0_init.sql +++ b/src/lib/server/database/migrations/0_init.sql @@ -46,7 +46,7 @@ CREATE TABLE IF NOT EXISTS tutorials ( CREATE TABLE IF NOT EXISTS tutor_trainings ( "id" SERIAL PRIMARY KEY, - "date" DATE NOT NULL, + "date" TEXT NOT NULL, "location" TEXT NOT NULL, "max_participants" INT NOT NULL DEFAULT 0, "internal" BOOLEAN NOT NULL DEFAULT FALSE, @@ -165,3 +165,12 @@ CREATE TABLE IF NOT EXISTS schedule_entries ( "end_uncertainty" INT NOT NULL DEFAULT 0, "date" DATE NOT NULL ); + +CREATE TABLE IF NOT EXISTS master_freshers ( + "id" SERIAL PRIMARY KEY, + "firstname" TEXT NOT NULL, + "lastname" TEXT NOT NULL, + "email" TEXT NOT NULL, + "study_program" TEXT NOT NULL, + "aachen_experience" TEXT NOT NULL +); diff --git a/src/lib/server/mail.ts b/src/lib/server/mail.ts index d0cab1df748b2cf5274883f63e4c4e936aa588eb..7ed38385690bec9bbf50df5c01995822f6b2155b 100644 --- a/src/lib/server/mail.ts +++ b/src/lib/server/mail.ts @@ -2,7 +2,7 @@ import { env } from "$env/dynamic/private"; import nodemailer from "nodemailer"; import { Tutor } from "./database/entities/Tutor.entity"; import type Mail from "nodemailer/lib/mailer"; -import { compileTemplate, parseTemplate, renderTemplate, type MailTemplate } from "$lib/mail"; +import { compileTemplate, parseTemplate, renderTemplate, subjectFormatter, type MailTemplate } from "$lib/mail"; import { Config } from "./database/entities/Config.entity"; import ical from "ical-generator"; import type SMTPTransport from "nodemailer/lib/smtp-transport"; @@ -120,12 +120,13 @@ export function sendMail({ template, tutors, attachments = [], from, icalEvent } icalEvent?: Mail.IcalAttachment, }): Promise<SendMailResult> { const replyTo = template.replyTo === "-" ? undefined : template.replyTo; - const textParts = parseTemplate(template.subject); - const subjectParts = parseTemplate(template.subject); + const textParts = parseTemplate(template.type, template.subject); + const subjectParts = parseTemplate(template.type, template.subject); + const config = Config.get(); return Promise.all(tutors.map(tutor => { - const compiled = compileTemplate(textParts, tutor); + const compiled = compileTemplate(template.type, textParts, tutor, config); const rendered = renderTemplate(compiled); - const subject = compileTemplate(subjectParts, tutor, ({de, en})=>`${de} / ${en}`); + const subject = compileTemplate(template.type, subjectParts, tutor, config, subjectFormatter); return transporter.sendMail({ to: tutor.email, from: from || env.MAIL_FROM, diff --git a/src/routes/(non-admin)/datenschutz/+page.svelte b/src/routes/(non-admin)/datenschutz/+page.svelte index b6a1bb6348baff0ead153a704189aa5c5eea8b1f..5e74045377a513c164de91b073cd8f9b52221699 100644 --- a/src/routes/(non-admin)/datenschutz/+page.svelte +++ b/src/routes/(non-admin)/datenschutz/+page.svelte @@ -2,7 +2,7 @@ import { LL, type Translation, type TranslationFunction } from "$lib/i18n/i18n"; import { Heading, P } from "flowbite-svelte"; - $: paragraphs = Object.keys($LL.PrivacyPolicy.Paragraphs) as (keyof Translation["PrivacyPolicy"]["Paragraphs"])[]; + let paragraphs = $derived(Object.keys($LL.PrivacyPolicy.Paragraphs) as (keyof Translation["PrivacyPolicy"]["Paragraphs"])[]); type Paragraph = { Headline: TranslationFunction; Content: TranslationFunction; }; </script> diff --git a/src/routes/(non-admin)/eswe/+page.svelte b/src/routes/(non-admin)/eswe/+page.svelte index 645c160dbc2b2da2b7ee0e1110a9c014caf3e866..39d70938b7940289e8e9d72fd121ae81b3711544 100644 --- a/src/routes/(non-admin)/eswe/+page.svelte +++ b/src/routes/(non-admin)/eswe/+page.svelte @@ -3,11 +3,11 @@ import { LL, locale } from "$lib/i18n/i18n"; import Image from "$lib/components/Image.svelte"; - export let data; + let { data } = $props(); const images = data.images; - let showModal = false; - let index = 0; + let showModal = $state(false); + let index = $state(0); function openModal(idx: number){ index = idx; showModal = true; @@ -89,7 +89,7 @@ <!-- images array is just a dummy, item is not an image property object but the index within the gallery --> <Gallery items={Array.from({length: images.length}, (_, i)=>i)} class="gap-4 grid-cols-2 md:grid-cols-3" let:item={index}> - <button on:click={()=>openModal(index)}> + <button onclick={()=>openModal(index)}> <Image src={images[index]} class="h-auto max-w-full rounded-lg" loading="lazy" sizes="(min-width: 768px) 33vw, min(50vw, 363px)" autosize/> </button> </Gallery> diff --git a/src/routes/(non-admin)/flyer/+page.svelte b/src/routes/(non-admin)/flyer/+page.svelte index 3701fe7d2c169d583022374db476db24f1a976a2..41380b547051d4996dda9c3ad554ed2aa1414fd1 100644 --- a/src/routes/(non-admin)/flyer/+page.svelte +++ b/src/routes/(non-admin)/flyer/+page.svelte @@ -5,10 +5,10 @@ import LL from '$lib/i18n/i18n'; import Image from '$lib/components/Image.svelte'; - export let data; + let { data } = $props(); const slideTransition = (x: Element) => fade(x, { duration: 700, easing: quintOut }); - let index = 0; + let index = $state(0); </script> <div class="max-w-3xl mx-auto space-y-4"> diff --git a/src/routes/(non-admin)/impressum/+page.svelte b/src/routes/(non-admin)/impressum/+page.svelte index 2474eb4e702a997e6ffc29e8eedabc69e2289f0d..9ec9bde09fff1eb95258e05e35bc5364e4cf3a84 100644 --- a/src/routes/(non-admin)/impressum/+page.svelte +++ b/src/routes/(non-admin)/impressum/+page.svelte @@ -1,8 +1,6 @@ <script lang="ts"> - import { LL } from "$lib/i18n/i18n"; - import { A, Blockquote, Heading, P } from "flowbite-svelte"; - - + import { LL } from "$lib/i18n/i18n"; + import { A, Blockquote, Heading, P } from "flowbite-svelte"; </script> <Heading tag="h1" customSize="text-4xl font-bold" class="mb-4 text-center">{$LL.Legal.Headline()}</Heading> diff --git a/src/routes/(non-admin)/information/+page.svelte b/src/routes/(non-admin)/information/+page.svelte index 30b1e847bfe308486c6be8d594bda6fe0536dd05..1d28791d3361e2bda8d957bf5db22741c567f9f1 100644 --- a/src/routes/(non-admin)/information/+page.svelte +++ b/src/routes/(non-admin)/information/+page.svelte @@ -2,9 +2,10 @@ import { A, Button, Heading, Label, Li, List, P, Select } from 'flowbite-svelte'; import { LL, locale } from "$lib/i18n/i18n"; - export let data; - let selectedStudyProgram = Object.keys(data.schedules)[0]; - $: studyProgramName = data.schedules[selectedStudyProgram]?.studyProgramName[$locale]; + let { data } = $props(); + + let selectedStudyProgram = $state(Object.keys(data.schedules)[0]); + let studyProgramName = $derived(data.schedules[selectedStudyProgram]?.studyProgramName[$locale]); </script> <Heading tag="h2" customSize="text-3xl font-bold" class="mb-6">{$LL.Information.Schedule()}</Heading> diff --git a/src/routes/(non-admin)/rabatte/+page.svelte b/src/routes/(non-admin)/rabatte/+page.svelte index e6f61bb4be689d4070e26bd1cd7a220ca98ad190..0d56365ef9a1e8829a04ed8027c2d7cc8a75f0fb 100644 --- a/src/routes/(non-admin)/rabatte/+page.svelte +++ b/src/routes/(non-admin)/rabatte/+page.svelte @@ -9,7 +9,7 @@ import { makeSearchable, matches } from "./assets/search"; import { locale } from "$lib/i18n/i18n"; - export let data; + let { data } = $props(); function getValidityStatus(startDate: string, endDate: string, currentDate: number): number{ if(startDate && endDate){ @@ -55,10 +55,10 @@ }); const allTags = data.allTags; // TODO rerun filter again on locale change - $: searchTexts = Object.fromEntries(locations.map(location=>[location.id, makeSearchable(location.title, location.description[$locale], location.address)])); - let onlyOpen = false; - let search = ""; - let tags: number[] = []; + let searchTexts = $derived(Object.fromEntries(locations.map(location=>[location.id, makeSearchable(location.title, location.description[$locale], location.address)]))); + let onlyOpen = $state(false); + let search = $state(""); + let tags: number[] = $state([]); const filter = (search: string, tags: number[], onlyOpen: boolean)=>(location: typeof locations[number])=>{ if(onlyOpen && !location.isOpen && !location.openingSoon) return false; if(search && !matches(searchTexts[location.id], search)) return false; @@ -72,9 +72,11 @@ let completelyHidden = false; let minOffsetY = 0; - let showOnMap: (id: number)=>void; + let leafletMap: LeafletMap|undefined = $state(); + let locationList: LocationList|undefined = $state(); + let showOnMap: (id: number)=>void = $derived(leafletMap?.showOnMap||(()=>{})); let isScrolling = false; - let locationElements: {[k: number]: HTMLDivElement}; + let locationElements: {[k: number]: HTMLDivElement} = $derived(locationList?.locationElements||{}); function scrollTo(locationId: number, highlight=true){ const element = locationElements[locationId]; if(!element) return; @@ -219,9 +221,9 @@ </style> <div class="map-container z-40 mx-auto max-w-6xl px-4" bind:this={mapContainer}> - <LeafletMap {locations} {scrollTo} bind:showOnMap holidays={data.holidays} /> + <LeafletMap bind:this={leafletMap} {locations} {scrollTo} holidays={data.holidays} /> </div> <div class="list-container"> <LocationFilter {allTags} bind:onlyOpen bind:search bind:tags /> - <LocationList {locations} holidays={data.holidays} filter={filter(search, tags, onlyOpen)} showOnMap={focusLocationOnMap} bind:locationElements bind:searchTags={tags} /> + <LocationList bind:this={locationList} {locations} holidays={data.holidays} filter={filter(search, tags, onlyOpen)} showOnMap={focusLocationOnMap} bind:searchTags={tags} /> </div> diff --git a/src/routes/(non-admin)/rabatte/assets/LeafletMap.svelte b/src/routes/(non-admin)/rabatte/assets/LeafletMap.svelte index e0c5b23fb5533527f6658acd023aeaea7bd10374..4f6f65cdccdcfa966c48375a5008a17c91847b45 100644 --- a/src/routes/(non-admin)/rabatte/assets/LeafletMap.svelte +++ b/src/routes/(non-admin)/rabatte/assets/LeafletMap.svelte @@ -8,10 +8,11 @@ import type { Map, Marker } from 'leaflet'; let leaflet: typeof import("leaflet"); - export let + let { locations, scrollTo, holidays }: { locations: (Location&{closingSoon: boolean, openingSoon: boolean, isOpen: boolean})[], scrollTo: (id: number)=>void, holidays: string[]; + } = $props(); let map: Map; let mapElement: HTMLDivElement; diff --git a/src/routes/(non-admin)/rabatte/assets/Location.svelte b/src/routes/(non-admin)/rabatte/assets/Location.svelte index 414cb99bb4533f91e0c38d4d231be3b44b011c62..f82ca954bff650917ce2323c5d2ca4354c2b837f 100644 --- a/src/routes/(non-admin)/rabatte/assets/Location.svelte +++ b/src/routes/(non-admin)/rabatte/assets/Location.svelte @@ -7,20 +7,23 @@ import type { Discount as LocationType, Tag } from "$lib/server/database/entities/Discount.entity"; import type { MouseEventHandler } from "svelte/elements"; - export let + let { location, showOnMap, searchTags = $bindable(), locationElement = $bindable(), holidays }: { location: LocationType&{status:number,closingSoon:boolean,isOpen:boolean,openingSoon:boolean}, showOnMap: MouseEventHandler<HTMLAnchorElement>, - searchTags: Tag[], + searchTags: number[], locationElement: HTMLElement, - holidays: string[]; + holidays: string[] + } = $props(); - let element: HTMLDivElement; - $: locationElement = element?.parentElement as HTMLElement; + let element: HTMLDivElement = null as unknown as HTMLDivElement; + $effect(()=>{ + locationElement = element?.parentElement as HTMLElement; + }); - $: open = formatOpeningHours(getOpeningHoursForNextDays(holidays, location.openingHours, new Date(), 7, true), $locale); - let showAllOpeningHours = false; + let open = $derived(formatOpeningHours(getOpeningHoursForNextDays(holidays, location.openingHours, new Date(), 7, true), $locale)); + let showAllOpeningHours = $state(false); - $: rangeFormatter = new Intl.DateTimeFormat($locale, {day: "2-digit", month: "long"}); + let rangeFormatter = $derived(new Intl.DateTimeFormat($locale, {day: "2-digit", month: "long"})); </script> <Card padding="sm" size="md"> @@ -59,7 +62,7 @@ {showAllOpeningHours ? $LL.Discounts.Item.ShowLess() : $LL.Discounts.Item.ShowMore()} </Button> </div> - <a href="#{location.id}" on:click={showOnMap} class="font-semibold text-gray-900 dark:text-white mb-3 mt-auto"><!--<Icon name="map-location-outline" size="md" class="inline" />--> {location.address}</a> + <a href="#{location.id}" onclick={showOnMap} class="font-semibold text-gray-900 dark:text-white mb-3 mt-auto"><!--<Icon name="map-location-outline" size="md" class="inline" />--> {location.address}</a> <ButtonGroup class="w-max"> {#each location.tags as tag} <!-- TODO highlight searched button --> diff --git a/src/routes/(non-admin)/rabatte/assets/LocationFilter.svelte b/src/routes/(non-admin)/rabatte/assets/LocationFilter.svelte index 8d57762db824e1a5c48c9fb727d3d4e9279b0bb3..a352149a5b930bf1f9b6d09089cbef20bb3aa355 100644 --- a/src/routes/(non-admin)/rabatte/assets/LocationFilter.svelte +++ b/src/routes/(non-admin)/rabatte/assets/LocationFilter.svelte @@ -3,7 +3,12 @@ import { LL, locale } from "$lib/i18n/i18n"; import type { Tag } from "$lib/server/database/entities/Discount.entity"; - export let onlyOpen: boolean, search: string, tags: number[], allTags: Tag[]; + let { onlyOpen = $bindable(), search = $bindable(), tags = $bindable(), allTags }: { + onlyOpen: boolean, + search: string, + tags: number[], + allTags: Tag[] + } = $props(); </script> <div class="mb-4"> diff --git a/src/routes/(non-admin)/rabatte/assets/LocationList.svelte b/src/routes/(non-admin)/rabatte/assets/LocationList.svelte index c8b73aa805a6e573a15a10cd83623bfceb8e17dc..f2eb4b2e689edca1b2efeeec2c52b58331fda921 100644 --- a/src/routes/(non-admin)/rabatte/assets/LocationList.svelte +++ b/src/routes/(non-admin)/rabatte/assets/LocationList.svelte @@ -2,16 +2,17 @@ import Location from './Location.svelte'; import { LL } from "$lib/i18n/i18n"; import { P } from 'flowbite-svelte'; - import type { Discount, Tag } from '$lib/server/database/entities/Discount.entity'; + import type { Discount } from '$lib/server/database/entities/Discount.entity'; - export let + let { locations, filter, showOnMap, searchTags = $bindable(), holidays }: { locations: (Discount&{isOpen:boolean,openingSoon:boolean,closingSoon:boolean,status:number})[], filter: (location: typeof locations[number])=>boolean, showOnMap: (id: number)=>void, - searchTags: Tag[], - holidays: string[]; + searchTags: number[], + holidays: string[], + } = $props(); export const locationElements: {[k: number]: HTMLDivElement} = {}; - $: filteredLocations = locations.filter(filter); + let filteredLocations = $derived(locations.filter(filter)); </script> <style> @@ -22,9 +23,9 @@ <div class="grid gap-4 mb-5"> {#each filteredLocations as location (location.id)} - <Location {location} {holidays} bind:searchTags showOnMap={()=>showOnMap(location.id)} bind:locationElement={locationElements[location.id]} /> + <Location {location} {holidays} bind:searchTags showOnMap={()=>showOnMap(location.id)} bind:locationElement={locationElements[location.id]} /> {/each} {#if filteredLocations.length === 0} - <P>{$LL.Discounts.Search.NoResults()}</P> + <P>{$LL.Discounts.Search.NoResults()}</P> {/if} </div> diff --git a/src/routes/(non-admin)/tutor/+page.svelte b/src/routes/(non-admin)/tutor/+page.svelte index 91661472983ae97e384a6dc2f2d60c106b4c90e0..4f0f45c667de230f4c1a7a1360a8c9b5d55276e7 100644 --- a/src/routes/(non-admin)/tutor/+page.svelte +++ b/src/routes/(non-admin)/tutor/+page.svelte @@ -4,9 +4,9 @@ import { LL, locale } from "$lib/i18n/i18n"; import LocalizedText from "$lib/components/LocalizedText.svelte"; - export let data; + let { data } = $props(); - $: dateFormatter = new Intl.DateTimeFormat($locale, {day: "2-digit", month: "long", year: "numeric", weekday: "long"}); + let dateFormatter = $derived(new Intl.DateTimeFormat($locale, {day: "2-digit", month: "long", year: "numeric", weekday: "long"})); </script> <Heading tag="h1" customSize="text-4xl font-bold" class="mb-6 text-center">{$LL.Tutor.Headline()}</Heading> @@ -44,12 +44,12 @@ <div class="flex mb-6 justify-center"> {#if data.registrationOpen} - <Button href="/tutor/register" size="xl"> - <svg aria-hidden="true" class="mr-2 -ml-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg> - {$LL.Tutor.RegistrationButton()} - </Button> + <Button href="/tutor/register" size="xl"> + <svg aria-hidden="true" class="mr-2 -ml-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg> + {$LL.Tutor.RegistrationButton()} + </Button> {:else} - <Button disabled size="xl">{$LL.Tutor.RegistrationClosed()}</Button> + <Button disabled size="xl">{$LL.Tutor.RegistrationClosed()}</Button> {/if} </div> diff --git a/src/routes/(non-admin)/tutor/register/+page.server.ts b/src/routes/(non-admin)/tutor/register/+page.server.ts index 1e2dd3ab1c0425cc0a27129fdde1bccb630d0fb6..5a2baf85ee875055f30657de0778b8faa3d1f0c1 100644 --- a/src/routes/(non-admin)/tutor/register/+page.server.ts +++ b/src/routes/(non-admin)/tutor/register/+page.server.ts @@ -47,7 +47,7 @@ export const actions = { if(!Number.isInteger(studyProgramId) || studyProgramId < 0){ throw error(422, "Ungültiger Studiengang"); } - if(typeof email !== "string" || !(email.endsWith("@rwth-aachen.de") || email.endsWith(".rwth-aachen.de"))){ + if(typeof email !== "string" || !email.includes("@") || !(email.endsWith("@rwth-aachen.de") || email.endsWith(".rwth-aachen.de"))){ throw error(422, "Ungültige E-Mail Adresse"); } if(typeof birthdayString !== "string" || !birthdayString.match(/^\d{4}-\d{2}-\d{2}$/)){ diff --git a/src/routes/(non-admin)/tutor/register/+page.svelte b/src/routes/(non-admin)/tutor/register/+page.svelte index aa45f9c2f02f581b1f9afc00ed2a30e7dd87eb25..75cc83d7184f5ce2eb81e9aa35b52e36b0bae440 100644 --- a/src/routes/(non-admin)/tutor/register/+page.svelte +++ b/src/routes/(non-admin)/tutor/register/+page.svelte @@ -7,17 +7,18 @@ import LocalizedText from "$lib/components/LocalizedText.svelte"; import { addMessage } from "$lib/messages.js"; - export let data; + let { data } = $props(); - let birthdayError = false; - let ageError = false; - let emailError = -1; + let birthdayError = $state(false); + let ageError = $state(false); + let emailError = $state(-1); - let success = false; - let error: {message: string}|undefined; - let waitlist: boolean; + let success = $state(false); + let error: {message: string}|undefined = $state(); + let waitlist: boolean = $state(false); - let studyProgram: number; + let studyProgramId: number|null = $state(null); + let studyProgram = $derived(data.studyPrograms.find(sp=>sp.id===studyProgramId)); function validateEmail(email: any) { if(typeof email !== "string" || !(email.endsWith("@rwth-aachen.de") || email.endsWith(".rwth-aachen.de"))){ @@ -46,7 +47,7 @@ return now + 1000 * 60 * 60 * 24 < date; // can't sign up the day before and later } - $: dateFormatter = new Intl.DateTimeFormat($locale, {day: "2-digit", month: "long", year: "numeric", weekday: "short"}); + let dateFormatter = $derived(new Intl.DateTimeFormat($locale, {day: "2-digit", month: "long", year: "numeric", weekday: "short"})); </script> <Heading tag="h1" customSize="text-4xl font-bold" class="mb-6 text-center">{$LL.Tutor.SignUp.Headline()}</Heading> @@ -136,9 +137,9 @@ </Label> <Label class="mb-2"> {$LL.Tutor.SignUp.StudyProgram()} - <Select name="study-program" required placeholder={$LL.Tutor.SignUp.PleaseChoose()} items={data.studyPrograms.map(x=>({name: x.name[$locale], value: x.id}))} bind:value={studyProgram} /> - {#if data.studyPrograms.find(sp=>sp.id===studyProgram)?.hasWaitlist} - <Helper color="red">{$LL.Tutor.SignUp.StudyProgramWaitlist({studyProgram: data.studyPrograms.find(sp=>sp.id===studyProgram)?.name[$locale]??""})}</Helper> + <Select name="study-program" required placeholder={$LL.Tutor.SignUp.PleaseChoose()} items={data.studyPrograms.map(x=>({name: x.name[$locale], value: x.id}))} bind:value={studyProgramId} /> + {#if studyProgram?.hasWaitlist} + <Helper color="red">{$LL.Tutor.SignUp.StudyProgramWaitlist({studyProgram: studyProgram!.name[$locale]??""})}</Helper> {/if} </Label> <Label class="mb-2"> diff --git a/src/routes/(non-admin)/upload/+page.svelte b/src/routes/(non-admin)/upload/+page.svelte index fb04694d11370179fd27c2e8211ca8ad34cd8158..e9a0eb1cbdf06d5a82a8ee44daaf379421daf91e 100644 --- a/src/routes/(non-admin)/upload/+page.svelte +++ b/src/routes/(non-admin)/upload/+page.svelte @@ -3,9 +3,10 @@ import { addMessage } from '$lib/messages'; import { Heading, P, Fileupload, Button, Checkbox } from 'flowbite-svelte'; import { LL } from "$lib/i18n/i18n"; + + let disabled = $state(false); </script> -<!-- TODO i18n --> <Heading tag="h1" customSize="text-4xl font-bold" class="mb-6 text-center">{$LL.ShareMoments.Headline()}</Heading> <P class="mb-3">{$LL.ShareMoments.Description()}</P> @@ -13,16 +14,20 @@ <P class="mb-3">{$LL.ShareMoments.Disclaimer2()}</P> <P class="mb-3">{$LL.ShareMoments.LookingForwards()}</P> -<form action="?/upload" method="post" enctype="multipart/form-data" use:enhance={()=>({result, update})=>{ - if(result.type === "success"){ - addMessage({ type: "success", text: $LL.ShareMoments.UploadSuccess() }); - update(); - }else{ - addMessage({ type: "error", text: $LL.ShareMoments.UploadError() }); - console.error(result); +<form action="?/upload" method="post" enctype="multipart/form-data" use:enhance={()=>{ + disabled = true; + return ({result, update})=>{ + disabled = false; + if(result.type === "success"){ + addMessage({ type: "success", text: $LL.ShareMoments.UploadSuccess() }); + update(); + }else{ + addMessage({ type: "error", text: $LL.ShareMoments.UploadError() }); + console.error(result); + } } }}> <Fileupload name="files" accept="image/*" multiple required class="mb-2" /> <Checkbox class="mb-2" required>{$LL.ShareMoments.Compliance()}</Checkbox> - <Button type="submit">{$LL.ShareMoments.Upload()}</Button> + <Button type="submit" {disabled}>{$LL.ShareMoments.Upload()}</Button> </form> diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index 651ad3a7d68c394d6d743ac410e4a64c901f6118..a529e7987107820c8798c48f8400b78d21924d8d 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -49,12 +49,13 @@ <option value="/eswe">/eswe</option> <option value="/rabatte">/rabatte</option> <option value="/flyer">/flyer</option> + <option value="/upload">/upload</option> </Select> </Label> <Label class="mb-2"> Im Header angezeigte Links <!-- die hier verwendete Reihenfolge in items bestimmt die Reihenfolge im Header selbst --> - <MultiSelect {disabled} name="headerLinks" value={data.config.headerLinks as string[]} items={["/information","/rabatte","/eswe","/flyer","/tutor"].map(link=>({name: link, value: link}))} /> + <MultiSelect {disabled} name="headerLinks" value={data.config.headerLinks as string[]} items={["/information","/rabatte","/eswe","/flyer","/tutor","/upload"].map(link=>({name: link, value: link}))} /> </Label> {#if canEdit} <Button type="submit" class="mb-2">Speichern</Button> diff --git a/src/routes/admin/eswe/+page.svelte b/src/routes/admin/eswe/+page.svelte index bdd265a5fd6f25d20091bb2e8e3efdb8b8d6ab84..1b4e98420fcc31ab68f844600aed6500b349b652 100644 --- a/src/routes/admin/eswe/+page.svelte +++ b/src/routes/admin/eswe/+page.svelte @@ -5,7 +5,7 @@ import { addMessage } from "$lib/messages.js"; import { Breadcrumb, BreadcrumbItem, Button, Input, Label, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell } from "flowbite-svelte"; - export let data; + let { data } = $props(); </script> <Breadcrumb class="mb-4"> diff --git a/src/routes/admin/rabatte/+page.svelte b/src/routes/admin/rabatte/+page.svelte index 479b9b3ac8030ac2223acb3d65754bec6a6ec8e8..114194cfb64046b5b8c111568b1429050461ca4a 100644 --- a/src/routes/admin/rabatte/+page.svelte +++ b/src/routes/admin/rabatte/+page.svelte @@ -1,12 +1,11 @@ <script lang="ts"> import { Breadcrumb, BreadcrumbItem, Button, Input, Label, P, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell, Textarea } from 'flowbite-svelte'; - import type { PageData } from './$types'; import { locales } from '$lib/i18n/i18n'; import { enhance } from '$app/forms'; import { goto, invalidateAll } from '$app/navigation'; import { addMessage } from '$lib/messages'; - export let data: PageData; + let { data } = $props(); const rangeFormatter = new Intl.DateTimeFormat('de', { year: 'numeric', @@ -18,14 +17,14 @@ fetch(`/admin/rabatte/${id}`, { method: 'DELETE', }).then(() => { - location.reload(); + invalidateAll(); }); } function deleteTag(id: number){ fetch(`/admin/rabatte/tag/${id}`, { method: "DELETE", }).then(()=>{ - location.reload(); + invalidateAll(); }); } </script> @@ -100,10 +99,10 @@ </Table> {#each data.tags as tag (tag.id)} -<form id="tag-{tag.id}" action="?/updateTag" method="post" use:enhance={()=>({result})=>{ +<form id="tag-{tag.id}" action="?/updateTag" method="post" use:enhance={()=>({result, update})=>{ if(result.type === "success"){ addMessage({type: "success", text: "Tag gespeichert"}); - invalidateAll(); + update({ reset: false }); }else{ addMessage({type: "error", text: "Fehler beim Speichern"}); console.error(result); @@ -112,10 +111,10 @@ <input type="hidden" name="id" value={tag.id} /> </form> {/each} -<form id="tag-new" action="?/createTag" method="post" use:enhance={()=>({result})=>{ +<form id="tag-new" action="?/createTag" method="post" use:enhance={()=>({result, update})=>{ if(result.type === "success"){ addMessage({type: "success", text: "Tag erstellt"}); - invalidateAll(); + update(); }else{ addMessage({type: "error", text: "Fehler beim Erstellen"}); console.error(result); diff --git a/src/routes/admin/rabatte/[id=number]/+page.svelte b/src/routes/admin/rabatte/[id=number]/+page.svelte index 319a46b1c74d18ad9bd580121e51ee87754cd4e4..ecf70bcbf8456e7371a0a489fdbb19e8b5908209 100644 --- a/src/routes/admin/rabatte/[id=number]/+page.svelte +++ b/src/routes/admin/rabatte/[id=number]/+page.svelte @@ -1,19 +1,17 @@ <script lang="ts"> import { Breadcrumb, BreadcrumbItem, Button, Input, Label, MultiSelect, Textarea } from 'flowbite-svelte'; - import type { PageData } from './$types'; import { locale, locales } from '$lib/i18n/i18n'; import { onDestroy, onMount } from 'svelte'; import type { Map, Marker } from 'leaflet'; import { enhance } from '$app/forms'; - import { invalidateAll } from '$app/navigation'; import OpeningHoursInput from './OpeningHoursInput.svelte'; import { addMessage } from '$lib/messages'; - export let data: PageData; + let { data } = $props(); - let [latitude, longitude] = data.discount.location; - let openingHours = data.discount.openingHours; - let tags = data.discount.tags.map(tag=>tag.id); + let latitude = $state(data.discount.location[0]); + let longitude = $state(data.discount.location[1]); + let tags = $state(data.discount.tags.map(tag=>tag.id)); let mapElement: HTMLDivElement; let map: Map; @@ -100,10 +98,10 @@ <BreadcrumbItem href="/admin/rallye/{data.discount.id}">{data.discount.title}</BreadcrumbItem> </Breadcrumb> -<form method="post" action="?/update" use:enhance={()=>({result})=>{ +<form method="post" action="?/update" use:enhance={()=>({result, update})=>{ if(result.type === "success"){ addMessage({type: "success", text: "Daten gespeichert"}); - invalidateAll(); + update({ reset: false }); }else{ addMessage({type: "error", text: "Fehler beim Speichern"}); console.error(result); @@ -140,7 +138,7 @@ <MultiSelect name="tags" bind:value={tags} items={data.tags.map(t=>({name: t.name[$locale], value: t.id}))} /> </Label> <div class="mb-2"> - <OpeningHoursInput bind:openingHours /> + <OpeningHoursInput openingHours={data.discount.openingHours} /> </div> <div class="mx-auto w-full h-72 mb-2"> <div class="h-full" bind:this={mapElement}></div> diff --git a/src/routes/admin/rabatte/[id=number]/OpeningHoursInput.svelte b/src/routes/admin/rabatte/[id=number]/OpeningHoursInput.svelte index 4bd81c0812a0ce2e0c45078e6935accc0e6723d0..50a89ef5b4bef8c024fab3e0a8b2c659f2e27d53 100644 --- a/src/routes/admin/rabatte/[id=number]/OpeningHoursInput.svelte +++ b/src/routes/admin/rabatte/[id=number]/OpeningHoursInput.svelte @@ -2,7 +2,8 @@ import type { Discount } from "$lib/server/database/entities/Discount.entity"; import { Button, CloseButton, Input, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell } from "flowbite-svelte"; - export let openingHours: Discount["openingHours"]; + let { openingHours }: { openingHours: Discount["openingHours"] } = $props(); + for(const day of [1,2,3,4,5,6,7] as const){ if(!openingHours[day]) openingHours[day] = []; } @@ -19,7 +20,7 @@ <TableHeadCell>Öffnungszeiten</TableHeadCell> </TableHead> <TableBody> - {#each Object.keys(openingHours).filter(day=>openingHours[day]) as day, i} + {#each Object.keys(openingHours).filter(day=>openingHours[day as keyof typeof openingHours]) as day, i} <TableBodyRow> <TableBodyCell class="px-3 py-1"> {#if ["1","2","3","4","5","6","7"].includes(day)} @@ -30,12 +31,12 @@ <CloseButton name="entfernen" class="p-1 my-0" on:click={()=>openingHours = Object.assign(openingHours, { holiday: undefined })} /> <input name="openingHours[{i}]" type="hidden" value="holiday" /> {:else} - <Input name="openingHours[{i}]" type="date" bind:value={day} class="w-min inline" /> + <Input name="openingHours[{i}]" type="date" value={day} class="w-min inline" /> <CloseButton name="entfernen" class="p-1 my-0" on:click={()=>openingHours = Object.assign(openingHours, { [day]: undefined })} /> {/if} </TableBodyCell> <TableBodyCell class="px-3 py-1"> - {#each openingHours[day]||[] as [start, end], j} + {#each openingHours[day as keyof typeof openingHours]||[] as [start, end], j} <div class="w-min mx-1 inline-block rtl:text-right px-2 py-[0.15rem] border focus-within:ring-1 focus-within:border-primary-500 focus-within:ring-primary-500 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500 bg-gray-50 text-gray-900 dark:bg-gray-700 dark:text-white border-gray-300 dark:border-gray-600 text-sm rounded-lg"> <input name="openingHours[{i}][{j}][sh]" class="w-[2em] p-1 border-none focus:ring-0 [appearance:textfield] bg-gray-50 text-gray-900 dark:bg-gray-700 dark:text-white" type="number" step="1" min="0" max="23" bind:value={start[0]} required /> : @@ -45,10 +46,10 @@ <input name="openingHours[{i}][{j}][eh]" class="w-[2em] p-1 border-none focus:ring-0 [appearance:textfield] bg-gray-50 text-gray-900 dark:bg-gray-700 dark:text-white" type="number" step="1" min="0" max="47" bind:value={end[0]} required /> : <input name="openingHours[{i}][{j}][em]" class="w-[2em] p-1 border-none focus:ring-0 [appearance:textfield] bg-gray-50 text-gray-900 dark:bg-gray-700 dark:text-white" type="number" step="1" min="0" max="59" bind:value={end[1]} required /> - <CloseButton name="entfernen" class="p-1 my-0" on:click={()=>openingHours = Object.assign(openingHours, {[day]: openingHours[day].filter((_,k)=>k!==j)})} /> + <CloseButton name="entfernen" class="p-1 my-0" on:click={()=>openingHours = Object.assign(openingHours, {[day]: openingHours[day as keyof typeof openingHours]?.filter((_,k)=>k!==j)})} /> </div> {/each} - <Button on:click={()=>openingHours = Object.assign(openingHours, {[day]: [...(openingHours[day]||[]), [[0,0],[0,0]]]})}>+</Button> + <Button on:click={()=>openingHours = Object.assign(openingHours, {[day]: [...(openingHours[day as keyof typeof openingHours]||[]), [[0,0],[0,0]]]})}>+</Button> </TableBodyCell> </TableBodyRow> {/each} @@ -59,7 +60,7 @@ {/if} <Button on:click={()=>{ let date = new Date(); - while(openingHours[date.toISOString().slice(0,10)]) date.setDate(date.getDate()+1); + while(openingHours[date.toISOString().slice(0,10) as keyof typeof openingHours]) date.setDate(date.getDate()+1); openingHours = Object.assign(openingHours, {[date.toISOString().slice(0,10)]: []}); }}>bestimmten Tag hinzufügen</Button> </TableBodyCell> diff --git a/src/routes/admin/rallye/points/+page.svelte b/src/routes/admin/rallye/points/+page.svelte index 601b18d8c9fcebbdfedf40d7d03ac7856d17b6e4..57f8bc57ea7ee90c10886924cbdc2796d3f0cc62 100644 --- a/src/routes/admin/rallye/points/+page.svelte +++ b/src/routes/admin/rallye/points/+page.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { Breadcrumb, BreadcrumbItem, Input, Label, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell } from "flowbite-svelte"; + import { Breadcrumb, BreadcrumbItem, Button, Heading, Input, Label, Li, List, Modal, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell } from "flowbite-svelte"; let { data } = $props(); @@ -17,7 +17,7 @@ return points.map(p => p / max * maxPoints); } - function normalize(){ + function normalizeStations(){ return points.map((_, stationIndex) => normalizeStation(stationIndex)); } @@ -41,18 +41,34 @@ } function evaluate(){ - const normalized = normalize(); + const normalized = normalizeStations(); const transposed = normalized[0].map((_, i) => normalized.map(row => row[i])); const numberOfStationsToGrade = Math.min(6, stationIds.length); for(let tutorialIndex = 0; tutorialIndex < transposed.length; tutorialIndex++){ transposed[tutorialIndex].sort((a, b) => (b.points+b.bribe) - (a.points+a.bribe)).splice(numberOfStationsToGrade); } const normalizedQuestionnaires = normalizeQuestionnaires(); - return transposed.map((points, tutorialIndex) => ({ - tutorialId: tutorialIds[tutorialIndex], + const result = transposed.map((points, tutorialIndex) => ({ + tutorial: data.tutorials[tutorialIndex], points: points.reduce((acc, point) => acc + point.points + point.bribe + normalizedQuestionnaires[tutorialIndex], 0) })).sort((a, b) => b.points - a.points); + let ranking = []; + let i = 0; + while(i < result.length){ + let curr = i; + ranking.push([result[i]]); + i++; + for(let j = curr+1; j < result.length && result[j].points === result[curr].points; j++){ + ranking[curr].push(result[j]); + ranking.push([]); + i++; + } + } + return ranking; } + + let showModal = $state(false); + let ranking = $derived(showModal ? evaluate() : []); </script> <Breadcrumb class="mb-4"> @@ -61,6 +77,28 @@ <BreadcrumbItem href="/admin/rallye/points">Punkte</BreadcrumbItem> </Breadcrumb> +<Modal bind:open={showModal} outsideclose autoclose> + <Heading tag="h2" customSize="text-2xl" class="text-center mb-3">Auswertung</Heading> + <Heading tag="h3" customSize="text-xl" class="mb-2">Erster Platz</Heading> + <List list="none"> + {#each ranking[0] as {tutorial, points}} + <Li>{tutorial.name} ({points} Punkte)</Li> + {/each} + </List> + <Heading tag="h3" customSize="text-xl" class="mb-2">Zweiter Platz</Heading> + <List list="none"> + {#each ranking[1] as {tutorial, points}} + <Li>{tutorial.name} ({points} Punkte)</Li> + {/each} + </List> + <Heading tag="h3" customSize="text-xl" class="mb-2">Dritter Platz</Heading> + <List list="none"> + {#each ranking[2] as {tutorial, points}} + <Li>{tutorial.name} ({points} Punkte)</Li> + {/each} + </List> +</Modal> + {#each data.tutorials as tutorial} {#each data.stations as station} <form id="form-{station.id}/{tutorial.id}" method="post" action="?/updateStation"> @@ -73,6 +111,8 @@ </form> {/each} +<Button on:click={() => showModal = true} class="mb-4">Gewinner auswerten</Button> + <Table> <TableHead> <TableHeadCell></TableHeadCell> @@ -82,6 +122,7 @@ </TableHead> <TableBody> {#each data.stations as station, stationIndex} + {@const normalized = normalizeStation(stationIndex)} <TableBodyRow> <TableBodyCell>{station.name}</TableBodyCell> {#each data.tutorials as tutorial, tutorialIndex} diff --git a/src/routes/admin/schedule/+page.svelte b/src/routes/admin/schedule/+page.svelte index 0d680d740c97d85cd1aa1be48425597a5c2b2cf2..ab9f572b5f54d13a1971ab9c1cbda614fdd76b55 100644 --- a/src/routes/admin/schedule/+page.svelte +++ b/src/routes/admin/schedule/+page.svelte @@ -7,14 +7,14 @@ import { locale, locales, LL } from "$lib/i18n/i18n"; import { addMessage } from "$lib/messages.js"; - export let data; + let { data } = $props(); function pad(num: number){ return num.toString().padStart(2, "0"); } - let showModal = false; - let scheduleToDelete: Schedule; + let showModal = $state(false); + let scheduleToDelete: Schedule|undefined = $state(); function confirmDeleteSchedule(schedule: Schedule){ scheduleToDelete = schedule; showModal = true; @@ -25,7 +25,7 @@ }); } - let brandingImageElement: HTMLImageElement; + let brandingImageElement: HTMLImageElement|undefined = $state(); </script> <Breadcrumb class="mb-4"> @@ -48,7 +48,7 @@ }}> <img src="/stundenplaene/branding.png" class="mb-2 max-h-28" bind:this={brandingImageElement} alt="Branding für Stundenpläne" /> <Input type="file" name="image" accept="image/png" on:change={e=>{ - brandingImageElement.src = URL.createObjectURL(e.target?.files[0]); + brandingImageElement!.src = URL.createObjectURL(e.target?.files[0]); }} /> <Button type="submit">Speichern</Button> </form> @@ -221,12 +221,12 @@ {#if data.user.permissions.has(Permission.UPDATE_SCHEDULES)} <div class="mb-6"> - <form method="post" action="?/createSchedule" use:enhance={()=>({result})=>{ + <form method="post" action="?/createSchedule" use:enhance={()=>({result, update})=>{ if(result.type === "success"){ - addMessage({type: "success", text: "Einstellungen gespeichert"}); - invalidateAll(); + addMessage({type: "success", text: "Stundenplan erstellt"}); + update(); }else{ - addMessage({type: "error", text: "Fehler beim Speichern der Einstellungen"}); + addMessage({type: "error", text: "Fehler beim Erstellen des Stundenplans"}); console.error(result); } }}> @@ -282,10 +282,10 @@ <Modal open={showModal} title="Bestätigen" autoclose outsideclose> <P> - Soll der Stundenplan für {scheduleToDelete.studyProgram.name[$locale]} wirklich gelöscht werden? + Soll der Stundenplan für {scheduleToDelete!.studyProgram.name[$locale]} wirklich gelöscht werden? </P> <svelte:fragment slot="footer"> <Button>Behalten</Button> - <Button color="alternative" on:click={()=>deleteSchedule(scheduleToDelete.id)}>Löschen</Button> + <Button color="alternative" on:click={()=>deleteSchedule(scheduleToDelete!.id)}>Löschen</Button> </svelte:fragment> </Modal> diff --git a/src/routes/admin/schedule/[id=number]/+page.svelte b/src/routes/admin/schedule/[id=number]/+page.svelte index b164378427e56dee7d92bdcfa446c9871ffeaa23..c0bb1d7bf2bccfa4fee12be37052b14c1ca907c6 100644 --- a/src/routes/admin/schedule/[id=number]/+page.svelte +++ b/src/routes/admin/schedule/[id=number]/+page.svelte @@ -5,7 +5,8 @@ import type { Timeslot } from "$lib/server/database/entities/Schedule.entity.js"; import { addMessage } from "$lib/messages.js"; - export let data; + let { data } = $props(); + const schedule = Object.assign(data.schedule, {timeslots: data.schedule.timeslots.sort((a,b)=>a.date.localeCompare(b.date))}); function pad(num: number){ diff --git a/src/routes/admin/schedule/fonts/+page.svelte b/src/routes/admin/schedule/fonts/+page.svelte index 051a881294ace29220f242028326661e3f9e68fe..4f0abbbfaeee5b5151f25c7e562b3609aee4b08a 100644 --- a/src/routes/admin/schedule/fonts/+page.svelte +++ b/src/routes/admin/schedule/fonts/+page.svelte @@ -4,7 +4,8 @@ import { addMessage } from "$lib/messages.js"; import { Breadcrumb, BreadcrumbItem, Button, Input, Label, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell } from "flowbite-svelte"; - export let data; + let { data } = $props(); + function transformFontName(filename: string): string{ // remove file extension // add space before the last one of consequent capital letters @@ -17,7 +18,7 @@ .replace(/([A-Z])(?=[A-Z][a-z])/g, "$1 ") .replace(/([a-z])(?=[A-Z])/g, "$1 "); } - let fontName = ""; + let fontName = $state(""); </script> <Breadcrumb class="mb-4"> diff --git a/src/routes/admin/setup/+page.server.ts b/src/routes/admin/setup/+page.server.ts index e5af9ebbff13709d9ba081114f3eda818e17b1ad..6d0c9e56f70e16b29a094d0bc6cb32151fa780f9 100644 --- a/src/routes/admin/setup/+page.server.ts +++ b/src/routes/admin/setup/+page.server.ts @@ -102,11 +102,13 @@ export const actions = { replyTo: "-", subject: { de: "", en: "" }, text: { de: "", en: "" }, + type: "tutor", }, trainingInformation: { replyTo: "-", subject: { de: "", en: "" }, text: { de: "", en: "" }, + type: "tutor", }, }, trainingMailReminderDays: 7, diff --git a/src/routes/admin/studyprogram/+page.svelte b/src/routes/admin/studyprogram/+page.svelte index 63a21f64b824d139f8ef7479a84594ff746955aa..c5dc601ba10afc520aee0c9c9ce2d46adfb3024c 100644 --- a/src/routes/admin/studyprogram/+page.svelte +++ b/src/routes/admin/studyprogram/+page.svelte @@ -6,7 +6,7 @@ import type { StudyProgram } from '$lib/server/database/entities/StudyProgram.entity.js'; import { Breadcrumb, BreadcrumbItem, Button, Input, Label, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell } from 'flowbite-svelte'; - export let data; + let { data } = $props(); function confirmStudyProgramDelete(studyProgram: StudyProgram) { if (confirm('Wirklich löschen?')) { // TODO @@ -38,10 +38,10 @@ <input type="hidden" name="id" value={studyProgram.id} /> </form> {/each} -<form method="post" action="?/createStudyProgram" id="new" use:enhance={()=>({result})=>{ +<form method="post" action="?/createStudyProgram" id="new" use:enhance={()=>({result, update})=>{ if(result.type === "success"){ addMessage({type: "success", text: "Studiengang erstellt"}); - invalidateAll(); + update(); }else{ addMessage({type: "error", text: "Fehler beim Erstellen"}); console.error(result); diff --git a/src/routes/admin/templates/+page.server.ts b/src/routes/admin/templates/+page.server.ts index 608799ffb9adfc176d087ddea6e91f2087002330..9bd9ab2b684ddac6f8fb479ecbea8f47365c012f 100644 --- a/src/routes/admin/templates/+page.server.ts +++ b/src/routes/admin/templates/+page.server.ts @@ -1,9 +1,9 @@ import { locales } from '$lib/i18n/i18n'; -import type { MailTemplate } from '$lib/mail'; +import { configProps, type MailTemplate } from '$lib/mail'; import { Config } from '$lib/server/database/entities/Config.entity'; import { StudyProgram } from '$lib/server/database/entities/StudyProgram.entity'; import { TutorTraining } from '$lib/server/database/entities/TutorTraining.entity'; -import type { Localized } from '$lib/utils'; +import { pick, type Localized } from '$lib/utils'; import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; import { Permission } from '$lib/perms'; @@ -17,10 +17,11 @@ export const load = (async () => { trainingMailReminderDays: config.trainingMailReminderDays, studyPrograms: studyPrograms.map(sp => ({ id: sp.id, name: sp.name })), trainings: trainings.map(t => ({ id: t.id, date: t.date, location: t.location })), + config: pick(config, configProps as unknown as (keyof Config)[]), }; }) satisfies PageServerLoad; -function parseMail(data: FormData): MailTemplate { +function parseMail(data: FormData): Pick<MailTemplate, "replyTo"|"subject"|"text"> { const replyTo = data.get('replyTo'); if (typeof replyTo !== 'string') error(400, 'Missing replyTo'); const subject = {} as Localized; diff --git a/src/routes/admin/templates/+page.svelte b/src/routes/admin/templates/+page.svelte index f760d6598b7e852d5d9bb482310bfbf0d06b3333..d63f358edafe6e0150a63c178f77b49a67a5ac32 100644 --- a/src/routes/admin/templates/+page.svelte +++ b/src/routes/admin/templates/+page.svelte @@ -40,6 +40,8 @@ buttonText="Speichern" buttonType="submit" disabled={!canEdit} + config={data.config} + type={templates.tutorRegistered.type} /> </form> </TabItem> @@ -69,6 +71,8 @@ buttonText="Speichern" buttonType="submit" disabled={!canEdit} + config={data.config} + type={templates.trainingInformation.type} /> </TabItem> </Tabs> diff --git a/src/routes/admin/tutor/+page.svelte b/src/routes/admin/tutor/+page.svelte index 983573156ee5d6444bd8459cbd54acd2af63aec9..18206e1e69a287886ce4e9ac020b03fa3f215f2c 100644 --- a/src/routes/admin/tutor/+page.svelte +++ b/src/routes/admin/tutor/+page.svelte @@ -7,6 +7,7 @@ import type { Tutor } from "$lib/server/database/entities/Tutor.entity"; export let data; + let sortedTutors = data.tutors; $: dateFormatter = new Intl.DateTimeFormat($locale, { day: "2-digit", month: "2-digit", year: "numeric" }); diff --git a/src/routes/admin/tutor/mail/+page.server.ts b/src/routes/admin/tutor/mail/+page.server.ts index 89af0bede7e18e9a32f1d0a8029474c66fad9c21..ed6c25077f93a14b3b8910de60857ddd1682a3ad 100644 --- a/src/routes/admin/tutor/mail/+page.server.ts +++ b/src/routes/admin/tutor/mail/+page.server.ts @@ -1,7 +1,10 @@ +import { configProps } from '$lib/mail'; import { Permission } from '$lib/perms'; +import { Config } from '$lib/server/database/entities/Config.entity'; import { StudyProgram } from '$lib/server/database/entities/StudyProgram.entity'; import { Tutor } from '$lib/server/database/entities/Tutor.entity'; import { TutorTraining } from '$lib/server/database/entities/TutorTraining.entity'; +import { pick } from '$lib/utils'; import type { PageServerLoad } from './$types'; export const load = (async event => { @@ -9,10 +12,12 @@ export const load = (async event => { const tutors = event.locals.user.permissions.has(Permission.VIEW_TUTORS) ? (await Tutor.getAll()).map(t=>({id: t.id, firstname: t.firstname, lastname: t.lastname})) : null; const degrees = (await StudyProgram.getAll()).map(s=>({id: s.id, name: s.name})); const trainings = (await TutorTraining.getAll()).map(t=>({id: t.id, date: t.date, location: t.location})); + const config = Config.get(); return { studyPrograms, tutors, degrees, trainings, + config: pick(config, configProps as unknown as (keyof Config)[]), }; }) satisfies PageServerLoad; diff --git a/src/routes/admin/tutor/mail/+page.svelte b/src/routes/admin/tutor/mail/+page.svelte index 17ef6d2c7ae0c76e7b1c445177d9fe7bebb15c3e..fbe83ebace82e128a3da6fc6b27d256c6b52085c 100644 --- a/src/routes/admin/tutor/mail/+page.svelte +++ b/src/routes/admin/tutor/mail/+page.svelte @@ -81,4 +81,4 @@ {/each} <Button color="green" on:click={addFilter} class="mt-2">+</Button> -<MailInput bind:text={mailText} bind:subject={mailSubject} bind:replyTo={mailReplyTo} replyToOptions={[data.user.email, "esa@fsmpi.rwth-aachen.de"]} allowCustomReplyTo studyPrograms={data.studyPrograms} trainings={data.trainings} onsubmit={sendMail} /> +<MailInput bind:text={mailText} bind:subject={mailSubject} bind:replyTo={mailReplyTo} replyToOptions={[data.user.email, "esa@fsmpi.rwth-aachen.de"]} allowCustomReplyTo studyPrograms={data.studyPrograms} trainings={data.trainings} onsubmit={sendMail} config={data.config} type="tutor" /> diff --git a/src/routes/admin/tutor/training/new/+page.server.ts b/src/routes/admin/tutor/training/new/+page.server.ts index 04a3a9f98575ca527c4d535d87587e7b9896d552..356790e81eae82301c2e136107c3d1ee6c62108a 100644 --- a/src/routes/admin/tutor/training/new/+page.server.ts +++ b/src/routes/admin/tutor/training/new/+page.server.ts @@ -18,7 +18,7 @@ export const actions = { if(!Number.isInteger(maxParticipants) || maxParticipants < 0) error(400, "Invalid maxParticipants"); if(typeof notes !== "string") error(400, "Invalid notes"); if(internal !== null && internal !== "on") error(400, "Invalid internal"); - const id = TutorTraining.create({ date, location, maxParticipants, notes, internal: internal === "on" }); + const id = await TutorTraining.create({ date, location, maxParticipants, notes, internal: internal === "on" }); return { id }; }, }; diff --git a/src/routes/admin/uploads/+page.server.ts b/src/routes/admin/uploads/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7a464fc16a7762fdfb48aa2a627145d47b9d440 --- /dev/null +++ b/src/routes/admin/uploads/+page.server.ts @@ -0,0 +1,10 @@ +import type { PageServerLoad } from './$types'; +import fs from "node:fs/promises"; + +export const load = (async () => { + await fs.mkdir("static/uploads", { recursive: true }); + const filenames = await fs.readdir("static/uploads"); + return { + filenames: filenames.map(f=>`/uploads/${f}`), + }; +}) satisfies PageServerLoad; diff --git a/src/routes/admin/uploads/+page.svelte b/src/routes/admin/uploads/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..3f7ff90ec57102a3f5661daebc2a932afbcd0373 --- /dev/null +++ b/src/routes/admin/uploads/+page.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import { Li, List, P } from "flowbite-svelte"; + + let { data } = $props(); +</script> + +{#if data.filenames.length === 0} +<P>Bisher wurden noch keine Bilder hochgeladen.</P> +{:else} +<List list="none"> + {#each data.filenames as filename} + <Li class="mb-4"> + <div> + <a href={filename} download> + <!-- svelte-ignore a11y_missing_attribute --> + <img src={filename} class="w-36 max-h-36 max-w-full object-contain inline-block" /> + </a> + </div> + </Li> + {/each} +</List> +{/if}