Utilities & API Client
The HTTP client and every shared helper in Luminaux's src/lib — cn, csv, ics, appStorage, accent, plus the brand config and build toolchain.
Overview
src/lib/ holds framework-agnostic helpers with no React dependency (except routeLayout.ts, which is a
small hook): the HTTP client, class merging, immutable Set toggling, CSV and iCalendar (de)serialization,
the categorical accent-tile map, the storage-key registry, and the shared customizer option tables. The
api client is the one piece that talks to the live backend — the template is built to run as a real
REST-connected app, not a static-JSON demo.
Architecture & files
| Export | File | What it does |
|---|---|---|
api, ApiError, API_CONFIG | src/lib/api.ts | HTTP client + typed error + config |
cn | src/lib/cn.ts | clsx + tailwind-merge class composer |
toggleSet | src/lib/collections.ts | immutable Set toggle |
escapeCsv, toCsv, downloadCsv | src/lib/csv.ts | RFC-4180 CSV writer + download |
eventsToIcs, downloadIcs, parseIcs | src/lib/ics.ts | RFC-5545 iCalendar export/import |
accentTile | src/lib/accent.ts | accent → literal Tailwind token classes |
asset | src/lib/asset.ts | resolve a public/ path against the app's base URL (subpath hosting) |
monthsShort, monthDayShort, relTimeShort | src/lib/dates.ts | pure Intl date/label helpers for charts + demo data |
parseLocalDate, formatLocalDate, parseLocalTime, formatLocalTime | src/lib/datetime.ts | local-timezone string↔Date converters for the date/time pickers |
APP_LOCAL_KEYS, APP_SESSION_KEYS, clearAppStorage | src/lib/appStorage.ts | storage-key registry + Reset |
cap, TOAST_POSITIONS, PAGE_TRANSITION_OPTS, … | src/lib/customizerOptions.ts | shared picker option tables |
useRouteLayout | src/layout/routeLayout.ts | per-route forced presentation |
| motion tokens | src/lib/motion.ts | durations/easings/variants (see motion doc) |
brand | src/config/brand.ts | product name / wordmark / version |
Usage
Import via the @/ alias:
import {api, ApiError, API_CONFIG} from '@/lib/api'
import {cn} from '@/lib/cn'
import {toggleSet} from '@/lib/collections'
import {downloadCsv, toCsv} from '@/lib/csv'
import {eventsToIcs, parseIcs, downloadIcs} from '@/lib/ics'
import {accentTile} from '@/lib/accent'
import {asset} from '@/lib/asset'
import {monthsShort, monthDayShort, relTimeShort} from '@/lib/dates'
import {parseLocalDate, formatLocalDate} from '@/lib/datetime'
import {clearAppStorage, APP_LOCAL_KEYS} from '@/lib/appStorage'
import {brand} from '@/config/brand'API / Props
The HTTP client lives in src/lib/api.ts. It is a thin fetch wrapper: base URL from an env var, an
auto-attached bearer token, JSON in/out, and a typed ApiError on any non-2xx response.
API_CONFIG
import {env} from '@/platform/env'
export const API_CONFIG = {
baseUrl: env.apiBaseUrl,
tokenKey: 'auth_token',
}baseUrlcomes from theVITE_API_BASE_URLenvironment variable (set in.env); it falls back to'/api'for local dev behind a proxy.tokenKeyis thelocalStoragekey the bearer token is read from —'auth_token'. This is the one key deliberately excluded fromclearAppStorage(), so a "Reset to defaults" does not sign the user out.
ApiError
export class ApiError extends Error {
constructor(
public status: number, // HTTP status code
message: string, // server `message` field, or a generated fallback
public body?: unknown, // parsed response payload (JSON or text)
)
name = 'ApiError'
}Thrown on every non-2xx response. message prefers the response body's message field, falling back to
"<METHOD> <path> failed (<status>)". body carries the full parsed payload for inspection.
The api client
export const api = {
get: <T = unknown>(path: string) => Promise<T>,
post: <T = unknown>(path: string, body?: unknown) => Promise<T>,
put: <T = unknown>(path: string, body?: unknown) => Promise<T>,
patch: <T = unknown>(path: string, body?: unknown) => Promise<T>,
delete: <T = unknown>(path: string) => Promise<T>,
}- Every method is generic — pass the expected response type as
Tand get a typed result. pathis joined ontoAPI_CONFIG.baseUrl; a leading slash is added if missing ('users'and'/users'both work).- When a
bodyis provided, the request sendsContent-Type: application/jsonand aJSON.stringifyd body. - The response is parsed as JSON when the
content-typesays so, otherwise as text. On a non-2xx status it throwsApiError; otherwise it returns the parsed payload cast toT.
How the token is attached
Before each request, the client reads localStorage.getItem('auth_token'). If present, it adds
Authorization: Bearer <token> to the headers; if absent (or localStorage throws), no auth header is
sent. You never pass the token manually — sign-in writes auth_token, sign-out clears it.
Fetch example
import {api, ApiError} from '@/lib/api'
interface User {
id: string
name: string
}
async function loadUsers(): Promise<User[]> {
try {
// GET {VITE_API_BASE_URL}/users, with Authorization if signed in
return await api.get<User[]>('/users')
} catch (err) {
if (err instanceof ApiError) {
console.error(`HTTP ${err.status}: ${err.message}`, err.body)
if (err.status === 401) {
// token missing/expired — redirect to /auth/login
}
}
throw err
}
}
async function createUser(data: {name: string}) {
return api.post<User>('/users', data) // sends JSON body + auth header
}Configuration & customization
cn — class merging
function cn(...inputs: ClassValue[]): stringRuns clsx (conditional class names) then tailwind-merge (resolves conflicting Tailwind utilities,
last-wins). Use it anywhere you compose classes, especially when a prop can override a default.
import {cn} from '@/lib/cn'
// tailwind-merge keeps the last conflicting utility: result is "px-4 py-2 bg-primary"
cn('px-2 py-2 bg-surface', condition && 'px-4', 'bg-primary')collections — toggleSet
function toggleSet<T>(set: Set<T>, value: T): Set<T>Immutably toggles a value's membership in a Set (adds if absent, removes if present) and returns a
new Set — safe to drop straight into a functional setState updater.
import {toggleSet} from '@/lib/collections'
const [selected, setSelected] = useState<Set<string>>(new Set())
const toggle = (id: string) => setSelected((prev) => toggleSet(prev, id))csv — CSV export
function escapeCsv(value: string): string // quotes if it contains , " CR LF; doubles inner "
function toCsv(rows: string[][]): string // rows -> CSV text, CRLF line endings (RFC 4180)
function downloadCsv(filename: string, text: string): void // triggers a browser downloadPure JS, no dependency. downloadCsv prepends a UTF-8 BOM so Excel reads accented characters correctly,
and appends .csv if the filename lacks it.
import {toCsv, downloadCsv} from '@/lib/csv'
const rows = [
['Name', 'Email', 'Status'],
['Ada Lovelace', 'ada@example.com', 'Approved'],
]
downloadCsv('applicants', toCsv(rows)) // downloads applicants.csvUsed by the Contact Applications CSV export.
ics — iCalendar export/import
function eventsToIcs(events: CalendarEvent[]): string // VCALENDAR string
function downloadIcs(filename: string, text: string): void // browser download (.ics)
function parseIcs(text: string): Partial<CalendarEvent>[] // parse a VCALENDAR stringPure JS, no dependency. Handles the common VEVENT properties (SUMMARY, DTSTART/DTEND,
DESCRIPTION, LOCATION, RRULE), RFC-5545 line folding (≤75 octets, CRLF + space), text escaping, and
RRULE ⇄ the app's Recurrence type (FREQ/INTERVAL/BYDAY/UNTIL). Times are floating local time
(no TZID), matching how the app stores dates; parseIcs unfolds continuation lines and, for UTC
date-times ending in Z, converts back to local wall-clock. parseIcs returns partial events (the
caller assigns id/category) and supplies a 'Untitled event' title fallback.
import {eventsToIcs, downloadIcs, parseIcs} from '@/lib/ics'
// export
downloadIcs('calendar', eventsToIcs(events))
// import (from a file input)
const text = await file.text()
const partials = parseIcs(text) // Partial<CalendarEvent>[] — assign ids before savingUsed by the Calendar app's export/import.
accent — accentTile
const accentTile: Record<Accent, string>
// Accent = 'primary' | 'success' | 'warning' | 'info' | 'danger'Maps a categorical accent to a literal pair of Tailwind token classes (bg-<tone>/10 text-<tone>)
for icon tiles. The strings must stay spelled out because Tailwind only emits class strings it sees
literally in source — building them dynamically (`bg-${tone}/10`) would not compile. Used by the
quick-create and apps (waffle) menus.
import {accentTile} from '@/lib/accent'
<span className={accentTile[item.accent]}>
<Icon />
</span>asset — base-path asset paths
const asset: (path: string) => string // env.basePath + normalized pathResolves a runtime public/ asset path against the app's configured base URL. Vite rewrites bundled
asset imports for the base, but not runtime string literals like an <img src> — so every
public/ reference (avatars, photos, flags, illustrations) must go through asset() to work when the app
is served under a subpath (e.g. the hosted demo at /luminaux/). At the default root base it is a
no-op. Leading slashes are normalized, so asset('/avatars/x.webp') and asset('avatars/x.webp')
behave the same.
import {asset} from '@/lib/asset'
// base '/' -> '/avatars/avatar-1.webp'
// base '/luminaux/' -> '/luminaux/avatars/avatar-1.webp'
<img src={asset('/avatars/avatar-1.webp')} loading="lazy" decoding="async" alt="" />The base itself is set by vite.config.ts (base: process.env.VITE_BASE || '/') and consumed by
<BrowserRouter basename> in main.tsx. See Getting started for hosting.
dates — Intl label helpers
function monthsShort(locale: string): string[] // ["Jan"…"Dec"] / ["1月"…"12月"]
function monthDayShort(locale: string, month: number, day: number): string // "Jul 12" / "7月12日" (1-based month)
function relTimeShort(locale: string, value: number, unit: Intl.RelativeTimeFormatUnit): string // "2 min. ago" / "2分前"Pure Intl-based helpers for chart axes and demo data. Month/weekday names must never be i18n keys —
they come from the active locale via Intl, so charts and feeds re-label themselves on language change.
Pass i18n.language in (the calling component re-renders via useTranslation). Slice monthsShort for
partial ranges.
import {monthsShort} from '@/lib/dates'
import {useTranslation} from '@/platform/i18n'
function AxisLabels() {
const {i18n} = useTranslation()
const months = monthsShort(i18n.language).slice(0, 6) // Jan…Jun in the active locale
return <>{months.join(' · ')}</>
}Consumed by the charts and dashboards; see also i18n.
datetime — local date/time strings
function parseLocalDate(value?: string | null): Date | null // 'YYYY-MM-DD' -> local Date at midnight
function formatLocalDate(d: Date | null): string // Date -> 'YYYY-MM-DD' (local)
function parseLocalTime(value?: string | null): Date | null // 'HH:mm' -> today's Date at that time
function formatLocalTime(d: Date | null): string // Date -> 'HH:mm' (local)Pure string↔Date converters in the local timezone, bridging string-based date/time state with the
Date-based DatePicker / TimePicker primitives. They exist because bare new Date('YYYY-MM-DD')
parses as UTC midnight (off-by-one in negative offsets) — these build/read local dates instead.
Invalid/empty input returns null (parsers) or '' (formatters).
import {parseLocalDate, formatLocalDate} from '@/lib/datetime'
const date = parseLocalDate('2026-07-15') // local midnight, not UTC
const value = formatLocalDate(date) // '2026-07-15'See the form primitives for the pickers these feed.
appStorage
const APP_LOCAL_KEYS: readonly string[] // every localStorage key the app writes
const APP_SESSION_KEYS: readonly string[] // every sessionStorage key
function clearAppStorage(): void // removes them all EXCEPT auth_tokenThe single source of truth for the storage keys the app owns. "Reset to defaults" calls
clearAppStorage() then reloads, so every in-memory store (theme, skin, favorites, presence, scrumboard,
calendar, chat, config, …) re-hydrates from defaults + seed. auth_token is intentionally left in place
so a reset does not sign the user out.
Whenever you add a new persisted key
(a new store, hook, or feature-state key), add it to
APP_LOCAL_KEYS / APP_SESSION_KEYS or Reset will leave it behind. Prefer an exported STORAGE_KEY
constant in the owning data/*.ts file over an inline string.
import {clearAppStorage} from '@/lib/appStorage'
function resetToDefaults() {
clearAppStorage() // keeps auth_token
window.location.reload() // stores re-hydrate from defaults
}customizerOptions
const cap: (s: string) => string // capitalize first letter -> i18n key suffix
const TOAST_POSITIONS: {id: ToastPosition; key: string}[]
const PAGE_TRANSITION_OPTS: {id: PageTransitionKind; key: string}[]
const PAGE_LOADER_OPTS: {id: PageLoaderKind; key: string}[]
const SPLASH_FREQ_OPTS: {id: SplashFrequency; key: string}[]
const PANEL_HEADER_OPTS: {id: PanelHeaderVariant; key: string}[]Shared option tables so the Layout Customizer panel and its full-page twin (Layout Settings)
render identical pickers from one list. Each entry pairs an option id with an i18n key that resolves
in the customizer namespace. cap turns an option id into its <prefix><Cap> key suffix (e.g. 'fade'
→ pageTransitionFade); the transition/loader tables are derived by mapping cap over the kind arrays in
src/lib/motion.ts.
import {TOAST_POSITIONS} from '@/lib/customizerOptions'
import {useTranslation} from '@/platform/i18n'
function ToastPositionPicker() {
const {t} = useTranslation()
return TOAST_POSITIONS.map((o) => <Option key={o.id} value={o.id} label={t(`customizer:${o.key}`)} />)
}routeLayout — useRouteLayout
Documented in full in the Hooks reference. It returns a
route's forced presentation (forceHeaderOnly / forceFixedFooter / forceHideWidgets / fullBleed),
derived from the route registry in src/routes.tsx. Presentation-only — it never mutates the persisted
config. See also Architecture & Routing.
motion — animation tokens
src/lib/motion.ts is the single source of truth for animation durations, easings, springs, variants,
and the page-transition / page-loader kind enums. It's covered in depth in the
Animation & effects documentation — don't hardcode magic numbers in
components; import the tokens from here.
brand.ts
export const brand = {
name: 'Luminaux', // full product name (doc title, footer)
nameLead: 'Admin', // wordmark: leading text
nameAccent: 'Panel', // wordmark: primary-coloured accent
tagline: 'React + Tailwind Dashboard', // short descriptor
version: '1.0.0', // shown in the footer Badge
} as constThe single source of truth for the product name. The sidebar logo, the header brand (header-only
layout), the footer copyright (footer:rights interpolates {{brand}}), the document title (via
useDocumentTitle), and the footer version Badge all read from here. Never hardcode the product name
elsewhere — index.html's <title> is only a pre-paint placeholder.
import {brand} from '@/config/brand'
<span>
{brand.nameLead}
<span className="text-primary">{brand.nameAccent}</span>
</span>Build config
- Vite (
vite.config.ts): React +@tailwindcss/viteplugins; path alias@→./src. Import from@/…everywhere. - TypeScript (
tsconfig.app.json):strict: true, plusnoUnusedLocals/noUnusedParameters,target: ES2022,jsx: react-jsx, and the matching@/*paths. Unused imports/exports fail the build — prune them. - Scripts (
package.json):npm run dev— Vite dev server (:5173/:5174).npm run build—tsc -b && vite build(run before considering a change done).npm run preview— preview the production build.npm run lint— ESLint (flat config).npm run format/format:check— Prettier write / CI check.npm run i18n:translate— fill target-language locale files (needsDEEPL_API_KEY).
- Prettier (
.prettierrc.json) owns formatting: no semicolons, single quotes, 4-space indent,bracketSpacing: false(tight import braces),printWidth: 110,trailingComma: all,arrowParens: always. Don't hand-fight it. - ESLint (
eslint.config.js, flat config):typescript-eslintrecommended + the classic react-hooks rules (rules-of-hooks: error,exhaustive-deps: warn) +react-refresh. It enforces correctness only, so it never fights Prettier. - Tailwind CSS v4 is config-less — configured CSS-first via
@import "tailwindcss"and@theme inlineinsrc/styles/index.css. There is notailwind.config.js. Colors live only in the CSS token layer — see Design tokens & dark mode.
Examples
A feature "export selected rows" flow combining toggleSet + csv:
import {useState} from 'react'
import {toggleSet} from '@/lib/collections'
import {toCsv, downloadCsv} from '@/lib/csv'
function useExport(rows: {id: string; name: string; email: string}[]) {
const [selected, setSelected] = useState<Set<string>>(new Set())
const toggle = (id: string) => setSelected((prev) => toggleSet(prev, id))
const exportCsv = () => {
const chosen = rows.filter((r) => selected.has(r.id))
const csv = toCsv([['Name', 'Email'], ...chosen.map((r) => [r.name, r.email])])
downloadCsv('export', csv)
}
return {selected, toggle, exportCsv}
}A guarded API call with typed error handling:
import {api, ApiError} from '@/lib/api'
async function updateStatus(id: string, status: string) {
try {
return await api.patch<{ok: true}>(`/applicants/${id}`, {status})
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
// handle conflict, e.g. someone else already changed it
}
throw err
}
}Best practices
- Route all backend calls through
api. You get the base URL, the bearer token, JSON handling, andApiErrorfor free — don't callfetchdirectly. - Never construct Tailwind color classes dynamically. Use
accentTile(or add a literal entry) so the classes survive Tailwind's content scan. - Compose classes with
cnwhenever a caller prop can override a default —tailwind-mergeresolves the conflict deterministically. - Register every persisted key in
appStorage.ts; prefer an exportedSTORAGE_KEYconstant over an inline string. - Read colors from tokens, never hardcode hex — this keeps skins and dark mode in sync.
- Run
npm run buildbefore done — strict TypeScript flags unused imports and will fail otherwise.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Requests hit the wrong host | VITE_API_BASE_URL unset → falls back to /api | Set it in .env, restart the dev server |
| 401 on every request | No auth_token in localStorage (not signed in) | Sign in; the client attaches the token automatically |
| Accented CSV garbled in Excel | Missing BOM | Use downloadCsv (it prepends the BOM) — don't roll your own |
Imported .ics events shift by hours | Source used UTC (Z) times | Expected — parseIcs converts Z to local wall-clock |
| A Tailwind color class doesn't apply | Built dynamically, so it wasn't scanned | Use accentTile / a literal class |
| Persisted state survives "Reset to defaults" | Key not registered | Add it to APP_LOCAL_KEYS / APP_SESSION_KEYS |
| Build fails on an unused import | noUnusedLocals/noUnusedParameters | Remove the import/param |
FAQ
How do I change the API base URL per environment? Set VITE_API_BASE_URL in .env (or
.env.production, etc.). Vite inlines import.meta.env.* at build time.
Why is auth_token not cleared on reset? So a "Reset to defaults" resets the look and layout
without logging the user out. It's the one key excluded from clearAppStorage().
Do csv.ts / ics.ts pull in a library? No — both are pure JS, no dependencies, matching the
template's "no needless deps" stance.
Where do animation constants live? In src/lib/motion.ts — see the
Animation & effects doc.
Is there a tailwind.config.js? No. Tailwind v4 is CSS-first; configuration lives in
src/styles/index.css.
Notes for designers & content editors
- Product name & version are edited in one place —
src/config/brand.ts. Changename/nameLead/nameAccent/tagline/versionand the sidebar, header, footer, and tab title all follow. - Picker labels (toast position, page transition, page loader, splash frequency) are i18n keys,
not literals — edit the copy in
src/locales/<lng>/customizer.json, keyed as listed incustomizerOptions.ts. - These utilities carry logic and formatting, not visible copy beyond the above — user-facing strings belong in the locale files.
Related
Hooks reference
sibling reference (useTheme, useRouteLayout, useChartTokens, …)
Architecture & Routing
route registry, persistence & Reset
Getting started
install, .env, scripts
Design tokens & dark mode
the token layer / Tailwind v4
Calendar
consumer of ics.ts
Contact Applications
consumer of csv.ts
Animation & effects
motion.ts tokens
Was this page helpful?
