Rewrite with React after AI got stuck in some obscure state errors on SolidJS

This commit is contained in:
2026-03-02 22:04:58 +02:00
parent 81c4c70c51
commit 154b9b66ce
38 changed files with 4131 additions and 1878 deletions

View File

@@ -1,8 +0,0 @@
import { defineConfig } from "@solidjs/start/config";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
ssr: true, // false for client-side rendering only
server: { preset: "" }, // your deployment
vite: { plugins: [tailwindcss()] }
});

File diff suppressed because it is too large Load Diff

13
ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KlAPI UI</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2792
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,28 +2,29 @@
"name": "example-with-auth",
"type": "module",
"scripts": {
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start",
"dev": "vite",
"build": "vite build",
"start": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "biome lint src",
"lint:fix": "biome check --write src"
},
"dependencies": {
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.3",
"@solidjs/start": "^1.1.7",
"@types/node": "^25.2.0",
"solid-js": "^1.9.9",
"start-oauth": "^1.3.0",
"unstorage": "1.17.1",
"vinxi": "^0.5.8"
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"recoil": "^0.7.7"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"@biomejs/biome": "^1.9.4",
"@tailwindcss/vite": "^4.1.13",
"tailwindcss": "^4.1.13",
"vite": "^5.4.10",
"vitest": "^2.1.9"
},
"engines": {

View File

@@ -1,4 +1,3 @@
import { action, query } from "@solidjs/router";
import { buildApiUrl } from "./url";
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
@@ -22,15 +21,10 @@ async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
return (await response.json()) as T;
}
export const queryApiVersion = query(async () => {
"use server";
const data = await fetchApi<{ version: string }>("/");
return data.version;
}, "api-version");
export type LokOpenHours = {
id: number;
name: string;
isActive: boolean;
version: string;
paragraph1: string;
paragraph2: string;
@@ -39,14 +33,28 @@ export type LokOpenHours = {
kitchenNotice: string;
};
export const queryLokOpenHours = query(async (_refreshKey = 0) => {
"use server";
return await fetchApi<LokOpenHours[]>("/lok/open-hours");
}, "lok-open-hours");
export type LokOpenHoursInput = {
name: string;
paragraph1: string;
paragraph2: string;
paragraph3: string;
paragraph4: string;
kitchenNotice: string;
};
export const createLokOpenHours = action(async (formData: FormData) => {
"use server";
const name = String(formData.get("name") ?? "").trim();
export async function queryApiVersion(): Promise<string> {
const data = await fetchApi<{ version: string }>("/");
return data.version;
}
export async function queryLokOpenHours(): Promise<LokOpenHours[]> {
return await fetchApi<LokOpenHours[]>("/lok/open-hours");
}
export async function createLokOpenHours(
input: LokOpenHoursInput,
): Promise<LokOpenHours> {
const name = input.name.trim();
if (!name) {
throw new Error("Open hours version name is required.");
@@ -55,25 +63,54 @@ export const createLokOpenHours = action(async (formData: FormData) => {
const payload = {
id: 0,
name,
isActive: false,
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") ?? ""),
paragraph1: input.paragraph1,
paragraph2: input.paragraph2,
paragraph3: input.paragraph3,
paragraph4: input.paragraph4,
kitchenNotice: input.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);
export async function updateLokOpenHours(
id: number,
input: LokOpenHoursInput,
): Promise<LokOpenHours> {
if (!Number.isFinite(id) || id <= 0) {
throw new Error("Open hours id is required for update.");
}
const name = input.name.trim();
if (!name) {
throw new Error("Open hours version name is required.");
}
const payload = {
id,
name,
isActive: false,
version: new Date().toISOString(),
paragraph1: input.paragraph1,
paragraph2: input.paragraph2,
paragraph3: input.paragraph3,
paragraph4: input.paragraph4,
kitchenNotice: input.kitchenNotice,
} satisfies LokOpenHours;
return await fetchApi<LokOpenHours>(`/lok/open-hours/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
export async function deleteLokOpenHours(id: number): Promise<void> {
if (!Number.isFinite(id) || id <= 0) {
throw new Error("Open hours id is required for delete.");
}
@@ -81,6 +118,19 @@ export const deleteLokOpenHours = action(async (formData: FormData) => {
await fetchApi<void>(`/lok/open-hours/${id}`, {
method: "DELETE",
});
}
return { deleted: true };
});
export async function setActiveLokOpenHours(
id: number,
): Promise<{ id: number; isActive: boolean }> {
if (!Number.isFinite(id) || id <= 0) {
throw new Error("Open hours id is required for setting active version.");
}
return await fetchApi<{ id: number; isActive: boolean }>(
`/lok/open-hours/${id}/active`,
{
method: "PUT",
},
);
}

View File

@@ -1,4 +1,7 @@
const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:5013";
const API_BASE_URL =
(typeof process !== "undefined" ? process.env?.API_BASE_URL : undefined) ??
import.meta.env.VITE_API_BASE_URL ??
"/api";
export const buildApiUrl = (path: string) =>
`${API_BASE_URL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;

View File

@@ -5,7 +5,7 @@
font-family: "Manrope", "Avenir Next", "Segoe UI", sans-serif;
}
#app {
#root {
user-select: none;
}

View File

@@ -1,41 +1,57 @@
import { type RouteDefinition, Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Meta, MetaProvider } from "@solidjs/meta";
import { createEffect, Suspense } from "solid-js";
import { querySession } from "./auth";
import Auth from "./components/Context";
import Nav from "./components/Nav";
import ErrorNotification from "./components/Error";
import { language, t } from "~/i18n";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { useEffect } from "react";
import { useSetRecoilState } from "recoil";
import Nav from "~/components/Nav";
import Home from "~/routes/index";
import About from "~/routes/about";
import Login from "~/routes/login";
import NotFound from "~/routes/[...404]";
import Toasts from "~/components/Toasts";
import { initializeLanguage, useLanguage } from "~/i18n";
import { sessionAtom } from "~/state/appState";
import "./app.css";
export const route: RouteDefinition = {
preload: ({ location }) => querySession(location.pathname)
};
function AppShell() {
const { language, setLanguage } = useLanguage();
const setSession = useSetRecoilState(sessionAtom);
export default function App() {
createEffect(() => {
if (typeof document !== "undefined") {
document.documentElement.lang = language();
useEffect(() => {
initializeLanguage(setLanguage);
}, [setLanguage]);
useEffect(() => {
const storedEmail = localStorage.getItem("session-email");
if (!storedEmail) {
setSession(null);
return;
}
});
setSession({ email: storedEmail });
}, [setSession]);
useEffect(() => {
document.documentElement.lang = language;
}, [language]);
return (
<Router
root={props => (
<MetaProvider>
<Meta name="description" content={t("meta.description")} />
<Auth>
<Suspense>
<Nav />
{props.children}
<ErrorNotification />
</Suspense>
</Auth>
</MetaProvider>
)}
>
<FileRoutes />
</Router>
<>
<Nav />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/login" element={<Login />} />
<Route path="*" element={<NotFound />} />
<Route path="/index.html" element={<Navigate to="/" replace />} />
</Routes>
<Toasts />
</>
);
}
export default function App() {
return (
<BrowserRouter>
<AppShell />
</BrowserRouter>
);
}

View File

@@ -1,28 +0,0 @@
import { createStorage } from "unstorage";
import fsLiteDriver from "unstorage/drivers/fs-lite";
interface User {
id: number;
email: string;
password?: string;
}
const storage = createStorage({ driver: fsLiteDriver({ base: "./.data" }) });
export async function createUser(data: Pick<User, "email" | "password">) {
const users = (await storage.getItem<User[]>("users:data")) ?? [];
const counter = (await storage.getItem<number>("users:counter")) ?? 1;
const user: User = { id: counter, ...data };
await Promise.all([
storage.setItem("users:data", [...users, user]),
storage.setItem("users:counter", counter + 1)
]);
return user;
}
export async function findUser({ email, id }: { email?: string; id?: number }) {
const users = (await storage.getItem<User[]>("users:data")) ?? [];
if (id) return users.find(u => u.id === id);
if (email) return users.find(u => u.email === email);
return undefined;
}

View File

@@ -1,40 +0,0 @@
import { action, query, redirect } from "@solidjs/router";
import { getLanguageFromFormData, getTranslations } from "~/i18n";
import { getSession, passwordLogin } from "./server";
// Define routes that require being logged in
const PROTECTED_ROUTES = ["/"];
const isProtected = (path: string) =>
PROTECTED_ROUTES.some((route) =>
route.endsWith("/*")
? path.startsWith(route.slice(0, -2))
: path === route || path.startsWith(`${route}/`),
);
export const querySession = query(async (path: string) => {
"use server";
const { data } = await getSession();
if (path === "/login" && data.id) return redirect("/");
if (data.id) return data;
if (isProtected(path)) throw redirect(`/login?redirect=${path}`);
return null;
}, "session");
export const formLogin = action(async (formData: FormData) => {
"use server";
const lang = getLanguageFromFormData(formData);
const translations = getTranslations(lang);
const email = formData.get("email");
const password = formData.get("password");
if (typeof email !== "string" || typeof password !== "string")
return new Error(translations["errors.requiredEmailPassword"]);
return await passwordLogin(email.trim().toLowerCase(), password, lang);
});
export const logout = action(async () => {
"use server";
const session = await getSession();
await session.update({ id: undefined });
throw redirect("/login", { revalidate: "session" });
});

View File

@@ -1,102 +0,0 @@
import { redirect } from "@solidjs/router";
import { useSession } from "vinxi/http";
import { getRandomValues, subtle, timingSafeEqual } from "node:crypto";
import { createUser, findUser } from "./db";
import type { Language } from "~/i18n";
import { getTranslations } from "~/i18n";
export interface Session {
id: number;
email: string;
}
export const getSessionSecret = () => {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error("SESSION_SECRET is required");
}
return secret;
};
export const getSession = () =>
useSession<Session>({
password: getSessionSecret(),
});
export async function createSession(user: Session, redirectTo?: string) {
const validDest = redirectTo?.[0] === "/" && redirectTo[1] !== "/";
const session = await getSession();
await session.update(user);
return redirect(validDest ? redirectTo : "/");
}
async function createHash(password: string) {
const salt = getRandomValues(new Uint8Array(16));
const saltHex = Buffer.from(salt).toString("hex");
const key = await subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: 100_000,
hash: "SHA-512",
},
await subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"],
),
512,
);
const hash = Buffer.from(key).toString("hex");
return `${saltHex}:${hash}`;
}
async function checkPassword(
storedPassword: string,
providedPassword: string,
lang: Language,
) {
const translations = getTranslations(lang);
const [storedSalt, storedHash] = storedPassword.split(":");
if (!storedSalt || !storedHash)
throw new Error(translations["errors.invalidStoredPasswordFormat"]);
const key = await subtle.deriveBits(
{
name: "PBKDF2",
salt: Buffer.from(storedSalt, "hex"),
iterations: 100_000,
hash: "SHA-512",
},
await subtle.importKey(
"raw",
new TextEncoder().encode(providedPassword),
"PBKDF2",
false,
["deriveBits"],
),
512,
);
const hash = Buffer.from(key);
const stored = Buffer.from(storedHash, "hex");
if (stored.length !== hash.length || !timingSafeEqual(stored, hash))
throw new Error(translations["errors.invalidEmailOrPassword"]);
}
export async function passwordLogin(
email: string,
password: string,
lang: Language = "en",
) {
const translations = getTranslations(lang);
let user = await findUser({ email });
if (!user)
user = await createUser({
email,
password: await createHash(password),
});
else if (!user.password) throw new Error(translations["errors.oauthOnly"]);
else await checkPassword(user.password, password, lang);
return createSession(user);
}

View File

@@ -1,28 +0,0 @@
import { createAsync, useLocation, type AccessorWithLatest } from "@solidjs/router";
import { createContext, useContext, type ParentProps } from "solid-js";
import { logout, querySession } from "../auth";
import type { Session } from "../auth/server";
const Context = createContext<{
session: AccessorWithLatest<Session | null | undefined>;
signedIn: () => boolean;
logout: typeof logout;
}>();
export default function Auth(props: ParentProps) {
const location = useLocation();
const session = createAsync(() => querySession(location.pathname), {
deferStream: true
});
const signedIn = () => Boolean(session()?.id);
return (
<Context.Provider value={{ session, signedIn, logout }}>{props.children}</Context.Provider>
);
}
export function useAuth() {
const context = useContext(Context);
if (!context) throw new Error("useAuth must be used within Auth context");
return context;
}

View File

@@ -1,35 +0,0 @@
import { useSearchParams } from "@solidjs/router";
import { createEffect, onCleanup, Show } from "solid-js";
import { t } from "~/i18n";
import { X } from "./Icons";
export default function ErrorNotification() {
const [searchParams, setSearchParams] = useSearchParams();
createEffect(() => {
if (searchParams.error) {
const timer = setTimeout(() => setSearchParams({ error: "" }), 5000);
onCleanup(() => clearTimeout(timer));
}
});
return (
<Show when={typeof searchParams.error === "string" && searchParams.error} keyed>
{msg => (
<aside class="flex items-start gap-3 fixed bottom-4 left-4 max-w-sm bg-[#F5D1A9] border border-[#C99763] rounded-xl p-4 shadow-lg z-50 transition-all duration-300 text-sm">
<div>
<strong class="font-medium text-[#4C250E]">{t("error.title")}</strong>
<p class="text-[#70421E] mt-1 select-text">{msg}</p>
</div>
<button
type="button"
onclick={() => setSearchParams({ error: "" })}
class="text-[#A56C38] hover:text-[#4C250E] transition-colors"
>
<X class="w-4 h-4" />
</button>
</aside>
)}
</Show>
);
}

View File

@@ -1,17 +0,0 @@
type Icon = { class: string };
export const X = (props: Icon) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class={props.class}
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);

View File

@@ -1,69 +1,73 @@
import { useMatch } from "@solidjs/router";
import { Show } from "solid-js";
import { useAuth } from "~/components/Context";
import { language, setLanguage, t } from "~/i18n";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useRecoilState } from "recoil";
import { sessionAtom } from "~/state/appState";
import { useLanguage, useT } from "~/i18n";
export default function Nav() {
const { session, signedIn, logout } = useAuth();
const isHome = useMatch(() => "/");
const isAbout = useMatch(() => "/about");
const location = useLocation();
const navigate = useNavigate();
const t = useT();
const { language, setLanguage } = useLanguage();
const [session, setSession] = useRecoilState(sessionAtom);
const signOut = () => {
setSession(null);
localStorage.removeItem("session-email");
navigate("/login");
};
const linkClass = (path: string) =>
`px-3 py-2 text-[#F5D1A9] uppercase transition-colors duration-200 border-b-2 ${location.pathname === path
? "border-[#E3A977] text-[#FFF7EE]"
: "border-transparent hover:text-[#FFF7EE]"
}`;
return (
<nav class="fixed top-0 left-0 w-full bg-[#70421E] shadow-sm z-50 flex items-center justify-between py-3 px-4 font-medium text-sm">
<a
href="/"
class={`px-3 py-2 text-[#F5D1A9] uppercase transition-colors duration-200 border-b-2 ${isHome() ? "border-[#E3A977] text-[#FFF7EE]" : "border-transparent hover:text-[#FFF7EE]"
}`}
>
{t("nav.home")}
</a>
<a
href="/about"
class={`px-3 py-2 text-[#F5D1A9] uppercase transition-colors duration-200 border-b-2 ${isAbout() ? "border-[#E3A977] text-[#FFF7EE]" : "border-transparent hover:text-[#FFF7EE]"
}`}
>
{t("nav.about")}
</a>
<div class="ml-auto flex items-center gap-2">
<b class="font-large text-[#F5D1A9]">{session()?.email}</b>
<div class="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
<nav className="fixed top-0 left-0 z-50 flex w-full items-center justify-between bg-[#70421E] px-4 py-3 text-sm font-medium shadow-sm">
<Link to="/" className={linkClass("/")}>{t("nav.home")}</Link>
<Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link>
<div className="ml-auto flex items-center gap-2">
<b className="text-[#F5D1A9]">{session?.email ?? ""}</b>
<div className="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
<button
type="button"
onclick={() => setLanguage("fi")}
class={`px-2 py-1 text-xs rounded ${language() === "fi" ? "bg-[#E3A977] text-[#4C250E]" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
onClick={() => setLanguage("fi")}
className={`rounded px-2 py-1 text-xs ${language === "fi"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.fi")}
</button>
<button
type="button"
onclick={() => setLanguage("en")}
class={`px-2 py-1 text-xs rounded ${language() === "en" ? "bg-[#E3A977] text-[#4C250E]" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
onClick={() => setLanguage("en")}
className={`rounded px-2 py-1 text-xs ${language === "en"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.en")}
</button>
</div>
<Show
when={signedIn()}
fallback={
<a
href="/login"
class="px-4 py-2 text-[#F5D1A9] bg-[#8E4F24] border border-[#A56C38] rounded-md hover:bg-[#A56C38] hover:text-[#FFF7EE] focus:outline-none transition-colors duration-200"
>
{t("nav.login")}
</a>
}
>
<form action={logout} method="post">
<button
type="submit"
class="px-4 py-2 text-[#F5D1A9] bg-[#8E4F24] border border-[#A56C38] rounded-md hover:bg-[#A56C38] hover:text-[#FFF7EE] focus:outline-none transition-colors duration-200"
>
{t("nav.signOut")}
</button>
</form>
</Show>
{session ? (
<button
type="button"
onClick={signOut}
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{t("nav.signOut")}
</button>
) : (
<Link
to="/login"
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{t("nav.login")}
</Link>
)}
</div>
</nav>
);

View File

@@ -1,228 +1,416 @@
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";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRecoilState } from "recoil";
import {
createLokOpenHours,
deleteLokOpenHours,
queryLokOpenHours,
setActiveLokOpenHours,
updateLokOpenHours,
type LokOpenHours,
} from "~/api";
import { useT } from "~/i18n";
import { openHoursAtom, toastsAtom, type Toast } from "~/state/appState";
const NEW_VERSION_OPTION = "__new__";
type FormState = {
name: string;
paragraph1: string;
paragraph2: string;
paragraph3: string;
paragraph4: string;
kitchenNotice: string;
};
const EMPTY_FORM: FormState = {
name: "",
paragraph1: "",
paragraph2: "",
paragraph3: "",
paragraph4: "",
kitchenNotice: "",
};
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 t = useT();
const [versions, setVersions] = useRecoilState(openHoursAtom);
const [, setToasts] = useRecoilState(toastsAtom);
const latestFive = createMemo(() => openHours() ?? []);
const [selectedVersion, setSelectedVersion] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [editingVersionId, setEditingVersionId] = useState("");
const [deletingId, setDeletingId] = useState("");
const [activatingId, setActivatingId] = useState("");
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const selectedOpenHours = createMemo(() =>
latestFive().find(version => String(version.id) === selectedVersion())
);
const initializedRef = useRef(false);
const toastGuardRef = useRef<Record<string, number>>({});
createEffect(() => {
if (!createSubmission.result) return;
setRefreshKey(previous => previous + 1);
setSelectedVersion("");
});
const isUpdateMode = editingVersionId.length > 0;
createEffect(() => {
if (!deleteSubmission.result) return;
setRefreshKey(previous => previous + 1);
setSelectedVersion("");
});
const pushToast = (message: string, kind: Toast["kind"], dedupeKey: string) => {
const now = Date.now();
const lastShown = toastGuardRef.current[dedupeKey] ?? 0;
if (now - lastShown < 800) return;
createEffect(() => {
const versions = latestFive();
const current = selectedVersion();
toastGuardRef.current[dedupeKey] = now;
setToasts((previous) => [
...previous,
{
id: now + Math.floor(Math.random() * 1000),
message,
kind,
leaving: false,
},
]);
};
const clearForm = () => {
setForm(EMPTY_FORM);
};
const hydrate = async () => {
const loaded = await queryLokOpenHours().catch(() => []);
setVersions(loaded);
initializedRef.current = true;
};
useEffect(() => {
if (initializedRef.current) return;
void hydrate();
}, []);
useEffect(() => {
if (versions.length === 0) {
if (current !== NEW_VERSION_OPTION) {
setSelectedVersion(NEW_VERSION_OPTION);
if (selectedVersion) setSelectedVersion("");
return;
}
const activeVersion = versions.find((version) => version.isActive);
if (activeVersion) {
const activeId = String(activeVersion.id);
if (activeId !== selectedVersion) {
setSelectedVersion(activeId);
}
return;
}
if (current === NEW_VERSION_OPTION) {
return;
}
const hasCurrent = versions.some(version => String(version.id) === current);
if (!current || !hasCurrent) {
if (!versions.some((version) => String(version.id) === selectedVersion)) {
setSelectedVersion(String(versions[0].id));
}
});
}, [versions, selectedVersion]);
const reuseSelected = () => {
const selected = selectedOpenHours();
useEffect(() => {
if (!isEditing || !isUpdateMode) return;
if (versions.some((version) => String(version.id) === editingVersionId)) return;
setEditingVersionId("");
setIsEditing(false);
clearForm();
}, [versions, isEditing, isUpdateMode, editingVersionId]);
useEffect(() => {
if (!isEditing || isUpdateMode) return;
clearForm();
}, [isEditing, isUpdateMode]);
const populateForm = (versionId: string) => {
const selected = versions.find((version) => String(version.id) === versionId);
if (!selected) {
setName("");
setParagraph1("");
setParagraph2("");
setParagraph3("");
setParagraph4("");
setKitchenNotice("");
clearForm();
return;
}
setName(selected.name);
setParagraph1(selected.paragraph1);
setParagraph2(selected.paragraph2);
setParagraph3(selected.paragraph3);
setParagraph4(selected.paragraph4);
setKitchenNotice(selected.kitchenNotice);
setForm({
name: selected.name,
paragraph1: selected.paragraph1,
paragraph2: selected.paragraph2,
paragraph3: selected.paragraph3,
paragraph4: selected.paragraph4,
kitchenNotice: selected.kitchenNotice,
});
};
createEffect(() => {
reuseSelected();
});
const startCreate = () => {
setEditingVersionId("");
clearForm();
setIsEditing(true);
};
const startEdit = (versionId: string) => {
setEditingVersionId(versionId);
populateForm(versionId);
setIsEditing(true);
};
const cancelEdit = () => {
setEditingVersionId("");
clearForm();
setIsEditing(false);
};
const submitForm = async (event: React.FormEvent) => {
event.preventDefault();
setSaving(true);
try {
if (isUpdateMode) {
const updated = await updateLokOpenHours(Number(editingVersionId), form);
setVersions((previous) =>
previous.map((version) =>
version.id === updated.id ? updated : version,
),
);
pushToast(
t("home.openHours.updated"),
"success",
`update:${updated.id}:${updated.version}`,
);
} else {
const created = await createLokOpenHours(form);
setVersions((previous) => {
const next = created.isActive
? [created, ...previous.map((version) => ({ ...version, isActive: false }))]
: [created, ...previous];
return next.slice(0, 5);
});
setSelectedVersion(String(created.id));
pushToast(
t("home.openHours.saved"),
"success",
`create:${created.id}:${created.version}`,
);
}
setEditingVersionId("");
clearForm();
setIsEditing(false);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pushToast(message, "error", `error:save:${message}`);
} finally {
setSaving(false);
}
};
const onDelete = async (version: LokOpenHours) => {
setDeletingId(String(version.id));
try {
await deleteLokOpenHours(version.id);
setVersions((previous) =>
previous.filter((item) => item.id !== version.id),
);
if (editingVersionId === String(version.id)) {
setEditingVersionId("");
clearForm();
setIsEditing(false);
}
pushToast(t("home.openHours.deleted"), "success", `delete:${version.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pushToast(message, "error", `error:delete:${message}`);
} finally {
setDeletingId("");
}
};
const onSetActive = async (version: LokOpenHours) => {
if (version.isActive) return;
setActivatingId(String(version.id));
try {
const result = await setActiveLokOpenHours(version.id);
setVersions((previous) =>
previous.map((item) => ({
...item,
isActive: item.id === result.id,
})),
);
setSelectedVersion(String(result.id));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pushToast(message, "error", `error:active:${message}`);
} finally {
setActivatingId("");
}
};
const tooltipText = (version: LokOpenHours) =>
[
version.name,
version.paragraph1,
version.paragraph2,
version.paragraph3,
version.paragraph4,
version.kitchenNotice,
]
.filter(Boolean)
.join("\n");
const nameRequired = useMemo(() => form.name.trim().length === 0, [form.name]);
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>
<section className="w-full max-w-3xl rounded-2xl border border-[#C99763] bg-[#F5D1A9] p-4 shadow-md sm:p-6">
<h2 className="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 className="mt-4">
<div
className={`transition-all duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${!isEditing
? "max-h-[1200px] translate-y-0 opacity-100"
: "pointer-events-none max-h-0 -translate-y-3 overflow-hidden opacity-0"
}`}
>
<div className="mb-3 flex justify-end">
<button
type="button"
onClick={startCreate}
className="w-full rounded-md border border-[#70421E] bg-[#8E4F24] px-4 py-2 text-[#FFF7EE] hover:bg-[#70421E] sm:w-auto"
>
{t("home.openHours.new")}
</button>
</div>
{versions.length === 0 ? (
<p className="text-[#70421E]">{t("home.openHours.empty")}</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{versions.map((version) => {
const versionId = String(version.id);
const active = version.isActive;
const deleting = deletingId === versionId;
const settingActive = activatingId === versionId;
return (
<article
key={version.id}
onClick={() => {
if (active || settingActive) return;
void onSetActive(version);
}}
className={`rounded-xl border p-3 transition-all duration-400 ease-[cubic-bezier(0.22,1,0.36,1)] ${active ? "border-[#8E4F24] bg-[#D6A06B]" : "border-[#C99763] bg-[#EED5B8]"
} ${deleting ? "-translate-y-1 scale-90 opacity-0" : ""} ${settingActive && !active ? "opacity-80" : ""
}`}
>
<div className="mb-2 flex items-center justify-between gap-2" title={tooltipText(version)}>
<p className="truncate font-medium text-[#4C250E]">
{version.name || t("home.openHours.latest")}
</p>
{active && (
<span className="rounded-md bg-[#8E4F24] px-2 py-0.5 text-xs font-medium text-[#FFF7EE]">
{t("home.openHours.active")}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
startEdit(versionId);
}}
className="rounded-md border border-[#A56C38] bg-[#FFF7EE] px-3 py-1.5 text-sm text-[#70421E] hover:bg-[#E3A977]"
>
{t("home.openHours.edit")}
</button>
<button
type="button"
disabled={active || deleting}
onClick={(event) => {
event.stopPropagation();
void onDelete(version);
}}
className={`rounded-md border border-[#8E4F24] bg-[#EED5B8] px-3 py-1.5 text-sm text-[#70421E] disabled:cursor-not-allowed disabled:opacity-50 ${!active ? "hover:bg-[#E3A977]" : ""
}`}
>
{t("home.openHours.delete")}
</button>
</div>
</article>
);
})}
</div>
)}
</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
onSubmit={submitForm}
className={`space-y-3 transition-all duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${isEditing
? "max-h-[1600px] translate-y-0 opacity-100"
: "pointer-events-none max-h-0 translate-y-3 overflow-hidden opacity-0"
}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
{isUpdateMode && (
<p className="text-sm font-medium text-[#4C250E]">{t("home.openHours.editing")}</p>
)}
<button
type="button"
onClick={cancelEdit}
className="rounded-md border border-[#A56C38] bg-[#EED5B8] px-3 py-1.5 text-sm text-[#70421E] hover:bg-[#E3A977]"
>
{t("home.openHours.cancel")}
</button>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-[#4C250E]">{t("home.openHours.name")}</label>
<input
id="name"
name="name"
required
value={form.name}
onChange={(event) => setForm((previous) => ({ ...previous, name: event.target.value }))}
className="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]"
/>
{nameRequired && (
<p className="mt-1 text-xs text-[#8E4F24]">{t("home.openHours.nameRequired")}</p>
)}
</div>
{(["paragraph1", "paragraph2", "paragraph3", "paragraph4", "kitchenNotice"] as const).map((field) => (
<div key={field}>
<label htmlFor={field} className="block text-sm font-medium text-[#4C250E]">
{t(`home.openHours.${field}`)}
</label>
<textarea
id={field}
name={field}
rows={2}
value={form[field]}
onChange={(event) =>
setForm((previous) => ({
...previous,
[field]: event.target.value,
}))
}
className="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 className="flex flex-wrap gap-3 pt-1">
<button
type="submit"
disabled={saving}
className="w-full rounded-md border border-[#70421E] bg-[#8E4F24] px-4 py-2 text-[#FFF7EE] hover:bg-[#70421E] disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
>
{isUpdateMode
? saving
? t("home.openHours.updating")
: t("home.openHours.update")
: saving
? t("home.openHours.saving")
: t("home.openHours.submit")}
</button>
</div>
</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>
</div>
</section>
);
}

View File

@@ -0,0 +1,64 @@
import { useEffect } from "react";
import { useRecoilState } from "recoil";
import { toastsAtom } from "~/state/appState";
export default function Toasts() {
const [toasts, setToasts] = useRecoilState(toastsAtom);
useEffect(() => {
if (toasts.length === 0) return;
const timers = toasts.map((toast) =>
window.setTimeout(() => {
setToasts((previous) =>
previous.map((item) =>
item.id === toast.id ? { ...item, leaving: true } : item,
),
);
window.setTimeout(() => {
setToasts((previous) => previous.filter((item) => item.id !== toast.id));
}, 220);
}, 3200),
);
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [toasts, setToasts]);
const dismiss = (id: number) => {
setToasts((previous) =>
previous.map((toast) =>
toast.id === id ? { ...toast, leaving: true } : toast,
),
);
window.setTimeout(() => {
setToasts((previous) => previous.filter((toast) => toast.id !== id));
}, 220);
};
return (
<div className="pointer-events-none fixed bottom-3 left-3 right-3 z-50 flex flex-col gap-2 sm:bottom-5 sm:left-auto sm:right-5 sm:w-full sm:max-w-sm">
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto rounded-lg border px-4 py-3 text-sm shadow-lg transition-all duration-200 ease-out ${toast.kind === "success"
? "border-[#8E4F24] bg-[#F5E2CB] text-[#4C250E]"
: "border-[#8E4F24] bg-[#F7D3B7] text-[#4C250E]"
} ${toast.leaving ? "translate-y-2 opacity-0" : "translate-y-0 opacity-100"}`}
>
<div className="flex items-start justify-between gap-3">
<p className="leading-snug">{toast.message}</p>
<button
type="button"
onClick={() => dismiss(toast.id)}
className="rounded px-1 text-base leading-none text-[#70421E] hover:bg-[#EED5B8]"
aria-label="Dismiss"
>
×
</button>
</div>
</div>
))}
</div>
);
}

View File

@@ -1,9 +0,0 @@
import { mount, StartClient } from "@solidjs/start/client";
const appRoot = document.getElementById("app");
if (!appRoot) {
throw new Error("App root element '#app' not found");
}
mount(() => <StartClient />, appRoot);

View File

@@ -1,21 +0,0 @@
import { createHandler, StartServer } from "@solidjs/start/server";
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="SolidStart with-auth example" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg" href="favicon.svg" />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));

2
ui/src/global.d.ts vendored
View File

@@ -1 +1 @@
/// <reference types="@solidjs/start/env" />
/// <reference types="vite/client" />

View File

@@ -1,7 +1,6 @@
import { createSignal } from "solid-js";
import { isServer } from "solid-js/web";
export type Language = "fi" | "en";
import { useMemo } from "react";
import { useRecoilState, useRecoilValue } from "recoil";
import { languageAtom, type Language } from "~/state/appState";
const STORAGE_KEY = "ui-language";
@@ -13,7 +12,7 @@ const translations = {
"nav.signOut": "Sign Out",
"nav.language.fi": "FI",
"nav.language.en": "EN",
"meta.description": "SolidStart with-auth example",
"meta.description": "React + Recoil example",
"home.title": "Home",
"home.heading": "KlAPI",
"home.signedInAs": "You are signed in as",
@@ -21,8 +20,12 @@ const translations = {
"home.openHours.heading": "Open hours versions",
"home.openHours.latest": "Latest",
"home.openHours.new": "New version",
"home.openHours.edit": "Edit",
"home.openHours.cancel": "Cancel",
"home.openHours.editing": "Editing version",
"home.openHours.active": "Active",
"home.openHours.reuse": "Reuse selected",
"home.openHours.delete": "Delete selected",
"home.openHours.delete": "Delete",
"home.openHours.empty": "No open-hours versions found yet",
"home.openHours.name": "Version name",
"home.openHours.nameRequired": "Version name is required",
@@ -32,8 +35,11 @@ const translations = {
"home.openHours.paragraph4": "Paragraph 4",
"home.openHours.kitchenNotice": "Kitchen notice",
"home.openHours.submit": "Add new version",
"home.openHours.update": "Save changes",
"home.openHours.saving": "Saving...",
"home.openHours.updating": "Saving changes...",
"home.openHours.saved": "New version saved",
"home.openHours.updated": "Version updated",
"home.openHours.deleted": "Version deleted",
"about.title": "About",
"about.apiVersion": "API version",
@@ -48,12 +54,7 @@ const translations = {
"notFound.message": "Sorry, the page youre looking for doesn't exist",
"notFound.goHome": "Go Home",
"error.title": "Error",
"counter.clicks": "Clicks",
"errors.requiredEmailPassword": "Email and password are required",
"errors.invalidStoredPasswordFormat": "Invalid stored password format",
"errors.invalidEmailOrPassword": "Invalid email or password",
"errors.oauthOnly":
"Account exists via OAuth. Sign in with your OAuth provider",
},
fi: {
"nav.home": "Etusivu",
@@ -62,7 +63,7 @@ const translations = {
"nav.signOut": "Kirjaudu ulos",
"nav.language.fi": "FI",
"nav.language.en": "EN",
"meta.description": "SolidStart with-auth -esimerkki",
"meta.description": "React + Recoil -esimerkki",
"home.title": "Etusivu",
"home.heading": "KlAPI",
"home.signedInAs": "Olet kirjautunut käyttäjänä",
@@ -70,8 +71,12 @@ const translations = {
"home.openHours.heading": "Aukioloaikaversiot",
"home.openHours.latest": "Viimeisin",
"home.openHours.new": "Uusi versio",
"home.openHours.edit": "Muokkaa",
"home.openHours.cancel": "Peruuta",
"home.openHours.editing": "Muokataan versiota",
"home.openHours.active": "Aktiivinen",
"home.openHours.reuse": "Käytä valittua uudelleen",
"home.openHours.delete": "Poista valittu",
"home.openHours.delete": "Poista",
"home.openHours.empty": "Aukioloaikaversioita ei vielä löytynyt",
"home.openHours.name": "Version nimi",
"home.openHours.nameRequired": "Version nimi on pakollinen",
@@ -81,8 +86,11 @@ const translations = {
"home.openHours.paragraph4": "Kappale 4",
"home.openHours.kitchenNotice": "Keittiöhuomio",
"home.openHours.submit": "Lisää uusi versio",
"home.openHours.update": "Tallenna muutokset",
"home.openHours.saving": "Tallennetaan...",
"home.openHours.updating": "Tallennetaan muutoksia...",
"home.openHours.saved": "Uusi versio tallennettu",
"home.openHours.updated": "Versio päivitetty",
"home.openHours.deleted": "Versio poistettu",
"about.title": "Tietoja",
"about.apiVersion": "API-versio",
@@ -97,12 +105,7 @@ const translations = {
"notFound.message": "Valitettavasti etsimääsi sivua ei ole olemassa",
"notFound.goHome": "Takaisin etusivulle",
"error.title": "Virhe",
"counter.clicks": "Klikkauksia",
"errors.requiredEmailPassword": "Sähköposti ja salasana vaaditaan",
"errors.invalidStoredPasswordFormat":
"Tallennetun salasanan muoto on virheellinen",
"errors.invalidEmailOrPassword": "Virheellinen sähköposti tai salasana",
"errors.oauthOnly": "Tili löytyy OAuthin kautta. Kirjaudu OAuth-palvelulla",
},
} as const;
@@ -111,25 +114,27 @@ export type TranslationKey = keyof typeof translations.en;
export const normalizeLanguage = (value: unknown): Language =>
value === "fi" ? "fi" : "en";
const [language, setLanguageSignal] = createSignal<Language>("en");
if (!isServer) {
export const initializeLanguage = (setLanguage: (lang: Language) => void) => {
const stored = normalizeLanguage(localStorage.getItem(STORAGE_KEY));
setLanguageSignal(stored);
}
export const setLanguage = (lang: Language) => {
setLanguageSignal(lang);
if (!isServer) {
localStorage.setItem(STORAGE_KEY, lang);
}
setLanguage(stored);
};
export { language };
export const useLanguage = () => {
const [language, setLanguageState] = useRecoilState(languageAtom);
export const getTranslations = (lang: Language) => translations[lang];
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem(STORAGE_KEY, lang);
};
export const t = (key: TranslationKey) => translations[language()][key];
return { language, setLanguage };
};
export const getLanguageFromFormData = (formData: FormData): Language =>
normalizeLanguage(formData.get("lang"));
export const useT = () => {
const language = useRecoilValue(languageAtom);
return useMemo(
() => (key: TranslationKey) => translations[language][key],
[language],
);
};

12
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { RecoilRoot } from "recoil";
import App from "~/app";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<RecoilRoot>
<App />
</RecoilRoot>
</React.StrictMode>,
);

View File

@@ -1,18 +1,24 @@
import { Title } from "@solidjs/meta";
import { t } from "~/i18n";
import { Link } from "react-router-dom";
import { useEffect } from "react";
import { useT } from "~/i18n";
export default function NotFound() {
const t = useT();
useEffect(() => {
document.title = t("notFound.title");
}, [t]);
return (
<main class="text-center">
<Title>{t("notFound.title")}</Title>
<main className="text-center">
<h1>{t("notFound.heading")}</h1>
{t("notFound.message")}
<a
href="/"
class="px-4 py-2 border border-[#C99763] rounded-xl text-[#70421E] hover:bg-[#F5D1A9] transition-colors duration-200"
<p>{t("notFound.message")}</p>
<Link
to="/"
className="rounded-xl border border-[#C99763] px-4 py-2 text-[#70421E] transition-colors duration-200 hover:bg-[#F5D1A9]"
>
{t("notFound.goHome")}
</a>
</Link>
</main>
);
}

View File

@@ -1,20 +1,39 @@
import { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router";
import { Show } from "solid-js";
import { useEffect, useState } from "react";
import { queryApiVersion } from "~/api";
import { t } from "~/i18n";
import { useT } from "~/i18n";
export default function About() {
const apiVersion = createAsync(() => queryApiVersion());
const t = useT();
const [apiVersion, setApiVersion] = useState<string | null>(null);
useEffect(() => {
document.title = t("about.title");
}, [t]);
useEffect(() => {
let active = true;
queryApiVersion()
.then((version) => {
if (active) {
setApiVersion(version);
}
})
.catch(() => {
if (active) {
setApiVersion(null);
}
});
return () => {
active = false;
};
}, []);
return (
<main>
<Title>{t("about.title")}</Title>
<p class="text-[#70421E] text-center">
{t("about.apiVersion")}:
<Show when={apiVersion()} fallback={t("about.loading")}>
{apiVersion()}
</Show>
<p className="text-center text-[#70421E]">
{t("about.apiVersion")}: {apiVersion ?? t("about.loading")}
</p>
</main>
);

View File

@@ -1,12 +0,0 @@
import OAuth from "start-oauth";
import { createUser, findUser } from "~/auth/db";
import { createSession, getSessionSecret } from "~/auth/server";
export const GET = OAuth({
password: getSessionSecret(),
async handler({ email }, redirectTo) {
let user = await findUser({ email });
if (!user) user = await createUser({ email });
return createSession(user, redirectTo);
},
});

View File

@@ -1,13 +1,18 @@
import { Title } from "@solidjs/meta";
import { useEffect } from "react";
import OpenHoursForm from "~/components/OpenHoursForm";
import { t } from "~/i18n";
import { useT } from "~/i18n";
export default function Home() {
const t = useT();
useEffect(() => {
document.title = t("home.title");
}, [t]);
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" />
<h1 className="text-center">{t("home.heading")}</h1>
<img src="/favicon.svg" alt={t("home.logoAlt")} className="w-28" />
<OpenHoursForm />
</main>
);

View File

@@ -1,65 +1,83 @@
import { Title } from "@solidjs/meta";
import { useSubmission } from "@solidjs/router";
import { Show } from "solid-js";
import { useOAuthLogin } from "start-oauth";
import { formLogin } from "~/auth";
import { language, t } from "~/i18n";
import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useRecoilState } from "recoil";
import { useT } from "~/i18n";
import { sessionAtom } from "~/state/appState";
export default function Login() {
const login = useOAuthLogin();
const t = useT();
const navigate = useNavigate();
const [session, setSession] = useRecoilState(sessionAtom);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
useEffect(() => {
document.title = t("login.title");
}, [t]);
useEffect(() => {
if (!session) return;
navigate("/");
}, [session, navigate]);
const submit = (event: FormEvent) => {
event.preventDefault();
if (!email.trim() || !password.trim()) {
setError(t("errors.requiredEmailPassword"));
return;
}
const normalizedEmail = email.trim().toLowerCase();
setSession({ email: normalizedEmail });
localStorage.setItem("session-email", normalizedEmail);
navigate("/");
};
return (
<main>
<Title>{t("login.title")}</Title>
<h1>{t("login.heading")}</h1>
<div class="space-y-6 font-medium">
<PasswordLogin />
</div>
<form onSubmit={submit} className="w-full max-w-md space-y-4 px-4">
<label htmlFor="email" className="block w-full text-left">
{t("login.email")}
<input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="john@doe.com"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
<label htmlFor="password" className="block w-full text-left">
{t("login.password")}
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
minLength={6}
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
<button
type="submit"
className="w-full rounded-lg bg-gradient-to-r from-[#A56C38] to-[#70421E] px-4 py-2 text-[#FFF7EE] shadow-lg shadow-[#70421E]/30 transition-colors duration-300 hover:from-[#8E4F24] hover:to-[#4C250E]"
>
{t("login.submit")}
</button>
{error && <p className="mt-2 text-center text-xs text-[#8E4F24]">{error}</p>}
</form>
</main>
);
}
function PasswordLogin() {
const submission = useSubmission(formLogin);
return (
<form action={formLogin} method="post" class="space-y-4 space-x-12">
<input type="hidden" name="lang" value={language()} />
<label for="email" class="block text-left w-full">
{t("login.email")}
<input
id="email"
name="email"
type="email"
autocomplete="email"
placeholder="john@doe.com"
required
class="bg-[#FFF7EE] mt-1 block w-full px-4 py-2 border border-[#C99763] rounded-md focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
<label for="password" class="block text-left w-full">
{t("login.password")}
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
placeholder="••••••••"
minLength={6}
required
class="bg-[#FFF7EE] mt-1 block w-full px-4 py-2 border border-[#C99763] rounded-md focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
<button
type="submit"
disabled={submission.pending}
class="w-full px-4 py-2 bg-gradient-to-r from-[#A56C38] to-[#70421E] text-[#FFF7EE] rounded-lg hover:from-[#8E4F24] hover:to-[#4C250E] focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-300 shadow-lg shadow-[#70421E]/30"
>
{t("login.submit")}
</button>
<Show when={submission.error} keyed>
{({ message }) => <p class="text-[#8E4F24] mt-2 text-xs text-center">{message}</p>}
</Show>
</form>
);
}

35
ui/src/state/appState.ts Normal file
View File

@@ -0,0 +1,35 @@
import { atom } from "recoil";
import type { LokOpenHours } from "~/api";
export type Language = "fi" | "en";
export type Session = {
email: string;
};
export type Toast = {
id: number;
message: string;
kind: "success" | "error";
leaving: boolean;
};
export const languageAtom = atom<Language>({
key: "languageAtom",
default: "en",
});
export const sessionAtom = atom<Session | null>({
key: "sessionAtom",
default: null,
});
export const openHoursAtom = atom<LokOpenHours[]>({
key: "openHoursAtom",
default: [],
});
export const toastsAtom = atom<Toast[]>({
key: "toastsAtom",
default: [],
});

View File

@@ -5,13 +5,14 @@
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"jsx": "react-jsx",
"allowJs": true,
"strict": true,
"noEmit": true,
"types": ["vinxi/types/client"],
"types": ["vite/client"],
"isolatedModules": true,
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": {
"~/*": ["./src/*"]
}

22
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/api": {
target: "http://localhost:5013",
changeOrigin: true,
rewrite: (pathValue) => pathValue.replace(/^\/api/, ""),
},
},
},
resolve: {
alias: {
"~": path.resolve(__dirname, "./src"),
},
},
});