@webjsdev/cli 0.10.30 → 0.10.32
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/bin/webjs.js +1 -1
- package/lib/api-gallery.js +229 -0
- package/lib/create.js +462 -161
- package/lib/saas-template.js +39 -15
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +78 -1
- package/templates/.claude/hooks/check-server-imports.mjs +86 -0
- package/templates/.claude/hooks/check-server-imports.sh +26 -0
- package/templates/.claude/hooks/cleanup-merged-worktree.sh +129 -0
- package/templates/.claude/hooks/commit-before-stop.sh +52 -0
- package/templates/.claude/settings.json +28 -0
- package/templates/.cursorrules +50 -1
- package/templates/.github/copilot-instructions.md +50 -1
- package/templates/AGENTS.md +180 -13
- package/templates/CLAUDE.md +22 -0
- package/templates/CONVENTIONS.md +165 -12
- package/templates/gallery/app/examples/todo/page.ts +34 -0
- package/templates/gallery/app/features/async-render/page.ts +14 -0
- package/templates/gallery/app/features/broadcast/feed/route.ts +19 -0
- package/templates/gallery/app/features/broadcast/page.ts +24 -0
- package/templates/gallery/app/features/caching/page.ts +39 -0
- package/templates/gallery/app/features/client-router/page.ts +34 -0
- package/templates/gallery/app/features/client-router/second/page.ts +20 -0
- package/templates/gallery/app/features/components/page.ts +14 -0
- package/templates/gallery/app/features/directives/page.ts +14 -0
- package/templates/gallery/app/features/env/page.ts +36 -0
- package/templates/gallery/app/features/file-storage/file/[key]/route.ts +19 -0
- package/templates/gallery/app/features/file-storage/page.ts +62 -0
- package/templates/gallery/app/features/forms/page.ts +78 -0
- package/templates/gallery/app/features/metadata/page.ts +55 -0
- package/templates/gallery/app/features/optimistic-ui/page.ts +14 -0
- package/templates/gallery/app/features/rate-limit/page.ts +29 -0
- package/templates/gallery/app/features/rate-limit/ping/middleware.ts +8 -0
- package/templates/gallery/app/features/rate-limit/ping/route.ts +7 -0
- package/templates/gallery/app/features/route-handler/data/route.ts +8 -0
- package/templates/gallery/app/features/route-handler/page.ts +13 -0
- package/templates/gallery/app/features/routing/[id]/page.ts +25 -0
- package/templates/gallery/app/features/routing/page.ts +47 -0
- package/templates/gallery/app/features/server-actions/page.ts +14 -0
- package/templates/gallery/app/features/service-worker/page.ts +36 -0
- package/templates/gallery/app/features/websockets/echo/route.ts +19 -0
- package/templates/gallery/app/features/websockets/page.ts +25 -0
- package/templates/gallery/modules/async-render/components/server-clock.ts +26 -0
- package/templates/gallery/modules/async-render/queries/server-greeting.server.ts +9 -0
- package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +61 -0
- package/templates/gallery/modules/components/components/browser/counter-card.test.js +36 -0
- package/templates/gallery/modules/components/components/counter-card.ts +35 -0
- package/templates/gallery/modules/directives/components/directive-demo.ts +53 -0
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +18 -0
- package/templates/gallery/modules/optimistic-ui/actions/like-post.server.ts +9 -0
- package/templates/gallery/modules/optimistic-ui/components/like-button.ts +35 -0
- package/templates/gallery/modules/rate-limit/components/rate-probe.ts +49 -0
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +12 -0
- package/templates/gallery/modules/server-actions/components/greeter.ts +30 -0
- package/templates/gallery/modules/server-actions/utils/format.server.ts +8 -0
- package/templates/gallery/modules/todo/actions/create-todo.server.ts +16 -0
- package/templates/gallery/modules/todo/actions/delete-todo.server.ts +15 -0
- package/templates/gallery/modules/todo/actions/toggle-todo.server.ts +21 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +140 -0
- package/templates/gallery/modules/todo/queries/list-todos.server.ts +25 -0
- package/templates/gallery/modules/todo/types.ts +12 -0
- package/templates/gallery/modules/websockets/components/ws-echo.ts +62 -0
- package/templates/lib/utils/ui.ts +4 -4
- package/templates/test/hello/browser/hello.test.js +6 -0
- package/templates/web-test-runner.config.js +9 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// 'use server' so the page's action can call it (server-side, a direct call) and
|
|
2
|
+
// it never crashes the browser module that imports it (the client gets a safe
|
|
3
|
+
// RPC stub, not the node:fs code). getFileStore() is the pluggable storage
|
|
4
|
+
// singleton: a local diskStore rooted at <cwd>/.webjs/uploads by default
|
|
5
|
+
// (gitignored), swappable for S3/R2/GCS with one setFileStore() call at boot.
|
|
6
|
+
// generateKey() mints a collision-free, traversal-safe key preserving a
|
|
7
|
+
// whitelisted extension.
|
|
8
|
+
'use server';
|
|
9
|
+
import { getFileStore, generateKey } from '@webjsdev/server';
|
|
10
|
+
|
|
11
|
+
export async function storeUpload(file: File) {
|
|
12
|
+
if (!(file instanceof File) || file.size === 0) {
|
|
13
|
+
return { success: false as const, error: 'No file provided.' };
|
|
14
|
+
}
|
|
15
|
+
const key = generateKey(file.name);
|
|
16
|
+
const { size, contentType } = await getFileStore().put(key, file, { contentType: file.type });
|
|
17
|
+
return { success: true as const, data: { key, name: file.name, size, contentType } };
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
// A tiny mutation for the optimistic-UI feature demo. It validates and echoes
|
|
3
|
+
// (no persistence needed to show the mechanic). In a real app this would write
|
|
4
|
+
// to the db and return the saved row.
|
|
5
|
+
import type { ActionResult } from '@webjsdev/server';
|
|
6
|
+
|
|
7
|
+
export async function likePost(input: { liked: boolean }): Promise<ActionResult<{ liked: boolean }>> {
|
|
8
|
+
return { success: true, data: { liked: !!input?.liked } };
|
|
9
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The IMPERATIVE optimistic() form for a simple flip: set the signal to the new
|
|
2
|
+
// value instantly, run the action, and roll back automatically if it rejects.
|
|
3
|
+
// Use optimistic UI where the client can PREDICT the result (likes, toggles,
|
|
4
|
+
// reorders, renames). Skip it where it hurts: unpredictable / server-computed
|
|
5
|
+
// results, side-effectful or OAuth / payment mutations, and destructive
|
|
6
|
+
// irreversible actions (confirm-first is better there).
|
|
7
|
+
//
|
|
8
|
+
// This is the simple-flip API. For a LIST mutation (add / remove with rollback)
|
|
9
|
+
// see the declarative optimistic(host, { source, update }) + .add() form in the
|
|
10
|
+
// /examples/todo app.
|
|
11
|
+
import { WebComponent, signal, optimistic, html } from '@webjsdev/core';
|
|
12
|
+
import { likePost } from '../actions/like-post.server.ts';
|
|
13
|
+
|
|
14
|
+
export class LikeButton extends WebComponent {
|
|
15
|
+
private liked = signal(false);
|
|
16
|
+
|
|
17
|
+
private async toggle() {
|
|
18
|
+
const next = !this.liked.get();
|
|
19
|
+
// optimistic(signal, value, action) flips `liked` to `next` immediately,
|
|
20
|
+
// runs the action, and restores the old value if the action rejects.
|
|
21
|
+
await optimistic(this.liked, next, () => likePost({ liked: next }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
render() {
|
|
25
|
+
const liked = this.liked.get();
|
|
26
|
+
return html`
|
|
27
|
+
<button @click=${() => this.toggle()} aria-pressed=${liked ? 'true' : 'false'}
|
|
28
|
+
class="inline-flex items-center gap-2 px-4 py-2 rounded-full font-semibold text-sm border cursor-pointer transition-all active:scale-[0.97] ${liked ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-foreground border-border hover:border-border-strong'}">
|
|
29
|
+
<svg viewBox="0 0 24 24" class="w-4 h-4 ${liked ? 'fill-current stroke-none' : 'fill-none stroke-current'}" style="stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1L12 21l7.7-7.6 1.1-1a5.5 5.5 0 0 0 0-7.8Z"/></svg>
|
|
30
|
+
${liked ? 'Liked' : 'Like'}
|
|
31
|
+
</button>
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
LikeButton.register('like-button');
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A small probe for the rate-limited endpoint. Each click fetches
|
|
2
|
+
// /features/rate-limit/ping and records the status plus the X-RateLimit-Remaining
|
|
3
|
+
// header the middleware stamped on. This is a legitimate hand-written fetch: the
|
|
4
|
+
// target is a route.ts HTTP endpoint (not a server action, which you would
|
|
5
|
+
// import and call instead). The button is the interactivity signal, so this
|
|
6
|
+
// component ships and hydrates.
|
|
7
|
+
import { WebComponent, signal, html } from '@webjsdev/core';
|
|
8
|
+
|
|
9
|
+
interface Probe { n: number; status: number; remaining: string }
|
|
10
|
+
|
|
11
|
+
export class RateProbe extends WebComponent {
|
|
12
|
+
private log = signal<Probe[]>([]);
|
|
13
|
+
private busy = signal(false);
|
|
14
|
+
#n = 0;
|
|
15
|
+
|
|
16
|
+
private async ping() {
|
|
17
|
+
if (this.busy.get()) return;
|
|
18
|
+
this.busy.set(true);
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch('/features/rate-limit/ping', { headers: { accept: 'application/json' } });
|
|
21
|
+
this.#n += 1;
|
|
22
|
+
this.log.set([
|
|
23
|
+
{ n: this.#n, status: res.status, remaining: res.headers.get('x-ratelimit-remaining') ?? '?' },
|
|
24
|
+
...this.log.get(),
|
|
25
|
+
].slice(0, 8));
|
|
26
|
+
} finally {
|
|
27
|
+
this.busy.set(false);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
render() {
|
|
32
|
+
return html`
|
|
33
|
+
<div class="grid gap-4 max-w-[420px]">
|
|
34
|
+
<button @click=${() => this.ping()}
|
|
35
|
+
class="w-fit px-3.5 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Ping the endpoint</button>
|
|
36
|
+
<ul class="grid gap-1.5 list-none m-0 p-0">
|
|
37
|
+
${this.log.get().map((p) => html`
|
|
38
|
+
<li class="flex items-center justify-between px-3 py-2 rounded-xl bg-card border border-border text-sm">
|
|
39
|
+
<span class="text-muted-foreground">#${p.n}</span>
|
|
40
|
+
<span class="font-mono ${p.status === 429 ? 'text-destructive' : 'text-primary'}">${p.status === 429 ? '429 limited' : '200 ok'}</span>
|
|
41
|
+
<span class="text-muted-foreground/70 text-[13px]">remaining: ${p.remaining}</span>
|
|
42
|
+
</li>
|
|
43
|
+
`)}
|
|
44
|
+
</ul>
|
|
45
|
+
</div>
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
RateProbe.register('rate-probe');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
// A 'use server' action IS the API: a client import is rewritten to a typed RPC
|
|
3
|
+
// stub POSTing to the server. It may use server-only utilities (they run here,
|
|
4
|
+
// server-side), which is why the util above stays off the client.
|
|
5
|
+
import { shout } from '../utils/format.server.ts';
|
|
6
|
+
import type { ActionResult } from '@webjsdev/server';
|
|
7
|
+
|
|
8
|
+
export async function greet(input: { name: string }): Promise<ActionResult<{ message: string }>> {
|
|
9
|
+
const name = String(input?.name ?? '').trim();
|
|
10
|
+
if (!name) return { success: false, error: 'Name required.', status: 400 };
|
|
11
|
+
return { success: true, data: { message: shout('hello ' + name) } };
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Calls the 'use server' action via a normal import (the RPC stub). Never
|
|
2
|
+
// hand-write fetch(); importing the action IS the API.
|
|
3
|
+
import { WebComponent, signal, html } from '@webjsdev/core';
|
|
4
|
+
import { greet } from '../actions/greet.server.ts';
|
|
5
|
+
|
|
6
|
+
export class Greeter extends WebComponent {
|
|
7
|
+
private msg = signal('');
|
|
8
|
+
async run(e: SubmitEvent) {
|
|
9
|
+
e.preventDefault();
|
|
10
|
+
const name = String(new FormData(e.target as HTMLFormElement).get('name') ?? '');
|
|
11
|
+
const r = await greet({ name });
|
|
12
|
+
// Narrow on r.success so TS knows `data` (success) vs `error` (failure).
|
|
13
|
+
this.msg.set(r.success ? (r.data?.message ?? '') : (r.error ?? 'error'));
|
|
14
|
+
}
|
|
15
|
+
render() {
|
|
16
|
+
return html`
|
|
17
|
+
<div class="grid gap-3 max-w-[420px]">
|
|
18
|
+
<form @submit=${(e: SubmitEvent) => this.run(e)}
|
|
19
|
+
class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border">
|
|
20
|
+
<input name="name" placeholder="your name" autocomplete="off"
|
|
21
|
+
class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground/70 py-1.5" />
|
|
22
|
+
<button type="submit"
|
|
23
|
+
class="shrink-0 px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Greet</button>
|
|
24
|
+
</form>
|
|
25
|
+
${this.msg.get() ? html`<p class="m-0 font-semibold text-foreground">${this.msg.get()}</p>` : ''}
|
|
26
|
+
</div>
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
Greeter.register('server-greeter');
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// A server-only utility: a `.server.ts` file with NO 'use server' directive.
|
|
2
|
+
// Its browser import is a throw-at-load stub, so NEVER import this into a page
|
|
3
|
+
// or component. Reach it only from a 'use server' action / route.ts / middleware.
|
|
4
|
+
// (See the server-actions section in this app's AGENTS.md for the .server.ts
|
|
5
|
+
// vs 'use server' rule.)
|
|
6
|
+
export function shout(s: string): string {
|
|
7
|
+
return s.trim().toUpperCase() + '!';
|
|
8
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
// A MUTATION (no `method` export defaults to POST: CSRF-protected, rich body).
|
|
3
|
+
// Args and the returned ActionResult round-trip through webjs's serializer.
|
|
4
|
+
import { db } from '#db/connection.server.ts';
|
|
5
|
+
import { todos } from '#db/schema.server.ts';
|
|
6
|
+
import type { Todo } from '../types.ts';
|
|
7
|
+
import type { ActionResult } from '@webjsdev/server';
|
|
8
|
+
|
|
9
|
+
export async function createTodo(input: { title: string }): Promise<ActionResult<Todo>> {
|
|
10
|
+
const title = String(input?.title ?? '').trim();
|
|
11
|
+
if (!title) return { success: false, error: 'A task needs a title.', status: 400 };
|
|
12
|
+
// rc.3 mutation: `.returning()` takes NO field args in rc.3 (see the Database
|
|
13
|
+
// section in this app's AGENTS.md).
|
|
14
|
+
const [row] = await db.insert(todos).values({ title }).returning();
|
|
15
|
+
return { success: true, data: row as Todo };
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
// A MUTATION (no `method` export defaults to POST: CSRF-protected). Imported
|
|
3
|
+
// directly by the client component (the import becomes a typed RPC stub); the
|
|
4
|
+
// id argument and the ActionResult round-trip through webjs's serializer.
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import { db } from '#db/connection.server.ts';
|
|
7
|
+
import { todos } from '#db/schema.server.ts';
|
|
8
|
+
import type { ActionResult } from '@webjsdev/server';
|
|
9
|
+
|
|
10
|
+
export async function deleteTodo(input: { id: string }): Promise<ActionResult<{ id: string }>> {
|
|
11
|
+
const id = String(input?.id ?? '');
|
|
12
|
+
if (!id) return { success: false, error: 'Missing id.', status: 400 };
|
|
13
|
+
await db.delete(todos).where(eq(todos.id, id));
|
|
14
|
+
return { success: true, data: { id } };
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
import { eq } from 'drizzle-orm';
|
|
3
|
+
import { db } from '#db/connection.server.ts';
|
|
4
|
+
import { todos } from '#db/schema.server.ts';
|
|
5
|
+
import type { Todo } from '../types.ts';
|
|
6
|
+
import type { ActionResult } from '@webjsdev/server';
|
|
7
|
+
|
|
8
|
+
// `completed` is explicit on the JS path; omit it on the no-JS <form> path and
|
|
9
|
+
// the server reads the row and flips it (so the example degrades without JS).
|
|
10
|
+
export async function toggleTodo(input: { id: string; completed?: boolean }): Promise<ActionResult<Todo>> {
|
|
11
|
+
const id = String(input?.id ?? '');
|
|
12
|
+
if (!id) return { success: false, error: 'Missing id.', status: 400 };
|
|
13
|
+
let completed = input?.completed;
|
|
14
|
+
if (typeof completed !== 'boolean') {
|
|
15
|
+
const cur = await db.query.todos.findFirst({ where: { id } });
|
|
16
|
+
if (!cur) return { success: false, error: 'Not found.', status: 404 };
|
|
17
|
+
completed = !cur.completed;
|
|
18
|
+
}
|
|
19
|
+
const [row] = await db.update(todos).set({ completed }).where(eq(todos.id, id)).returning();
|
|
20
|
+
return { success: true, data: row as Todo };
|
|
21
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The interactive surface. Demonstrates: the WebComponent factory + reactive
|
|
2
|
+
// prop, the DECLARATIVE optimistic() API (instant update, auto-rollback), and
|
|
3
|
+
// progressive enhancement (each mutation is a <form> posting to the page action,
|
|
4
|
+
// intercepted by JS for the optimistic path). All interactivity lives in a
|
|
5
|
+
// component; a page/layout cannot be interactive in its own markup.
|
|
6
|
+
//
|
|
7
|
+
// Styling uses the shadcn-standard design tokens the @webjsdev/ui theme defines
|
|
8
|
+
// (bg-card, text-foreground, bg-primary, text-muted-foreground, border-border,
|
|
9
|
+
// ...), so the whole app (this component AND any `webjs ui add` component) shares
|
|
10
|
+
// one coherent theme. Prefer these tokens (and opacity modifiers like
|
|
11
|
+
// bg-primary/90) over ad-hoc colors.
|
|
12
|
+
import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
|
|
13
|
+
import { createTodo } from '../actions/create-todo.server.ts';
|
|
14
|
+
import { toggleTodo } from '../actions/toggle-todo.server.ts';
|
|
15
|
+
import { deleteTodo } from '../actions/delete-todo.server.ts';
|
|
16
|
+
import type { Todo } from '../types.ts';
|
|
17
|
+
|
|
18
|
+
type Op =
|
|
19
|
+
| { kind: 'add'; tempId: string; title: string }
|
|
20
|
+
| { kind: 'toggle'; id: string; completed: boolean }
|
|
21
|
+
| { kind: 'delete'; id: string };
|
|
22
|
+
|
|
23
|
+
export class TodoApp extends WebComponent({
|
|
24
|
+
// A reactive prop: the SSR'd list arrives via `.todos=${todos}` and hydrates.
|
|
25
|
+
todos: prop<Todo[]>(Array),
|
|
26
|
+
}) {
|
|
27
|
+
// One optimistic store, three ops. `.add(payload, promise)` applies `update`
|
|
28
|
+
// instantly and auto-releases when the promise settles (resolve OR reject),
|
|
29
|
+
// so there is no manual try-catch / rollback / temp-id bookkeeping.
|
|
30
|
+
private store = optimistic(this, {
|
|
31
|
+
source: () => this.todos ?? [],
|
|
32
|
+
update: (state: Todo[], op: Op): Todo[] => {
|
|
33
|
+
if (op.kind === 'add') return [{ id: op.tempId, title: op.title, completed: false, createdAt: new Date(), pending: true }, ...state];
|
|
34
|
+
if (op.kind === 'toggle') return state.map((t) => (t.id === op.id ? { ...t, completed: op.completed } : t));
|
|
35
|
+
return state.filter((t) => t.id !== op.id);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
async add(e: SubmitEvent) {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
const form = e.target as HTMLFormElement;
|
|
42
|
+
const title = String(new FormData(form).get('title') ?? '').trim();
|
|
43
|
+
if (!title) return;
|
|
44
|
+
form.reset();
|
|
45
|
+
const tempId = crypto.randomUUID();
|
|
46
|
+
const promise = createTodo({ title });
|
|
47
|
+
this.store.add({ kind: 'add', tempId, title }, promise);
|
|
48
|
+
const r = await promise;
|
|
49
|
+
if (r.success && r.data) this.todos = [r.data, ...(this.todos ?? [])];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async toggle(e: Event, todo: Todo) {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
if (todo.pending) return;
|
|
55
|
+
const completed = !todo.completed;
|
|
56
|
+
const promise = toggleTodo({ id: todo.id, completed });
|
|
57
|
+
this.store.add({ kind: 'toggle', id: todo.id, completed }, promise);
|
|
58
|
+
const r = await promise;
|
|
59
|
+
if (r.success && r.data) { const d = r.data; this.todos = (this.todos ?? []).map((t) => (t.id === todo.id ? d : t)); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async removeTodo(e: Event, todo: Todo) {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
if (todo.pending) return;
|
|
65
|
+
const promise = deleteTodo({ id: todo.id });
|
|
66
|
+
this.store.add({ kind: 'delete', id: todo.id }, promise);
|
|
67
|
+
const r = await promise;
|
|
68
|
+
if (r.success) this.todos = (this.todos ?? []).filter((t) => t.id !== todo.id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
render() {
|
|
72
|
+
const list = this.store.value;
|
|
73
|
+
const done = list.filter((t) => t.completed).length;
|
|
74
|
+
const pct = list.length ? Math.round((done / list.length) * 100) : 0;
|
|
75
|
+
return html`
|
|
76
|
+
<section class="w-full max-w-[540px] grid gap-5">
|
|
77
|
+
<!-- Header: a title, a live done/total count, and a progress bar. -->
|
|
78
|
+
<header class="grid gap-3">
|
|
79
|
+
<div class="flex items-center gap-3">
|
|
80
|
+
<span class="grid place-items-center w-10 h-10 rounded-2xl bg-primary/15 text-primary shrink-0">
|
|
81
|
+
<svg viewBox="0 0 24 24" class="w-5 h-5 stroke-current fill-none" style="stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round"><path d="m5 13 4 4L19 7"/></svg>
|
|
82
|
+
</span>
|
|
83
|
+
<div class="grid gap-0.5">
|
|
84
|
+
<h2 class="m-0 text-[1.4rem] font-bold tracking-[-0.02em] leading-none text-foreground">Tasks</h2>
|
|
85
|
+
<p class="m-0 text-[13px] text-muted-foreground/70 leading-none">${done} of ${list.length} done</p>
|
|
86
|
+
</div>
|
|
87
|
+
<span class="ml-auto text-[13px] font-semibold tabular-nums text-muted-foreground">${pct}%</span>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="h-1.5 rounded-full bg-muted overflow-hidden">
|
|
90
|
+
<div class="h-full rounded-full bg-primary transition-[width] duration-300" style="width:${pct}%"></div>
|
|
91
|
+
</div>
|
|
92
|
+
</header>
|
|
93
|
+
|
|
94
|
+
<!-- Add: a real <form> so it works with JS off (posts to the page action);
|
|
95
|
+
with JS, @submit intercepts and runs the optimistic path. -->
|
|
96
|
+
<form method="post" action="" @submit=${(e: SubmitEvent) => this.add(e)}
|
|
97
|
+
class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border shadow-[0_1px_0_0_color-mix(in_oklch,var(--foreground)_5%,transparent)]">
|
|
98
|
+
<input type="hidden" name="intent" value="create" />
|
|
99
|
+
<input name="title" required maxlength="280" autocomplete="off" placeholder="What needs doing?"
|
|
100
|
+
class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground/70 py-1.5" />
|
|
101
|
+
<button type="submit"
|
|
102
|
+
class="shrink-0 px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Add</button>
|
|
103
|
+
</form>
|
|
104
|
+
|
|
105
|
+
<ul class="list-none m-0 p-0 grid gap-2">
|
|
106
|
+
${list.length ? list.map((todo) => html`
|
|
107
|
+
<li>
|
|
108
|
+
<form method="post" action=""
|
|
109
|
+
class="group flex items-center gap-3 px-3 py-2.5 rounded-xl bg-card border border-border transition-colors hover:border-border-strong ${todo.pending ? 'opacity-55' : ''}">
|
|
110
|
+
<input type="hidden" name="id" value=${todo.id} />
|
|
111
|
+
<!-- Toggle is a submit button (degrades to a form POST no-JS); with JS
|
|
112
|
+
@click intercepts for the optimistic toggle. The check is centered
|
|
113
|
+
(grid place-items-center) and only visible once completed. -->
|
|
114
|
+
<button id="t-${todo.id}" type="submit" name="intent" value="toggle"
|
|
115
|
+
aria-pressed=${todo.completed ? 'true' : 'false'}
|
|
116
|
+
aria-label=${todo.completed ? 'Mark as not done' : 'Mark as done'}
|
|
117
|
+
@click=${(e: Event) => this.toggle(e, todo)}
|
|
118
|
+
class="shrink-0 grid place-items-center w-5 h-5 rounded-full border-2 cursor-pointer transition-all ${todo.completed ? 'bg-primary border-primary text-primary-foreground' : 'bg-transparent border-border-strong text-transparent hover:border-primary'}">
|
|
119
|
+
<svg viewBox="0 0 24 24" class="w-3 h-3 stroke-current fill-none" style="stroke-width:3.4;stroke-linecap:round;stroke-linejoin:round"><path d="m5 13 4 4L19 7"/></svg>
|
|
120
|
+
</button>
|
|
121
|
+
<!-- The title is a <label for> the toggle: clicking the text toggles
|
|
122
|
+
the task (works on JS and no-JS paths, and screen readers). -->
|
|
123
|
+
<label for="t-${todo.id}" class="flex-1 min-w-0 text-[15px] leading-snug break-words cursor-pointer select-none ${todo.completed ? 'line-through text-muted-foreground/70' : 'text-foreground'}">${todo.title}</label>
|
|
124
|
+
<!-- Delete: a proper icon button, revealed on row hover / focus. -->
|
|
125
|
+
<button type="submit" name="intent" value="delete" aria-label="Delete task"
|
|
126
|
+
@click=${(e: Event) => this.removeTodo(e, todo)}
|
|
127
|
+
class="shrink-0 grid place-items-center w-7 h-7 rounded-lg border-0 bg-transparent text-muted-foreground/70 cursor-pointer opacity-0 group-hover:opacity-100 focus:opacity-100 transition-all hover:text-destructive hover:bg-[color-mix(in_oklch,var(--color-destructive)_12%,transparent)]">
|
|
128
|
+
<svg viewBox="0 0 24 24" class="w-4 h-4 stroke-current fill-none" style="stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
|
129
|
+
</button>
|
|
130
|
+
</form>
|
|
131
|
+
</li>
|
|
132
|
+
`) : html`
|
|
133
|
+
<li class="text-center text-muted-foreground/70 text-sm py-14 border border-dashed border-border rounded-2xl">No tasks yet. Add your first one above.</li>
|
|
134
|
+
`}
|
|
135
|
+
</ul>
|
|
136
|
+
</section>
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
TodoApp.register('todo-app');
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
// A READ is a `'use server'` action so the client (and SSR) can call it via the
|
|
3
|
+
// normal import (rewritten to a typed RPC stub). `method = 'GET'` rides args in
|
|
4
|
+
// the URL, is CSRF-exempt, and its result is SSR-seeded so the component does
|
|
5
|
+
// not re-fetch on hydration.
|
|
6
|
+
import { db } from '#db/connection.server.ts';
|
|
7
|
+
import type { Todo } from '../types.ts';
|
|
8
|
+
|
|
9
|
+
export const method = 'GET';
|
|
10
|
+
|
|
11
|
+
export async function listTodos(): Promise<Todo[]> {
|
|
12
|
+
// rc.3 read: the relational query API. `orderBy` uses the object form
|
|
13
|
+
// (`{ column: 'asc' | 'desc' }`); passing `[desc(todos.createdAt)]` with an
|
|
14
|
+
// imported column mis-compiles to a bad SQL alias in rc.3. Do NOT use
|
|
15
|
+
// `db.select({ col })` either (its projection overload trips TS2554 in rc.3).
|
|
16
|
+
// See the Database (Drizzle) section in this app's AGENTS.md.
|
|
17
|
+
//
|
|
18
|
+
// Two equivalent read styles, both fine: the relational query API
|
|
19
|
+
// (`db.query.todos.findFirst({ where: { id } })` / `findMany`, used here and in
|
|
20
|
+
// toggle-todo) reads by an object filter; the core builder
|
|
21
|
+
// (`db.select().from(todos).where(eq(todos.id, id))`) reads with the `eq()`
|
|
22
|
+
// helper. Reach for whichever fits; the relational form is terser for by-id reads.
|
|
23
|
+
const rows = await db.query.todos.findMany({ orderBy: { createdAt: 'desc' } });
|
|
24
|
+
return rows as Todo[];
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// WHY a hand-written interface (not `typeof todos.$inferSelect` from the schema):
|
|
2
|
+
// this type is imported by the browser-shipped <todo-app> component. A VALUE
|
|
3
|
+
// import from `db/*.server.ts` would pin the component to a server module and
|
|
4
|
+
// crash it at load (webjs check flags it). A browser-safe shape here is safe.
|
|
5
|
+
// See the server-actions section in this app's AGENTS.md.
|
|
6
|
+
export interface Todo {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
completed: boolean;
|
|
10
|
+
createdAt: Date;
|
|
11
|
+
pending?: boolean; // client-only: true while an optimistic create is in flight
|
|
12
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// A WebSocket echo client. connectWS() (from '@webjsdev/core') opens the socket,
|
|
2
|
+
// auto-reconnects with backoff, JSON-encodes/decodes, and queues sends while
|
|
3
|
+
// disconnected. The connection is opened in connectedCallback (browser-only, so
|
|
4
|
+
// it never runs during SSR) and closed in disconnectedCallback. At SSR the
|
|
5
|
+
// component renders its disconnected state, so with JS off the page still reads
|
|
6
|
+
// (a live socket has no no-JS equivalent, which is the honest fallback here).
|
|
7
|
+
import { WebComponent, signal, html, connectWS } from '@webjsdev/core';
|
|
8
|
+
|
|
9
|
+
export class WsEcho extends WebComponent {
|
|
10
|
+
private connected = signal(false);
|
|
11
|
+
private lines = signal<string[]>([]);
|
|
12
|
+
#conn: ReturnType<typeof connectWS> | null = null;
|
|
13
|
+
|
|
14
|
+
connectedCallback() {
|
|
15
|
+
super.connectedCallback();
|
|
16
|
+
this.#conn = connectWS('/features/websockets/echo', {
|
|
17
|
+
onOpen: () => this.connected.set(true),
|
|
18
|
+
onClose: () => this.connected.set(false),
|
|
19
|
+
onMessage: (msg: unknown) => this.lines.set([...this.lines.get(), String(msg)]),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
disconnectedCallback() {
|
|
24
|
+
super.disconnectedCallback();
|
|
25
|
+
this.#conn?.close();
|
|
26
|
+
this.#conn = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private send(e: SubmitEvent) {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
const form = e.target as HTMLFormElement;
|
|
32
|
+
const input = form.elements.namedItem('msg') as HTMLInputElement;
|
|
33
|
+
const text = input.value.trim();
|
|
34
|
+
if (!text) return;
|
|
35
|
+
this.#conn?.send(text);
|
|
36
|
+
input.value = '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
render() {
|
|
40
|
+
const on = this.connected.get();
|
|
41
|
+
return html`
|
|
42
|
+
<div class="grid gap-4 max-w-[420px]">
|
|
43
|
+
<div class="flex items-center gap-2 text-sm">
|
|
44
|
+
<span class="w-2 h-2 rounded-full ${on ? 'bg-primary' : 'bg-muted-foreground/40'}"></span>
|
|
45
|
+
<span class="text-muted-foreground">${on ? 'connected' : 'connecting (live echo needs JavaScript)'}</span>
|
|
46
|
+
</div>
|
|
47
|
+
<form @submit=${(e: SubmitEvent) => this.send(e)} class="flex gap-2">
|
|
48
|
+
<input name="msg" autocomplete="off" placeholder="Say something"
|
|
49
|
+
class="flex-1 px-3 py-2 rounded-xl bg-card border border-border text-foreground text-sm outline-none focus:border-border-strong" />
|
|
50
|
+
<button type="submit"
|
|
51
|
+
class="px-3.5 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Send</button>
|
|
52
|
+
</form>
|
|
53
|
+
<ul class="grid gap-1.5 list-none m-0 p-0">
|
|
54
|
+
${this.lines.get().map((line) => html`
|
|
55
|
+
<li class="px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground font-mono">${line}</li>
|
|
56
|
+
`)}
|
|
57
|
+
</ul>
|
|
58
|
+
</div>
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
WsEcho.register('ws-echo');
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { html } from '@webjsdev/core';
|
|
18
18
|
|
|
19
|
-
/** `● label` kicker: small caps,
|
|
19
|
+
/** `● label` kicker: small caps, brand colour, above headings. */
|
|
20
20
|
export function rubric(label: string, mb: 'sm' | 'md' = 'md') {
|
|
21
21
|
const mbCls = mb === 'sm' ? 'mb-3' : 'mb-4';
|
|
22
22
|
return html`
|
|
23
|
-
<span class="block font-mono text-[11px] leading-none font-semibold tracking-[0.2em] uppercase text-
|
|
23
|
+
<span class="block font-mono text-[11px] leading-none font-semibold tracking-[0.2em] uppercase text-primary ${mbCls}">● ${label}</span>
|
|
24
24
|
`;
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -68,10 +68,10 @@ export function banner(content: unknown) {
|
|
|
68
68
|
`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** Inline
|
|
71
|
+
/** Inline brand link: used inside body copy and banners. */
|
|
72
72
|
export function accentLink(href: string, label: unknown) {
|
|
73
73
|
return html`
|
|
74
|
-
<a href=${href} class="text-
|
|
74
|
+
<a href=${href} class="text-primary font-semibold no-underline hover:underline hover:underline-offset-[3px]">${label}</a>
|
|
75
75
|
`;
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Run: webjs test --browser
|
|
5
5
|
* npx wtr
|
|
6
|
+
* First run only, if Chromium is missing: npx playwright install chromium
|
|
7
|
+
*
|
|
8
|
+
* The runner's mocha UI is `tdd`: use suite() / test() (NOT describe / it), and
|
|
9
|
+
* throw to fail (there is no chai / expect in the importmap; a tiny inline assert
|
|
10
|
+
* like the one below is the idiom). See modules/components/components/browser for
|
|
11
|
+
* a co-located COMPONENT browser test using ssrFixture.
|
|
6
12
|
*
|
|
7
13
|
* Tests here have full access to real browser APIs: Shadow DOM,
|
|
8
14
|
* adoptedStyleSheets, IntersectionObserver, events, etc.
|
|
@@ -33,7 +33,15 @@ export default {
|
|
|
33
33
|
// Browser tests are `.js` (web-test-runner serves them through its own test
|
|
34
34
|
// framework); the components + modules they import are `.ts`, served
|
|
35
35
|
// transformed by the webjs middleware below.
|
|
36
|
-
|
|
36
|
+
// Browser tests live under any `browser/` dir: the top-level `test/<feature>/
|
|
37
|
+
// browser/`, OR co-located next to the code they exercise (a component's
|
|
38
|
+
// `modules/<feature>/components/browser/`, the modules-architecture default).
|
|
39
|
+
files: [
|
|
40
|
+
'test/**/browser/**/*.test.js',
|
|
41
|
+
'app/**/browser/**/*.test.js',
|
|
42
|
+
'modules/**/browser/**/*.test.js',
|
|
43
|
+
'components/**/browser/**/*.test.js',
|
|
44
|
+
],
|
|
37
45
|
// webjs's importmap resolves `@webjsdev/core`, the `#` app aliases, and
|
|
38
46
|
// vendors, so web-test-runner must NOT rewrite bare specifiers to
|
|
39
47
|
// node_modules paths.
|