restructure project, add claudemd

This commit is contained in:
2026-07-08 16:36:17 -04:00
parent a7964f9410
commit 2a5fbffaa2
315 changed files with 81075 additions and 0 deletions

101
CLAUDE.md Normal file
View File

@@ -0,0 +1,101 @@
# kjol
`kjol` ("keel" in Norwegian) is a **shared, multilingual base layer** factored out of the
user's applications so they stay in sync. It is consumed as a **git submodule** inside each
app (at `<app>/kjol`). Current consumers: `cdrateline.com_2.0` and `Hotlap` — near-identical
forks it was extracted from. Scope will grow to more projects and languages.
## Golden rules
1. **The framework NEVER imports application code.** Wherever kjol needs app-specific
behavior, the app injects it (interfaces, registration functions, config structs,
callbacks). See **Coupling inversions** below. If you find yourself wanting to
`import "<app>/internal/..."` from kjol, invert it instead.
2. **Never run git operations here on the user's behalf.** The user creates the submodule and
makes all commits. You may edit files, build, and test.
3. kjol is edited **in place** via the submodule + `go.work` — there is no publish / `go get`
step. Editing a kjol file takes effect in the consuming app immediately.
4. When a file exists in both apps and has drifted, reconcile by **merging best-of-both**.
## Organization — by language
Each top-level directory is one language / build root:
```
kjol/
go/ all Go. Module `kjol` (go.mod lives in go/). Imports are `kjol/<pkg>`.
web/ all JS/TS (browser + SSR). No build system of its own; built by go/bundler.
# future: cpp/ kotlin/ swift/
```
Language-first, **not** feature-first. Consequence: the **bundler is Go** and lives in
`go/bundler` even though it builds `web/`. Don't "fix" this by splitting it.
### go/ — module `kjol`
Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil
l4g security snailmail validation bundler`, plus `cmd/{bundle,migrate,loc,passgen,typecheck}`.
Build / test (run from repo root):
```
go -C go build ./...
go -C go vet ./...
go -C go test ./...
```
### web/
- `kit/` — Solid.js `.tsx` component kit. Apps import components as `@ui/*`.
- `runtime/` — vendored Solid runtime + `vendor.json` (base entrypoints). The app merges its
own `vendor.json` (chart.js, pdf-lib, ...) on top; **kjol's solid-js must resolve first** so
there is a single reactive instance.
- `icons/` — FontAwesome SVG source kit (the bundler scans usage and generates a per-app
registry; the generated file is app-owned, not committed here).
- `styles/``theme.css` (`@theme` scaffold + `:root` fa vars). Brand color/font tokens stay
app-side; the app's `style.css` `@import`s this.
- `auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts` — generic TS
scaffolding. Apps import as `@kjol/*`. (Concrete permission constants stay app-side.)
**Frontend import aliases** (resolved by the bundler and mirrored in each app's tsconfig
`paths`): `@ui/*``web/kit`, `@kjol/*``web/`, `@appgen/*` → the app's generated dir
(e.g. the FA `faIcons` registry — app-owned, gitignored, regenerated each build). The kit's
own imports of sibling components stay relative (`./Buttons.tsx`).
## Consumption (per app)
- `go.work` at the app root:
```
use (
.
./kjol/go
)
```
then vendor with `go work vendor` (not `go mod vendor`).
- Startup wiring the app performs (this is how the inversions get their app-side halves):
`dbutil.RegisterAll(models.Tables)`, `l4g.SetDatabaseWriter(...)`,
`dbutil.Init(dbutil.ConnConfig{...})`, `snailmail.Configure(snailmail.Settings{...})`, a thin
`config.Load` wrapper over `config.Load[T]`, and an app-side `internal/httpauth` for the
session/authn middleware.
## Coupling inversions (how the framework stays app-agnostic)
| Package | Inversion |
|---|---|
| `dbutil` | table names via `Register`/`RegisterAll` (not the app's `models.Tables`) |
| `l4g` | owns the `Entry` type; DB persistence via `SetDatabaseWriter(func(Entry) error)` |
| `config` | generic `Load[T](file, *T) error`; each app defines its own config struct |
| `dbutil.ConnConfig`, `snailmail.Settings` | DB / mail credentials injected, never read from app config |
| `appenv` | compile-time environment via build tags (`-tags staging` / `-tags production`); the bundler reads `appenv.Environment` for the JS `__ENV_TYPE__` define |
| `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected |
## Stays app-side (never moves into kjol)
Domain models/repository/handlers-api, migrations, pages/routes/layouts, brand UI
(`TopBar`/`AppSidebar`/`TransitionOverlay`), concrete permission constants, the app config
struct, `embed.go` + `wwwroot/`, and generated artifacts (`faIcons` registry, `routes.gen.ts`,
`public_pages.gen.go`).
## Provenance
Big-bang extraction from cdrateline + Hotlap. The detailed migration plan lives on the
author's machine at `~/.claude/plans/foamy-humming-tulip.md`.

View File

View File

308
web/auth/AuthContext.ts Normal file
View File

@@ -0,0 +1,308 @@
import { createContext, useContext, createSignal, createMemo, onMount, JSXElement } from "solid-js";
import html from "solid-js/html";
import { getToken, setToken, clearToken, isTokenExpired } from "./useAuthFetch.js";
import { apiFetch } from "./checkBundleVersion.js";
export interface Identity {
id: string;
username: string;
email: string;
first_name: string;
last_name: string;
permissions: string[];
org_id: string | null;
org_name: string | null;
org_cert_num: string | null;
}
export interface MemberOrg {
id: string;
name: string;
certnum: string | null;
}
export interface SuperAdminState {
enabled: boolean;
orgName: string | null;
firstMemberOrgId: string | null;
}
export interface LoginResult {
success: boolean;
error?: string;
}
export interface SwitchOrgResult {
success: boolean;
org_id?: string;
org_name?: string;
org_cert_num?: string | null;
error?: string;
}
export interface AuthContextValue {
identity: () => Identity | null;
loading: () => boolean;
error: () => string | null;
isAuthenticated: () => boolean;
superAdminMode: () => SuperAdminState;
memberOrganizations: () => MemberOrg[];
login: (username: string, password: string, rememberMe?: boolean) => Promise<LoginResult>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
switchOrganization: (orgId: string) => Promise<SwitchOrgResult>;
disableSuperAdminMode: () => Promise<void>;
}
interface AuthProviderProps {
children?: JSXElement;
}
const AuthContext = createContext<AuthContextValue>();
export function AuthProvider(props: AuthProviderProps) {
const [identity, setIdentity] = createSignal<Identity | null>(null);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
const [memberOrganizations, setMemberOrganizations] = createSignal<MemberOrg[]>([]);
const [isAdmin, setIsAdmin] = createSignal(false);
const isAuthenticated = createMemo(() => !!identity());
const checkAuth = async (): Promise<void> => {
try {
setLoading(true);
setError(null);
const token = getToken();
if (!token) {
setIdentity(null);
setLoading(false);
return;
}
if (isTokenExpired()) {
clearToken();
setIdentity(null);
setLoading(false);
return;
}
// Retry transient network failures a few times so a brief blip
// self-heals instead of dropping the user to an error screen. A
// 401 is a real response and is handled below, not retried. -mta 6/3/26
let response;
for (let attempt = 0; ; attempt++) {
try {
response = await apiFetch("/api/auth/me", {
headers: {
"Authorization": `Bearer ${token}`,
},
});
break;
} catch (err) {
if (attempt >= 2) throw err;
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
}
}
// Only a definitive 401 means the session is actually invalid. A
// network error, an aborted request (e.g. the user refreshed
// mid-flight), or a 5xx must NOT clear the token -- otherwise a
// quick refresh aborts this request and silently logs the user
// out. -mta 6/3/26
if (response.status === 401) {
clearToken();
setIdentity(null);
setMemberOrganizations([]);
setIsAdmin(false);
return;
}
if (!response.ok) {
throw new Error(`Auth check failed: ${response.status}`);
}
const data = await response.json();
setIdentity(data);
const orgsResponse = await apiFetch("/api/auth/my-organizations", {
headers: {
"Authorization": `Bearer ${token}`,
},
});
if (orgsResponse.ok) {
const orgsData = await orgsResponse.json();
setMemberOrganizations(orgsData.member_organizations || []);
setIsAdmin(orgsData.is_admin || false);
}
} catch (err) {
// Transient failure (network/abort/5xx). Preserve the token so the
// next load can retry instead of forcing a logout. -mta 6/3/26
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
};
onMount(() => {
checkAuth();
});
const login = async (username: string, password: string, rememberMe = false): Promise<LoginResult> => {
try {
setLoading(true);
setError(null);
const response = await apiFetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
password,
remember_me: rememberMe,
}),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || "Login failed");
}
setToken(data.token, data.expires_at);
setIdentity(data.user);
const orgsResponse = await apiFetch("/api/auth/my-organizations", {
headers: {
"Authorization": `Bearer ${data.token}`,
},
});
if (orgsResponse.ok) {
const orgsData = await orgsResponse.json();
setMemberOrganizations(orgsData.member_organizations || []);
setIsAdmin(orgsData.is_admin || false);
}
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
return { success: false, error: message };
} finally {
setLoading(false);
}
};
const logout = async (): Promise<void> => {
const token = getToken();
if (token) {
try {
await apiFetch("/api/auth/logout", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
},
});
} catch {
// Continue with logout even if API call fails
}
}
clearToken();
setIdentity(null);
window.location.href = "/login";
};
const switchOrganization = async (orgId: string): Promise<SwitchOrgResult> => {
try {
setError(null);
const token = getToken();
if (!token) {
throw new Error("Not authenticated");
}
const response = await apiFetch("/api/auth/switch-org", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ org_id: orgId }),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || "Failed to switch organization");
}
setIdentity((prev) => prev ? {
...prev,
org_id: data.org_id,
org_name: data.org_name,
org_cert_num: data.org_cert_num,
} : null);
return {
success: true,
org_id: data.org_id,
org_name: data.org_name,
org_cert_num: data.org_cert_num,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
return { success: false, error: message };
}
};
const disableSuperAdminMode = async (): Promise<void> => {
const orgs = memberOrganizations();
if (orgs.length > 0) {
await switchOrganization(orgs[0].id);
}
};
const superAdminMode = (): SuperAdminState => {
const id = identity();
const orgs = memberOrganizations();
return {
enabled: isAdmin() && id?.org_id != null && !orgs.some((org) => org.id === id.org_id),
orgName: id?.org_name || null,
firstMemberOrgId: orgs.length > 0 ? orgs[0].id : null,
};
};
const value: AuthContextValue = {
identity,
loading,
error,
isAuthenticated,
superAdminMode,
memberOrganizations,
login,
logout,
checkAuth,
switchOrganization,
disableSuperAdminMode,
};
// CRITICAL: Use lazy children evaluation
return html`<${AuthContext.Provider} value=${value}>${() => props.children}<//>`;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,61 @@
import { createEffect, createSignal, createMemo, JSXElement } from "solid-js";
import html from "solid-js/html";
import { useAuth } from "./AuthContext.ts";
import { getToken } from "./useAuthFetch.js";
import { hasPermission } from "./Permissions.js";
import { Loader } from "../ui/General.tsx";
interface ProtectedRouteProps {
permissions?: string[];
children?: JSXElement;
}
export function ProtectedRoute(props: ProtectedRouteProps) {
const auth = useAuth();
const [isRedirecting, setIsRedirecting] = createSignal(false);
createEffect(() => {
// Redirect to login ONLY when there is genuinely no session. A real
// 401 clears the token (so getToken() is null here), but a transient
// check failure preserves it -- in that case we keep the user here and
// show a retry screen instead of logging them out. -mta 6/3/26
if (!auth.loading() && !auth.isAuthenticated() && !getToken() && !isRedirecting()) {
setIsRedirecting(true);
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.replace(`/login?redirect=${returnUrl}`);
}
});
const hasRequiredPermissions = createMemo(() => {
const permissions = props.permissions || [];
if (permissions.length === 0) return true;
const userPermissions = auth.identity()?.permissions || [];
return permissions.every((perm) => hasPermission(userPermissions, perm));
});
const state = createMemo(() => {
if (isRedirecting() || auth.loading()) return 'loading';
if (!auth.isAuthenticated()) {
// Token still present => the check failed transiently => offer a
// retry. No token => the redirect effect is taking over.
return getToken() ? 'error' : 'loading';
}
if (!hasRequiredPermissions()) return 'unauthorized';
return 'ready';
});
return html`<div>
${() => state() === 'loading' && html`<${Loader} />`}
${() => state() === 'error' && html`<div class="flex flex-col items-center justify-center min-h-[50vh] text-center p-8">
<h1 class="text-2xl font-semibold text-neutral-800 mb-2">Connection problem</h1>
<p class="text-neutral-600 mb-4">We couldn't verify your session. You're still signed in -- please try again.</p>
<button onclick=${() => auth.checkAuth()} class="text-sky-600 hover:text-sky-800 hover:underline">Retry</button>
</div>`}
${() => state() === 'unauthorized' && html`<div class="flex flex-col items-center justify-center min-h-[50vh] text-center p-8">
<h1 class="text-2xl font-semibold text-neutral-800 mb-2">Access Denied</h1>
<p class="text-neutral-600 mb-4">You don't have permission to access this page.</p>
<a href="/app/dashboard" class="text-sky-600 hover:text-sky-800 hover:underline">Return to Dashboard</a>
</div>`}
${() => state() === 'ready' && props.children}
</div>`;
}

View File

@@ -0,0 +1,51 @@
let bundleStaleNotified = false;
export function checkBundleVersion(response) {
const serverVersion = response.headers.get("X-Bundle-Version");
const clientVersion = window.__BUNDLE_VERSION__;
if (!serverVersion || !clientVersion || serverVersion === clientVersion) {
return;
}
if (bundleStaleNotified) {
return;
}
bundleStaleNotified = true;
const bar = document.createElement("div");
bar.setAttribute("role", "status");
bar.style.cssText = [
"position:fixed",
"top:0",
"left:0",
"right:0",
"z-index:99999",
"padding:12px 16px",
"background:#141620",
"color:#fff",
"text-align:center",
"font:14px system-ui,sans-serif",
"box-shadow:0 2px 8px rgba(0,0,0,0.2)",
].join(";");
const message = document.createElement("span");
message.textContent = "A new version of National CD Rateline is available.";
const button = document.createElement("button");
button.type = "button";
button.textContent = "Refresh";
button.style.cssText = "margin-left:12px;padding:4px 12px;cursor:pointer;border:0;border-radius:4px;background:#fff;color:#141620;font:inherit";
button.addEventListener("click", () => {
window.location.reload();
});
bar.append(message, button);
document.body.prepend(bar);
}
export async function apiFetch(url, options) {
const response = await fetch(url, options);
checkBundleVersion(response);
return response;
}

106
web/auth/useAuthFetch.js Normal file
View File

@@ -0,0 +1,106 @@
import { apiFetch } from "./checkBundleVersion.js";
const TOKEN_KEY = "session_key";
const TOKEN_EXPIRY_KEY = "session_key_expiry";
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token, expiresAt) {
localStorage.setItem(TOKEN_KEY, token);
if (expiresAt) {
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt.toString());
}
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
}
export function isTokenExpired() {
const expiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
if (!expiry) return true;
return Date.now() >= parseInt(expiry, 10) * 1000;
}
export function useAuthFetch(options = {}) {
const { onUnauthorized } = options;
return async (url, fetchOptions = {}) => {
const fullUrl = url.startsWith("http") ? url : url;
const token = getToken();
const headers = {
"Content-Type": "application/json",
...fetchOptions.headers,
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await apiFetch(fullUrl, {
...fetchOptions,
headers,
});
if (response.status === 401) {
clearToken();
if (onUnauthorized) {
onUnauthorized();
} else {
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?redirect=${returnUrl}`;
}
throw new AuthError("Unauthorized", 401);
}
if (response.status === 403) {
throw new AuthError("Forbidden: insufficient permissions", 403);
}
return response;
};
}
export async function authFetch(url, options = {}) {
const fullUrl = url.startsWith("http") ? url : url;
const token = getToken();
const headers = {
"Content-Type": "application/json",
...options.headers,
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(fullUrl, {
...options,
headers,
});
if (response.status === 401) {
clearToken();
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?redirect=${returnUrl}`;
throw new AuthError("Unauthorized", 401);
}
if (response.status === 403) {
throw new AuthError("Forbidden: insufficient permissions", 403);
}
return response;
}
export class AuthError extends Error {
constructor(message, status) {
super(message);
this.name = "AuthError";
this.status = status;
}
}

56
web/basic.ts Normal file
View File

@@ -0,0 +1,56 @@
export function capitalizeFirstLetter(input: string): string {
return input.charAt(0).toUpperCase() + input.slice(1);
}
export function toSnakeCase(input: string): string {
return input
.replace(/([a-z])([A-Z])/g, "$1_$2")
.replace(/[\s\-]+/g, "_")
.toLowerCase();
}
export function snakeCaseToTitleCase(input: string): string {
return input
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
// Strips non-numeric characters from a string including spaces.
export function sanitizeNum(input: string): string {
return input.replaceAll(/[^\d]+/g, "");
}
// Strips non-alphanumeric, non-space characters from a string.
export function sanitizeAlphaNum(input: string): string {
return input.replaceAll(/[^a-zA-Z0-9 ]+/g, "");
}
// Strips non-alphanumeric characters from a string including spaces.
export function sanitizeAlphaNumStrict(input: string): string {
return input.replaceAll(/[^a-zA-Z0-9]+/g, "");
}
export function numberToStringWithCommas(n: number):string {
var str = n.toString();
var negative = false;
if (str.startsWith("-")) {
negative = true;
str = str.slice(1);
}
var result = "";
for (let i=0; i < str.length; i++) {
if (i > 0 && (str.length - i)%3 === 0) {
result += ",";
}
result += str[i]
}
if (negative) {
result = "-" + result;
}
return result;
}

16
web/env.ts Normal file
View File

@@ -0,0 +1,16 @@
// Compile-time deployment environment: "development" | "staging" | "production".
//
// `__ENV_TYPE__` is substituted at bundle time by esbuild's `define`, fed the Go
// compile-time constant `internal/constants.AppEnvironment` (see the
// `esbuildDefine` helper in internal/bundler). The production bundles and the dev
// HMR per-module transform both define it. The build-time SSR render of the
// public pages does NOT, so the `typeof` guard yields "" there — the env badge
// renders only after the client takeover, matching the prior runtime behavior.
declare const __ENV_TYPE__: string;
export const ENV_TYPE: string = typeof __ENV_TYPE__ !== "undefined" ? __ENV_TYPE__ : "";
// True in every environment except production (and the badge-less SSR render).
export function isNonProdEnv(): boolean {
return ENV_TYPE !== "" && ENV_TYPE !== "production";
}

98
web/finance.ts Normal file
View File

@@ -0,0 +1,98 @@
// Format a number as USD currency.
// If `showChangeIfExists` is false, the cents value is only shown if there are leftover cents
// after converting to dollars (i.e. amount=1000 would be shown as $10, but amount=1001 would be
// shown as $10.01). If true, cents are always displayed.
export function formatCurrency(amount: number, showChangeIfExists: boolean = true): string {
const dollars = amount / 100;
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
trailingZeroDisplay: (showChangeIfExists ? "auto" : "stripIfInteger"),
useGrouping: false,
}).format(dollars);
}
// // Same as formatMoney() but with commas.
export function formatCurrencyWithCommas(amount: number, showChangeIfExists: boolean = true): string {
const dollars = amount / 100;
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
trailingZeroDisplay: (showChangeIfExists ? "auto" : "stripIfInteger"),
}).format(dollars);
}
// takes a number such as 123456, and outputs (1234, 56) as strings
export function splitCurrency(amount: number): [string, string] {
// Calculate dollars as a string (without cents)
const dollars = Math.floor(amount / 100).toString();
// Calculate cents as a string with leading zero if necessary;
const cents = (amount % 100).toString().padStart(2, "0");
return [dollars, cents];
}
export function moneyToNumber(input: string): number {
if (!input.includes(".")) {
input += ".00";
} else if (input.split(".").length - 1 === 1) {
const digits = input.split(".");
if (digits[1].length === 1) {
input += "0";
}
}
let processed = input
.replaceAll(".", "")
.replaceAll(",", "")
.replaceAll(" ", "");
return parseInt(processed);
}
export function formatRate(rate: number): string {
return (rate / 1000).toFixed(3);
}
export function processDiscount(discount: string): number {
const discountFloat = parseFloat(discount);
if (discountFloat < 0) {
return 0;
} else if (discountFloat > 100) {
return 100;
}
return discountFloat;
}
// Takes days as an input and outputs the string representation of the number
// of days, months, or years in the term based on which unit is the best fit
// for the amount of days along with the corresponding unit string.
export function daysToRateTerm(days: number): [string, string] {
const years = Math.floor(days / 365);
const months = Math.floor((days - years*365) / 30);
const dayRemainder = days - years*365 - months*30;
if (days == 0) { // Return empty string for value
return ["", "days"];
} else if (days <= 270 || dayRemainder > 0) {
return [days.toString(), "days"]
} else if (months > 0) {
return [(months + years*12).toString(), "months"];
} else {
return [years.toString(), "years"];
}
}
export function rateTermToDays(value: string, unit: string): number {
const valueNum = parseInt(value);
switch (unit) {
case "months":
const years = Math.floor(valueNum / 12);
const months = valueNum % 12;
return years*365 + months*30;
case "years":
return valueNum * 365;
default: // case "days":
return valueNum;
}
}

View File

@@ -0,0 +1,20 @@
import { createEffect, onCleanup } from "solid-js";
const SITE_NAME = "National CD Rateline";
export function createDocumentTitle(title) {
createEffect(() => {
const previousTitle = document.title;
const t = title();
document.title = t ? `${t} | ${SITE_NAME}` : SITE_NAME;
onCleanup(() => {
document.title = previousTitle;
});
});
}
export function PageWithTitle(props) {
createDocumentTitle(() => props.route.title || props.route.label);
return props.children;
}

127
web/kit/Accordion.tsx Normal file
View File

@@ -0,0 +1,127 @@
import { createSignal, createEffect, For, Show, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
// Baseline Tailwind for the scoped .ui-accordion stack. The ui-* class
// names are kept so page-specific CSS overrides (e.g. sale.css,
// licensing.css) can continue to layer on top.
const ROOT = "ui-accordion border border-neutral-200 rounded-default overflow-hidden";
const ITEM = "ui-accordion-item border-b border-neutral-200 last:border-b-0";
const TRIGGER = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-neutral-900 bg-neutral-50 cursor-pointer border-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus:outline-hidden disabled:text-neutral-400 disabled:cursor-not-allowed disabled:bg-neutral-50";
const TITLE = "ui-accordion-title flex-1";
const CONTENT = "ui-accordion-content px-4 pb-4 text-neutral-700";
function iconCls(open: boolean): string {
return "ui-accordion-icon text-neutral-500 leading-none transition-transform duration-200" +
(open ? " rotate-180" : "");
}
interface AccordionItemProps {
startOpen?: boolean;
isOpen?: boolean;
disabled?: boolean;
title?: string;
children?: JSXElement;
}
export function AccordionItem(props: AccordionItemProps) {
const getStartOpen = () => typeof props.startOpen === "function" ? (props.startOpen as () => boolean)() : !!props.startOpen;
const [isOpen, setIsOpen] = createSignal(getStartOpen());
createEffect(() => {
if (props.isOpen === undefined) return;
const v = typeof props.isOpen === "function" ? (props.isOpen as () => boolean)() : !!props.isOpen;
setIsOpen(v);
});
const isDisabled = () => typeof props.disabled === "function" ? (props.disabled as () => boolean)() : !!props.disabled;
const title = () => typeof props.title === "function" ? (props.title as () => string)() : props.title;
const toggle = () => {
if (isDisabled()) return;
setIsOpen(!isOpen());
};
return (
<div class={ITEM}>
<button type="button" class={TRIGGER} disabled={isDisabled()} onclick={toggle} aria-expanded={isOpen()}>
<span class={TITLE}>{title()}</span>
<Show when={!isDisabled()}>
<span class={iconCls(isOpen())}>
<Icon icon={isOpen() ? "chevron-up" : "chevron-down"} size={18}/>
</span>
</Show>
</button>
<Show when={isOpen() && !isDisabled()}>
<div class={CONTENT}>
{props.children}
</div>
</Show>
</div>
);
}
interface AccordionItemData {
title: string;
content: JSXElement;
disabled?: boolean;
}
interface AccordionProps {
items?: AccordionItemData[];
}
export function Accordion(props: AccordionProps) {
const items = () => props.items || [];
return (
<div class={ROOT}>
<For each={items()}>{(item) => (
<AccordionItem title={(() => {
const t = item.title;
return typeof t === "function" ? (t as () => string)() : t;
})()} disabled={(() => {
const d = item.disabled;
return typeof d === "function" ? (d as () => boolean)() : !!d;
})()}>
{item.content}
</AccordionItem>
)}</For>
</div>
);
}
interface SingleAccordionProps {
items?: AccordionItemData[];
startOpen?: number;
}
export function SingleAccordion(props: SingleAccordionProps) {
const items = () => props.items || [];
const [openIndex, setOpenIndex] = createSignal(props.startOpen ?? 0);
const toggleItem = (index: number) => {
setOpenIndex(openIndex() === index ? -1 : index);
};
return (
<div class={ROOT}>
<For each={items()}>{(item, index) => (
<div class={ITEM}>
<button type="button" class={TRIGGER} disabled={!!item.disabled} onclick={() => toggleItem(index())} aria-expanded={openIndex() === index()}>
<span class={TITLE}>{item.title}</span>
<Show when={!item.disabled}>
<span class={iconCls(openIndex() === index())}>
<Icon icon={openIndex() === index() ? "chevron-up" : "chevron-down"} size={18}/>
</span>
</Show>
</button>
<Show when={openIndex() === index() && !item.disabled}>
<div class={CONTENT}>
{item.content}
</div>
</Show>
</div>
)}</For>
</div>
);
}

44
web/kit/Alerts.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { JSXElement, Show } from "solid-js";
type AlertColor = "white" | "gray" | "blue" | "green" | "red" | "yellow";
interface AlertProps {
header?: string;
class?: string;
children: JSXElement;
}
const BASE = "p-4 rounded-default shadow-xs border";
const COLORS: Record<AlertColor, string> = {
white: "bg-white border-neutral-100",
gray: "bg-neutral-50 border-neutral-200",
blue: "bg-sky-50 border-sky-200",
green: "bg-green-50 border-green-200",
red: "bg-red-50 border-red-200",
yellow: "bg-yellow-50 border-yellow-200",
};
function alertClass(color: AlertColor, extra?: string): string {
return BASE + " " + COLORS[color] + (extra ? " " + extra : "");
}
function makeAlert(color: AlertColor) {
return function Alert(props: AlertProps) {
return (
<div class={alertClass(color, props.class)}>
<Show when={props.header}>
<h3 class="font-semibold mb-2">{props.header}</h3>
</Show>
<p class="text-sm">{props.children}</p>
</div>
);
};
}
export const AlertWhite = makeAlert("white");
export const AlertGray = makeAlert("gray");
export const AlertBlue = makeAlert("blue");
export const AlertGreen = makeAlert("green");
export const AlertRed = makeAlert("red");
export const AlertYellow = makeAlert("yellow");

Some files were not shown because too many files have changed in this diff Show More