PVR Tech Studio
Utilities and api

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.

9 min read
Updated July 15, 2026

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

ExportFileWhat it does
api, ApiError, API_CONFIGsrc/lib/api.tsHTTP client + typed error + config
cnsrc/lib/cn.tsclsx + tailwind-merge class composer
toggleSetsrc/lib/collections.tsimmutable Set toggle
escapeCsv, toCsv, downloadCsvsrc/lib/csv.tsRFC-4180 CSV writer + download
eventsToIcs, downloadIcs, parseIcssrc/lib/ics.tsRFC-5545 iCalendar export/import
accentTilesrc/lib/accent.tsaccent → literal Tailwind token classes
assetsrc/lib/asset.tsresolve a public/ path against the app's base URL (subpath hosting)
monthsShort, monthDayShort, relTimeShortsrc/lib/dates.tspure Intl date/label helpers for charts + demo data
parseLocalDate, formatLocalDate, parseLocalTime, formatLocalTimesrc/lib/datetime.tslocal-timezone string↔Date converters for the date/time pickers
APP_LOCAL_KEYS, APP_SESSION_KEYS, clearAppStoragesrc/lib/appStorage.tsstorage-key registry + Reset
cap, TOAST_POSITIONS, PAGE_TRANSITION_OPTS, …src/lib/customizerOptions.tsshared picker option tables
useRouteLayoutsrc/layout/routeLayout.tsper-route forced presentation
motion tokenssrc/lib/motion.tsdurations/easings/variants (see motion doc)
brandsrc/config/brand.tsproduct 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',
}
  • baseUrl comes from the VITE_API_BASE_URL environment variable (set in .env); it falls back to '/api' for local dev behind a proxy.
  • tokenKey is the localStorage key the bearer token is read from — 'auth_token'. This is the one key deliberately excluded from clearAppStorage(), 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 T and get a typed result.
  • path is joined onto API_CONFIG.baseUrl; a leading slash is added if missing ('users' and '/users' both work).
  • When a body is provided, the request sends Content-Type: application/json and a JSON.stringifyd body.
  • The response is parsed as JSON when the content-type says so, otherwise as text. On a non-2xx status it throws ApiError; otherwise it returns the parsed payload cast to T.

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[]): string

Runs 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')

collectionstoggleSet

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 download

Pure 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.csv

Used 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 string

Pure 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 saving

Used by the Calendar app's export/import.

accentaccentTile

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 path

Resolves 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_token

The 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.

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}`)} />)
}

routeLayoutuseRouteLayout

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 const

The 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/vite plugins; path alias @./src. Import from @/… everywhere.
  • TypeScript (tsconfig.app.json): strict: true, plus noUnusedLocals / 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 buildtsc -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 (needs DEEPL_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-eslint recommended + 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 inline in src/styles/index.css. There is no tailwind.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, and ApiError for free — don't call fetch directly.
  • Never construct Tailwind color classes dynamically. Use accentTile (or add a literal entry) so the classes survive Tailwind's content scan.
  • Compose classes with cn whenever a caller prop can override a default — tailwind-merge resolves the conflict deterministically.
  • Register every persisted key in appStorage.ts; prefer an exported STORAGE_KEY constant over an inline string.
  • Read colors from tokens, never hardcode hex — this keeps skins and dark mode in sync.
  • Run npm run build before done — strict TypeScript flags unused imports and will fail otherwise.

Troubleshooting

SymptomLikely causeFix
Requests hit the wrong hostVITE_API_BASE_URL unset → falls back to /apiSet it in .env, restart the dev server
401 on every requestNo auth_token in localStorage (not signed in)Sign in; the client attaches the token automatically
Accented CSV garbled in ExcelMissing BOMUse downloadCsv (it prepends the BOM) — don't roll your own
Imported .ics events shift by hoursSource used UTC (Z) timesExpected — parseIcs converts Z to local wall-clock
A Tailwind color class doesn't applyBuilt dynamically, so it wasn't scannedUse accentTile / a literal class
Persisted state survives "Reset to defaults"Key not registeredAdd it to APP_LOCAL_KEYS / APP_SESSION_KEYS
Build fails on an unused importnoUnusedLocals/noUnusedParametersRemove 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. Change name/nameLead/ nameAccent/tagline/version and 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 in customizerOptions.ts.
  • These utilities carry logic and formatting, not visible copy beyond the above — user-facing strings belong in the locale files.

Was this page helpful?