Skip to content

basalt79/petstore-ui

Repository files navigation

Petstore Admin UI

CI TypeScript Vite Tailwind CSS Node No Framework

A framework-free admin panel for the Petstore v3 API — built with plain TypeScript, Vite, and Tailwind CSS v4. No React. No Vue. No virtual DOM.

npm install && npm run dev   →   http://localhost:5173

What this is

A fully featured CRUD admin panel — pets, orders, users — backed by a Javalin 7 + MongoDB implementation of the OpenAPI Petstore spec. The interesting part isn't what it does, it's how it's built.

Glassmorphism UI · Mobile-first · Runtime URL switching · ~4 KB of hand-written infrastructure


The stack (and why)

Layer Choice Why
Bundler Vite 5 + TypeScript (strict) Fast HMR, native ESM, zero config
Styling Tailwind CSS v4 via @tailwindcss/vite No config file, no PostCSS — just @import "tailwindcss" in one CSS file
Reactivity Hand-rolled signal / effect 80 lines. Same pull-based dependency-tracking model as SolidJS and Vue 3, without the framework
Routing Hand-rolled hash router 40 lines. #/pets, #/store, #/users — zero server config needed
HTTP Hand-rolled typed fetch wrapper Injects baseUrl + X-API-KEY on every call, returns { data, error } — never throws
Font Inter via @fontsource/inter Bundled, no Google Fonts DNS lookup

No React. No Vue. No Svelte. No component framework of any kind.


The reactive system — 80 lines, no dependencies

The entire reactivity layer lives in src/lib/signal.ts. It implements the same pull-based dependency-tracking model used by SolidJS and Vue's Composition API.

// A signal is a reactive value container
const count = signal(0)

// An effect re-runs automatically whenever any signal it reads changes.
// Dependency tracking is automatic — no dependency arrays.
effect(() => {
  document.title = `Count: ${count.get()}`
})

count.set(1)  // → document.title updates to "Count: 1"

How it works: each signal.get() call during an active effect registers that signal as a dependency of that effect. When signal.set() is called, it snapshots its subscriber set and runs each one. computed() is just an effect that writes to a backing signal.

The whole system is ~80 lines with no magic, no proxies, no Object.defineProperty.


The router — 40 lines

src/lib/router.ts is a hash-based SPA router. Pages implement a simple two-function interface:

export function mount(container: HTMLElement): void { /* render + wire events */ }
export function unmount(): void { /* abort fetches, close modals */ }

The router calls unmount() on the current page before calling mount() on the next one. Navigation is just location.hash = '#/pets'. No server config needed — the server always returns index.html; the fragment never leaves the browser.


Interesting problems solved

int64 IDs and Number.MAX_SAFE_INTEGER

The backend uses Java long for IDs — values up to 2⁶³−1. JavaScript's JSON.parse silently mangles any integer above 2⁵³−1 (9007199254740991):

JSON.parse('{"id":7281654362742761859}')
// → { id: 7281654362742761000 }   ← silent precision loss

The fix lives in src/lib/api.ts: before calling JSON.parse, a regex replaces 16+ digit integers in the raw response text with quoted strings.

const LARGE_INT_RE = /([:\[,])\s*(-?\d{16,})(?=[,\}\]\s])/g

function safeParseJson<T>(text: string): T {
  return JSON.parse(text.replace(LARGE_INT_RE, '$1 "$2"')) as T
}

7281654362742761859 becomes "7281654362742761859". All id fields in the TypeScript types are string. No precision loss, no external dependency.


backdrop-filter: blur() kills pointer events

The settings panel uses a frosted-glass backdrop behind the slide-out drawer. Early implementation:

// ❌ backdrop-blur on a fixed inset-0 overlay creates a compositing layer that
//    intercepts pointer events — even when sibling elements have higher z-index.
//    Reproducible in Chrome and Safari. Inputs inside the drawer become unclickable.
backdrop.className = 'fixed inset-0 z-20 bg-black/20 backdrop-blur-sm'
backdrop.addEventListener('click', close)

Fix: remove backdrop-filter from the overlay, use pointer-events-none, and detect outside-clicks with a document listener instead.

// ✅ purely visual — pointer events pass through
backdrop.className = 'fixed inset-0 z-20 bg-black/20 pointer-events-none'

// outside-click-to-close via document, not the backdrop
document.addEventListener('click', (e) => {
  if (isOpen && !drawer.contains(e.target as Node)) close()
})
triggerBtn.addEventListener('click', (e) => { e.stopPropagation(); toggle() })

Tailwind v4 and dynamic class strings

Tailwind v4 scans source files at build time to generate CSS. Classes added only via classList.add() in JavaScript are invisible to the scanner — they never make it into the bundle.

// ❌ translate-x-0 won't be in the generated CSS
drawer.classList.remove('translate-x-full')
drawer.classList.add('translate-x-0')   // no-op at runtime
// ✅ inline styles are always applied
drawer.style.transform = 'translateX(0)'

This applies to every animated element in the project (sidebar, settings drawer). The transition-transform duration-300 class stays in the static class string so Tailwind generates it — only the transform value is set via JS.


Features

Pets — full CRUD with filter tabs (available / pending / sold), photo upload via application/octet-stream, inline photoBase64 thumbnails, tag badges.

Store — live inventory summary cards (counts from GET /store/inventory), order management. Orders are tracked in sessionStorage — the Petstore API has no list-all endpoint.

Users — full CRUD, login/logout with session token display. Known usernames tracked in sessionStorage.

Settings panel — slide-out drawer with two backend URL presets (local dev + petstore3.swagger.io) plus a free-text custom input. API key field. Both persisted to localStorage and read as defaults from .env (VITE_BASE_URL, VITE_API_KEY).

Mobile — off-canvas sidebar (hamburger toggle), card-list layout instead of tables, full-screen modals, bottom-centre toasts.


Design

Dark slate-green glassmorphism. No design library — every token is a Tailwind utility class.

Background  from-[#060a06] via-[#0d1a0f] to-[#060a06]
Glass panel bg-white/10 backdrop-blur-md border border-white/15 rounded-2xl
Accent      emerald-600 / emerald-500

Status badges use semi-transparent colour rings (ring-1 ring-emerald-500/30) over matching backgrounds (bg-emerald-500/20). The API returns uppercase status values (AVAILABLE); the badge component normalises with .toLowerCase() before switching.


Backend

petstore-sync — a Javalin 7 + MongoDB implementation of the OpenAPI Petstore v3 spec, running on port 7070. Live OpenAPI spec at http://localhost:7070/openapi.

export MONGO_URI="mongodb+srv://..."
export API_KEY="special-key"
java -jar target/petstore-sync-*-jar-with-dependencies.jar

Running

cp .env.example .env   # set VITE_API_KEY and VITE_BASE_URL
npm install
npm run dev

For contributors and AI assistants: see CLAUDE.md for architecture details, file map, and known gotchas.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors