create-mikstack 0.1.0

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.
Files changed (99) hide show
  1. package/README.md +54 -0
  2. package/dist/index.js +410 -0
  3. package/package.json +43 -0
  4. package/templates/adapters/cloudflare/package.json.partial +5 -0
  5. package/templates/adapters/cloudflare/svelte.config.js +19 -0
  6. package/templates/adapters/node/Dockerfile +30 -0
  7. package/templates/adapters/node/docker-compose.prod.yml +27 -0
  8. package/templates/adapters/node/package.json.partial +5 -0
  9. package/templates/adapters/node/svelte.config.js +19 -0
  10. package/templates/adapters/vercel/package.json.partial +5 -0
  11. package/templates/adapters/vercel/svelte.config.js +19 -0
  12. package/templates/base/.env.example +23 -0
  13. package/templates/base/.gitignore.append +2 -0
  14. package/templates/base/.mcp.json +9 -0
  15. package/templates/base/.prettierignore +10 -0
  16. package/templates/base/.vscode/extensions.json +3 -0
  17. package/templates/base/AGENTS.md +123 -0
  18. package/templates/base/README.md +27 -0
  19. package/templates/base/agents.md +28 -0
  20. package/templates/base/docker-compose.yml +15 -0
  21. package/templates/base/drizzle-zero.config.ts +17 -0
  22. package/templates/base/drizzle.config.ts +17 -0
  23. package/templates/base/eslint.config.ts +65 -0
  24. package/templates/base/package.json.partial +43 -0
  25. package/templates/base/prettier.config.js +6 -0
  26. package/templates/base/src/app.d.ts +12 -0
  27. package/templates/base/src/app.html +11 -0
  28. package/templates/base/src/hooks.server.ts +15 -0
  29. package/templates/base/src/lib/auth-client.ts +6 -0
  30. package/templates/base/src/lib/server/auth.ts +52 -0
  31. package/templates/base/src/lib/server/db/index.ts +19 -0
  32. package/templates/base/src/lib/server/db/schema.ts +117 -0
  33. package/templates/base/src/lib/server/db/seed.ts +21 -0
  34. package/templates/base/src/lib/server/emails/magic-link.ts +77 -0
  35. package/templates/base/src/lib/server/emails/send.ts +55 -0
  36. package/templates/base/src/lib/server/notifications/definitions.ts +12 -0
  37. package/templates/base/src/lib/server/notifications.ts +38 -0
  38. package/templates/base/src/lib/z.svelte.ts +14 -0
  39. package/templates/base/src/lib/zero/context.ts +9 -0
  40. package/templates/base/src/lib/zero/db-provider.server.ts +11 -0
  41. package/templates/base/src/lib/zero/mutators.ts +35 -0
  42. package/templates/base/src/lib/zero/queries.ts +21 -0
  43. package/templates/base/src/lib/zero/schema.ts +1 -0
  44. package/templates/base/src/routes/+layout.server.ts +5 -0
  45. package/templates/base/src/routes/+layout.svelte +7 -0
  46. package/templates/base/src/routes/+page.server.ts +7 -0
  47. package/templates/base/src/routes/+page.svelte +319 -0
  48. package/templates/base/src/routes/api/dev/emails/+server.ts +89 -0
  49. package/templates/base/src/routes/api/dev/emails/[id]/+server.ts +24 -0
  50. package/templates/base/src/routes/api/notifications/[...path]/+server.ts +10 -0
  51. package/templates/base/src/routes/api/zero/get-queries/+server.ts +29 -0
  52. package/templates/base/src/routes/api/zero/mutate/+server.ts +31 -0
  53. package/templates/base/src/routes/sign-in/+page.svelte +97 -0
  54. package/templates/base/tsconfig.json +40 -0
  55. package/templates/github-actions-bun/.github/workflows/ci.yml +22 -0
  56. package/templates/github-actions-npm/.github/workflows/ci.yml +25 -0
  57. package/templates/github-actions-pnpm/.github/workflows/ci.yml +27 -0
  58. package/templates/i18n/lingui.config.ts +16 -0
  59. package/templates/i18n/package.json.partial +14 -0
  60. package/templates/i18n/src/lib/i18n.ts +10 -0
  61. package/templates/i18n/src/locales/en.po +6 -0
  62. package/templates/i18n/src/po.d.ts +3 -0
  63. package/templates/i18n/vite.config.ts +7 -0
  64. package/templates/supply-chain-bun/bunfig.toml +3 -0
  65. package/templates/testing/package.json.partial +11 -0
  66. package/templates/testing/src/example.test.ts +7 -0
  67. package/templates/testing/src/lib/server/db/test-utils.ts +25 -0
  68. package/templates/testing/vitest.config.ts +9 -0
  69. package/templates/ui/.vscode/extensions.json +8 -0
  70. package/templates/ui/package.json.partial +13 -0
  71. package/templates/ui/src/app.css +94 -0
  72. package/templates/ui/src/routes/+layout.svelte +12 -0
  73. package/templates/ui/stylelint.config.js +7 -0
  74. package/templates/ui/vite.config.ts +6 -0
  75. package/templates/ui-dependency/package.json.partial +5 -0
  76. package/templates/ui-vendor/src/lib/components/ui/Accordion/Accordion.svelte +71 -0
  77. package/templates/ui-vendor/src/lib/components/ui/Accordion/index.ts +1 -0
  78. package/templates/ui-vendor/src/lib/components/ui/Alert/Alert.svelte +60 -0
  79. package/templates/ui-vendor/src/lib/components/ui/Alert/index.ts +1 -0
  80. package/templates/ui-vendor/src/lib/components/ui/Badge/Badge.svelte +48 -0
  81. package/templates/ui-vendor/src/lib/components/ui/Badge/index.ts +1 -0
  82. package/templates/ui-vendor/src/lib/components/ui/Button/Button.svelte +77 -0
  83. package/templates/ui-vendor/src/lib/components/ui/Button/index.ts +1 -0
  84. package/templates/ui-vendor/src/lib/components/ui/Card/Card.svelte +49 -0
  85. package/templates/ui-vendor/src/lib/components/ui/Card/index.ts +1 -0
  86. package/templates/ui-vendor/src/lib/components/ui/Dialog/Dialog.svelte +70 -0
  87. package/templates/ui-vendor/src/lib/components/ui/Dialog/index.ts +1 -0
  88. package/templates/ui-vendor/src/lib/components/ui/FormField/FormField.svelte +53 -0
  89. package/templates/ui-vendor/src/lib/components/ui/FormField/index.ts +1 -0
  90. package/templates/ui-vendor/src/lib/components/ui/Input/Input.svelte +27 -0
  91. package/templates/ui-vendor/src/lib/components/ui/Input/index.ts +1 -0
  92. package/templates/ui-vendor/src/lib/components/ui/Separator/Separator.svelte +26 -0
  93. package/templates/ui-vendor/src/lib/components/ui/Separator/index.ts +1 -0
  94. package/templates/ui-vendor/src/lib/components/ui/Skeleton/Skeleton.svelte +40 -0
  95. package/templates/ui-vendor/src/lib/components/ui/Skeleton/index.ts +1 -0
  96. package/templates/ui-vendor/src/lib/components/ui/Switch/Switch.svelte +86 -0
  97. package/templates/ui-vendor/src/lib/components/ui/Switch/index.ts +1 -0
  98. package/templates/ui-vendor/src/lib/components/ui/Textarea/Textarea.svelte +29 -0
  99. package/templates/ui-vendor/src/lib/components/ui/Textarea/index.ts +1 -0
