restructure project, add claudemd
This commit is contained in:
101
CLAUDE.md
Normal file
101
CLAUDE.md
Normal 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`.
|
||||||
308
web/auth/AuthContext.ts
Normal file
308
web/auth/AuthContext.ts
Normal 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;
|
||||||
|
}
|
||||||
61
web/auth/ProtectedRoute.ts
Normal file
61
web/auth/ProtectedRoute.ts
Normal 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>`;
|
||||||
|
}
|
||||||
51
web/auth/checkBundleVersion.js
Normal file
51
web/auth/checkBundleVersion.js
Normal 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
106
web/auth/useAuthFetch.js
Normal 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
56
web/basic.ts
Normal 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
16
web/env.ts
Normal 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
98
web/finance.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
web/hooks/useDocumentTitle.js
Normal file
20
web/hooks/useDocumentTitle.js
Normal 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
127
web/kit/Accordion.tsx
Normal 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
44
web/kit/Alerts.tsx
Normal 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
Reference in New Issue
Block a user