@webjsdev/cli 0.10.29 → 0.10.31
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 +452 -158
- package/lib/saas-template.js +39 -15
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +70 -2
- package/templates/.claude/hooks/check-server-imports.mjs +86 -0
- package/templates/.claude/hooks/check-server-imports.sh +26 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.cursorrules +41 -2
- package/templates/.github/copilot-instructions.md +41 -2
- package/templates/AGENTS.md +160 -10
- package/templates/CONVENTIONS.md +150 -11
- 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 +72 -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 +35 -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/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 +19 -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 +12 -6
- package/templates/test/hello/e2e/hello.test.ts +11 -9
- package/templates/test/hello/hello.test.ts +4 -6
- package/templates/web-test-runner.config.js +84 -9
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// async render() awaits server data directly in the leaf component. Three
|
|
2
|
+
// decoupled concerns to know (see AGENTS.md "Async render"):
|
|
3
|
+
// 1. SSR always BLOCKS, so the resolved data is in the first paint with no
|
|
4
|
+
// fallback markup (progressive-enhancement safe, readable with JS off).
|
|
5
|
+
// 2. The client re-fetch default is stale-while-revalidate (old content stays
|
|
6
|
+
// until the new render resolves, no blank flash).
|
|
7
|
+
// 3. renderFallback() is the OPTIONAL re-fetch loading UI, never first paint.
|
|
8
|
+
// Reach for async render() when request-time SERVER data belongs in the first
|
|
9
|
+
// paint; reach for a Task / signals for genuinely client-only data.
|
|
10
|
+
import { WebComponent, html } from '@webjsdev/core';
|
|
11
|
+
import { serverGreeting } from '../queries/server-greeting.server.ts';
|
|
12
|
+
|
|
13
|
+
export class ServerClock extends WebComponent {
|
|
14
|
+
// A bare async-render component (no other client signal) is ELIDED, since its
|
|
15
|
+
// SSR'd HTML is already the complete output. `static refresh = true` opts into
|
|
16
|
+
// keeping the on-load re-fetch so the module ships (drop it for request-stable
|
|
17
|
+
// data you are happy to leave server-rendered).
|
|
18
|
+
static refresh = true;
|
|
19
|
+
|
|
20
|
+
async render() {
|
|
21
|
+
const info = await serverGreeting();
|
|
22
|
+
return html`<p class="font-mono text-sm">server rendered this at
|
|
23
|
+
<strong>${info.at}</strong> (pid ${info.pid})</p>`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
ServerClock.register('server-clock');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
// A GET 'use server' query a component awaits directly inside `async render()`.
|
|
3
|
+
// It returns request-time SERVER data the client cannot know, which is exactly
|
|
4
|
+
// what async render() is for (the resolved value lands in the first paint).
|
|
5
|
+
export const method = 'GET';
|
|
6
|
+
|
|
7
|
+
export async function serverGreeting(): Promise<{ at: string; pid: number }> {
|
|
8
|
+
return { at: new Date().toISOString(), pid: process.pid };
|
|
9
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A shared broadcast room. Same connectWS() client as the websockets demo, but
|
|
2
|
+
// the server fans each message out to all connected sockets, so every open tab
|
|
3
|
+
// shows the same feed. The socket opens in connectedCallback (browser-only) and
|
|
4
|
+
// closes in disconnectedCallback. At SSR it renders the disconnected state, so
|
|
5
|
+
// the page still reads with JS off (a live feed has no no-JS equivalent).
|
|
6
|
+
import { WebComponent, signal, html, connectWS } from '@webjsdev/core';
|
|
7
|
+
|
|
8
|
+
export class BroadcastFeed extends WebComponent {
|
|
9
|
+
private connected = signal(false);
|
|
10
|
+
private lines = signal<string[]>([]);
|
|
11
|
+
#conn: ReturnType<typeof connectWS> | null = null;
|
|
12
|
+
|
|
13
|
+
connectedCallback() {
|
|
14
|
+
super.connectedCallback();
|
|
15
|
+
this.#conn = connectWS('/features/broadcast/feed', {
|
|
16
|
+
onOpen: () => this.connected.set(true),
|
|
17
|
+
onClose: () => this.connected.set(false),
|
|
18
|
+
onMessage: (msg: unknown) => this.lines.set([...this.lines.get(), String(msg)]),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
disconnectedCallback() {
|
|
23
|
+
super.disconnectedCallback();
|
|
24
|
+
this.#conn?.close();
|
|
25
|
+
this.#conn = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private send(e: SubmitEvent) {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
const form = e.target as HTMLFormElement;
|
|
31
|
+
const input = form.elements.namedItem('msg') as HTMLInputElement;
|
|
32
|
+
const text = input.value.trim();
|
|
33
|
+
if (!text) return;
|
|
34
|
+
this.#conn?.send(text);
|
|
35
|
+
input.value = '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
render() {
|
|
39
|
+
const on = this.connected.get();
|
|
40
|
+
return html`
|
|
41
|
+
<div class="grid gap-4 max-w-[420px]">
|
|
42
|
+
<div class="flex items-center gap-2 text-sm">
|
|
43
|
+
<span class="w-2 h-2 rounded-full ${on ? 'bg-primary' : 'bg-muted-foreground/40'}"></span>
|
|
44
|
+
<span class="text-muted-foreground">${on ? 'in the room' : 'connecting (the live room needs JavaScript)'}</span>
|
|
45
|
+
</div>
|
|
46
|
+
<form @submit=${(e: SubmitEvent) => this.send(e)} class="flex gap-2">
|
|
47
|
+
<input name="msg" autocomplete="off" placeholder="Message everyone"
|
|
48
|
+
class="flex-1 px-3 py-2 rounded-xl bg-card border border-border text-foreground text-sm outline-none focus:border-border-strong" />
|
|
49
|
+
<button type="submit"
|
|
50
|
+
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>
|
|
51
|
+
</form>
|
|
52
|
+
<ul class="grid gap-1.5 list-none m-0 p-0">
|
|
53
|
+
${this.lines.get().map((line) => html`
|
|
54
|
+
<li class="px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">${line}</li>
|
|
55
|
+
`)}
|
|
56
|
+
</ul>
|
|
57
|
+
</div>
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
BroadcastFeed.register('broadcast-feed');
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Demonstrates the WebComponent essentials: the declare-free reactive-prop
|
|
2
|
+
// factory, an instance signal for local state, a lifecycle hook, and a <slot>
|
|
3
|
+
// for content projection (works in light DOM, the default). This is the core
|
|
4
|
+
// component authoring shape. Styling is crafted Tailwind on the app's design
|
|
5
|
+
// tokens so it stays visually coherent with the rest of the app.
|
|
6
|
+
import { WebComponent, prop, signal, html } from '@webjsdev/core';
|
|
7
|
+
|
|
8
|
+
export class CounterCard extends WebComponent({
|
|
9
|
+
// A reactive prop rides an HTML attribute / SSR hydration. Declared ONLY here
|
|
10
|
+
// (no `static properties`, no class-field `label = ''` which would clobber the
|
|
11
|
+
// accessor). Set defaults in the constructor.
|
|
12
|
+
label: prop(String),
|
|
13
|
+
}) {
|
|
14
|
+
private count = signal(0); // instance signal: component-local state
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
super();
|
|
18
|
+
this.label = this.label || 'Clicks';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
render() {
|
|
22
|
+
return html`
|
|
23
|
+
<div class="grid gap-4 p-5 rounded-2xl bg-card border border-border max-w-[320px]">
|
|
24
|
+
<slot></slot>
|
|
25
|
+
<div class="flex items-baseline gap-2">
|
|
26
|
+
<span class="text-[2.5rem] font-bold tabular-nums leading-none text-foreground">${this.count.get()}</span>
|
|
27
|
+
<span class="text-sm text-muted-foreground/70">${this.label}</span>
|
|
28
|
+
</div>
|
|
29
|
+
<button @click=${() => this.count.set(this.count.get() + 1)}
|
|
30
|
+
class="w-fit 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]">Increment</button>
|
|
31
|
+
</div>
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
CounterCard.register('counter-card');
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The lit-html directive set webjs re-exports (from '@webjsdev/core/directives').
|
|
2
|
+
// `repeat` keys a list so DOM nodes are REUSED across reorders instead of being
|
|
3
|
+
// recreated (use it for keyed lists that reorder; plain `.map()` is fine for
|
|
4
|
+
// static lists). `watch(signal)` does a fine-grained DOM swap of ONE node when
|
|
5
|
+
// the signal changes, without re-running the whole template.
|
|
6
|
+
import { WebComponent, signal, html } from '@webjsdev/core';
|
|
7
|
+
import { repeat, watch } from '@webjsdev/core/directives';
|
|
8
|
+
|
|
9
|
+
interface Item { id: number; label: string }
|
|
10
|
+
|
|
11
|
+
let nextId = 4;
|
|
12
|
+
|
|
13
|
+
export class DirectiveDemo extends WebComponent {
|
|
14
|
+
private items = signal<Item[]>([
|
|
15
|
+
{ id: 1, label: 'Alpha' },
|
|
16
|
+
{ id: 2, label: 'Bravo' },
|
|
17
|
+
{ id: 3, label: 'Charlie' },
|
|
18
|
+
]);
|
|
19
|
+
private ticks = signal(0);
|
|
20
|
+
|
|
21
|
+
private reverse() {
|
|
22
|
+
this.items.set(this.items.get().slice().reverse());
|
|
23
|
+
}
|
|
24
|
+
private add() {
|
|
25
|
+
const id = nextId++;
|
|
26
|
+
this.items.set([...this.items.get(), { id, label: 'Item ' + id }]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
render() {
|
|
30
|
+
return html`
|
|
31
|
+
<div class="grid gap-4 max-w-[420px]">
|
|
32
|
+
<div class="flex gap-2">
|
|
33
|
+
<button @click=${() => this.reverse()}
|
|
34
|
+
class="px-3.5 py-1.5 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]">Reverse</button>
|
|
35
|
+
<button @click=${() => this.add()}
|
|
36
|
+
class="px-3.5 py-1.5 rounded-xl bg-card border border-border text-foreground font-medium text-sm cursor-pointer transition-colors hover:border-border-strong">Add</button>
|
|
37
|
+
</div>
|
|
38
|
+
<!-- repeat keyed by item.id: reversing reuses the existing <li> nodes. -->
|
|
39
|
+
<ul class="grid gap-2 list-none m-0 p-0">
|
|
40
|
+
${repeat(this.items.get(), (it: Item) => it.id, (it: Item) => html`
|
|
41
|
+
<li class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">
|
|
42
|
+
<span class="text-muted-foreground/70 tabular-nums text-[13px]">#${it.id}</span>${it.label}
|
|
43
|
+
</li>
|
|
44
|
+
`)}
|
|
45
|
+
</ul>
|
|
46
|
+
<!-- watch(signal) swaps only this node when ticks changes. -->
|
|
47
|
+
<button @click=${() => this.ticks.set(this.ticks.get() + 1)}
|
|
48
|
+
class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">ticks: ${watch(this.ticks)}</button>
|
|
49
|
+
</div>
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
DirectiveDemo.register('directive-demo');
|
|
@@ -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,19 @@
|
|
|
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
|
+
const rows = await db.query.todos.findMany({ orderBy: { createdAt: 'desc' } });
|
|
18
|
+
return rows as Todo[];
|
|
19
|
+
}
|
|
@@ -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
|
+
}
|