@@ -0,0 +1,77 @@
1
+ import { html, body, section, text, button, spacer, divider, render } from "@mikstack/email";
2
+
3
+ export function magicLinkEmail(url: string) {
4
+ const email = html(
5
+ body(
6
+ [
7
+ section(
8
+ [
9
+ text("{{projectName}}", {
10
+ fontSize: 24,
11
+ fontWeight: "bold",
12
+ textAlign: "center",
13
+ color: "#111827",
14
+ }),
15
+ ],
16
+ { padding: [32, 24, 0, 24] },
17
+ ),
18
+ section(
19
+ [
20
+ text("Sign in to your account", {
21
+ fontSize: 20,
22
+ fontWeight: "bold",
23
+ color: "#111827",
24
+ }),
25
+ spacer(8),
26
+ text("Click the button below to sign in. This link will expire in 10 minutes.", {
27
+ fontSize: 16,
28
+ lineHeight: 1.5,
29
+ color: "#4b5563",
30
+ }),
31
+ spacer(24),
32
+ button("Sign in", {
33
+ href: url,
34
+ backgroundColor: "#111827",
35
+ color: "#ffffff",
36
+ borderRadius: 6,
37
+ padding: [12, 32],
38
+ fontSize: 16,
39
+ fontWeight: "bold",
40
+ }),
41
+ spacer(24),
42
+ text("Or copy and paste this URL into your browser:", {
43
+ fontSize: 14,
44
+ color: "#6b7280",
45
+ }),
46
+ spacer(4),
47
+ text(url, {
48
+ fontSize: 14,
49
+ color: "#2563eb",
50
+ textDecoration: "underline",
51
+ }),
52
+ ],
53
+ { padding: [24, 24] },
54
+ ),
55
+ divider({ borderColor: "#e5e7eb" }),
56
+ section(
57
+ [
58
+ text("If you didn't request this email, you can safely ignore it.", {
59
+ fontSize: 13,
60
+ color: "#9ca3af",
61
+ textAlign: "center",
62
+ }),
63
+ ],
64
+ { padding: [16, 24, 32, 24] },
65
+ ),
66
+ ],
67
+ { maxWidth: 480, backgroundColor: "#ffffff" },
68
+ ),
69
+ { lang: "en" },
70
+ );
71
+
72
+ return {
73
+ subject: "Sign in to {{projectName}}",
74
+ html: render(email),
75
+ text: render(email, { plainText: true }),
76
+ };
77
+ }
@@ -0,0 +1,55 @@
1
+ import { createTransport } from "nodemailer";
2
+ import { dev } from "$app/environment";
3
+ import { env } from "$env/dynamic/private";
4
+
5
+ interface Email {
6
+ subject: string;
7
+ html: string;
8
+ text: string;
9
+ }
10
+
11
+ /**
12
+ * Send an email via SMTP.
13
+ *
14
+ * This is used as the transport for @mikstack/notifications emailChannel.
15
+ * Delivery tracking (status, retries, errors) is handled by the notifications system.
16
+ *
17
+ * In dev mode, SMTP is skipped — emails are logged to the console only.
18
+ *
19
+ * The SMTP transport uses nodemailer. Easy to replace with your preferred provider:
20
+ * - Resend: https://resend.com/docs
21
+ * - Postmark: https://postmarkapp.com/developer
22
+ * - Mailgun: https://documentation.mailgun.com
23
+ * - AWS SES, SendGrid, etc.
24
+ */
25
+ export async function sendEmail(to: string, email: Email): Promise<void> {
26
+ if (dev) {
27
+ console.log(`\n✉️ Email: "${email.subject}" → ${to}\n`);
28
+ return;
29
+ }
30
+
31
+ const host = env.SMTP_HOST;
32
+ const port = Number(env.SMTP_PORT ?? 587);
33
+ const user = env.SMTP_USER;
34
+ const pass = env.SMTP_PASS;
35
+ const from = env.SMTP_FROM ?? "noreply@example.com";
36
+
37
+ if (!host || !user || !pass) {
38
+ throw new Error("SMTP not configured. Set SMTP_HOST, SMTP_USER, and SMTP_PASS in .env");
39
+ }
40
+
41
+ const transport = createTransport({
42
+ host,
43
+ port,
44
+ secure: port === 465,
45
+ auth: { user, pass },
46
+ });
47
+
48
+ await transport.sendMail({
49
+ from,
50
+ to,
51
+ subject: email.subject,
52
+ html: email.html,
53
+ text: email.text,
54
+ });
55
+ }
@@ -0,0 +1,12 @@
1
+ import { defineNotification } from "@mikstack/notifications";
2
+ import { magicLinkEmail } from "../emails/magic-link";
3
+
4
+ export const notifications = {
5
+ "magic-link": defineNotification({
6
+ key: "magic-link",
7
+ critical: true, // Auth emails bypass user preferences
8
+ channels: {
9
+ email: (data: { url: string }) => magicLinkEmail(data.url),
10
+ },
11
+ }),
12
+ } as const;
@@ -0,0 +1,38 @@
1
+ import { createNotifications, emailChannel, inAppChannel } from "@mikstack/notifications";
2
+ import { building } from "$app/environment";
3
+ import { db } from "./db";
4
+ import * as schema from "./db/schema";
5
+ import { sendEmail } from "./emails/send";
6
+ import { notifications } from "./notifications/definitions";
7
+
8
+ type Notif = ReturnType<typeof createNotif>;
9
+
10
+ function createNotif() {
11
+ return createNotifications({
12
+ database: { db, schema, provider: "pg" },
13
+ channels: [
14
+ emailChannel({
15
+ sendEmail: async ({ to, subject, html, text }) => {
16
+ await sendEmail(to, { subject, html, text });
17
+ },
18
+ }),
19
+ inAppChannel(),
20
+ ],
21
+ notifications,
22
+ defaultPreferences: { enabledChannels: ["email", "in-app"] },
23
+ });
24
+ }
25
+
26
+ let _notif: Notif | undefined;
27
+
28
+ export const notif: Notif = new Proxy({} as Notif, {
29
+ get(_, prop) {
30
+ if (building) {
31
+ throw new Error("Cannot access notif during build");
32
+ }
33
+ if (!_notif) {
34
+ _notif = createNotif();
35
+ }
36
+ return (_notif as unknown as Record<string | symbol, unknown>)[prop];
37
+ },
38
+ });
@@ -0,0 +1,14 @@
1
+ import type { Z } from "zero-svelte";
2
+ import type { Schema } from "./zero/schema";
3
+ import { getContext, setContext } from "svelte";
4
+
5
+ const zSymbol = Symbol("z");
6
+
7
+ export function set_z(z: Z<Schema>) {
8
+ setContext(zSymbol, z);
9
+ return z;
10
+ }
11
+
12
+ export function get_z() {
13
+ return getContext(zSymbol) as Z<Schema>;
14
+ }
@@ -0,0 +1,9 @@
1
+ export type ZeroContext = {
2
+ userID: string;
3
+ };
4
+
5
+ declare module "@rocicorp/zero" {
6
+ interface DefaultTypes {
7
+ context: ZeroContext;
8
+ }
9
+ }
@@ -0,0 +1,11 @@
1
+ import { zeroDrizzle } from "@rocicorp/zero/server/adapters/drizzle";
2
+ import { schema } from "./schema";
3
+ import { db } from "$lib/server/db";
4
+
5
+ export const dbProvider = zeroDrizzle(schema, db);
6
+
7
+ declare module "@rocicorp/zero" {
8
+ interface DefaultTypes {
9
+ dbProvider: typeof dbProvider;
10
+ }
11
+ }
@@ -0,0 +1,35 @@
1
+ import { defineMutators, defineMutator } from "@rocicorp/zero";
2
+ import * as v from "valibot";
3
+
4
+ export const mutators = defineMutators({
5
+ note: {
6
+ create: defineMutator(
7
+ v.object({ id: v.string(), title: v.string(), content: v.string() }),
8
+ async ({ tx, ctx, args }) => {
9
+ const now = Date.now();
10
+ await tx.mutate.note.insert({
11
+ id: args.id,
12
+ title: args.title,
13
+ content: args.content,
14
+ userId: ctx.userID,
15
+ createdAt: now,
16
+ updatedAt: now,
17
+ });
18
+ },
19
+ ),
20
+ update: defineMutator(
21
+ v.object({ id: v.string(), title: v.string(), content: v.string() }),
22
+ async ({ tx, args }) => {
23
+ await tx.mutate.note.update({
24
+ id: args.id,
25
+ title: args.title,
26
+ content: args.content,
27
+ updatedAt: Date.now(),
28
+ });
29
+ },
30
+ ),
31
+ delete: defineMutator(v.object({ id: v.string() }), async ({ tx, args }) => {
32
+ await tx.mutate.note.delete({ id: args.id });
33
+ }),
34
+ },
35
+ });
@@ -0,0 +1,21 @@
1
+ import { defineQueries, defineQuery } from "@rocicorp/zero";
2
+ import { zql } from "./schema";
3
+
4
+ export const queries = defineQueries({
5
+ note: {
6
+ mine: defineQuery(({ ctx }) =>
7
+ zql.note.where("userId", ctx.userID).orderBy("updatedAt", "desc"),
8
+ ),
9
+ },
10
+ inAppNotification: {
11
+ mine: defineQuery(({ ctx }) =>
12
+ zql.inAppNotification.where("userId", ctx.userID).orderBy("createdAt", "desc"),
13
+ ),
14
+ unread: defineQuery(({ ctx }) =>
15
+ zql.inAppNotification
16
+ .where("userId", ctx.userID)
17
+ .where("read", false)
18
+ .orderBy("createdAt", "desc"),
19
+ ),
20
+ },
21
+ });
@@ -0,0 +1 @@
1
+ export { schema, zql, type Schema } from "./zero-schema.gen";
@@ -0,0 +1,5 @@
1
+ import type { LayoutServerLoad } from "./$types";
2
+
3
+ export const load: LayoutServerLoad = ({ locals }) => {
4
+ return { user: locals.user };
5
+ };
@@ -0,0 +1,7 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
4
+ let { children }: { children: Snippet } = $props();
5
+ </script>
6
+
7
+ {@render children()}
@@ -0,0 +1,7 @@
1
+ import { redirect } from "@sveltejs/kit";
2
+ import type { PageServerLoad } from "./$types";
3
+
4
+ export const load: PageServerLoad = ({ locals }) => {
5
+ if (!locals.user) redirect(302, "/sign-in");
6
+ return { user: locals.user };
7
+ };
@@ -0,0 +1,319 @@
1
+ <script lang="ts">
2
+ import NotePencil from "phosphor-svelte/lib/NotePencil";
3
+ import PencilSimple from "phosphor-svelte/lib/PencilSimple";
4
+ import Plus from "phosphor-svelte/lib/Plus";
5
+ import SignOut from "phosphor-svelte/lib/SignOut";
6
+ import Trash from "phosphor-svelte/lib/Trash";
7
+ import X from "phosphor-svelte/lib/X";
8
+ import Button from "{{uiPrefix}}/Button";
9
+ import FormField from "{{uiPrefix}}/FormField";
10
+ import Input from "{{uiPrefix}}/Input";
11
+ import Separator from "{{uiPrefix}}/Separator";
12
+ import Textarea from "{{uiPrefix}}/Textarea";
13
+ import { createForm } from "@mikstack/form";
14
+ import { dropAllDatabases } from "@rocicorp/zero";
15
+ import * as v from "valibot";
16
+ import { goto } from "$app/navigation";
17
+ import { resolve } from "$app/paths";
18
+ import { authClient } from "$lib/auth-client";
19
+ import { get_z } from "$lib/z.svelte";
20
+ import { queries } from "$lib/zero/queries";
21
+ import { mutators } from "$lib/zero/mutators";
22
+
23
+ let { data } = $props();
24
+
25
+ const z = get_z();
26
+ const notesQuery = z.q(queries.note.mine());
27
+ const notes = $derived(notesQuery.data);
28
+
29
+ let editingId = $state<string | null>(null);
30
+
31
+ const noteSchema = v.object({
32
+ title: v.pipe(v.string(), v.minLength(1, "Title is required")),
33
+ content: v.string(),
34
+ });
35
+
36
+ const createNoteForm = createForm({
37
+ schema: noteSchema,
38
+ initialValues: { title: "", content: "" },
39
+ onSubmit(data) {
40
+ z.mutate(
41
+ mutators.note.create({
42
+ id: crypto.randomUUID(),
43
+ title: data.title,
44
+ content: data.content,
45
+ }),
46
+ );
47
+ createNoteForm.reset();
48
+ },
49
+ });
50
+
51
+ function startEdit(note: { id: string; title: string; content: string | null }) {
52
+ editingId = note.id;
53
+ editForm.fields.set({
54
+ title: note.title,
55
+ content: note.content ?? "",
56
+ });
57
+ }
58
+
59
+ function cancelEdit() {
60
+ editingId = null;
61
+ editForm.reset();
62
+ }
63
+
64
+ const editForm = createForm({
65
+ schema: noteSchema,
66
+ initialValues: { title: "", content: "" },
67
+ onSubmit(data) {
68
+ if (!editingId) return;
69
+ z.mutate(
70
+ mutators.note.update({
71
+ id: editingId,
72
+ title: data.title,
73
+ content: data.content,
74
+ }),
75
+ );
76
+ editingId = null;
77
+ editForm.reset();
78
+ },
79
+ });
80
+
81
+ function deleteNote(id: string) {
82
+ z.mutate(mutators.note.delete({ id }));
83
+ if (editingId === id) cancelEdit();
84
+ }
85
+
86
+ async function signOut() {
87
+ await authClient.signOut();
88
+ await dropAllDatabases();
89
+ await goto(resolve("/sign-in"));
90
+ }
91
+ </script>
92
+
93
+ <div class="container">
94
+ <header class="header">
95
+ <div class="header-title">
96
+ <NotePencil size={24} weight="duotone" />
97
+ <h1>Notes</h1>
98
+ </div>
99
+ <div class="header-actions">
100
+ <span class="email">{data.user.email}</span>
101
+ <Button variant="ghost" onclick={signOut}>
102
+ <SignOut size={16} weight="bold" /> Sign out
103
+ </Button>
104
+ </div>
105
+ </header>
106
+
107
+ <Separator />
108
+
109
+ <section>
110
+ <h2>New note</h2>
111
+ <form id={createNoteForm.id} onsubmit={createNoteForm.onsubmit} class="note-form">
112
+ <FormField for={createNoteForm.fields.title.as("text").id}>
113
+ {#snippet label(attrs)}
114
+ <label {...attrs}>Title</label>
115
+ {/snippet}
116
+ <Input {...createNoteForm.fields.title.as("text")} placeholder="Note title" />
117
+ {#snippet error(attrs)}
118
+ {#each createNoteForm.fields.title.issues() as issue (issue.message)}
119
+ <p {...attrs}>{issue.message}</p>
120
+ {/each}
121
+ {/snippet}
122
+ </FormField>
123
+
124
+ <FormField for={`${createNoteForm.id}-content`}>
125
+ {#snippet label(attrs)}
126
+ <label {...attrs}>Content</label>
127
+ {/snippet}
128
+ <Textarea
129
+ name={createNoteForm.fields.content.name()}
130
+ id="{createNoteForm.id}-content"
131
+ oninput={(e) =>
132
+ createNoteForm.fields.content.set((e.target as HTMLTextAreaElement).value)}
133
+ value={createNoteForm.fields.content.value() as string}
134
+ placeholder="Write something..."
135
+ />
136
+ </FormField>
137
+
138
+ {#if createNoteForm.error}
139
+ <p class="form-error">{createNoteForm.error}</p>
140
+ {/if}
141
+
142
+ <Button type="submit" disabled={createNoteForm.pending}>
143
+ <Plus size={16} weight="bold" />
144
+ {createNoteForm.pending ? "Creating..." : "Create note"}
145
+ </Button>
146
+ </form>
147
+ </section>
148
+
149
+ <Separator />
150
+
151
+ <section>
152
+ <h2>Your notes</h2>
153
+ {#if notes.length === 0}
154
+ <p class="empty">No notes yet. Create one above!</p>
155
+ {:else}
156
+ <ul class="note-list">
157
+ {#each notes as note (note.id)}
158
+ <li class="note-card">
159
+ {#if editingId === note.id}
160
+ <form id={editForm.id} onsubmit={editForm.onsubmit} class="note-form">
161
+ <FormField for={editForm.fields.title.as("text").id}>
162
+ {#snippet label(attrs)}
163
+ <label {...attrs}>Title</label>
164
+ {/snippet}
165
+ <Input {...editForm.fields.title.as("text")} />
166
+ {#snippet error(attrs)}
167
+ {#each editForm.fields.title.issues() as issue (issue.message)}
168
+ <p {...attrs}>{issue.message}</p>
169
+ {/each}
170
+ {/snippet}
171
+ </FormField>
172
+
173
+ <FormField for={`${editForm.id}-content`}>
174
+ {#snippet label(attrs)}
175
+ <label {...attrs}>Content</label>
176
+ {/snippet}
177
+ <Textarea
178
+ name={editForm.fields.content.name()}
179
+ id="{editForm.id}-content"
180
+ oninput={(e) =>
181
+ editForm.fields.content.set((e.target as HTMLTextAreaElement).value)}
182
+ value={editForm.fields.content.value() as string}
183
+ />
184
+ </FormField>
185
+
186
+ {#if editForm.error}
187
+ <p class="form-error">{editForm.error}</p>
188
+ {/if}
189
+
190
+ <div class="actions">
191
+ <Button type="submit" disabled={editForm.pending}>
192
+ {editForm.pending ? "Saving..." : "Save"}
193
+ </Button>
194
+ <Button variant="ghost" type="button" onclick={cancelEdit}>
195
+ <X size={16} weight="bold" /> Cancel
196
+ </Button>
197
+ </div>
198
+ </form>
199
+ {:else}
200
+ <div class="note-body">
201
+ <strong>{note.title}</strong>
202
+ {#if note.content}
203
+ <p class="note-text">{note.content}</p>
204
+ {/if}
205
+ </div>
206
+ <div class="actions">
207
+ <Button variant="ghost" onclick={() => startEdit(note)}>
208
+ <PencilSimple size={16} /> Edit
209
+ </Button>
210
+ <Button variant="danger" onclick={() => deleteNote(note.id)}>
211
+ <Trash size={16} /> Delete
212
+ </Button>
213
+ </div>
214
+ {/if}
215
+ </li>
216
+ {/each}
217
+ </ul>
218
+ {/if}
219
+ </section>
220
+ </div>
221
+
222
+ <style>
223
+ .container {
224
+ max-width: 40rem;
225
+ margin: 0 auto;
226
+ padding: var(--space-5) var(--space-4);
227
+ display: flex;
228
+ flex-direction: column;
229
+ gap: var(--space-5);
230
+ }
231
+
232
+ .header {
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: space-between;
236
+ flex-wrap: wrap;
237
+ gap: var(--space-3);
238
+ }
239
+
240
+ .header-title {
241
+ display: flex;
242
+ align-items: center;
243
+ gap: var(--space-2);
244
+
245
+ & h1 {
246
+ font-size: var(--text-2xl);
247
+ }
248
+ }
249
+
250
+ .header-actions {
251
+ display: flex;
252
+ align-items: center;
253
+ gap: var(--space-3);
254
+ }
255
+
256
+ .email {
257
+ font-size: var(--text-sm);
258
+ color: var(--text-2);
259
+ }
260
+
261
+ h2 {
262
+ font-size: var(--text-lg);
263
+ margin-bottom: var(--space-3);
264
+ }
265
+
266
+ .note-form {
267
+ display: flex;
268
+ flex-direction: column;
269
+ gap: var(--space-3);
270
+ }
271
+
272
+ .form-error {
273
+ font-size: var(--text-sm);
274
+ color: var(--danger);
275
+ }
276
+
277
+ .note-list {
278
+ list-style: none;
279
+ padding: 0;
280
+ display: flex;
281
+ flex-direction: column;
282
+ gap: var(--space-3);
283
+ }
284
+
285
+ .note-card {
286
+ padding: var(--space-4);
287
+ border: 1px solid var(--border);
288
+ border-radius: var(--radius-lg);
289
+ background-color: var(--surface-2);
290
+ display: flex;
291
+ flex-direction: column;
292
+ gap: var(--space-3);
293
+ }
294
+
295
+ .note-body {
296
+ display: flex;
297
+ flex-direction: column;
298
+ gap: var(--space-1);
299
+ }
300
+
301
+ .note-text {
302
+ color: var(--text-2);
303
+ font-size: var(--text-sm);
304
+ display: -webkit-box;
305
+ -webkit-line-clamp: 3;
306
+ -webkit-box-orient: vertical;
307
+ overflow: hidden;
308
+ }
309
+
310
+ .actions {
311
+ display: flex;
312
+ gap: var(--space-2);
313
+ }
314
+
315
+ .empty {
316
+ color: var(--text-2);
317
+ font-size: var(--text-sm);
318
+ }
319
+ </style>