Template
Initial commit: Bun · Hono · React SPA · Postgres/Drizzle template
The starting point for an app with a React frontend. Same server, env, database and API skeleton as template-htmx — the two differ only in what is served at / — with a React 19 SPA, Tailwind v4 and shadcn/ui on top. Carried from a working project rather than designed in the abstract, and carrying the lessons that cost it time: - The dev script states NODE_ENV=development rather than inheriting it. React resolves its runtime against NODE_ENV: Bun emits jsxDEV() in development and React's production jsx-dev-runtime defines jsxDEV as void 0. Inherit production while transforming for development and the app dies at first paint. Verified: the dev bundle resolves the development runtime and contains no void-0 stub. - Asset URLs in components are relative and <base href="/"> is in the HTML, so a path-prefix proxy can rewrite one attribute and every asset follows. A test enforces it. - Routing is in the hash, because the app may be mounted under a prefix it only learns at runtime and a hash means the same thing at either mount. - index.html holds __PUBLIC_URL__ and gen-index.ts substitutes it, because Bun's HTML bundler fails the build on a root-relative favicon href. - APP_ENV failing closed, env files before the ambient environment, the database enforced by name, one Postgres client per process — as in the htmx template. The database has no tables. The App is a shell with one tab and no features, named as a placeholder. Renaming `app` is the first step and the README lists every place it appears. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# Copy to .env and adjust. Production uses .env.production.local, which wins over this file.
|
||||
|
||||
# The dev server's port.
|
||||
BUN_PORT=3000
|
||||
|
||||
# The absolute base URL this site is served from. Must be the public https origin in production:
|
||||
# a loopback address means the reader's own machine, and mobile clients refuse cleartext silently.
|
||||
PUBLIC_URL=http://localhost:3000
|
||||
|
||||
# Development and test connect here. Production uses POSTGRES_URL from .env.production.local,
|
||||
# and the pairing is enforced — a dev process cannot reach the production database.
|
||||
POSTGRES_URL_DEV=postgresql://postgres:PASSWORD@127.0.0.1:5432/app_dev
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tgz
|
||||
coverage
|
||||
*.log
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
||||
# generated by scripts/gen-index.ts
|
||||
src/apps/frontend/index.gen.html
|
||||
@@ -0,0 +1,51 @@
|
||||
# app
|
||||
|
||||
A project started from `template-react`. **Rename `app` first** — README step 1.
|
||||
|
||||
**Bun · Hono · React 19 · Tailwind v4 · shadcn/ui · Postgres with Drizzle.**
|
||||
|
||||
## Read these before writing code
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `CONVENTIONS.md` | React patterns — feature folders, manager hooks, colocated state — and layout |
|
||||
| `docs/DATABASE.md` | Drizzle structure, `push` not migrations, `check()` vs open `text` |
|
||||
| `docs/SURFACES.md` | The JSON API and the app: one process, one query layer |
|
||||
| `README.md` | Setup, scripts, configuration |
|
||||
|
||||
## Rules that are load-bearing
|
||||
|
||||
**`APP_ENV`, never `NODE_ENV`.** Must be `production` or `development`; throws when unset. Hosts export
|
||||
`NODE_ENV=production` into everything they spawn, so anything keyed on it treats development as
|
||||
production silently. `NODE_ENV` is still set explicitly by both scripts because React's runtime
|
||||
resolves against it — Bun emits `jsxDEV()` in development and React's production runtime defines it
|
||||
as `void 0`.
|
||||
|
||||
**Env files are read with the ambient environment LAST**: `.env.production.local` (production only)
|
||||
→ `.env.local` → `.env` → `process.env`.
|
||||
|
||||
**Asset URLs in components are relative — never start with `/`.** `<base href="/">` in `index.html`
|
||||
is what a path-prefix proxy rewrites; a leading slash bypasses it. `asset-urls.test.ts` enforces this.
|
||||
`fetch('/api/...')` is the exception.
|
||||
|
||||
**Routing is in the hash.** The app may be mounted under a prefix it only learns at runtime, and a
|
||||
hash means the same thing at either mount point. `lib/routes.ts`.
|
||||
|
||||
**One feature folder per screen, one manager hook per feature.** State lives in the hook, components
|
||||
receive the manager. `CONVENTIONS.md`.
|
||||
|
||||
**Serializers live in `src/servers/serialize.ts` only.** Its test walks the module's exports for
|
||||
camelCase keys and for serializers without a case.
|
||||
|
||||
## Bun
|
||||
|
||||
- `bun <file>` not `node`, `bun test` not jest/vitest, `bun install`, `bunx`
|
||||
- `Bun.serve()` — not express. `Bun.file` over `node:fs`. `Bun.$` over execa
|
||||
- Bun loads `.env` automatically; do not add dotenv
|
||||
- `bun dev` and `bun start` set the env markers. Running `src/server.ts` directly throws, by design
|
||||
|
||||
## Before you finish
|
||||
|
||||
- `bunx tsc --noEmit` and `bun test`
|
||||
- Verify claims rather than asserting them — start the server and request the thing
|
||||
- Commit with a message that says *why*, not what the diff already shows
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
# Code Conventions
|
||||
|
||||
This document outlines the coding patterns and conventions used in this project.
|
||||
|
||||
A living document — adjust it as the project's needs change.
|
||||
|
||||
## Component Organization
|
||||
|
||||
### Feature Folder Pattern
|
||||
|
||||
Components that represent a feature or screen should use a folder structure with a barrel export.
|
||||
|
||||
```
|
||||
Feature/
|
||||
├── index.tsx # exports from Feature.tsx
|
||||
├── Feature.tsx # main component
|
||||
├── SubComponent.tsx
|
||||
└── utils.ts
|
||||
```
|
||||
|
||||
**index.tsx:**
|
||||
|
||||
```tsx
|
||||
export * from './Feature';
|
||||
```
|
||||
|
||||
**Rationale:** Keeps imports clean (`import { Feature } from './Feature'`) while allowing the feature to grow into multiple files without changing import paths. Subcomponents that only serve this feature live in the same directory rather than being abstracted to a shared components folder.
|
||||
|
||||
### Types Alongside Components
|
||||
|
||||
Export component prop types from the same file as the component.
|
||||
|
||||
```tsx
|
||||
export type FeatureProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const Feature = ({ value, onChange }: FeatureProps) => {
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**Rationale:** Keeps types discoverable and colocated with their usage. Consumers can import both the component and its types from the same path.
|
||||
|
||||
## State Management
|
||||
|
||||
### Manager Pattern for Hooks
|
||||
|
||||
Hooks that manage complex state should return a "manager" object. Components receive this manager as a prop.
|
||||
|
||||
```tsx
|
||||
// Hook
|
||||
export const useFeatureManager = () => {
|
||||
const [state, setState] = useState('');
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filteredItems = (() => {
|
||||
// derived state computation
|
||||
})();
|
||||
|
||||
return {
|
||||
state,
|
||||
setState,
|
||||
filter,
|
||||
setFilter,
|
||||
filteredItems,
|
||||
};
|
||||
};
|
||||
|
||||
export type FeatureManager = ReturnType<typeof useFeatureManager>;
|
||||
|
||||
// Parent component
|
||||
const Parent = () => {
|
||||
const manager = useFeatureManager();
|
||||
return <Child manager={manager} />;
|
||||
};
|
||||
|
||||
// Child component
|
||||
const Child = ({ manager }: { manager: FeatureManager }) => {
|
||||
const { state, filteredItems } = manager;
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**Rationale:** Centralizes state logic in one place. Child components don't need to know about individual state setters - they just receive the manager. Makes refactoring easier since state shape changes only affect the hook.
|
||||
|
||||
### State Colocation in Hooks
|
||||
|
||||
All local state (search, filters, pagination, expanded states) should live in the feature's hook, not scattered across components.
|
||||
|
||||
```tsx
|
||||
// Good
|
||||
export const useFeatureManager = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
// ...
|
||||
};
|
||||
|
||||
// Avoid
|
||||
const Component = () => {
|
||||
const [search, setSearch] = useState(''); // state in component
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**Rationale:** Single source of truth for feature state. Easier to understand, test, and modify. Prevents state synchronization bugs.
|
||||
|
||||
### Derived State in Hooks
|
||||
|
||||
Computed/derived values should be calculated in the hook, not in components.
|
||||
|
||||
```tsx
|
||||
export const useFeatureManager = () => {
|
||||
const [items, setItems] = useState([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
// Derived state computed in hook
|
||||
const filteredItems = (() => {
|
||||
return items.filter((item) => item.name.includes(filter));
|
||||
})();
|
||||
|
||||
const itemCount = filteredItems.length;
|
||||
|
||||
return { items, filter, setFilter, filteredItems, itemCount };
|
||||
};
|
||||
```
|
||||
|
||||
**Rationale:** Components stay focused on rendering. Business logic stays in one place. Derived values are computed once and shared across all consuming components.
|
||||
|
||||
## React Patterns
|
||||
|
||||
### Reach for useMemo / useCallback only when they do work
|
||||
|
||||
Default to writing the code plainly. Most derived values are cheap and re-computing them per render costs
|
||||
less than the memo that guards them.
|
||||
|
||||
```tsx
|
||||
// ✅ Fine — cheap, so just write it
|
||||
const filteredItems = items.filter((item) => item.active);
|
||||
const handleClick = () => doSomething();
|
||||
```
|
||||
|
||||
But they are ordinary tools, not forbidden ones. Use them where they earn it:
|
||||
|
||||
- a value or callback in a **dependency array**, where an unstable identity re-runs an effect or
|
||||
re-subscribes a socket every render;
|
||||
- a genuinely **expensive** computation over a large list;
|
||||
- a prop passed to a **memoized** child.
|
||||
|
||||
```tsx
|
||||
// ✅ Earns it — an unstable callback here would re-subscribe on every render
|
||||
const onConnectionChange = useCallback((state) => setConnState(state), [setConnState]);
|
||||
```
|
||||
|
||||
The React Compiler is a separate, opt-in build plugin and is **not installed here** (there is no
|
||||
`babel-plugin-react-compiler` in `package.json`); React 19 on its own memoizes nothing.
|
||||
|
||||
### Computation Functions Outside Components
|
||||
|
||||
Extract complex computations into functions declared below the component, not as IIFEs inside.
|
||||
|
||||
```tsx
|
||||
export const Component = ({ data }: Props) => {
|
||||
const metrics = computeMetrics(data);
|
||||
const stats = computeStats(data, metrics.total);
|
||||
|
||||
return <div>{/* ... */}</div>;
|
||||
};
|
||||
|
||||
// Functions below component
|
||||
function computeMetrics(data: Data[]): Metrics {
|
||||
// complex computation
|
||||
}
|
||||
|
||||
function computeStats(data: Data[], total: number): Stats {
|
||||
// complex computation
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:** Keeps the component body focused on rendering logic. Functions are testable in isolation. Easier to read and understand the component's purpose.
|
||||
|
||||
### Fragment Shorthand
|
||||
|
||||
Use `<>` for fragments. Only import and use `Fragment` when a `key` prop is required.
|
||||
|
||||
```tsx
|
||||
// Good - no key needed
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Content />
|
||||
</>
|
||||
);
|
||||
|
||||
// Good - key required
|
||||
import { Fragment } from 'react';
|
||||
|
||||
return items.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<ItemHeader item={item} />
|
||||
<ItemContent item={item} />
|
||||
</Fragment>
|
||||
));
|
||||
```
|
||||
|
||||
**Rationale:** `<>` is cleaner and more concise. `Fragment` is only needed for the `key` prop which `<>` doesn't support.
|
||||
|
||||
## Imports
|
||||
|
||||
### Single-Line Imports
|
||||
|
||||
Keep imports on a single line. If an import has too many items, split into multiple import statements.
|
||||
|
||||
```tsx
|
||||
// Good
|
||||
import { Button, Input, Select } from '@/components/ui';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
|
||||
// Good - split when too long
|
||||
import { TableBody, TableCell, TableHead } from '@/components/ui/table';
|
||||
import { TableHeader, TableRow } from '@/components/ui/table';
|
||||
|
||||
// Avoid - multiline imports
|
||||
import { Button, Input, Select, Card } from '@/components/ui';
|
||||
```
|
||||
|
||||
**Rationale:** Single-line imports are easier to scan and take less vertical space. Splitting by source module keeps related imports together.
|
||||
|
||||
## Utilities
|
||||
|
||||
### Use Existing Helpers
|
||||
|
||||
Prefer existing helper functions over inline implementations.
|
||||
|
||||
```tsx
|
||||
// Good
|
||||
import { formatCurrency } from 'helpers/formatters';
|
||||
|
||||
const display = formatCurrency(amount / 100, currency, 0);
|
||||
|
||||
// Avoid
|
||||
const display = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount / 100);
|
||||
```
|
||||
|
||||
**Rationale:** Consistent formatting across the app. Single place to modify behavior. Less code duplication and potential for bugs.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
server.ts Bun.serve — public routes, /api/*, then the SPA
|
||||
env-file.ts APP_ENV, layered env files, ambient environment last
|
||||
servers/
|
||||
create-router.ts createRouter() and HonoVariables
|
||||
hono.ts the MOUNTS list, mounted under /api
|
||||
errors.ts the one error envelope
|
||||
serialize.ts every API serializer, in one module — its test walks the exports
|
||||
api/<name>.ts one router per resource
|
||||
databases/app_db/ Drizzle — see docs/DATABASE.md
|
||||
apps/frontend/
|
||||
index.html tracked template, holds __PUBLIC_URL__
|
||||
index.gen.html generated, gitignored, what the server imports
|
||||
index.tsx React entry
|
||||
App.tsx the shell
|
||||
globals.css Tailwind and the shadcn theme
|
||||
components/ui/ shadcn primitives
|
||||
lib/routes.ts hash routing
|
||||
lib/api.ts the one fetch helper
|
||||
features/<Feature>/ one folder per screen — see above
|
||||
public/ served verbatim, globbed into routes at boot
|
||||
scripts/gen-index.ts __PUBLIC_URL__ substitution
|
||||
```
|
||||
|
||||
`@/*` resolves to `src/apps/frontend/*`, so `@/components/ui/button` and `@/lib/utils` work as shadcn
|
||||
generates them.
|
||||
|
||||
## Assets and URLs
|
||||
|
||||
**Asset URLs in components must be relative — never start with `/`.**
|
||||
|
||||
```tsx
|
||||
<img src="hero.webp" /> // ✅ resolves against <base>
|
||||
<img src="/hero.webp" /> // ❌ escapes a path-prefix proxy
|
||||
```
|
||||
|
||||
The app is served at `/` directly, and may be served under a prefix by a proxy. `<base href="/">` in
|
||||
`index.html` is what a rewriting proxy changes, so relative URLs follow the app; a leading slash
|
||||
bypasses it. `src/apps/frontend/asset-urls.test.ts` enforces this.
|
||||
|
||||
`fetch('/api/...')` is the exception — proxies that patch `window.fetch` rewrite absolute API paths.
|
||||
|
||||
Anything in `index.html` that a bundler would resolve on disk (a favicon `href`) must be an absolute
|
||||
URL via `__PUBLIC_URL__` — Bun fails the build on a root-relative one.
|
||||
|
||||
## Configuration
|
||||
|
||||
`APP_ENV`, never `NODE_ENV` — see README. `NODE_ENV` is still set explicitly by the `dev` script
|
||||
because React's conditional exports resolve against it: Bun emits `jsxDEV()` in development, and
|
||||
React's production jsx-dev-runtime defines `jsxDEV` as `void 0`. The transform and the runtime must
|
||||
agree.
|
||||
@@ -0,0 +1,84 @@
|
||||
# template-react
|
||||
|
||||
A starting point for an app. **Bun · Hono · React 19 SPA · Tailwind v4 · shadcn/ui · Postgres with
|
||||
Drizzle.** A JSON API at `/api` and a React app at `/`, over one query layer.
|
||||
|
||||
## 1. Make it yours
|
||||
|
||||
Everything project-specific is called `app`. Rename it once, before the first commit:
|
||||
|
||||
| Find | Replace with | Where |
|
||||
|---|---|---|
|
||||
| `APP_ENV` | `MYPROJECT_ENV` | `src/env-file.ts`, `package.json` scripts, `test-setup.ts`, `src/databases/app_db/src/env.ts` |
|
||||
| `appEnv` | `myprojectEnv` | `src/env-file.ts` and its importers |
|
||||
| `appdb` / `app_db` | `myprojectdb` / `myproject_db` | `src/databases/`, `tsconfig.json` paths, `package.json` scripts |
|
||||
| `app` / `app_dev` (database names) | `myproject` / `myproject_dev` | `src/databases/app_db/src/env.ts`, `.env.example` |
|
||||
| `"name": "app"` | your package name | `package.json` |
|
||||
| `App` | your brand | `src/apps/frontend/App.tsx`, `index.html` `<title>` |
|
||||
| `3000` | a port of your own | `.env.example`, `src/server.ts` |
|
||||
|
||||
A case-sensitive search for `app`, `App` and `APP` finds all of it. Thirty seconds now, a chore later.
|
||||
|
||||
## 2. Setup
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp .env.example .env # BUN_PORT, PUBLIC_URL, POSTGRES_URL_DEV
|
||||
createdb app && createdb app_dev # or however you make databases
|
||||
bun run db:push # applies the schema — no tables yet, by design
|
||||
bun dev
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
bun dev # development on app_dev, with HMR
|
||||
bun start # production on app
|
||||
bun test
|
||||
bun run build # bundle the SPA to dist/
|
||||
bun run gen:index # index.gen.html from index.html — also run by predev/prestart/prebuild
|
||||
bun run db:push # schema -> app_dev; APP_ENV=production bun run db:push for production
|
||||
```
|
||||
|
||||
## What is served
|
||||
|
||||
| Path | |
|
||||
|---|---|
|
||||
| `/` and every unmatched path | the SPA — `src/apps/frontend` |
|
||||
| `/api/*` | the JSON API — `src/servers/api` |
|
||||
| everything in `public/` | from the root |
|
||||
|
||||
## Configuration, and the rules behind it
|
||||
|
||||
**`APP_ENV`, never `NODE_ENV`.** It must be `production` or `development` and throws when unset.
|
||||
Some hosts export `NODE_ENV=production` into every process they spawn, so anything keyed on it
|
||||
silently treats development as production.
|
||||
|
||||
**`NODE_ENV` is still set by both scripts, and must stay explicit.** React resolves its runtime
|
||||
against it: Bun's bundler emits `jsxDEV()` calls in development, and React's *production*
|
||||
`jsx-dev-runtime` defines `jsxDEV` as `void 0`. Inherit production while transforming for development
|
||||
and the app dies with "jsxDEV is not a function".
|
||||
|
||||
**Env files are read with the ambient environment last:** `.env.production.local` (production only)
|
||||
→ `.env.local` → `.env` → `process.env`.
|
||||
|
||||
**The database follows from `APP_ENV`** and is enforced before a connection opens; tests cannot reach
|
||||
production data.
|
||||
|
||||
**`PUBLIC_URL`** is substituted into `index.html` by `scripts/gen-index.ts` because Bun's HTML bundler
|
||||
fails the build on a root-relative favicon `href`. It must be the public origin in production.
|
||||
|
||||
## The placeholder
|
||||
|
||||
`src/apps/frontend/App.tsx` is a shell with one tab and no features. Add the first screen as a feature
|
||||
folder — `CONVENTIONS.md` describes the shape. The database has **no tables**; `docs/DATABASE.md`
|
||||
describes adding the first.
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `CLAUDE.md` | The rules an agent needs before touching anything |
|
||||
| `CONVENTIONS.md` | React patterns — feature folders, manager hooks — and the project layout |
|
||||
| `docs/DATABASE.md` | Drizzle structure, `push` not migrations, `check()` vs open `text` |
|
||||
| `docs/SURFACES.md` | The API and the app over one query layer |
|
||||
@@ -0,0 +1,26 @@
|
||||
import tailwind from "bun-plugin-tailwind";
|
||||
import { rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const outdir = path.join(process.cwd(), "dist");
|
||||
await rm(outdir, { recursive: true, force: true });
|
||||
|
||||
// Only the generated HTML. The tracked index.html still holds __PUBLIC_URL__ placeholders, and
|
||||
// building it would emit a page whose favicon hrefs are the literal placeholder text.
|
||||
const entrypoints = [...new Bun.Glob("src/**/*.gen.html").scanSync()];
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints,
|
||||
outdir,
|
||||
plugins: [tailwind],
|
||||
minify: true,
|
||||
target: "browser",
|
||||
sourcemap: "linked",
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
},
|
||||
});
|
||||
|
||||
for (const output of result.outputs) {
|
||||
console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
// Generated by `bun init`
|
||||
|
||||
declare module "*.svg" {
|
||||
/**
|
||||
* A path to the SVG file
|
||||
*/
|
||||
const path: `${string}.svg`;
|
||||
export = path;
|
||||
}
|
||||
|
||||
declare module "*.png" {
|
||||
/**
|
||||
* A path to the PNG file
|
||||
*/
|
||||
const path: `${string}.png`;
|
||||
export = path;
|
||||
}
|
||||
|
||||
declare module "*.jpg" {
|
||||
/**
|
||||
* A path to the JPEG file
|
||||
*/
|
||||
const path: `${string}.jpg`;
|
||||
export = path;
|
||||
}
|
||||
|
||||
declare module "*.webp" {
|
||||
/**
|
||||
* A path to the WebP file
|
||||
*/
|
||||
const path: `${string}.webp`;
|
||||
export = path;
|
||||
}
|
||||
|
||||
declare module "*.css" {}
|
||||
|
||||
declare module "*.module.css" {
|
||||
/**
|
||||
* A record of class names to their corresponding CSS module classes
|
||||
*/
|
||||
const classes: { readonly [key: string]: string };
|
||||
export = classes;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "platform",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hono": "^4.13.2",
|
||||
"lucide-react": "^1",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"tailwindcss": "^4.1.11",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||
|
||||
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||
|
||||
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="],
|
||||
|
||||
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="],
|
||||
|
||||
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="],
|
||||
|
||||
"@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="],
|
||||
|
||||
"@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="],
|
||||
|
||||
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="],
|
||||
|
||||
"@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="],
|
||||
|
||||
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="],
|
||||
|
||||
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="],
|
||||
|
||||
"@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="],
|
||||
|
||||
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="],
|
||||
|
||||
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="],
|
||||
|
||||
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="],
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.3.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="],
|
||||
|
||||
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.14.2", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw=="],
|
||||
|
||||
"hono": ["hono@4.13.2", "", {}, "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
|
||||
|
||||
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
|
||||
|
||||
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[serve.static]
|
||||
plugins = ["bun-plugin-tailwind"]
|
||||
env = "BUN_PUBLIC_*"
|
||||
|
||||
[test]
|
||||
# The repo, not just `src` — scripts/ are this project's tests too.
|
||||
root = "."
|
||||
preload = ["./test-setup.ts"]
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/apps/frontend/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# Database
|
||||
|
||||
One PostgreSQL database, `app`, exposed as the package `appdb`. Managed with Drizzle. There is no
|
||||
second database and no cache database.
|
||||
|
||||
```
|
||||
src/databases/app_db/
|
||||
├── drizzle.config.ts
|
||||
├── package.json exports ".", "./db", "./schema", "./*"
|
||||
└── src/
|
||||
├── db.ts the connection
|
||||
├── env.ts which database this process may reach
|
||||
├── index.ts public surface: one `export * from './<feature>'` per line
|
||||
├── schema.ts what db:push creates — see below
|
||||
└── <feature>/
|
||||
├── index.ts what this feature exports, declared beside the code it describes
|
||||
├── schema.ts its tables
|
||||
└── queries.ts everything that reads or writes them
|
||||
```
|
||||
|
||||
## A feature owns its own public surface
|
||||
|
||||
`src/index.ts` is one `export *` per feature and nothing else. What a feature exports is declared in
|
||||
its own `index.ts`.
|
||||
|
||||
Adding a query is then one file in one directory, rather than that file plus a hand-written list of
|
||||
every symbol in the package. Those lists drift: the platform's reached 297 lines with twelve features
|
||||
listed twice before it was replaced.
|
||||
|
||||
## One directory per feature, holding both halves
|
||||
|
||||
Not parallel `schema/` and `queries/` trees. Those drift too — the platform ended up with four
|
||||
features named differently on each side, one with no query file and one with no schema file, and
|
||||
nothing structural said so.
|
||||
|
||||
A directory that is lopsided says so by its contents.
|
||||
|
||||
## `src/schema.ts` is drizzle-kit's view, not the runtime's
|
||||
|
||||
`drizzle.config.ts` points at it, so **commenting one line there removes a table from the DATABASE
|
||||
without removing a line of code**. Every query imports its tables from `./schema` inside its own
|
||||
feature directory, so the code still compiles.
|
||||
|
||||
That is what lets a fresh install create only the tables it needs, with optional ones commented out
|
||||
until something wants them.
|
||||
|
||||
## Schema changes use `push`, not migrations
|
||||
|
||||
```bash
|
||||
bun run db:push # -> app_dev
|
||||
APP_ENV=production bun run db:push # -> app, deliberately
|
||||
```
|
||||
|
||||
`db:push` diffs the schema code against the live database and alters it directly. **Treat the schema
|
||||
code as the source of truth.** `db:gen` writes files to `migrations/`, but nothing applies them — do
|
||||
not start reading those files as though they were the record.
|
||||
|
||||
## The schema is the source of truth for what the database may CONTAIN
|
||||
|
||||
Not just for its shape. Where a column has a known set of legal values, say so with a `check()` rather
|
||||
than leaving it free `text` — otherwise a value the code stopped supporting sits there unnoticed, and
|
||||
nothing structural says it became illegal.
|
||||
|
||||
```ts
|
||||
export const ORDER_STATUSES = ["pending", "paid", "shipped"] as const;
|
||||
|
||||
status: text("status").notNull().$type<(typeof ORDER_STATUSES)[number]>(),
|
||||
// in the table's second argument:
|
||||
check("orders_status_check", sql`${table.status} in ('pending', 'paid', 'shipped')`),
|
||||
```
|
||||
|
||||
**But `text` without a check where the set must stay open.** A category, a provider name, anything a
|
||||
third party can add: an enum cannot contain a value nobody has compiled yet, and adding one must not
|
||||
be a schema change. The test is whether the set is closed by us or open to the world.
|
||||
|
||||
## Which database a process reaches
|
||||
|
||||
Decided by `APP_ENV` and **enforced** in `env.ts` before a connection opens:
|
||||
|
||||
| `APP_ENV` | Database |
|
||||
|---|---|
|
||||
| `production` | `app` (`POSTGRES_URL`, from `.env.production.local`) |
|
||||
| `development` | `app_dev` (`POSTGRES_URL_DEV`, from `.env`) |
|
||||
|
||||
Any other pairing throws. The check is on the database **name**, because host and credentials are
|
||||
usually shared and one word at the end of a long URL is all that separates them.
|
||||
|
||||
## Things that bite, already handled here
|
||||
|
||||
**The client is a per-process singleton on `globalThis`.** `bun --hot` re-evaluates modules on every
|
||||
edit; without it each save builds a fresh pool and abandons the last, queries in flight during the
|
||||
swap never resolve or release their connection, and the pool eventually wedges — every route that
|
||||
reads hangs while stateless ones stay fast.
|
||||
|
||||
**`waitForDatabase()` before startup work.** `postgres()` is lazy, so nothing fails at import when the
|
||||
database is not up. Startup work that queries otherwise fires once, fails, and is swallowed by
|
||||
whatever `.catch()` it was written with.
|
||||
|
||||
**Money is `numeric`, never a float**, and it comes back as a string. Sums of binary floating point
|
||||
are wrong in ways nobody notices until an invoice.
|
||||
|
||||
**Denormalise what is historical fact.** A row pointing at configuration — a provider, a price list —
|
||||
should also record what it actually used, as text. The configuration can be renamed, repointed or
|
||||
deleted; what happened cannot.
|
||||
|
||||
## Tests
|
||||
|
||||
Tests run against `app_dev`; the `APP_ENV` guard makes production unreachable from them.
|
||||
|
||||
Two rules learned expensively: **clean up what you write**, and **restore what you disturb**. A test
|
||||
that sets a "default" flag clears it on every other row, so deleting only your own rows afterwards is
|
||||
not enough — snapshot the previous value and put it back.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Apps and surfaces
|
||||
|
||||
Two surfaces, **one process, one port, one database**:
|
||||
|
||||
```
|
||||
example.com/ ──┤ apps/frontend │ React SPA, talks to /api
|
||||
example.com/api/thing ──┤ servers/api │ serializers -> JSON
|
||||
└───────┬─────────┘
|
||||
│
|
||||
┌───────┴─────────┐
|
||||
│ appdb │ queries
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
Here the SPA is a client of the API like any other, which is the point of a SPA. The rule below is
|
||||
about the server side: the API is the only door to the data, and it must never return HTML.
|
||||
|
||||
## HTML and JSON are siblings, not layers
|
||||
|
||||
## The rule
|
||||
|
||||
**Both surfaces call the query layer. Neither calls the other.**
|
||||
|
||||
### The web app does not call its own API
|
||||
|
||||
It runs in the same process. Going out over HTTP to talk to ourselves buys a round trip, a
|
||||
serialisation, a deserialisation, and a second way for two screens to disagree. It also makes every
|
||||
page render depend on the server being able to reach itself, which is a failure mode nobody wants to
|
||||
debug.
|
||||
|
||||
Import the query function. That is what it is for.
|
||||
|
||||
### The API does not return HTML
|
||||
|
||||
Not even for htmx. It is tempting — the fragment is right there — but the API is a documented,
|
||||
external contract consumed by things that are not this website, and htmx's needs are not theirs. A
|
||||
fragment that becomes a de facto API cannot be changed without breaking someone, and you find out
|
||||
when they tell you.
|
||||
|
||||
Fragments belong to `apps/web` and are free to change on any afternoon, precisely because nothing
|
||||
outside this repo depends on them.
|
||||
|
||||
## What they share, and what they must not
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Shared** | query functions, domain rules, database types |
|
||||
| **Not shared** | serialisation, error presentation, validation of transport, URL shape |
|
||||
|
||||
The API has serializers because JSON needs a stable, named, snake_case shape. The web app has
|
||||
templates because HTML needs markup. Trying to make one produce the other gives you a serializer with
|
||||
markup in it.
|
||||
|
||||
## They are allowed to behave differently
|
||||
|
||||
This is the part that feels wrong and is right.
|
||||
|
||||
The two surfaces have different callers, and the same event can deserve different answers:
|
||||
|
||||
| Situation | `/api` | the site |
|
||||
|---|---|---|
|
||||
| Creating something whose name is taken | **409** with an error envelope | show the list, with the existing one in it |
|
||||
| A malformed id | **404**, in the envelope | render the page's empty state |
|
||||
| Something failed | error object with a `type` a program can branch on | a sentence a person can read |
|
||||
|
||||
A program needs to distinguish "already exists" from "created". A human clicking Create wants the
|
||||
thing they asked for to be on screen; being shown an error for a collection that exists is a worse
|
||||
answer than showing it to them.
|
||||
|
||||
**What must never diverge is the query layer.** If the two surfaces can produce different *data* for
|
||||
the same request, that is a bug. If they present the same data differently, that is the point.
|
||||
|
||||
## Naming
|
||||
|
||||
`/thing` and `/api/thing` stay parallel. `/api` is mounted before the site's catch-all in
|
||||
`src/server.ts`, so it is resolved first and the site never swallows an API path.
|
||||
|
||||
Within the site, pages and partials are **different URLs**, never the same URL switching on
|
||||
`HX-Request` — see `docs/WEB.md`. Combined with the `/api` split, that gives three URL spaces with one
|
||||
representation each, and every one of them is cacheable without a `Vary` header.
|
||||
|
||||
## Adding a resource
|
||||
|
||||
1. `app_db/src/<feature>/` — schema and queries. Both surfaces will import these.
|
||||
2. `servers/api/<feature>.ts` — a router, plus a serializer in the shared serializer module.
|
||||
3. `apps/web/<feature>/partials.tsx` and a page — both calling the same queries.
|
||||
4. Mount the API router in `servers/hono.ts`; add the site's routes in `apps/web/index.tsx`.
|
||||
|
||||
If step 3 finds itself wanting something step 2 already computed, that logic belongs in step 1.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"gen:index": "bun run ./scripts/gen-index.ts",
|
||||
"predev": "bun run ./scripts/gen-index.ts",
|
||||
"dev": "NODE_ENV=development APP_ENV=development bun --hot src/server.ts",
|
||||
"prestart": "bun run ./scripts/gen-index.ts",
|
||||
"start": "NODE_ENV=production APP_ENV=production bun src/server.ts",
|
||||
"prebuild": "bun run ./scripts/gen-index.ts",
|
||||
"build": "bun run build.ts",
|
||||
"db:gen": "cd src/databases/app_db && APP_ENV=${APP_ENV:-development} bun run generate",
|
||||
"db:push": "cd src/databases/app_db && APP_ENV=${APP_ENV:-development} bun run push",
|
||||
"db:studio": "cd src/databases/app_db && APP_ENV=${APP_ENV:-development} bun run studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hono": "^4.13.2",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Static files served from the root. Anything here is public.
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bun
|
||||
// Generates src/apps/frontend/index.gen.html from index.html, substituting __PUBLIC_URL__ with
|
||||
// PUBLIC_URL from .env.
|
||||
//
|
||||
// Favicon and OpenGraph hrefs have to be absolute. Bun's HTML bundler treats a root-relative href as
|
||||
// an asset to resolve on disk and fails the build ("Could not resolve: /favicon.ico"), and crawlers
|
||||
// fetch OG tags standalone so they cannot resolve a relative URL either. Hardcoding the domain would
|
||||
// force every deployment to carry its own edit of index.html, and Bun's HTML import offers no
|
||||
// substitution hook — so the swap happens here and the server imports the generated file.
|
||||
//
|
||||
// index.gen.html is gitignored; index.html is the tracked template and is identical on every instance.
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const template = join(root, "src/apps/frontend/index.html");
|
||||
const output = join(root, "src/apps/frontend/index.gen.html");
|
||||
|
||||
// Where the URL comes from, most specific first:
|
||||
//
|
||||
// 1. the first argument `bun gen:index https://example.com`
|
||||
// 2. PUBLIC_URL in this project's .env
|
||||
// 3. PUBLIC_URL in the environment
|
||||
//
|
||||
// Note the order: .env BEATS the ambient environment, which is the reverse of the usual convention.
|
||||
// A host that exports its own PUBLIC_URL into every process it spawns would otherwise stamp its
|
||||
// domain onto this app's favicon hrefs. A value in this project's own .env is a statement about this
|
||||
// project; an inherited one is a statement about whatever launched it.
|
||||
const argUrl = process.argv[2]?.trim();
|
||||
|
||||
let dotenvUrl: string | undefined;
|
||||
const envPath = join(root, ".env");
|
||||
if (!argUrl && existsSync(envPath)) {
|
||||
for (const line of (await Bun.file(envPath).text()).split("\n")) {
|
||||
const match = line.match(/^\s*PUBLIC_URL\s*=\s*(.*)$/);
|
||||
if (match) dotenvUrl = match[1]!.trim().replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
const publicUrl = (argUrl || dotenvUrl || process.env.PUBLIC_URL || "").replace(/\/+$/, "");
|
||||
if (!publicUrl) {
|
||||
console.error("[gen-index] no public URL. Pass one — `bun gen:index http://localhost:3001` —");
|
||||
console.error("[gen-index] or set PUBLIC_URL in .env.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Caught here rather than left to a crawler: a relative or scheme-less value substitutes without
|
||||
// complaint and produces tags nothing can resolve, which stays invisible until someone shares a link.
|
||||
try {
|
||||
const parsed = new URL(publicUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("not http(s)");
|
||||
} catch {
|
||||
console.error(`[gen-index] "${publicUrl}" is not an absolute http(s) URL.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const html = await Bun.file(template).text();
|
||||
const generated = html.replaceAll("__PUBLIC_URL__", publicUrl);
|
||||
|
||||
if (generated.includes("__PUBLIC_URL__")) {
|
||||
console.error("[gen-index] substitution left placeholders behind");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Only write when the content actually changes, so `bun --hot` does not restart in a loop.
|
||||
if (existsSync(output) && (await Bun.file(output).text()) === generated) {
|
||||
console.log(`[gen-index] up to date (${publicUrl})`);
|
||||
} else {
|
||||
await Bun.write(output, generated);
|
||||
console.log(`[gen-index] wrote index.gen.html (${publicUrl})`);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { TABS, TAB_LABELS, useHashRoute } from "@/lib/routes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The shell. One tab, no features — a placeholder that proves the routing and the theme, to be
|
||||
* replaced by the first real screen.
|
||||
*
|
||||
* Routing is in the URL hash rather than the path, and not as a shortcut: this app is served at `/`
|
||||
* directly and may be served under a path prefix by a proxy, and a path router needs a basename it
|
||||
* can only learn at runtime. The hash means the same thing at either mount point. See lib/routes.ts.
|
||||
*/
|
||||
export function App() {
|
||||
const { tab } = useHashRoute();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="border-b border-border">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-3 px-6 py-4">
|
||||
<h1 className="flex-1 text-xl font-semibold tracking-tight">App</h1>
|
||||
<nav className="flex gap-1">
|
||||
{TABS.map((name) => (
|
||||
// Anchors, not buttons: the hash is the state, so navigation is a link. Back and
|
||||
// forward work, and the tab can be copied out of the address bar.
|
||||
<a
|
||||
key={name}
|
||||
href={`#${name}`}
|
||||
aria-current={tab === name ? "page" : undefined}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-sm transition",
|
||||
tab === name ? "bg-secondary text-secondary-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{TAB_LABELS[name]}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
||||
{tab === "home" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Placeholder. Delete this and add the first real screen as a feature folder — see CONVENTIONS.md.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Guards the one rule that broke the preview: an asset URL starting with "/" escapes the proxy prefix.
|
||||
//
|
||||
// Served through a path-prefix proxy the app lives under /<prefix>/, and only the HTML is
|
||||
// rewritten — a root-absolute path inside a component reaches the platform root, which answers
|
||||
// 200 text/html. The image is broken and the network tab shows no error, so this is worth a test
|
||||
// rather than a note someone reads after losing an afternoon to it.
|
||||
|
||||
const frontendDir = dirname(fileURLToPath(import.meta.url));
|
||||
const templatePath = resolve(frontendDir, "index.html");
|
||||
|
||||
describe("asset URLs", () => {
|
||||
test("index.html declares a base so relative URLs can be rewritten", async () => {
|
||||
const html = await Bun.file(templatePath).text();
|
||||
expect(html).toMatch(/<base\s+href="\/"\s*\/?>/);
|
||||
});
|
||||
|
||||
test("no component uses a root-absolute asset URL", async () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
for await (const relative of new Bun.Glob("**/*.tsx").scan({ cwd: frontendDir })) {
|
||||
const source = await Bun.file(resolve(frontendDir, relative)).text();
|
||||
|
||||
for (const [match] of source.matchAll(/\b(?:src|srcSet|poster)=["']\/[^/][^"']*["']/g)) {
|
||||
offenders.push(`${relative}: ${match}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<!-- `dark`: the shadcn theme is class-driven. Remove the class for a light UI; without it every
|
||||
primitive renders its light palette. -->
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Every relative asset URL on the page resolves against this.
|
||||
Served directly it stays "/", the app's real root. Served through a path-prefix proxy
|
||||
the app lives under /<prefix>/, and a proxy that rewrites root-relative href/src in
|
||||
the HTML — so this becomes that prefix and relative URLs follow the app instead of pointing
|
||||
at the platform root. Absolute paths in JS get no such treatment, which is why asset URLs in
|
||||
components are relative rather than starting with "/". -->
|
||||
<base href="/" />
|
||||
<title>App</title>
|
||||
|
||||
<!-- Favicons. These must stay absolute: Bun's HTML bundler treats a root-relative href as an
|
||||
asset to resolve on disk and fails the build ("Could not resolve: /favicon.ico").
|
||||
__PUBLIC_URL__ is substituted by scripts/gen-index.ts into index.gen.html, which is what the
|
||||
server imports. -->
|
||||
<link rel="icon" type="image/x-icon" href="__PUBLIC_URL__/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="__PUBLIC_URL__/icon-256.png" />
|
||||
|
||||
<script type="module" src="./index.tsx" async></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./globals.css";
|
||||
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
(import.meta.hot.data.root ??= createRoot(elem)).render(app);
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Every call the UI makes to its own server goes through here.
|
||||
*
|
||||
* Paths are absolute (`/api/...`) rather than relative, which is the opposite of the rule for assets.
|
||||
* A path-prefix proxy that patches `window.fetch` rewrites absolute paths onto its prefix; it does
|
||||
* not touch `<img src>`. So fetch wants the leading slash and images must not have one.
|
||||
*/
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: init?.body ? { "content-type": "application/json", ...init?.headers } : init?.headers,
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
// The server speaks one error envelope: { error: { message, type, param, code } }.
|
||||
const message = (body as { error?: { message?: string } } | null)?.error?.message;
|
||||
throw new Error(message || `Request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const health = () => request<{ status: string; env: string }>("/api/health");
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseHash, TAB_LABELS, TABS } from "./routes";
|
||||
|
||||
describe("parseHash", () => {
|
||||
test.each(["#home"])("reads %s", (hash) => {
|
||||
expect(parseHash(hash).tab).toBe(hash.slice(1) as never);
|
||||
});
|
||||
|
||||
// The hash a browser produces varies: some paths write `#/gallery`, and copied links arrive
|
||||
// capitalised. All of them mean the same tab.
|
||||
test.each(["#/home", "#Home", "home"])("normalises %s", (hash) => {
|
||||
expect(parseHash(hash).tab).toBe("home");
|
||||
});
|
||||
|
||||
// A refresh on the bare URL, and any hash left over from elsewhere, land somewhere valid rather
|
||||
// than rendering nothing.
|
||||
test.each(["", "#", "#nope", "#/../etc"])("falls back to home for %p", (hash) => {
|
||||
expect(parseHash(hash).tab).toBe("home");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested routes", () => {
|
||||
test("a collection id rides along in param", () => {
|
||||
const route = parseHash("#home/0004853c-e097-4af9-b40b-0666594703cf");
|
||||
expect(route.tab).toBe("home");
|
||||
expect(route.param).toBe("0004853c-e097-4af9-b40b-0666594703cf");
|
||||
});
|
||||
|
||||
test("a bare tab has no param", () => expect(parseHash("#home").param).toBeUndefined());
|
||||
|
||||
// A trailing slash is what you get from hand-editing the address bar.
|
||||
test("a trailing slash is not a param", () => expect(parseHash("#home/").param).toBeUndefined());
|
||||
|
||||
test("an unknown tab with a param still falls back", () => {
|
||||
expect(parseHash("#nope/123").tab).toBe("home");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tabs", () => {
|
||||
test("every tab has a label", () => {
|
||||
for (const tab of TABS) expect(TAB_LABELS[tab]).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the hash written for a tab parses back to it", () => {
|
||||
for (const tab of TABS) expect(parseHash(`#${tab}`).tab).toBe(tab);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const TABS = ["home"] as const;
|
||||
export type Tab = (typeof TABS)[number];
|
||||
|
||||
export const TAB_LABELS: Record<Tab, string> = {
|
||||
home: "Home",
|
||||
};
|
||||
|
||||
const DEFAULT_TAB: Tab = "home";
|
||||
|
||||
/**
|
||||
* Routing lives in the hash — `#gallery` — not in the path.
|
||||
*
|
||||
* Not a shortcut around a real router. The app is served at `/` directly and under
|
||||
* `/<prefix>/` behind a path-prefix proxy, so a path router needs a basename it can only
|
||||
* learn at runtime, and getting it wrong pushes a URL outside the proxy prefix — the same failure that
|
||||
* made the splash image a broken icon, except this one takes the whole page down rather than one
|
||||
* `<img>`.
|
||||
*
|
||||
* The hash is invisible to all of that. `#gallery` means the same thing at `/` and at
|
||||
* `/<prefix>/`, needs no server route, no basename and no build-time knowledge of where the
|
||||
* app is mounted. Back and forward work, links are copyable, and a refresh lands where it left.
|
||||
*
|
||||
* When there is a reason for real paths — a URL per generation, say — react-router with a basename
|
||||
* derived from `document.baseURI` is the upgrade. Three tabs is not that reason.
|
||||
*/
|
||||
/**
|
||||
* `#tab/<id>` — a tab and, optionally, one thing within it.
|
||||
*
|
||||
* One level of nesting, not a route tree. Anything deeper is the point at which react-router with a
|
||||
* basename derived from `document.baseURI` becomes the right upgrade.
|
||||
*/
|
||||
export type Route = { tab: Tab; param?: string };
|
||||
|
||||
export function parseHash(hash: string): Route {
|
||||
const [head, param] = hash.replace(/^#\/?/, "").split("/");
|
||||
const tab = (head ?? "").toLowerCase();
|
||||
|
||||
return {
|
||||
tab: (TABS as readonly string[]).includes(tab) ? (tab as Tab) : DEFAULT_TAB,
|
||||
param: param || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useHashRoute() {
|
||||
const [route, setRoute] = useState<Route>(() =>
|
||||
typeof window === "undefined" ? { tab: DEFAULT_TAB } : parseHash(window.location.hash),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setRoute(parseHash(window.location.hash));
|
||||
window.addEventListener("hashchange", onChange);
|
||||
return () => window.removeEventListener("hashchange", onChange);
|
||||
}, []);
|
||||
|
||||
/** For navigating without a click; anchors handle the rest. */
|
||||
const navigate = (tab: Tab, param?: string) => {
|
||||
const next = param ? `${tab}/${param}` : tab;
|
||||
if (window.location.hash.replace(/^#\/?/, "") !== next) window.location.hash = next;
|
||||
};
|
||||
|
||||
return { ...route, navigate };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
import { postgresUrl } from "./src/env";
|
||||
|
||||
// postgresUrl() reads this project's env files in preference to the ambient environment and refuses
|
||||
// any database that does not match APP_ENV. Without that, drizzle-kit resolves whatever
|
||||
// POSTGRES_URL happens to be exported into the shell and offers to push this schema onto it.
|
||||
export default defineConfig({
|
||||
schema: "./src/schema.ts",
|
||||
out: "./migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: { url: postgresUrl() },
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "appdb",
|
||||
"version": "0.0.1",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./db": "./src/db.ts",
|
||||
"./schema": "./src/schema.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||
"push": "drizzle-kit push --config=drizzle.config.ts",
|
||||
"studio": "drizzle-kit studio --config=drizzle.config.ts"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
import { postgresUrl } from "./env";
|
||||
|
||||
/**
|
||||
* One client per PROCESS, not one per module evaluation.
|
||||
*
|
||||
* `bun --hot` re-evaluates this module on every edit. Without the global, each reload builds a fresh
|
||||
* pool and abandons the previous one — its sockets stay open, and any query in flight when the swap
|
||||
* happened never resolves and never releases its connection. Connections climb with every save, and a
|
||||
* pool whose connections are all reserved by promises nobody holds queues the next query forever:
|
||||
* fast on everything stateless, hung on everything that reads.
|
||||
*/
|
||||
const globalForDb = globalThis as unknown as { __appPgClient?: ReturnType<typeof postgres> };
|
||||
|
||||
const client = (globalForDb.__appPgClient ??= postgres(postgresUrl(), {
|
||||
max: 10,
|
||||
// Recycle idle connections, so one left in a bad state by a failed query is replaced rather than
|
||||
// poisoning the pool for the life of the process.
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 10,
|
||||
// Bounds the server-side half of a hang; the global above bounds the client-side half.
|
||||
connection: { statement_timeout: 15_000 },
|
||||
}));
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
/**
|
||||
* Block until Postgres answers, or give up.
|
||||
*
|
||||
* `postgres()` is LAZY — no socket opens until the first query — so nothing fails at import when the
|
||||
* database is not up yet. That is the right default and it has a cost: startup work that queries
|
||||
* fires once, fails, and is swallowed by whatever `.catch()` it was written with. Callers that
|
||||
* genuinely cannot proceed without the database await this first.
|
||||
*/
|
||||
export async function waitForDatabase(timeoutMs = 30_000): Promise<boolean> {
|
||||
const started = Date.now();
|
||||
let announced = false;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await client`select 1`;
|
||||
if (announced) console.log("[db] Postgres is up");
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (Date.now() - started >= timeoutMs) {
|
||||
console.error("[db] Postgres did not answer:", error instanceof Error ? error.message : error);
|
||||
return false;
|
||||
}
|
||||
if (!announced) {
|
||||
console.log("[db] waiting for Postgres…");
|
||||
announced = true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { isProduction, readEnvFile } from "../../../env-file";
|
||||
|
||||
/** Shared with the server — see src/env-file.ts for why the ambient environment comes last. */
|
||||
export const projectEnv = readEnvFile;
|
||||
|
||||
/**
|
||||
* Which database this process may talk to, decided by APP_ENV and ENFORCED, not merely configured.
|
||||
*
|
||||
* APP_ENV=production -> app (POSTGRES_URL, from .env.production.local)
|
||||
* APP_ENV=development -> app_dev (POSTGRES_URL_DEV, from .env)
|
||||
*
|
||||
* Any other pairing throws before a connection is opened, so a development process cannot reach
|
||||
* production data even if someone edits the URL, and the test suite cannot write to rows a live
|
||||
* instance is serving.
|
||||
*
|
||||
* The check is on the DATABASE NAME rather than on the whole URL, because host and credentials are
|
||||
* usually shared: one word is all that separates the two, and it is at the end of a long string
|
||||
* nobody reads carefully.
|
||||
*
|
||||
* `db:push` inherits the rule, so schema changes land on app_dev unless APP_ENV=production is
|
||||
* stated out loud — which is the only time a migration should be near production anyway.
|
||||
*/
|
||||
export function postgresUrl(): string {
|
||||
const production = isProduction();
|
||||
const expected = production ? "app" : "app_dev";
|
||||
const key = production ? "POSTGRES_URL" : "POSTGRES_URL_DEV";
|
||||
|
||||
const url = projectEnv(key);
|
||||
if (!url) throw new Error(`${key} is required — set it in .env`);
|
||||
|
||||
const name = new URL(url).pathname.replace(/^\//, "");
|
||||
if (name !== expected) {
|
||||
throw new Error(
|
||||
`Refusing to connect: APP_ENV=${process.env.APP_ENV} requires database "${expected}", ` +
|
||||
`but ${key} points at "${name}".`,
|
||||
);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// One `export *` per feature, and nothing else. What a feature exports is declared in its own
|
||||
// index.ts, beside the code it describes.
|
||||
|
||||
export { db, waitForDatabase } from "./db";
|
||||
@@ -0,0 +1,16 @@
|
||||
// What drizzle-kit creates, and the runtime's view of the tables.
|
||||
//
|
||||
// Its own file rather than pointing at the feature directories, so a table can be taken out of the
|
||||
// DATABASE by commenting one line here without removing a line of code — every query imports its
|
||||
// tables from its own feature's ./schema.
|
||||
//
|
||||
// Empty on purpose. There is nothing to persist yet, and a speculative table is a guess that costs a
|
||||
// migration to correct. Add the first feature when something real needs storing:
|
||||
//
|
||||
// src/databases/app_db/src/<feature>/schema.ts the tables
|
||||
// src/databases/app_db/src/<feature>/queries.ts everything that reads or writes them
|
||||
// src/databases/app_db/src/<feature>/index.ts export * from both
|
||||
//
|
||||
// then one `export * from "./<feature>/schema"` here and one in index.ts, and `bun run db:push`.
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
/**
|
||||
* Which deployment this process is. Required, namespaced, and never inferred.
|
||||
*
|
||||
* NOT NODE_ENV. A host that exports NODE_ENV into the processes it spawns makes `bun dev` look like
|
||||
* production to anything that asks, and the failure is silent — you get a plausible wrong value
|
||||
* rather than an error. APP_ENV is prefixed, so nothing else on any machine sets it.
|
||||
*
|
||||
* NODE_ENV is still set by both scripts, deliberately: nothing here reads it, but third-party
|
||||
* packages resolve conditional exports against it, and the build and the runtime have to agree.
|
||||
*
|
||||
* It THROWS when unset rather than defaulting. Either default is a silent disaster — assume
|
||||
* production and a stray process writes to live data; assume development and the real instance
|
||||
* serves the wrong thing. Both package scripts set it, so arriving here unset means someone bypassed
|
||||
* them, which is exactly when nothing should be guessed.
|
||||
*/
|
||||
export function appEnv(): "production" | "development" {
|
||||
const value = process.env.APP_ENV;
|
||||
if (value === "production" || value === "development") return value;
|
||||
|
||||
throw new Error(
|
||||
`APP_ENV must be "production" or "development", got ${JSON.stringify(value)}. ` +
|
||||
"Start through `bun start` or `bun dev`, which set it.",
|
||||
);
|
||||
}
|
||||
|
||||
export const isProduction = () => appEnv() === "production";
|
||||
|
||||
/**
|
||||
* Configuration from this project's env files, with the ambient environment consulted LAST.
|
||||
*
|
||||
* .env.production.local only when APP_ENV=production
|
||||
* .env.local a machine's own overrides
|
||||
* .env the shared baseline
|
||||
* process.env last resort
|
||||
*
|
||||
* Files before environment, which is the reverse of the usual convention and is the point: an
|
||||
* inherited variable should never quietly outrank a value written down in this repo. Production runs
|
||||
* with its own `.env.production.local`, which leaves `.env` free to be the development instance's.
|
||||
*/
|
||||
export function readEnvFile(key: string): string | undefined {
|
||||
const candidates = [isProduction() ? ".env.production.local" : null, ".env.local", ".env"].filter(
|
||||
(name): name is string => name !== null,
|
||||
);
|
||||
|
||||
for (const name of candidates) {
|
||||
const path = resolve(ROOT, name);
|
||||
if (!existsSync(path)) continue;
|
||||
|
||||
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
||||
const match = line.match(/^\s*(\w+)\s*=\s*(.*)$/);
|
||||
if (match?.[1] === key) {
|
||||
const value = match[2]!.trim().replace(/^["']|["']$/g, "");
|
||||
if (value) return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return process.env[key];
|
||||
}
|
||||
|
||||
/** The absolute base URL this site is served from — used anywhere a URL must survive leaving here. */
|
||||
export const PUBLIC_URL = (readEnvFile("PUBLIC_URL") ?? "http://localhost:3000").replace(/\/+$/, "");
|
||||
|
||||
{
|
||||
const url = new URL(PUBLIC_URL);
|
||||
const isLoopback = ["localhost", "127.0.0.1", "::1", "0.0.0.0"].includes(url.hostname);
|
||||
|
||||
// A URL that travels has no local case: `localhost` on the receiving machine is that machine. And
|
||||
// iOS and Android both refuse cleartext silently — no error, just nothing rendering.
|
||||
if (isProduction() && isLoopback) {
|
||||
console.warn(`[env] PUBLIC_URL is a loopback address (${PUBLIC_URL}) in production.`);
|
||||
} else if (!isLoopback && url.protocol === "http:") {
|
||||
console.warn(`[env] PUBLIC_URL is cleartext (${PUBLIC_URL}). Mobile clients refuse http silently.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { serve } from "bun";
|
||||
import { honoServer } from "./servers/hono";
|
||||
import index from "./apps/frontend/index.gen.html";
|
||||
import { isProduction, PUBLIC_URL } from "./env-file";
|
||||
|
||||
// BUN_PORT rather than PORT: a PORT inherited from a parent process would be used silently, binding
|
||||
// a port that belongs to something else. Bun reads .env from the working directory, so start through
|
||||
// `bun dev` / `bun start`.
|
||||
const port = Number(process.env.BUN_PORT ?? 3000);
|
||||
|
||||
// Static files from public/.
|
||||
// A snapshot taken once at boot: one exact route per file, each holding a `Bun.file` handle opened
|
||||
// now. Exact routes rather than a wildcard, so a request for a file that does not exist falls through
|
||||
// to the app below rather than answering 500 from a missing handle.
|
||||
const publicRoutes: Record<string, (req: Request) => Response> = {};
|
||||
if (existsSync("./public")) {
|
||||
for await (const file of new Bun.Glob("**").scan({ cwd: "./public" })) {
|
||||
const bunFile = Bun.file(`./public/${file}`);
|
||||
publicRoutes[`/${file}`] = () => new Response(bunFile);
|
||||
}
|
||||
}
|
||||
|
||||
const server = serve({
|
||||
port,
|
||||
|
||||
// Bun's default is 10 SECONDS, which silently kills anything held open longer — a long poll, a
|
||||
// slow upstream, a large upload. 255 is the ceiling; 256 is rejected outright.
|
||||
idleTimeout: 255,
|
||||
|
||||
routes: {
|
||||
...publicRoutes,
|
||||
|
||||
// Hono owns everything under /api. Bun matches this table before its own fallback.
|
||||
"/api/*": (req: Request, server) => honoServer.fetch(req, server),
|
||||
|
||||
// The SPA. Every unmatched path returns the same document and the client router reads the hash.
|
||||
// Last, so public files and /api resolve first.
|
||||
"/*": index,
|
||||
},
|
||||
|
||||
// isProduction() rather than NODE_ENV: a host that exports NODE_ENV=production into every process
|
||||
// would otherwise leave the development server running without HMR and never say so.
|
||||
development: !isProduction() && { hmr: true, console: true },
|
||||
});
|
||||
|
||||
console.log(`🚀 app running at ${server.url} (public: ${PUBLIC_URL})`);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createRouter } from "../create-router";
|
||||
import { appEnv } from "../../env-file";
|
||||
|
||||
export const healthRouter = createRouter();
|
||||
|
||||
healthRouter.get("/", (ctx) => ctx.json({ status: "ok", env: appEnv(), at: new Date().toISOString() }));
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
/**
|
||||
* Request-scoped state shared by every router.
|
||||
*
|
||||
* Empty for now. Fields land here as middleware starts setting them, so a route reading `ctx.get(...)`
|
||||
* is typed by the same declaration the middleware writing it is checked against.
|
||||
*/
|
||||
export type HonoVariables = {};
|
||||
|
||||
export const createRouter = () => new Hono<{ Variables: HonoVariables }>();
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* One error envelope, shared by every route.
|
||||
*
|
||||
* In one place because it is a contract rather than a convenience: a client reads `err.error.message`,
|
||||
* so a route that answers with a bare string does not merely return the wrong shape — it breaks the
|
||||
* parse. `error` is always an object.
|
||||
*/
|
||||
export const apiError = (message: string, type: string, param?: string) => ({
|
||||
error: { message, type, param: param ?? null, code: null },
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { API_PREFIXES, honoServer } from "./hono";
|
||||
|
||||
describe("the API app", () => {
|
||||
test("answers an unknown /api path with JSON in the error envelope, not HTML", async () => {
|
||||
const res = await honoServer.request("/api/nothing-here");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
// A JSON body matters: the site's catch-all answers everything else with a document, and an HTML
|
||||
// 404 reaching a fetch() surfaces as "Unexpected token '<'" rather than as a missing route.
|
||||
expect(res.headers.get("content-type")).toContain("application/json");
|
||||
const body = (await res.json()) as { error: { message: string; type: string } };
|
||||
expect(typeof body.error).toBe("object");
|
||||
expect(body.error.type).toBe("not_found");
|
||||
});
|
||||
|
||||
test("the mount list is well formed", () => {
|
||||
expect(API_PREFIXES.length).toBeGreaterThan(0);
|
||||
expect(API_PREFIXES.every((prefix) => prefix.startsWith("/"))).toBe(true);
|
||||
expect(new Set(API_PREFIXES).size).toBe(API_PREFIXES.length);
|
||||
});
|
||||
|
||||
test("/api/health answers", async () => {
|
||||
const res = await honoServer.request("/api/health");
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as { status: string }).status).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Hono } from "hono";
|
||||
import { createRouter } from "./create-router";
|
||||
import type { HonoVariables } from "./create-router";
|
||||
import { apiError } from "./errors";
|
||||
import { healthRouter } from "./api/health";
|
||||
|
||||
/**
|
||||
* Every API router and the prefix it answers on.
|
||||
*
|
||||
* One list rather than a series of `app.route` calls, so the set of mounted prefixes is a value the
|
||||
* rest of the server can read instead of something only the router knows.
|
||||
*/
|
||||
const MOUNTS: [string, Hono<{ Variables: HonoVariables }>][] = [["/health", healthRouter]];
|
||||
|
||||
export const API_PREFIXES: string[] = MOUNTS.map(([prefix]) => prefix);
|
||||
|
||||
function buildHonoApp() {
|
||||
const app = createRouter();
|
||||
const api = createRouter();
|
||||
|
||||
for (const [prefix, router] of MOUNTS) api.route(prefix, router);
|
||||
app.route("/api", api);
|
||||
|
||||
app.notFound((ctx) => ctx.json(apiError("Not found", "not_found"), 404));
|
||||
app.onError((error, ctx) => {
|
||||
console.error(error);
|
||||
return ctx.json(apiError("Internal Server Error", "server_error"), 500);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export const honoServer = buildHonoApp();
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as serializers from "./serialize";
|
||||
|
||||
/**
|
||||
* One sample per serializer. The test below fails if this map does not cover every function exported
|
||||
* from ./serialize, so a new serializer cannot be added without a case — and every case is checked
|
||||
* for camelCase keys. Test the rule, not the incident that motivated it.
|
||||
*/
|
||||
const CASES: Record<string, () => unknown> = {
|
||||
// serializeThing: () => serializers.serializeThing(sampleThing()),
|
||||
};
|
||||
|
||||
const exported = Object.entries(serializers)
|
||||
.filter(([, value]) => typeof value === "function")
|
||||
.map(([name]) => name);
|
||||
|
||||
const CAMEL = /[a-z][A-Z]/;
|
||||
|
||||
describe("every serializer", () => {
|
||||
test("has a case in this file", () => {
|
||||
expect(exported.filter((name) => !(name in CASES))).toEqual([]);
|
||||
});
|
||||
|
||||
test.each(exported)("%s emits no camelCase keys", (name) => {
|
||||
const output = CASES[name]!() as Record<string, unknown>;
|
||||
expect(Object.keys(output).filter((key) => CAMEL.test(key))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* The one place a database row becomes JSON.
|
||||
*
|
||||
* Every API serializer lives in this file, and that is a structural choice rather than tidiness: the
|
||||
* test beside it walks this module's exports and fails if any of them emits a camelCase key, or if a
|
||||
* serializer is added without a case. Drizzle hands back camelCase because that is what the schema
|
||||
* declares in TypeScript; the wire is snake_case; this is where one becomes the other, and keeping
|
||||
* them in their feature folders is how a payload ships in the wrong case without anything noticing.
|
||||
*
|
||||
* Empty until there is a table. Add serializers here as features gain rows.
|
||||
*/
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Tests are a development process. Declared here rather than left to the caller: APP_ENV is
|
||||
// required and fails closed, and `bun test` is run directly rather than through a package script.
|
||||
process.env.APP_ENV ??= "development";
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"types": [
|
||||
"bun",
|
||||
"react"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/apps/frontend/*"
|
||||
],
|
||||
"appdb": [
|
||||
"./src/databases/app_db/src/index.ts"
|
||||
],
|
||||
"appdb/*": [
|
||||
"./src/databases/app_db/src/*"
|
||||
],
|
||||
"@/public/*": [
|
||||
"./public/*"
|
||||
]
|
||||
},
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user