Further improvements on the open hours endpoints

This commit is contained in:
2026-02-24 21:52:16 +02:00
parent 082eb2575e
commit bc4c849590
11 changed files with 425 additions and 18 deletions

View File

@@ -1,4 +1,4 @@
import { query } from "@solidjs/router";
import { action, query } from "@solidjs/router";
const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:5013";
@@ -19,6 +19,10 @@ async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
throw new Error(`API ${response.status}: ${text || response.statusText}`);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
@@ -27,3 +31,60 @@ export const queryApiVersion = query(async () => {
const data = await fetchApi<{ version: string }>("/");
return data.version;
}, "api-version");
export type LokOpenHours = {
id: number;
name: string;
version: string;
paragraph1: string;
paragraph2: string;
paragraph3: string;
paragraph4: string;
kitchenNotice: string;
};
export const queryLokOpenHours = query(async (_refreshKey = 0) => {
"use server";
return await fetchApi<LokOpenHours[]>("/lok/open-hours");
}, "lok-open-hours");
export const createLokOpenHours = action(async (formData: FormData) => {
"use server";
const name = String(formData.get("name") ?? "").trim();
if (!name) {
throw new Error("Open hours version name is required.");
}
const payload = {
id: 0,
name,
version: new Date().toISOString(),
paragraph1: String(formData.get("paragraph1") ?? ""),
paragraph2: String(formData.get("paragraph2") ?? ""),
paragraph3: String(formData.get("paragraph3") ?? ""),
paragraph4: String(formData.get("paragraph4") ?? ""),
kitchenNotice: String(formData.get("kitchenNotice") ?? ""),
} satisfies LokOpenHours;
return await fetchApi<LokOpenHours>("/lok/open-hours", {
method: "POST",
body: JSON.stringify(payload),
});
});
export const deleteLokOpenHours = action(async (formData: FormData) => {
"use server";
const idValue = String(formData.get("id") ?? "").trim();
const id = Number(idValue);
if (!Number.isFinite(id) || id <= 0) {
throw new Error("Open hours id is required for delete.");
}
await fetchApi<void>(`/lok/open-hours/${id}`, {
method: "DELETE",
});
return { deleted: true };
});

View File

@@ -0,0 +1,228 @@
import { createAsync, useSubmission } from "@solidjs/router";
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
import { createLokOpenHours, deleteLokOpenHours, queryLokOpenHours } from "~/api";
import { t } from "~/i18n";
const NEW_VERSION_OPTION = "__new__";
export default function OpenHoursForm() {
const [refreshKey, setRefreshKey] = createSignal(0);
const openHours = createAsync(() => queryLokOpenHours(refreshKey()).catch(() => []));
const createSubmission = useSubmission(createLokOpenHours);
const deleteSubmission = useSubmission(deleteLokOpenHours);
const [selectedVersion, setSelectedVersion] = createSignal("");
const [name, setName] = createSignal("");
const [paragraph1, setParagraph1] = createSignal("");
const [paragraph2, setParagraph2] = createSignal("");
const [paragraph3, setParagraph3] = createSignal("");
const [paragraph4, setParagraph4] = createSignal("");
const [kitchenNotice, setKitchenNotice] = createSignal("");
const latestFive = createMemo(() => openHours() ?? []);
const selectedOpenHours = createMemo(() =>
latestFive().find(version => String(version.id) === selectedVersion())
);
createEffect(() => {
if (!createSubmission.result) return;
setRefreshKey(previous => previous + 1);
setSelectedVersion("");
});
createEffect(() => {
if (!deleteSubmission.result) return;
setRefreshKey(previous => previous + 1);
setSelectedVersion("");
});
createEffect(() => {
const versions = latestFive();
const current = selectedVersion();
if (versions.length === 0) {
if (current !== NEW_VERSION_OPTION) {
setSelectedVersion(NEW_VERSION_OPTION);
}
return;
}
if (current === NEW_VERSION_OPTION) {
return;
}
const hasCurrent = versions.some(version => String(version.id) === current);
if (!current || !hasCurrent) {
setSelectedVersion(String(versions[0].id));
}
});
const reuseSelected = () => {
const selected = selectedOpenHours();
if (!selected) {
setName("");
setParagraph1("");
setParagraph2("");
setParagraph3("");
setParagraph4("");
setKitchenNotice("");
return;
}
setName(selected.name);
setParagraph1(selected.paragraph1);
setParagraph2(selected.paragraph2);
setParagraph3(selected.paragraph3);
setParagraph4(selected.paragraph4);
setKitchenNotice(selected.kitchenNotice);
};
createEffect(() => {
reuseSelected();
});
return (
<section class="w-full max-w-3xl rounded-2xl border border-[#C99763] bg-[#F5D1A9] p-6 shadow-md">
<h2 class="text-2xl font-semibold text-[#4C250E]">{t("home.openHours.heading")}</h2>
<Show when={latestFive().length > 0} fallback={<p class="mt-3 text-[#70421E]">{t("home.openHours.empty")}</p>}>
<div class="mt-2 flex gap-2">
<select
id="open-hours-version"
class="min-w-0 flex-1 rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
value={selectedVersion()}
onInput={event => setSelectedVersion(event.currentTarget.value)}
>
<For each={latestFive()}>
{version => (
<option value={String(version.id)}>
{version.name || t("home.openHours.latest")} · {new Date(version.version).toLocaleString()}
</option>
)}
</For>
<option value={NEW_VERSION_OPTION}>{t("home.openHours.new")}</option>
</select>
</div>
<form action={deleteLokOpenHours} method="post" class="mt-3">
<input type="hidden" name="id" value={selectedOpenHours()?.id ?? ""} />
<button
type="submit"
disabled={!selectedOpenHours() || deleteSubmission.pending}
class="rounded-md border border-[#8E4F24] bg-[#EED5B8] px-4 py-2 text-[#70421E] hover:bg-[#E3A977] disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("home.openHours.delete")}
</button>
</form>
</Show>
<form action={createLokOpenHours} method="post" class="mt-5 space-y-3">
<div>
<label for="name" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.name")}</label>
<input
id="name"
name="name"
required
value={name()}
onInput={event => setName(event.currentTarget.value)}
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
<Show when={!name().trim()}>
<p class="mt-1 text-xs text-[#8E4F24]">{t("home.openHours.nameRequired")}</p>
</Show>
</div>
<div>
<label for="paragraph1" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.paragraph1")}</label>
<textarea
id="paragraph1"
name="paragraph1"
rows={2}
value={paragraph1()}
onInput={event => setParagraph1(event.currentTarget.value)}
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</div>
<div>
<label for="paragraph2" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.paragraph2")}</label>
<textarea
id="paragraph2"
name="paragraph2"
rows={2}
value={paragraph2()}
onInput={event => setParagraph2(event.currentTarget.value)}
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</div>
<div>
<label for="paragraph3" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.paragraph3")}</label>
<textarea
id="paragraph3"
name="paragraph3"
rows={2}
value={paragraph3()}
onInput={event => setParagraph3(event.currentTarget.value)}
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</div>
<div>
<label for="paragraph4" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.paragraph4")}</label>
<textarea
id="paragraph4"
name="paragraph4"
rows={2}
value={paragraph4()}
onInput={event => setParagraph4(event.currentTarget.value)}
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</div>
<div>
<label for="kitchenNotice" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.kitchenNotice")}</label>
<textarea
id="kitchenNotice"
name="kitchenNotice"
rows={2}
value={kitchenNotice()}
onInput={event => setKitchenNotice(event.currentTarget.value)}
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</div>
<div class="flex flex-wrap gap-3 pt-1">
<button
type="button"
disabled={!selectedOpenHours()}
onClick={reuseSelected}
class="rounded-md border border-[#A56C38] bg-[#EED5B8] px-4 py-2 text-[#70421E] hover:bg-[#E3A977] disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("home.openHours.reuse")}
</button>
<button
type="submit"
disabled={createSubmission.pending}
class="rounded-md border border-[#70421E] bg-[#8E4F24] px-4 py-2 text-[#FFF7EE] hover:bg-[#70421E] disabled:opacity-50 disabled:cursor-not-allowed"
>
{createSubmission.pending ? t("home.openHours.saving") : t("home.openHours.submit")}
</button>
</div>
<Show when={createSubmission.result}>
<p class="text-sm text-[#4C250E]">{t("home.openHours.saved")}</p>
</Show>
<Show when={deleteSubmission.result}>
<p class="text-sm text-[#4C250E]">{t("home.openHours.deleted")}</p>
</Show>
<Show when={createSubmission.error} keyed>
{error => <p class="text-sm text-[#8E4F24]">{error.message}</p>}
</Show>
<Show when={deleteSubmission.error} keyed>
{error => <p class="text-sm text-[#8E4F24]">{error.message}</p>}
</Show>
</form>
</section>
);
}

View File

@@ -15,9 +15,26 @@ const translations = {
"nav.language.en": "EN",
"meta.description": "SolidStart with-auth example",
"home.title": "Home",
"home.heading": "Hello World",
"home.heading": "KlAPI",
"home.signedInAs": "You are signed in as",
"home.logoAlt": "logo",
"home.openHours.heading": "Open hours versions",
"home.openHours.latest": "Latest",
"home.openHours.new": "New version",
"home.openHours.reuse": "Reuse selected",
"home.openHours.delete": "Delete selected",
"home.openHours.empty": "No open-hours versions found yet",
"home.openHours.name": "Version name",
"home.openHours.nameRequired": "Version name is required",
"home.openHours.paragraph1": "Paragraph 1",
"home.openHours.paragraph2": "Paragraph 2",
"home.openHours.paragraph3": "Paragraph 3",
"home.openHours.paragraph4": "Paragraph 4",
"home.openHours.kitchenNotice": "Kitchen notice",
"home.openHours.submit": "Add new version",
"home.openHours.saving": "Saving...",
"home.openHours.saved": "New version saved",
"home.openHours.deleted": "Version deleted",
"about.title": "About",
"about.apiVersion": "API version",
"about.loading": "Loading...",
@@ -47,9 +64,26 @@ const translations = {
"nav.language.en": "EN",
"meta.description": "SolidStart with-auth -esimerkki",
"home.title": "Etusivu",
"home.heading": "Hei maailma",
"home.heading": "KlAPI",
"home.signedInAs": "Olet kirjautunut käyttäjänä",
"home.logoAlt": "logo",
"home.openHours.heading": "Aukioloaikaversiot",
"home.openHours.latest": "Viimeisin",
"home.openHours.new": "Uusi versio",
"home.openHours.reuse": "Käytä valittua uudelleen",
"home.openHours.delete": "Poista valittu",
"home.openHours.empty": "Aukioloaikaversioita ei vielä löytynyt",
"home.openHours.name": "Version nimi",
"home.openHours.nameRequired": "Version nimi on pakollinen",
"home.openHours.paragraph1": "Kappale 1",
"home.openHours.paragraph2": "Kappale 2",
"home.openHours.paragraph3": "Kappale 3",
"home.openHours.paragraph4": "Kappale 4",
"home.openHours.kitchenNotice": "Keittiöhuomio",
"home.openHours.submit": "Lisää uusi versio",
"home.openHours.saving": "Tallennetaan...",
"home.openHours.saved": "Uusi versio tallennettu",
"home.openHours.deleted": "Versio poistettu",
"about.title": "Tietoja",
"about.apiVersion": "API-versio",
"about.loading": "Ladataan...",

View File

@@ -1,15 +1,14 @@
import { Title } from "@solidjs/meta";
import { useAuth } from "~/components/Context";
import OpenHoursForm from "~/components/OpenHoursForm";
import { t } from "~/i18n";
export default function Home() {
const { session } = useAuth();
return (
<main>
<Title>{t("home.title")}</Title>
<h1 class="text-center">{t("home.heading")}</h1>
<img src="/favicon.svg" alt={t("home.logoAlt")} class="w-28" />
<OpenHoursForm />
</main>
);
}