@deepraxis/app-template 0.1.0-dev.3c36146 → 0.1.0-dev.660f2cc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -4
- package/package.json +2 -3
- package/template/README.md +1 -5
- package/template/app/account/page.tsx +4 -6
- package/template/app/admin/page.tsx +7 -11
- package/template/app/globals.css +0 -42
- package/template/app/layout.tsx +10 -10
- package/template/app/login/page.tsx +1 -1
- package/template/app/register/page.tsx +1 -1
- package/template/components/ui/button.tsx +5 -5
- package/template/components/ui/card.tsx +1 -1
- package/template/components/ui/input.tsx +1 -1
- package/template/tailwind.config.ts +1 -29
- package/references/design.md +0 -113
- package/references/research.md +0 -56
- package/template/components/ui/badge.tsx +0 -26
- package/template/lib/design-tokens.test.ts +0 -90
- package/template/lib/format.test.ts +0 -12
- package/template/lib/format.ts +0 -18
package/SKILL.md
CHANGED
|
@@ -58,8 +58,6 @@ cd <name>
|
|
|
58
58
|
Read `README.md` in the new project once. It has the folder map and the recipes below in more detail.
|
|
59
59
|
|
|
60
60
|
### 3. Develop
|
|
61
|
-
Before you write pages, read the [design guide](references/design.md): it says how to choose the look, which layout fits the kind of app, and how it must work on phones and in Chinese. Before you build anything beyond plain pages, forms and tables (a calendar, charts, maps, rich text, export, payments …), follow [find before you build](references/research.md): look at how others do it, pick an existing library or shadcn/ui component, and prove it with a short spike.
|
|
62
|
-
|
|
63
61
|
- **Tables**: add `migrations/0002_<what>.sql` using `CREATE TABLE IF NOT EXISTS …`. Import it with `?raw` and append it to the list in `lib/migrations.ts`, then describe the table in `lib/schema.ts`. Migrations run once, in order, on the first request after a deploy. **Never edit a migration that has already been deployed**; add a new one instead.
|
|
64
62
|
- **Rules** (prices, availability, who owns what) go in `lib/`. Pages and route handlers only call them.
|
|
65
63
|
- **Pages**: `app/<path>/page.tsx`. Protect them with `await requireUser("/<path>")` or `await requireAdmin("/<path>")` from `lib/guard.ts`.
|
|
@@ -67,7 +65,7 @@ Before you write pages, read the [design guide](references/design.md): it says h
|
|
|
67
65
|
- **Uploads**: follow `app/api/images/route.ts` and `lib/storage.ts`. That code checks type and size, stores the file under the owner and records it in D1. For an upload that belongs to a record (a pet photo on a booking, a cover on a book), read the file from the same form, call `checkImage` and `saveImage`, and store the returned `key` in a column of your row. Images are served by `/images/<key>` to their owner and admins only. If something must be public (a gallery, product photos), add a separate route that serves only keys you have marked public in your table, and leave `/images` private.
|
|
68
66
|
- **New bindings**: declare them in `wrangler.jsonc` (`d1_databases`, `kv_namespaces`, `r2_buckets`, `vars`), add the type to `env.d.ts`, and read them with `import { env } from "cloudflare:workers"`.
|
|
69
67
|
- **Secrets**: add them to `praxis.jsonc` `secrets`. `"generate"` creates a random value on the first deploy and keeps it on every later deploy. Never write a secret value into a file.
|
|
70
|
-
- **UI**: use the components in `components/ui/` and
|
|
68
|
+
- **UI**: use the components in `components/ui/` and Tailwind classes. Change the site name, home page, navigation links and colors freely; keep the sign-in, sign-up, account and admin pages working.
|
|
71
69
|
|
|
72
70
|
### 4. Check before deploying
|
|
73
71
|
```bash
|
|
@@ -82,7 +80,7 @@ praxis app deploy
|
|
|
82
80
|
It builds, uploads only the changed files, creates a version, deploys it, keeps or creates the secrets and calls the `healthcheck` path. The build log comes first; the result comes last and contains the URL. With `--json` the last line is a JSON object with `url`, `worker_id`, `version_id` and `secrets_created`. A deploy takes about one to two minutes.
|
|
83
81
|
|
|
84
82
|
### 6. Verify it like a user
|
|
85
|
-
- Open the URL. If the `control-browser` skill is available, use it, and walk through the main flow: register, sign in, do the main task, and check the admin page.
|
|
83
|
+
- Open the URL. If the `control-browser` skill is available, use it, and walk through the main flow: register, sign in, do the main task, and check the admin page.
|
|
86
84
|
- Without a browser, check over HTTP. Forms post `application/x-www-form-urlencoded` (uploads: `multipart/form-data`) and answer `303` with a `Location`; protected pages answer a redirect to `/login` when signed out.
|
|
87
85
|
```bash
|
|
88
86
|
U=<url>; J=$(mktemp)
|
package/package.json
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepraxis/app-template",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.660f2cc",
|
|
4
4
|
"description": "The standard Praxis application template: vinext + D1 + R2 + end-user auth + admin area.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"files": [
|
|
7
7
|
"template",
|
|
8
8
|
"README.md",
|
|
9
|
-
"SKILL.md"
|
|
10
|
-
"references"
|
|
9
|
+
"SKILL.md"
|
|
11
10
|
],
|
|
12
11
|
"publishConfig": {
|
|
13
12
|
"access": "public"
|
package/template/README.md
CHANGED
|
@@ -25,16 +25,12 @@ lib/ server-only code and business rules
|
|
|
25
25
|
session.ts session rows + cookie strings users.ts user rules (first user = admin)
|
|
26
26
|
guard.ts getCurrentUser / requireUser / requireAdmin
|
|
27
27
|
storage.ts image rules and R2/D1 access
|
|
28
|
-
components/ui/ Button, Input, Label, Card
|
|
28
|
+
components/ui/ Button, Input, Label, Card
|
|
29
29
|
migrations/ numbered SQL files
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
Keep business rules in `lib/`; pages and route handlers only call them.
|
|
33
33
|
|
|
34
|
-
## Look
|
|
35
|
-
|
|
36
|
-
The look lives in the design tokens at the top of `app/globals.css` (colors, radius) and the font stack in `tailwind.config.ts`. Pages and components use only semantic classes (`bg-primary`, `text-muted-foreground`, `text-destructive`), never raw palette classes or hex colors, so restyling means editing the tokens. `bun test` enforces it: `lib/design-tokens.test.ts` fails on a raw color class and on any text/background token pair under 4.5:1 contrast.
|
|
37
|
-
|
|
38
34
|
## Common changes
|
|
39
35
|
|
|
40
36
|
- **Add a page**: create `app/<name>/page.tsx`. Call `await requireUser("/<name>")` or `requireAdmin` from `lib/guard.ts` to protect it.
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { Badge } from "@/components/ui/badge";
|
|
2
|
-
import { formatBytes } from "@/lib/format";
|
|
3
1
|
import { Button } from "@/components/ui/button";
|
|
4
2
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
3
|
import { Input } from "@/components/ui/input";
|
|
@@ -17,16 +15,16 @@ export default async function Account({ searchParams }: { searchParams: Promise<
|
|
|
17
15
|
<div className="space-y-6">
|
|
18
16
|
<Card>
|
|
19
17
|
<CardHeader><CardTitle>{user.username}</CardTitle></CardHeader>
|
|
20
|
-
<CardContent className="text-sm"
|
|
18
|
+
<CardContent className="text-sm">Role: {user.role}</CardContent>
|
|
21
19
|
</Card>
|
|
22
20
|
<Card>
|
|
23
21
|
<CardHeader><CardTitle>Upload an image</CardTitle></CardHeader>
|
|
24
22
|
<CardContent>
|
|
25
|
-
<form action="/api/images" method="post" encType="multipart/form-data" className="flex
|
|
23
|
+
<form action="/api/images" method="post" encType="multipart/form-data" className="flex items-center gap-3">
|
|
26
24
|
<Input name="image" type="file" accept="image/jpeg,image/png,image/webp" required />
|
|
27
25
|
<Button type="submit">Upload</Button>
|
|
28
26
|
</form>
|
|
29
|
-
{error && <p role="alert" className="mt-2 text-sm text-
|
|
27
|
+
{error && <p role="alert" className="mt-2 text-sm text-red-600">{error}</p>}
|
|
30
28
|
</CardContent>
|
|
31
29
|
</Card>
|
|
32
30
|
<Card>
|
|
@@ -36,7 +34,7 @@ export default async function Account({ searchParams }: { searchParams: Promise<
|
|
|
36
34
|
{mine.map(img => (
|
|
37
35
|
<li key={img.id} className="text-xs">
|
|
38
36
|
<a href={`/images/${img.key}`}><img src={`/images/${img.key}`} alt="" className="h-32 w-full rounded-md border object-cover" /></a>
|
|
39
|
-
<span className="text-
|
|
37
|
+
<span className="text-slate-500">{img.contentType} · {img.size} bytes</span>
|
|
40
38
|
</li>
|
|
41
39
|
))}
|
|
42
40
|
</ul>
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { count, desc } from "drizzle-orm";
|
|
2
|
-
import { Badge } from "@/components/ui/badge";
|
|
3
2
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
4
3
|
import { getDb } from "@/lib/db";
|
|
5
|
-
import { formatBytes, formatDateTime } from "@/lib/format";
|
|
6
4
|
import { requireAdmin } from "@/lib/guard";
|
|
7
5
|
import { images, users } from "@/lib/schema";
|
|
8
6
|
|
|
@@ -23,29 +21,27 @@ export default async function Admin() {
|
|
|
23
21
|
<Card>
|
|
24
22
|
<CardHeader><CardTitle>Users ({userCount!.n})</CardTitle></CardHeader>
|
|
25
23
|
<CardContent>
|
|
26
|
-
<
|
|
24
|
+
<table className="w-full text-left text-sm">
|
|
27
25
|
<thead><tr><th>Username</th><th>Role</th><th>Created</th></tr></thead>
|
|
28
26
|
<tbody>
|
|
29
27
|
{allUsers.map(u => (
|
|
30
|
-
<tr key={u.id}><td>{u.username}</td><td
|
|
28
|
+
<tr key={u.id}><td>{u.username}</td><td>{u.role}</td><td>{new Date(u.createdAt).toISOString()}</td></tr>
|
|
31
29
|
))}
|
|
32
30
|
</tbody>
|
|
33
|
-
</table
|
|
31
|
+
</table>
|
|
34
32
|
</CardContent>
|
|
35
33
|
</Card>
|
|
36
34
|
<Card>
|
|
37
35
|
<CardHeader><CardTitle>Images ({imageCount!.n})</CardTitle></CardHeader>
|
|
38
36
|
<CardContent>
|
|
39
|
-
|
|
40
|
-
<
|
|
41
|
-
) : <div className="overflow-x-auto"><table className="w-full text-left text-sm [&_td]:py-2 [&_td]:pr-4 [&_th]:pb-2 [&_th]:pr-4 [&_th]:font-medium [&_th]:text-muted-foreground">
|
|
42
|
-
<thead><tr><th>Owner</th><th>Image</th><th>Type</th><th>Size</th></tr></thead>
|
|
37
|
+
<table className="w-full text-left text-sm">
|
|
38
|
+
<thead><tr><th>Owner</th><th>Key</th><th>Type</th><th>Size</th></tr></thead>
|
|
43
39
|
<tbody>
|
|
44
40
|
{allImages.map(i => (
|
|
45
|
-
<tr key={i.id}><td>{nameOf.get(i.ownerId) ?? i.ownerId}</td><td><a href={`/images/${i.key}`}
|
|
41
|
+
<tr key={i.id}><td>{nameOf.get(i.ownerId) ?? i.ownerId}</td><td><a className="underline" href={`/images/${i.key}`}>{i.key}</a></td><td>{i.contentType}</td><td>{i.size}</td></tr>
|
|
46
42
|
))}
|
|
47
43
|
</tbody>
|
|
48
|
-
</table
|
|
44
|
+
</table>
|
|
49
45
|
</CardContent>
|
|
50
46
|
</Card>
|
|
51
47
|
</div>
|
package/template/app/globals.css
CHANGED
|
@@ -1,45 +1,3 @@
|
|
|
1
1
|
@tailwind base;
|
|
2
2
|
@tailwind components;
|
|
3
3
|
@tailwind utilities;
|
|
4
|
-
|
|
5
|
-
/* The look of the app lives here. Values are HSL triplets (hue saturation lightness) used as hsl(var(--x)).
|
|
6
|
-
Every *-foreground must keep at least 4.5:1 contrast on its background; the design test checks it. */
|
|
7
|
-
@layer base {
|
|
8
|
-
:root {
|
|
9
|
-
--background: 0 0% 99%;
|
|
10
|
-
--foreground: 222 25% 12%;
|
|
11
|
-
--card: 0 0% 100%;
|
|
12
|
-
--card-foreground: 222 25% 12%;
|
|
13
|
-
--muted: 220 14% 96%;
|
|
14
|
-
--muted-foreground: 220 9% 38%;
|
|
15
|
-
--border: 220 13% 88%;
|
|
16
|
-
--input: 220 13% 78%;
|
|
17
|
-
--ring: 222 47% 11%;
|
|
18
|
-
--primary: 222 47% 11%;
|
|
19
|
-
--primary-foreground: 0 0% 98%;
|
|
20
|
-
--accent: 220 14% 93%;
|
|
21
|
-
--accent-foreground: 222 47% 11%;
|
|
22
|
-
--destructive: 0 70% 38%;
|
|
23
|
-
--destructive-foreground: 0 0% 100%;
|
|
24
|
-
--success: 142 70% 22%;
|
|
25
|
-
--success-foreground: 0 0% 100%;
|
|
26
|
-
--warning: 40 95% 55%;
|
|
27
|
-
--warning-foreground: 30 80% 10%;
|
|
28
|
-
--radius: 0.5rem;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
body {
|
|
32
|
-
@apply bg-background text-foreground antialiased;
|
|
33
|
-
font-size: 16px;
|
|
34
|
-
line-height: 1.7;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
h1, h2, h3, h4, h5, h6 {
|
|
38
|
-
@apply font-bold;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
:focus-visible {
|
|
42
|
-
outline: 2px solid hsl(var(--ring));
|
|
43
|
-
outline-offset: 2px;
|
|
44
|
-
}
|
|
45
|
-
}
|
package/template/app/layout.tsx
CHANGED
|
@@ -9,21 +9,21 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|
|
9
9
|
const user = await getCurrentUser();
|
|
10
10
|
return (
|
|
11
11
|
<html lang="en">
|
|
12
|
-
<body className="min-h-screen">
|
|
13
|
-
<header className="border-b border-
|
|
14
|
-
<nav className="mx-auto flex max-w-4xl
|
|
15
|
-
<Link href="/" className="
|
|
16
|
-
<span className="
|
|
12
|
+
<body className="min-h-screen bg-slate-50 text-slate-900 antialiased">
|
|
13
|
+
<header className="border-b border-slate-200 bg-white">
|
|
14
|
+
<nav className="mx-auto flex max-w-4xl items-center gap-4 px-4 py-3 text-sm">
|
|
15
|
+
<Link href="/" className="font-semibold">Praxis App</Link>
|
|
16
|
+
<span className="flex-1" />
|
|
17
17
|
{user ? (
|
|
18
18
|
<>
|
|
19
|
-
<Link href="/account"
|
|
20
|
-
{user.role === "admin" && <Link href="/admin"
|
|
21
|
-
<form action="/logout" method="post"><button className="
|
|
19
|
+
<Link href="/account">Account</Link>
|
|
20
|
+
{user.role === "admin" && <Link href="/admin">Admin</Link>}
|
|
21
|
+
<form action="/logout" method="post"><button className="text-slate-600 hover:underline">Sign out ({user.username})</button></form>
|
|
22
22
|
</>
|
|
23
23
|
) : (
|
|
24
24
|
<>
|
|
25
|
-
<Link href="/login"
|
|
26
|
-
<Link href="/register"
|
|
25
|
+
<Link href="/login">Sign in</Link>
|
|
26
|
+
<Link href="/register">Sign up</Link>
|
|
27
27
|
</>
|
|
28
28
|
)}
|
|
29
29
|
</nav>
|
|
@@ -16,7 +16,7 @@ export default async function Login({ searchParams }: { searchParams: Promise<{
|
|
|
16
16
|
<input type="hidden" name="next" value={next ?? ""} />
|
|
17
17
|
<div className="space-y-1.5"><Label htmlFor="username">Username</Label><Input id="username" name="username" autoComplete="username" required /></div>
|
|
18
18
|
<div className="space-y-1.5"><Label htmlFor="password">Password</Label><Input id="password" name="password" type="password" autoComplete="current-password" required /></div>
|
|
19
|
-
{error && <p role="alert" className="text-sm text-
|
|
19
|
+
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
|
20
20
|
<Button type="submit" className="w-full">Sign in</Button>
|
|
21
21
|
</form>
|
|
22
22
|
<p className="mt-4 text-sm"><Link className="underline" href="/register">Create an account</Link></p>
|
|
@@ -15,7 +15,7 @@ export default async function Register({ searchParams }: { searchParams: Promise
|
|
|
15
15
|
<form action="/api/auth/register" method="post" className="space-y-4">
|
|
16
16
|
<div className="space-y-1.5"><Label htmlFor="username">Username</Label><Input id="username" name="username" autoComplete="username" required /></div>
|
|
17
17
|
<div className="space-y-1.5"><Label htmlFor="password">Password</Label><Input id="password" name="password" type="password" autoComplete="new-password" required /></div>
|
|
18
|
-
{error && <p role="alert" className="text-sm text-
|
|
18
|
+
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
|
19
19
|
<Button type="submit" className="w-full">Create account</Button>
|
|
20
20
|
</form>
|
|
21
21
|
<p className="mt-4 text-sm"><Link className="underline" href="/login">Already have an account?</Link></p>
|
|
@@ -3,15 +3,15 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
3
3
|
import { cn } from "@/lib/utils";
|
|
4
4
|
|
|
5
5
|
const buttonVariants = cva(
|
|
6
|
-
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-
|
|
6
|
+
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:pointer-events-none disabled:opacity-50",
|
|
7
7
|
{
|
|
8
8
|
variants: {
|
|
9
9
|
variant: {
|
|
10
|
-
default: "bg-
|
|
11
|
-
outline: "border border-
|
|
12
|
-
ghost: "hover:bg-
|
|
10
|
+
default: "bg-slate-900 text-white hover:bg-slate-800",
|
|
11
|
+
outline: "border border-slate-300 bg-white hover:bg-slate-50",
|
|
12
|
+
ghost: "hover:bg-slate-100",
|
|
13
13
|
},
|
|
14
|
-
size: { default: "h-
|
|
14
|
+
size: { default: "h-9 px-4 py-2", sm: "h-8 px-3" },
|
|
15
15
|
},
|
|
16
16
|
defaultVariants: { variant: "default", size: "default" },
|
|
17
17
|
},
|
|
@@ -4,7 +4,7 @@ import { cn } from "@/lib/utils";
|
|
|
4
4
|
type DivProps = React.HTMLAttributes<HTMLDivElement>;
|
|
5
5
|
|
|
6
6
|
export function Card({ className, ...props }: DivProps) {
|
|
7
|
-
return <div className={cn("rounded-lg border border-
|
|
7
|
+
return <div className={cn("rounded-lg border border-slate-200 bg-white shadow-sm", className)} {...props} />;
|
|
8
8
|
}
|
|
9
9
|
export function CardHeader({ className, ...props }: DivProps) {
|
|
10
10
|
return <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />;
|
|
@@ -5,7 +5,7 @@ export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInp
|
|
|
5
5
|
return (
|
|
6
6
|
<input
|
|
7
7
|
className={cn(
|
|
8
|
-
"flex h-
|
|
8
|
+
"flex h-9 w-full rounded-md border border-slate-300 bg-white px-3 py-1 text-sm shadow-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:opacity-50",
|
|
9
9
|
className,
|
|
10
10
|
)}
|
|
11
11
|
{...props}
|
|
@@ -1,35 +1,7 @@
|
|
|
1
1
|
import type { Config } from "tailwindcss";
|
|
2
2
|
|
|
3
|
-
const token = (name: string) => `hsl(var(--${name}) / <alpha-value>)`;
|
|
4
|
-
const pair = (name: string) => ({ DEFAULT: token(name), foreground: token(`${name}-foreground`) });
|
|
5
|
-
|
|
6
3
|
export default {
|
|
7
4
|
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
|
8
|
-
theme: {
|
|
9
|
-
extend: {
|
|
10
|
-
colors: {
|
|
11
|
-
background: token("background"),
|
|
12
|
-
foreground: token("foreground"),
|
|
13
|
-
card: pair("card"),
|
|
14
|
-
muted: pair("muted"),
|
|
15
|
-
border: token("border"),
|
|
16
|
-
input: token("input"),
|
|
17
|
-
ring: token("ring"),
|
|
18
|
-
primary: pair("primary"),
|
|
19
|
-
accent: pair("accent"),
|
|
20
|
-
destructive: pair("destructive"),
|
|
21
|
-
success: pair("success"),
|
|
22
|
-
warning: pair("warning"),
|
|
23
|
-
},
|
|
24
|
-
borderRadius: {
|
|
25
|
-
lg: "var(--radius)",
|
|
26
|
-
md: "calc(var(--radius) - 2px)",
|
|
27
|
-
sm: "calc(var(--radius) - 4px)",
|
|
28
|
-
},
|
|
29
|
-
fontFamily: {
|
|
30
|
-
sans: ["-apple-system", '"PingFang SC"', '"Hiragino Sans GB"', '"Microsoft YaHei"', '"Noto Sans SC"', "sans-serif"],
|
|
31
|
-
},
|
|
32
|
-
},
|
|
33
|
-
},
|
|
5
|
+
theme: { extend: {} },
|
|
34
6
|
plugins: [],
|
|
35
7
|
} satisfies Config;
|
package/references/design.md
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
# Designing the app's interface
|
|
2
|
-
|
|
3
|
-
Read this before you build or restyle pages. The people who use these apps are the merchant's own customers and staff, not developers. Most customers arrive on a phone, often inside WeChat's in-app browser. Staff use the admin area on a laptop. The goal is an interface that looks like it was made for this business, works with one thumb, and never makes a customer wonder what to do next.
|
|
4
|
-
|
|
5
|
-
## 1. Decide the look before writing markup
|
|
6
|
-
|
|
7
|
-
Write a five-line design brief for yourself, grounded in the business, not in "modern and clean":
|
|
8
|
-
- **Who and where**: for example "pet owners booking on their phone between errands" or "front-desk staff checking today's list on a laptop".
|
|
9
|
-
- **Feel**: two or three adjectives taken from the business itself. A pet groomer is warm and playful, a law office calm and formal, a bakery homemade and appetizing.
|
|
10
|
-
- **Palette**: 4–6 colors with names and hex values. One brand color for the primary action and the current selection, one neutral family for text and surfaces, and the status colors (success, warning, danger). Pick the brand color from the business (its sign, its product, its logo), not purple by default.
|
|
11
|
-
- **Type**: see section 4. Mostly size, weight and spacing; the font family is fixed.
|
|
12
|
-
- **One memorable thing**: choose the single element that carries the personality, such as a big photo on the home page, an illustrated service card, or a bold headline. Keep everything else quiet.
|
|
13
|
-
|
|
14
|
-
Put the palette into the design tokens in `app/globals.css` and `tailwind.config.ts` (section 2). Never scatter hex values through pages.
|
|
15
|
-
|
|
16
|
-
### Looks that read as "generated"
|
|
17
|
-
Avoid these unless the user asks for them:
|
|
18
|
-
- a purple-to-blue gradient hero;
|
|
19
|
-
- every section chopped into identical rounded cards with the same grey shadow;
|
|
20
|
-
- a small all-caps label above every heading;
|
|
21
|
-
- one word in a headline highlighted in another color;
|
|
22
|
-
- emoji used as icons;
|
|
23
|
-
- fade-and-slide-up animation on every section;
|
|
24
|
-
- "→" appended to every button.
|
|
25
|
-
|
|
26
|
-
## 2. Tokens: change the look in one place
|
|
27
|
-
|
|
28
|
-
The template's colors and radius live as CSS variables in `app/globals.css` (`--background`, `--foreground`, `--muted`, `--muted-foreground`, `--border`, `--primary`, `--primary-foreground`, `--accent`, `--destructive`, `--success`, `--warning`, `--radius`). `tailwind.config.ts` maps them to classes such as `bg-primary`, `text-muted-foreground` and `border-border`.
|
|
29
|
-
|
|
30
|
-
- Pages and components use only these semantic classes, never raw palette classes such as `bg-slate-900` or `text-pink-500`.
|
|
31
|
-
- To rebrand, edit the variables. A site name, a brand color and a radius change should touch only `globals.css`, `tailwind.config.ts` and `app/layout.tsx`.
|
|
32
|
-
- Every background color has a matching foreground for text on it, with at least 4.5:1 contrast. Check the brand color against white text before you use it on buttons.
|
|
33
|
-
|
|
34
|
-
## 3. Layouts by kind of app
|
|
35
|
-
|
|
36
|
-
Pick the pattern that matches the request, then adapt it.
|
|
37
|
-
|
|
38
|
-
**Booking and services** (groomer, salon, clinic, class, repair):
|
|
39
|
-
- Home: what the business offers, a photo, and one primary button, "Book now".
|
|
40
|
-
- Service list: cards with name, price, duration and a one-line description.
|
|
41
|
-
- Booking flow:
|
|
42
|
-
1. Choose a service.
|
|
43
|
-
2. Pick a day from a horizontal strip of the next days, with the weekday shown.
|
|
44
|
-
3. Pick a time from a grid of tappable time chips. Show full slots as disabled; do not hide them.
|
|
45
|
-
4. Add details.
|
|
46
|
-
5. Confirm.
|
|
47
|
-
Never use a plain `<select>` for dates or times.
|
|
48
|
-
- Confirmation page: a summary of what, when, where and the price, plus what happens next.
|
|
49
|
-
- "My bookings": upcoming first, each with a status badge and the actions that are allowed.
|
|
50
|
-
|
|
51
|
-
**Store and catalog** (shop, menu, product list):
|
|
52
|
-
- A grid of product cards, two columns on phones, with photo, name and price.
|
|
53
|
-
- A detail page with a large photo and one primary action.
|
|
54
|
-
- The cart or order summary is reachable from every page.
|
|
55
|
-
|
|
56
|
-
**Membership and content** (club, community, course):
|
|
57
|
-
- A feed or list of items with clear ownership ("added by …").
|
|
58
|
-
- A profile or account page with the member's own items.
|
|
59
|
-
- Creating content is one obvious button.
|
|
60
|
-
|
|
61
|
-
**Admin area** (every app has one):
|
|
62
|
-
- Laptop first. A left sidebar or top tabs for sections.
|
|
63
|
-
- Up top, the few numbers that matter today (today's bookings, pending, new members), each linking to the filtered list.
|
|
64
|
-
- Tables with filters by status, a search box, status badges, and row actions on the right.
|
|
65
|
-
- Put the next action first: "3 bookings waiting for confirmation" with a button, not just a chart.
|
|
66
|
-
- Destructive actions (cancel, delete) ask for confirmation.
|
|
67
|
-
|
|
68
|
-
**Lead or registration form** (event sign-up, enquiry):
|
|
69
|
-
- One column with only the fields that are needed.
|
|
70
|
-
- A clear thank-you page that says what happens next.
|
|
71
|
-
|
|
72
|
-
## 4. Phones and Chinese text
|
|
73
|
-
|
|
74
|
-
- **Mobile first.** Design at 390 px wide first, then widen. Use one column on phones. The primary action on long forms and booking steps sits in a bar fixed to the bottom of the screen.
|
|
75
|
-
- **Tap targets** are at least 44×44 px, with at least 8 px between them. Nothing may depend on hover.
|
|
76
|
-
- **Inputs** use the right type (`tel`, `email`, `number`, `date`) so phones show the right keyboard. Labels sit above fields, never only as a placeholder.
|
|
77
|
-
- **WeChat in-app browser**: avoid fixed headers taller than 56 px, do not rely on new tabs or pop-ups, and keep pages fast because the network may be slow. Use no web fonts for Chinese (section below) and compress uploaded images.
|
|
78
|
-
- **Chinese type**:
|
|
79
|
-
- Use the system font stack only: `-apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`. Chinese web fonts weigh several megabytes, so do not load them.
|
|
80
|
-
- Body text is 16 px on phones with line height 1.7.
|
|
81
|
-
- Headings are bold, not huge (20–28 px on phones).
|
|
82
|
-
- Do not use letter-spacing, italics or all-caps on Chinese text.
|
|
83
|
-
- Use Chinese punctuation in Chinese sentences.
|
|
84
|
-
- **Language**: write the interface in the user's language. For a Chinese request, every label, button, message and date is Chinese, and `app/layout.tsx` sets `<html lang="zh-CN">`. Show dates as `10月28日 周三`, times as `14:00`, money as `¥228`. Format dates, times and sizes through `lib/format.ts` (set its `LOCALE` and `TIME_ZONE` for the audience), never by printing raw ISO strings or byte counts.
|
|
85
|
-
|
|
86
|
-
## 5. Every screen has four states
|
|
87
|
-
|
|
88
|
-
- **Empty**: say why it is empty and offer the next action ("还没有预约 · 去预约第一次洗护"). Never show an empty table with headers only.
|
|
89
|
-
- **Loading**: keep the layout steady; use skeleton blocks for lists.
|
|
90
|
-
- **Error**: say what went wrong and how to fix it, next to the thing that failed. Keep what the user typed: when a form fails validation, re-render the form with the values and the field errors, instead of redirecting to an empty form.
|
|
91
|
-
- **Success**: confirm with the same verb as the button. "确认预约" leads to "预约已确认", followed by where to go next.
|
|
92
|
-
|
|
93
|
-
Disable a submit button while it is submitting, so a double tap cannot create two bookings.
|
|
94
|
-
|
|
95
|
-
## 6. Words on the screen
|
|
96
|
-
|
|
97
|
-
- Name things the way customers say them ("预约", "我的宠物"), not the way the code does ("appointment record", "entity").
|
|
98
|
-
- Buttons say exactly what happens: "确认预约", not "提交".
|
|
99
|
-
- Never show ids, raw status codes, English enum values or stack traces to customers. Map statuses to words and colors: 待确认 (warning), 已确认 (primary), 已完成 (success), 已取消 (muted).
|
|
100
|
-
- No filler paragraphs that explain the interface.
|
|
101
|
-
|
|
102
|
-
## 7. Quality floor
|
|
103
|
-
|
|
104
|
-
- Contrast is at least 4.5:1 for text.
|
|
105
|
-
- Focus is visible for keyboard users.
|
|
106
|
-
- Every image has an `alt`.
|
|
107
|
-
- Buttons are `<button>` and links are `<a>`.
|
|
108
|
-
- Forms work without JavaScript, which the template's plain form posts already do.
|
|
109
|
-
- Pages use no horizontal scroll at 360 px.
|
|
110
|
-
|
|
111
|
-
## 8. Look at it before you report
|
|
112
|
-
|
|
113
|
-
If the `control-browser` skill is available, take screenshots of the home page, the main flow and the admin area at 390 px and at 1280 px wide, and fix what looks off: overflowing text, cramped buttons, misaligned columns, an empty state that shows nothing. Remove one decoration before you finish. Without a browser, at least fetch every page over HTTP and check that it renders the real content and the Chinese text you expect.
|
package/references/research.md
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# Find before you build: research and spike
|
|
2
|
-
|
|
3
|
-
Before you build anything beyond plain pages, forms and tables, find out how others solve it, pick an existing library or component, and prove it works here with a small spike. Typical cases:
|
|
4
|
-
- a date or time picker, calendar view or scheduling grid;
|
|
5
|
-
- charts;
|
|
6
|
-
- maps or address input;
|
|
7
|
-
- rich text or Markdown editing;
|
|
8
|
-
- drag and drop;
|
|
9
|
-
- image cropping;
|
|
10
|
-
- QR codes;
|
|
11
|
-
- CSV or Excel export;
|
|
12
|
-
- PDF output;
|
|
13
|
-
- search;
|
|
14
|
-
- payments;
|
|
15
|
-
- notifications.
|
|
16
|
-
|
|
17
|
-
Hand-writing these costs hours and usually ends up worse than an existing library.
|
|
18
|
-
|
|
19
|
-
## 1. Look for the pattern (5 minutes)
|
|
20
|
-
|
|
21
|
-
- **How do well-known products present it?** With the `control-browser` skill, open one or two products that do the same job and take screenshots. A booking flow might come from a salon or clinic booking site; an order list from a delivery or shop admin. Note what to copy: step order, which fields, how errors and empty states look. Copy the structure, never the brand.
|
|
22
|
-
- **Is it already in the template?** Check `components/ui/` and `lib/` first, and reuse what is there.
|
|
23
|
-
|
|
24
|
-
## 2. Pick a candidate (5 minutes)
|
|
25
|
-
|
|
26
|
-
Prefer, in this order:
|
|
27
|
-
1. **shadcn/ui components and blocks** (ui.shadcn.com). Copy the component source into `components/ui/`. They match the template's Tailwind and token setup. Most rely on Radix UI primitives (`@radix-ui/react-*`); install the ones a component imports.
|
|
28
|
-
2. **A focused, popular npm package** for the job, for example `date-fns` for dates, `react-day-picker` for calendars, `recharts` for charts, `qrcode` for QR codes, `papaparse` for CSV, and `@tiptap/react` for rich text. These names are starting points, not rules: check them with the questions below.
|
|
29
|
-
3. **Your own code**, only when the job is small or nothing passes the checks.
|
|
30
|
-
|
|
31
|
-
For each candidate, answer these questions. If any answer is "no", drop it:
|
|
32
|
-
|
|
33
|
-
| Question | How to check |
|
|
34
|
-
|---|---|
|
|
35
|
-
| Does it run on Cloudflare Workers (server parts) or in the browser (client parts)? | No native modules, no `fs` or `child_process` at runtime, no long-lived servers. Browser-only libraries go in `"use client"` components. |
|
|
36
|
-
| Is it maintained? | A release in the last 12 months (`npm view <pkg> time.modified`), and real usage (weekly downloads). |
|
|
37
|
-
| Is the license usable? | MIT, Apache-2.0, BSD or ISC. Avoid GPL and AGPL in the merchant's app. |
|
|
38
|
-
| Is it small enough? | Check the bundle size (bundlephobia, or the build output). The whole Worker must stay well under 10 MB. Prefer libraries that tree-shake. |
|
|
39
|
-
| Does it work for the users' region? | For users in mainland China, avoid anything that loads from Google (Maps, Fonts, reCAPTCHA) or other blocked hosts at runtime. Use regional services instead, for example AMap or Tencent Maps for maps. |
|
|
40
|
-
| Does it support the language? | Chinese labels and locale for dates (`date-fns/locale/zh-CN`), numbers and calendars. |
|
|
41
|
-
|
|
42
|
-
## 3. Spike it (10–15 minutes)
|
|
43
|
-
|
|
44
|
-
1. Install only the chosen package.
|
|
45
|
-
2. Build the smallest real use on a scratch page (for example `app/_spike/page.tsx`): the actual component with real data shapes, not a toy.
|
|
46
|
-
3. Run `bun run build`. The build must pass and must not grow by more than you expected; compare the size of `dist/` before and after.
|
|
47
|
-
4. If it has a server part, call it from a route handler and run `bun run dev` to see it work.
|
|
48
|
-
5. If the user gave a deploy target, a spike can go live with `praxis app deploy`, but remove the scratch page before the real release.
|
|
49
|
-
|
|
50
|
-
If the spike fails, try the next candidate. **Two failed candidates means stop and tell the user** what you tried and what you suggest instead (a simpler feature, or your own code), rather than fighting the platform.
|
|
51
|
-
|
|
52
|
-
## 4. Adopt and record
|
|
53
|
-
|
|
54
|
-
- Move the spike into the real feature and delete the scratch page.
|
|
55
|
-
- Add one line per adopted library under "## Libraries" in the project's `README.md`: name, what it is for, and why it was chosen ("react-day-picker: booking calendar; Workers-safe, 12 kB, zh-CN locale").
|
|
56
|
-
- In the report to the user, mention a library only when it affects them, for example a map provider that needs their own key.
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
|
-
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
-
import { cn } from "@/lib/utils";
|
|
4
|
-
|
|
5
|
-
const badgeVariants = cva(
|
|
6
|
-
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold",
|
|
7
|
-
{
|
|
8
|
-
variants: {
|
|
9
|
-
variant: {
|
|
10
|
-
default: "border-transparent bg-primary text-primary-foreground",
|
|
11
|
-
secondary: "border-transparent bg-accent text-accent-foreground",
|
|
12
|
-
success: "border-transparent bg-success text-success-foreground",
|
|
13
|
-
warning: "border-transparent bg-warning text-warning-foreground",
|
|
14
|
-
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
|
15
|
-
outline: "border-border text-foreground",
|
|
16
|
-
},
|
|
17
|
-
},
|
|
18
|
-
defaultVariants: { variant: "default" },
|
|
19
|
-
},
|
|
20
|
-
);
|
|
21
|
-
|
|
22
|
-
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
|
|
23
|
-
|
|
24
|
-
export function Badge({ className, variant, ...props }: BadgeProps) {
|
|
25
|
-
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
|
26
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
|
|
5
|
-
const root = path.join(import.meta.dir, "..");
|
|
6
|
-
|
|
7
|
-
/** WCAG 2.x contrast ratio between two HSL triplets written as "H S% L%". */
|
|
8
|
-
export function hslToRgb(triplet: string): [number, number, number] {
|
|
9
|
-
const m = triplet.trim().match(/^(-?[\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/);
|
|
10
|
-
if (!m) throw new Error(`not an HSL triplet: "${triplet}"`);
|
|
11
|
-
const h = ((Number(m[1]) % 360) + 360) % 360, s = Number(m[2]) / 100, l = Number(m[3]) / 100;
|
|
12
|
-
const k = (n: number) => (n + h / 30) % 12;
|
|
13
|
-
const a = s * Math.min(l, 1 - l);
|
|
14
|
-
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
15
|
-
return [f(0), f(8), f(4)];
|
|
16
|
-
}
|
|
17
|
-
export function contrast(fg: string, bg: string): number {
|
|
18
|
-
const lum = (t: string) => {
|
|
19
|
-
const [r, g, b] = hslToRgb(t).map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)) as [number, number, number];
|
|
20
|
-
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
21
|
-
};
|
|
22
|
-
const [hi, lo] = [lum(fg), lum(bg)].sort((x, y) => y - x) as [number, number];
|
|
23
|
-
return (hi + 0.05) / (lo + 0.05);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function tokens(): Record<string, string> {
|
|
27
|
-
const css = fs.readFileSync(path.join(root, "app/globals.css"), "utf8");
|
|
28
|
-
const block = css.match(/:root\s*\{([^}]*)\}/)?.[1] ?? "";
|
|
29
|
-
return Object.fromEntries([...block.matchAll(/--([\w-]+):\s*([^;]+);/g)].map(m => [m[1]!, m[2]!.trim()]));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const REQUIRED = [
|
|
33
|
-
"background", "foreground", "card", "card-foreground", "muted", "muted-foreground", "border", "input", "ring",
|
|
34
|
-
"primary", "primary-foreground", "accent", "accent-foreground", "destructive", "destructive-foreground",
|
|
35
|
-
"success", "success-foreground", "warning", "warning-foreground", "radius",
|
|
36
|
-
];
|
|
37
|
-
// [foreground token, background token] pairs that carry text.
|
|
38
|
-
const PAIRS: [string, string][] = [
|
|
39
|
-
["foreground", "background"], ["card-foreground", "card"], ["muted-foreground", "background"], ["muted-foreground", "card"],
|
|
40
|
-
["muted-foreground", "muted"], ["primary-foreground", "primary"], ["accent-foreground", "accent"],
|
|
41
|
-
["destructive-foreground", "destructive"], ["success-foreground", "success"], ["warning-foreground", "warning"],
|
|
42
|
-
["destructive", "background"], ["destructive", "card"],
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
describe("design tokens", () => {
|
|
46
|
-
const t = tokens();
|
|
47
|
-
test("every required token is defined", () => {
|
|
48
|
-
expect(REQUIRED.filter(k => !(k in t))).toEqual([]);
|
|
49
|
-
});
|
|
50
|
-
for (const [fg, bg] of PAIRS) {
|
|
51
|
-
test(`${fg} on ${bg} has at least 4.5:1 contrast`, () => {
|
|
52
|
-
const ratio = contrast(t[fg]!, t[bg]!);
|
|
53
|
-
expect(ratio, `${fg} (${t[fg]}) on ${bg} (${t[bg]}) is ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(4.5);
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
test("contrast function matches known values", () => {
|
|
57
|
-
expect(contrast("0 0% 0%", "0 0% 100%")).toBeCloseTo(21, 1);
|
|
58
|
-
expect(contrast("0 0% 100%", "0 0% 100%")).toBeCloseTo(1, 5);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const PALETTE = "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
|
|
63
|
-
const RAW_COLOR = new RegExp(`\\b(?:bg|text|border|ring|from|to|via|fill|stroke|outline|divide|placeholder|decoration|shadow)-(?:${PALETTE})-\\d+\\b`, "g");
|
|
64
|
-
const HEX_IN_CLASS = /className\s*=\s*(?:"[^"]*|'[^']*|\{[^}]*)#[0-9a-fA-F]{3,8}\b|\[(?:#[0-9a-fA-F]{3,8})\]/g;
|
|
65
|
-
|
|
66
|
-
function sources(dir: string): string[] {
|
|
67
|
-
const out: string[] = [];
|
|
68
|
-
const abs = path.join(root, dir);
|
|
69
|
-
if (!fs.existsSync(abs)) return out;
|
|
70
|
-
for (const e of fs.readdirSync(abs, { recursive: true, withFileTypes: true })) {
|
|
71
|
-
if (e.isFile() && /\.(ts|tsx)$/.test(e.name)) out.push(path.relative(root, path.join(e.parentPath, e.name)));
|
|
72
|
-
}
|
|
73
|
-
return out;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function findRawColors(file: string, text: string): string[] {
|
|
77
|
-
return [...(text.match(RAW_COLOR) ?? []), ...(text.match(HEX_IN_CLASS) ?? [])].map(c => `${file}: ${c}`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
describe("semantic classes only", () => {
|
|
81
|
-
test("detector flags palette classes and hex colors", () => {
|
|
82
|
-
expect(findRawColors("x.tsx", `<p className="bg-slate-900 hover:text-pink-500">`)).toHaveLength(2);
|
|
83
|
-
expect(findRawColors("x.tsx", `<p className="bg-[#ff0000]">`)).toHaveLength(1);
|
|
84
|
-
expect(findRawColors("x.tsx", `<p className="bg-primary text-muted-foreground">`)).toEqual([]);
|
|
85
|
-
});
|
|
86
|
-
test("app/ and components/ use no raw palette class or hex color", () => {
|
|
87
|
-
const hits = [...sources("app"), ...sources("components")].flatMap(f => findRawColors(f, fs.readFileSync(path.join(root, f), "utf8")));
|
|
88
|
-
expect(hits, `Use semantic classes (bg-primary, text-muted-foreground, text-destructive...) or edit the tokens in app/globals.css:\n${hits.join("\n")}`).toEqual([]);
|
|
89
|
-
});
|
|
90
|
-
});
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
2
|
-
import { formatBytes, formatDateTime } from "./format";
|
|
3
|
-
|
|
4
|
-
test("dates show minutes in the app time zone, not raw ISO", () => {
|
|
5
|
-
expect(formatDateTime(Date.UTC(2026, 9, 25, 15, 3, 49))).toBe("2026-10-25 23:03");
|
|
6
|
-
});
|
|
7
|
-
|
|
8
|
-
test("sizes use readable units", () => {
|
|
9
|
-
expect(formatBytes(70)).toBe("70 B");
|
|
10
|
-
expect(formatBytes(12_595)).toBe("12.3 KB");
|
|
11
|
-
expect(formatBytes(4_718_592)).toBe("4.5 MB");
|
|
12
|
-
});
|
package/template/lib/format.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
// How dates and sizes read on screen. Change TIME_ZONE and LOCALE for the app's audience.
|
|
2
|
-
export const TIME_ZONE = "Asia/Shanghai";
|
|
3
|
-
export const LOCALE = "en-GB";
|
|
4
|
-
|
|
5
|
-
/** 2026-10-25 23:03 in TIME_ZONE; never a raw ISO string on screen. */
|
|
6
|
-
export function formatDateTime(ms: number): string {
|
|
7
|
-
const parts = Object.fromEntries(new Intl.DateTimeFormat(LOCALE, {
|
|
8
|
-
timeZone: TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23",
|
|
9
|
-
}).formatToParts(new Date(ms)).map(p => [p.type, p.value]));
|
|
10
|
-
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** 70 B, 12.3 KB, 4.5 MB. */
|
|
14
|
-
export function formatBytes(bytes: number): string {
|
|
15
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
16
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
17
|
-
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
18
|
-
}
|