create-sanifyfe 0.1.3 → 0.2.1
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/package.json +1 -1
- package/templates/default/README.md +23 -5
- package/templates/default/dev-server.ts +7 -36
- package/templates/default/env.d.ts +14 -0
- package/templates/default/index.html +1 -1
- package/templates/default/package.json +1 -1
- package/templates/default/src/app.ts +35 -6
- package/templates/default/src/components/live-clock.ts +16 -0
- package/templates/default/src/components/nav-bar.ts +39 -0
- package/templates/default/src/components/todo-item.ts +4 -2
- package/templates/default/src/components/users-sidebar.ts +31 -0
- package/templates/default/src/data/users.ts +28 -0
- package/templates/default/src/main.ts +1 -1
- package/templates/default/src/pages/about-page.ts +18 -8
- package/templates/default/src/pages/home-page.ts +34 -0
- package/templates/default/src/pages/settings-page.ts +49 -0
- package/templates/default/src/pages/{todo-page.ts → todos-page.ts} +7 -12
- package/templates/default/src/pages/user-detail.ts +34 -0
- package/templates/default/src/pages/user-list.ts +13 -0
- package/templates/default/src/state/settings.ts +12 -0
- package/templates/default/src/state/todos.ts +2 -2
- package/templates/default/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@ bun install
|
|
|
16
16
|
|
|
17
17
|
| Perintah | Fungsi |
|
|
18
18
|
| --- | --- |
|
|
19
|
-
| `bun dev` |
|
|
19
|
+
| `bun dev` | Dev server di http://localhost:3000 dengan **HMR** |
|
|
20
20
|
| `bun run build` | Bundle produksi ke `dist/` |
|
|
21
21
|
| `bun run typecheck` | Cek tipe TypeScript |
|
|
22
22
|
|
|
@@ -25,10 +25,28 @@ bun install
|
|
|
25
25
|
```
|
|
26
26
|
src/
|
|
27
27
|
main.ts entry: memuat app-root
|
|
28
|
-
app.ts root
|
|
29
|
-
state/todos
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
app.ts root + router (termasuk nested route + outlet)
|
|
29
|
+
state/ todos (persisted), settings (createStore nested)
|
|
30
|
+
data/ data contoh + simulasi fetch async
|
|
31
|
+
components/ nav-bar, users-sidebar, todo-item, live-clock
|
|
32
|
+
pages/ home, todos, settings, user-list, user-detail, about
|
|
32
33
|
index.html halaman host, memuat main.js hasil bundling
|
|
33
34
|
dev-server.ts server dev Bun, transpile TS on-the-fly
|
|
34
35
|
```
|
|
36
|
+
|
|
37
|
+
## Fitur yang dicontohkan
|
|
38
|
+
|
|
39
|
+
| Halaman | Fitur Sanify |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| Home | `signal`, `onMount`/`onCleanup` (live-clock) |
|
|
42
|
+
| Todos | `For` (list keyed), `persisted` + cross-tab, `computed` |
|
|
43
|
+
| Settings | `createStore` objek nested fine-grained (update by path) |
|
|
44
|
+
| Users | router **nested + outlet** (layout bertahan), `params()` reaktif, `resource` (fetch async) |
|
|
45
|
+
| About | `query()` reaktif |
|
|
46
|
+
|
|
47
|
+
## HMR
|
|
48
|
+
|
|
49
|
+
`bun dev` mendukung Hot Module Replacement. Edit file komponen → tampilan
|
|
50
|
+
ter-update tanpa reload, dan state global (`persisted`/`createStore`) tetap.
|
|
51
|
+
State lokal di dalam `setup` (mis. signal) ter-reset saat hot-remount. Tiap file
|
|
52
|
+
komponen punya `if (import.meta.hot) import.meta.hot.accept();` di bawahnya.
|
|
@@ -1,42 +1,13 @@
|
|
|
1
|
-
// dev-server.ts — dev server
|
|
1
|
+
// dev-server.ts — dev server Bun dengan HMR (import.meta.hot) + bundling otomatis
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import index from "./index.html";
|
|
4
4
|
|
|
5
5
|
const server = Bun.serve({
|
|
6
|
-
port:
|
|
7
|
-
development: true,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
let path = url.pathname;
|
|
11
|
-
if (path === "/") path = "/index.html";
|
|
12
|
-
|
|
13
|
-
// bundel entry TS aplikasi on-the-fly
|
|
14
|
-
if (path === "/main.js") {
|
|
15
|
-
const built = await Bun.build({
|
|
16
|
-
entrypoints: ["./src/main.ts"],
|
|
17
|
-
target: "browser",
|
|
18
|
-
});
|
|
19
|
-
if (!built.success) {
|
|
20
|
-
return new Response("Build error:\n" + built.logs.join("\n"), {
|
|
21
|
-
status: 500,
|
|
22
|
-
headers: { "Content-Type": "text/plain" },
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
const js = await built.outputs[0]!.text();
|
|
26
|
-
return new Response(js, {
|
|
27
|
-
headers: { "Content-Type": "application/javascript" },
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// file statis dari root project
|
|
32
|
-
const file = Bun.file("." + path);
|
|
33
|
-
if (await file.exists()) return new Response(file);
|
|
34
|
-
|
|
35
|
-
// SPA fallback: route apa pun → index.html
|
|
36
|
-
return new Response(Bun.file("./index.html"), {
|
|
37
|
-
headers: { "Content-Type": "text/html" },
|
|
38
|
-
});
|
|
6
|
+
port: 3000,
|
|
7
|
+
development: { hmr: true, console: true },
|
|
8
|
+
routes: {
|
|
9
|
+
"/*": index, // SPA: semua route → index.html
|
|
39
10
|
},
|
|
40
11
|
});
|
|
41
12
|
|
|
42
|
-
console.log(`Dev server:
|
|
13
|
+
console.log(`Dev server: ${server.url}`);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Tipe fitur dev Bun: import file .html & HMR (import.meta.hot).
|
|
2
|
+
|
|
3
|
+
declare module "*.html" {
|
|
4
|
+
const content: import("bun").HTMLBundle;
|
|
5
|
+
export default content;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ImportMeta {
|
|
9
|
+
hot?: {
|
|
10
|
+
accept(callback?: (module: unknown) => void): void;
|
|
11
|
+
dispose(callback: (data: unknown) => void): void;
|
|
12
|
+
data: unknown;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -1,19 +1,48 @@
|
|
|
1
|
-
// app.ts — root komponen + router
|
|
1
|
+
// app.ts — root komponen + router (termasuk nested route + outlet)
|
|
2
2
|
|
|
3
3
|
import { component, html, router } from "@sanify/core";
|
|
4
|
-
import "./
|
|
4
|
+
import "./components/nav-bar.ts";
|
|
5
|
+
import "./components/users-sidebar.ts";
|
|
6
|
+
import "./pages/home-page.ts";
|
|
7
|
+
import "./pages/todos-page.ts";
|
|
8
|
+
import "./pages/settings-page.ts";
|
|
9
|
+
import "./pages/user-list.ts";
|
|
10
|
+
import "./pages/user-detail.ts";
|
|
5
11
|
import "./pages/about-page.ts";
|
|
6
12
|
|
|
7
13
|
component("app-root", () => {
|
|
8
14
|
const view = router({
|
|
9
|
-
"/": () => html`<
|
|
15
|
+
"/": () => html`<home-page></home-page>`,
|
|
16
|
+
"/todos": () => html`<todos-page></todos-page>`,
|
|
17
|
+
"/settings": () => html`<settings-page></settings-page>`,
|
|
18
|
+
// nested route: layout dengan outlet — sidebar bertahan saat ganti user
|
|
19
|
+
"/users": {
|
|
20
|
+
layout: (ctx) => html`
|
|
21
|
+
<div class="max-w-3xl mx-auto mt-8 flex gap-6">
|
|
22
|
+
<users-sidebar></users-sidebar>
|
|
23
|
+
<section class="flex-1 bg-white rounded-xl shadow p-6 min-h-40">
|
|
24
|
+
${ctx.outlet}
|
|
25
|
+
</section>
|
|
26
|
+
</div>
|
|
27
|
+
`,
|
|
28
|
+
children: {
|
|
29
|
+
"/": () => html`<user-list></user-list>`,
|
|
30
|
+
"/:id": () => html`<user-detail></user-detail>`,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
10
33
|
"/about": () => html`<about-page></about-page>`,
|
|
11
34
|
"*": () => html`
|
|
12
|
-
<div class="max-w-md mx-auto mt-
|
|
13
|
-
<p class="text-
|
|
35
|
+
<div class="max-w-md mx-auto mt-16 text-center text-slate-500">
|
|
36
|
+
<p class="text-lg">Halaman tidak ditemukan.</p>
|
|
14
37
|
<a data-link href="/" class="text-blue-600 hover:underline">Beranda</a>
|
|
15
38
|
</div>
|
|
16
39
|
`,
|
|
17
40
|
});
|
|
18
|
-
|
|
41
|
+
|
|
42
|
+
return () => html`
|
|
43
|
+
<nav-bar></nav-bar>
|
|
44
|
+
<main>${view}</main>
|
|
45
|
+
`;
|
|
19
46
|
});
|
|
47
|
+
|
|
48
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// components/live-clock.ts — demo lifecycle: onMount mulai interval, onCleanup stop
|
|
2
|
+
|
|
3
|
+
import { component, html, signal, onMount, onCleanup } from "@sanify/core";
|
|
4
|
+
|
|
5
|
+
component("live-clock", () => {
|
|
6
|
+
const [now, setNow] = signal(new Date().toLocaleTimeString());
|
|
7
|
+
|
|
8
|
+
onMount(() => {
|
|
9
|
+
const id = setInterval(() => setNow(new Date().toLocaleTimeString()), 1000);
|
|
10
|
+
onCleanup(() => clearInterval(id));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
return () => html`<span class="font-mono">${() => now()}</span>`;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// components/nav-bar.ts — navigasi atas (SPA link via data-link)
|
|
2
|
+
|
|
3
|
+
import { component, html, current } from "@sanify/core";
|
|
4
|
+
|
|
5
|
+
const links = [
|
|
6
|
+
{ href: "/", label: "Home" },
|
|
7
|
+
{ href: "/todos", label: "Todos" },
|
|
8
|
+
{ href: "/settings", label: "Settings" },
|
|
9
|
+
{ href: "/users", label: "Users" },
|
|
10
|
+
{ href: "/about", label: "About" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
component("nav-bar", () => {
|
|
14
|
+
const isActive = (href: string) =>
|
|
15
|
+
href === "/" ? current() === "/" : current().startsWith(href);
|
|
16
|
+
|
|
17
|
+
return () => html`
|
|
18
|
+
<nav class="bg-white border-b border-slate-200">
|
|
19
|
+
<div class="max-w-3xl mx-auto px-4 h-12 flex items-center gap-4 text-sm">
|
|
20
|
+
<span class="font-bold text-slate-800">Sanify</span>
|
|
21
|
+
${links.map(
|
|
22
|
+
(l) => html`
|
|
23
|
+
<a
|
|
24
|
+
data-link
|
|
25
|
+
href=${l.href}
|
|
26
|
+
class=${() =>
|
|
27
|
+
isActive(l.href)
|
|
28
|
+
? "text-blue-600 font-medium"
|
|
29
|
+
: "text-slate-500 hover:text-slate-800"}
|
|
30
|
+
>${l.label}</a
|
|
31
|
+
>
|
|
32
|
+
`,
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
</nav>
|
|
36
|
+
`;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// components/todo-item.ts — satu baris todo (menerima objek lewat
|
|
1
|
+
// components/todo-item.ts — satu baris todo (menerima objek lewat .prop)
|
|
2
2
|
|
|
3
3
|
import { component, html, type ComponentContext } from "@sanify/core";
|
|
4
4
|
import { toggleTodo, removeTodo, type Todo } from "../state/todos.ts";
|
|
@@ -11,7 +11,7 @@ component<TodoItemProps>(
|
|
|
11
11
|
"todo-item",
|
|
12
12
|
({ props }: ComponentContext<TodoItemProps>) => {
|
|
13
13
|
return () => html`
|
|
14
|
-
<li class="flex items-center gap-3 py-2 border-b border-slate-
|
|
14
|
+
<li class="flex items-center gap-3 py-2 border-b border-slate-100">
|
|
15
15
|
<input
|
|
16
16
|
type="checkbox"
|
|
17
17
|
class="h-4 w-4"
|
|
@@ -34,3 +34,5 @@ component<TodoItemProps>(
|
|
|
34
34
|
},
|
|
35
35
|
{ props: ["todo"] },
|
|
36
36
|
);
|
|
37
|
+
|
|
38
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// components/users-sidebar.ts — daftar user (bagian layout nested yang bertahan)
|
|
2
|
+
|
|
3
|
+
import { component, html, params } from "@sanify/core";
|
|
4
|
+
import { users } from "../data/users.ts";
|
|
5
|
+
|
|
6
|
+
component("users-sidebar", () => {
|
|
7
|
+
return () => html`
|
|
8
|
+
<aside class="w-40 shrink-0">
|
|
9
|
+
<h2 class="text-xs uppercase text-slate-400 mb-2">Users</h2>
|
|
10
|
+
<ul class="space-y-1">
|
|
11
|
+
${users.map(
|
|
12
|
+
(u) => html`
|
|
13
|
+
<li>
|
|
14
|
+
<a
|
|
15
|
+
data-link
|
|
16
|
+
href=${`/users/${u.id}`}
|
|
17
|
+
class=${() =>
|
|
18
|
+
params().id === u.id
|
|
19
|
+
? "text-blue-600 font-medium"
|
|
20
|
+
: "text-slate-600 hover:text-slate-900"}
|
|
21
|
+
>${u.name}</a
|
|
22
|
+
>
|
|
23
|
+
</li>
|
|
24
|
+
`,
|
|
25
|
+
)}
|
|
26
|
+
</ul>
|
|
27
|
+
</aside>
|
|
28
|
+
`;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// data/users.ts — data contoh + simulasi fetch async (untuk demo resource())
|
|
2
|
+
|
|
3
|
+
export interface User {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Profile extends User {
|
|
9
|
+
email: string;
|
|
10
|
+
bio: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const users: User[] = [
|
|
14
|
+
{ id: "ada", name: "Ada Lovelace" },
|
|
15
|
+
{ id: "alan", name: "Alan Turing" },
|
|
16
|
+
{ id: "grace", name: "Grace Hopper" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Tanpa jaringan: tiru latensi + kemungkinan error agar resource() terlihat.
|
|
20
|
+
export function fetchProfile(id: string): Promise<Profile> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
setTimeout(() => {
|
|
23
|
+
const u = users.find((x) => x.id === id);
|
|
24
|
+
if (!u) reject(new Error(`User "${id}" tidak ditemukan`));
|
|
25
|
+
else resolve({ ...u, email: `${u.id}@sanify.dev`, bio: `Profil ${u.name}.` });
|
|
26
|
+
}, 400);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -1,16 +1,26 @@
|
|
|
1
|
-
// pages/about-page.ts —
|
|
1
|
+
// pages/about-page.ts — info + demo query() reaktif
|
|
2
2
|
|
|
3
|
-
import { component, html } from "@sanify/core";
|
|
3
|
+
import { component, html, query } from "@sanify/core";
|
|
4
4
|
|
|
5
5
|
component("about-page", () => {
|
|
6
6
|
return () => html`
|
|
7
|
-
<div class="max-w-md mx-auto mt-10 p-6 bg-white rounded-xl shadow">
|
|
8
|
-
<h1 class="text-2xl font-bold
|
|
9
|
-
<p class="text-slate-600
|
|
10
|
-
|
|
11
|
-
Components
|
|
7
|
+
<div class="max-w-md mx-auto mt-10 p-6 bg-white rounded-xl shadow space-y-2">
|
|
8
|
+
<h1 class="text-2xl font-bold">About</h1>
|
|
9
|
+
<p class="text-sm text-slate-600">
|
|
10
|
+
Dibangun dengan Sanify — framework reaktif fine-grained di atas Web
|
|
11
|
+
Components, tanpa virtual DOM.
|
|
12
|
+
</p>
|
|
13
|
+
<p class="text-sm">
|
|
14
|
+
Coba query string:
|
|
15
|
+
<a data-link href="/about?msg=hai" class="text-blue-600 hover:underline"
|
|
16
|
+
>/about?msg=hai</a
|
|
17
|
+
>
|
|
18
|
+
</p>
|
|
19
|
+
<p class="text-sm">
|
|
20
|
+
query.msg = <strong>${() => query().get("msg") ?? "(kosong)"}</strong>
|
|
12
21
|
</p>
|
|
13
|
-
<a data-link href="/" class="text-blue-600 hover:underline">← Kembali</a>
|
|
14
22
|
</div>
|
|
15
23
|
`;
|
|
16
24
|
});
|
|
25
|
+
|
|
26
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// pages/home-page.ts — intro + signal counter + komponen lifecycle
|
|
2
|
+
|
|
3
|
+
import { component, html, signal } from "@sanify/core";
|
|
4
|
+
import "../components/live-clock.ts";
|
|
5
|
+
|
|
6
|
+
component("home-page", () => {
|
|
7
|
+
const [count, setCount] = signal(0);
|
|
8
|
+
|
|
9
|
+
return () => html`
|
|
10
|
+
<div class="max-w-md mx-auto mt-10 p-6 bg-white rounded-xl shadow space-y-4">
|
|
11
|
+
<h1 class="text-2xl font-bold">Sanify starter</h1>
|
|
12
|
+
<p class="text-sm text-slate-600">
|
|
13
|
+
Contoh kecil yang memamerkan fitur inti: signal, store, list keyed,
|
|
14
|
+
router nested, resource.
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<div class="flex items-center gap-3">
|
|
18
|
+
<button
|
|
19
|
+
class="bg-slate-800 text-white rounded px-3 py-1 hover:bg-slate-700"
|
|
20
|
+
@click=${() => setCount((n) => n + 1)}
|
|
21
|
+
>
|
|
22
|
+
+1
|
|
23
|
+
</button>
|
|
24
|
+
<span class="text-sm">signal: <strong>${() => count()}</strong></span>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<p class="text-sm text-slate-500">
|
|
28
|
+
onMount/onCleanup → <live-clock></live-clock>
|
|
29
|
+
</p>
|
|
30
|
+
</div>
|
|
31
|
+
`;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// pages/settings-page.ts — createStore nested fine-grained (update by path)
|
|
2
|
+
|
|
3
|
+
import { component, html } from "@sanify/core";
|
|
4
|
+
import { settings, setSettings } from "../state/settings.ts";
|
|
5
|
+
|
|
6
|
+
component("settings-page", () => {
|
|
7
|
+
return () => html`
|
|
8
|
+
<div class="max-w-md mx-auto mt-10 p-6 bg-white rounded-xl shadow space-y-4">
|
|
9
|
+
<h1 class="text-2xl font-bold">Settings</h1>
|
|
10
|
+
<p class="text-sm text-slate-500">
|
|
11
|
+
createStore + update by path — edit satu field tidak menyentuh field lain.
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<label class="block">
|
|
15
|
+
<span class="text-sm text-slate-600">Nama</span>
|
|
16
|
+
<input
|
|
17
|
+
class="mt-1 w-full border border-slate-300 rounded px-3 py-2"
|
|
18
|
+
.value=${() => settings.profile.name}
|
|
19
|
+
@input=${(e: Event) =>
|
|
20
|
+
setSettings("profile", "name", (e.target as HTMLInputElement).value)}
|
|
21
|
+
/>
|
|
22
|
+
</label>
|
|
23
|
+
|
|
24
|
+
<div class="flex items-center gap-3">
|
|
25
|
+
<span class="text-sm text-slate-600">Umur</span>
|
|
26
|
+
<button
|
|
27
|
+
class="border rounded w-8 h-8"
|
|
28
|
+
@click=${() => setSettings("profile", "age", (a) => a - 1)}
|
|
29
|
+
>
|
|
30
|
+
−
|
|
31
|
+
</button>
|
|
32
|
+
<strong>${() => settings.profile.age}</strong>
|
|
33
|
+
<button
|
|
34
|
+
class="border rounded w-8 h-8"
|
|
35
|
+
@click=${() => setSettings("profile", "age", (a) => a + 1)}
|
|
36
|
+
>
|
|
37
|
+
+
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<p class="text-sm">
|
|
42
|
+
Halo, <strong>${() => settings.profile.name}</strong> (umur
|
|
43
|
+
${() => settings.profile.age}).
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
`;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
// pages/
|
|
1
|
+
// pages/todos-page.ts — list keyed (For) + persisted + computed
|
|
2
2
|
|
|
3
3
|
import { component, html, signal, For } from "@sanify/core";
|
|
4
4
|
import { todos, remaining, addTodo, type Todo } from "../state/todos.ts";
|
|
5
5
|
import "../components/todo-item.ts";
|
|
6
6
|
|
|
7
|
-
component("
|
|
7
|
+
component("todos-page", () => {
|
|
8
8
|
const [draft, setDraft] = signal("");
|
|
9
|
-
|
|
10
9
|
const submit = () => {
|
|
11
10
|
addTodo(draft());
|
|
12
11
|
setDraft("");
|
|
@@ -14,10 +13,10 @@ component("todo-page", () => {
|
|
|
14
13
|
|
|
15
14
|
return () => html`
|
|
16
15
|
<div class="max-w-md mx-auto mt-10 p-6 bg-white rounded-xl shadow">
|
|
17
|
-
<h1 class="text-2xl font-bold mb-1">
|
|
16
|
+
<h1 class="text-2xl font-bold mb-1">Todos</h1>
|
|
18
17
|
<p class="text-sm text-slate-500 mb-4">
|
|
19
|
-
Sisa <strong>${() => remaining()}</strong>
|
|
20
|
-
tab
|
|
18
|
+
Sisa <strong>${() => remaining()}</strong> — persisted + cross-tab (buka
|
|
19
|
+
dua tab).
|
|
21
20
|
</p>
|
|
22
21
|
|
|
23
22
|
<div class="flex gap-2 mb-4">
|
|
@@ -47,12 +46,8 @@ component("todo-page", () => {
|
|
|
47
46
|
? html`<li class="text-slate-400 py-2">Belum ada tugas.</li>`
|
|
48
47
|
: null}
|
|
49
48
|
</ul>
|
|
50
|
-
|
|
51
|
-
<div class="mt-4 text-sm">
|
|
52
|
-
<a data-link href="/about" class="text-blue-600 hover:underline"
|
|
53
|
-
>Tentang →</a
|
|
54
|
-
>
|
|
55
|
-
</div>
|
|
56
49
|
</div>
|
|
57
50
|
`;
|
|
58
51
|
});
|
|
52
|
+
|
|
53
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// pages/user-detail.ts — resource() + params() reaktif (refetch saat :id berubah,
|
|
2
|
+
// tanpa membangun ulang komponen karena route-nya sama)
|
|
3
|
+
|
|
4
|
+
import { component, html, resource, params } from "@sanify/core";
|
|
5
|
+
import { fetchProfile } from "../data/users.ts";
|
|
6
|
+
|
|
7
|
+
component("user-detail", () => {
|
|
8
|
+
const profile = resource(() => fetchProfile(params().id ?? ""));
|
|
9
|
+
|
|
10
|
+
return () => html`
|
|
11
|
+
<div>
|
|
12
|
+
${() =>
|
|
13
|
+
profile.loading()
|
|
14
|
+
? html`<p class="text-slate-400">Memuat…</p>`
|
|
15
|
+
: null}
|
|
16
|
+
${() =>
|
|
17
|
+
profile.error()
|
|
18
|
+
? html`<p class="text-red-500">${String(profile.error())}</p>`
|
|
19
|
+
: null}
|
|
20
|
+
${() => {
|
|
21
|
+
const p = profile.data();
|
|
22
|
+
return p
|
|
23
|
+
? html`
|
|
24
|
+
<h2 class="text-xl font-bold">${p.name}</h2>
|
|
25
|
+
<p class="text-sm text-slate-500">${p.email}</p>
|
|
26
|
+
<p class="mt-2 text-slate-700">${p.bio}</p>
|
|
27
|
+
`
|
|
28
|
+
: null;
|
|
29
|
+
}}
|
|
30
|
+
</div>
|
|
31
|
+
`;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// pages/user-list.ts — index route di bawah layout /users
|
|
2
|
+
|
|
3
|
+
import { component, html } from "@sanify/core";
|
|
4
|
+
|
|
5
|
+
component("user-list", () => {
|
|
6
|
+
return () => html`
|
|
7
|
+
<p class="text-slate-500">
|
|
8
|
+
Pilih user di kiri untuk melihat profil (resource + params reaktif).
|
|
9
|
+
</p>
|
|
10
|
+
`;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// state/settings.ts — store objek nested fine-grained (createStore)
|
|
2
|
+
// Edit satu field tidak memicu pembaca field lain.
|
|
3
|
+
|
|
4
|
+
import { createStore } from "@sanify/core";
|
|
5
|
+
|
|
6
|
+
export interface Settings {
|
|
7
|
+
profile: { name: string; age: number };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const [settings, setSettings] = createStore<Settings>({
|
|
11
|
+
profile: { name: "Tamu", age: 20 },
|
|
12
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// state/todos.ts —
|
|
1
|
+
// state/todos.ts — daftar todo: persisted (localStorage + cross-tab) + computed
|
|
2
2
|
|
|
3
3
|
import { computed, persisted } from "@sanify/core";
|
|
4
4
|
|
|
@@ -8,7 +8,7 @@ export interface Todo {
|
|
|
8
8
|
done: boolean;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export const [todos, setTodos] = persisted<Todo[]>("
|
|
11
|
+
export const [todos, setTodos] = persisted<Todo[]>("app:todos", [], {
|
|
12
12
|
sync: true,
|
|
13
13
|
});
|
|
14
14
|
|