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.
- package/README.md +54 -0
- package/dist/index.js +410 -0
- package/package.json +43 -0
- package/templates/adapters/cloudflare/package.json.partial +5 -0
- package/templates/adapters/cloudflare/svelte.config.js +19 -0
- package/templates/adapters/node/Dockerfile +30 -0
- package/templates/adapters/node/docker-compose.prod.yml +27 -0
- package/templates/adapters/node/package.json.partial +5 -0
- package/templates/adapters/node/svelte.config.js +19 -0
- package/templates/adapters/vercel/package.json.partial +5 -0
- package/templates/adapters/vercel/svelte.config.js +19 -0
- package/templates/base/.env.example +23 -0
- package/templates/base/.gitignore.append +2 -0
- package/templates/base/.mcp.json +9 -0
- package/templates/base/.prettierignore +10 -0
- package/templates/base/.vscode/extensions.json +3 -0
- package/templates/base/AGENTS.md +123 -0
- package/templates/base/README.md +27 -0
- package/templates/base/agents.md +28 -0
- package/templates/base/docker-compose.yml +15 -0
- package/templates/base/drizzle-zero.config.ts +17 -0
- package/templates/base/drizzle.config.ts +17 -0
- package/templates/base/eslint.config.ts +65 -0
- package/templates/base/package.json.partial +43 -0
- package/templates/base/prettier.config.js +6 -0
- package/templates/base/src/app.d.ts +12 -0
- package/templates/base/src/app.html +11 -0
- package/templates/base/src/hooks.server.ts +15 -0
- package/templates/base/src/lib/auth-client.ts +6 -0
- package/templates/base/src/lib/server/auth.ts +52 -0
- package/templates/base/src/lib/server/db/index.ts +19 -0
- package/templates/base/src/lib/server/db/schema.ts +117 -0
- package/templates/base/src/lib/server/db/seed.ts +21 -0
- package/templates/base/src/lib/server/emails/magic-link.ts +77 -0
- package/templates/base/src/lib/server/emails/send.ts +55 -0
- package/templates/base/src/lib/server/notifications/definitions.ts +12 -0
- package/templates/base/src/lib/server/notifications.ts +38 -0
- package/templates/base/src/lib/z.svelte.ts +14 -0
- package/templates/base/src/lib/zero/context.ts +9 -0
- package/templates/base/src/lib/zero/db-provider.server.ts +11 -0
- package/templates/base/src/lib/zero/mutators.ts +35 -0
- package/templates/base/src/lib/zero/queries.ts +21 -0
- package/templates/base/src/lib/zero/schema.ts +1 -0
- package/templates/base/src/routes/+layout.server.ts +5 -0
- package/templates/base/src/routes/+layout.svelte +7 -0
- package/templates/base/src/routes/+page.server.ts +7 -0
- package/templates/base/src/routes/+page.svelte +319 -0
- package/templates/base/src/routes/api/dev/emails/+server.ts +89 -0
- package/templates/base/src/routes/api/dev/emails/[id]/+server.ts +24 -0
- package/templates/base/src/routes/api/notifications/[...path]/+server.ts +10 -0
- package/templates/base/src/routes/api/zero/get-queries/+server.ts +29 -0
- package/templates/base/src/routes/api/zero/mutate/+server.ts +31 -0
- package/templates/base/src/routes/sign-in/+page.svelte +97 -0
- package/templates/base/tsconfig.json +40 -0
- package/templates/github-actions-bun/.github/workflows/ci.yml +22 -0
- package/templates/github-actions-npm/.github/workflows/ci.yml +25 -0
- package/templates/github-actions-pnpm/.github/workflows/ci.yml +27 -0
- package/templates/i18n/lingui.config.ts +16 -0
- package/templates/i18n/package.json.partial +14 -0
- package/templates/i18n/src/lib/i18n.ts +10 -0
- package/templates/i18n/src/locales/en.po +6 -0
- package/templates/i18n/src/po.d.ts +3 -0
- package/templates/i18n/vite.config.ts +7 -0
- package/templates/supply-chain-bun/bunfig.toml +3 -0
- package/templates/testing/package.json.partial +11 -0
- package/templates/testing/src/example.test.ts +7 -0
- package/templates/testing/src/lib/server/db/test-utils.ts +25 -0
- package/templates/testing/vitest.config.ts +9 -0
- package/templates/ui/.vscode/extensions.json +8 -0
- package/templates/ui/package.json.partial +13 -0
- package/templates/ui/src/app.css +94 -0
- package/templates/ui/src/routes/+layout.svelte +12 -0
- package/templates/ui/stylelint.config.js +7 -0
- package/templates/ui/vite.config.ts +6 -0
- package/templates/ui-dependency/package.json.partial +5 -0
- package/templates/ui-vendor/src/lib/components/ui/Accordion/Accordion.svelte +71 -0
- package/templates/ui-vendor/src/lib/components/ui/Accordion/index.ts +1 -0
- package/templates/ui-vendor/src/lib/components/ui/Alert/Alert.svelte +60 -0
- package/templates/ui-vendor/src/lib/components/ui/Alert/index.ts +1 -0
- package/templates/ui-vendor/src/lib/components/ui/Badge/Badge.svelte +48 -0
- package/templates/ui-vendor/src/lib/components/ui/Badge/index.ts +1 -0
- package/templates/ui-vendor/src/lib/components/ui/Button/Button.svelte +77 -0
- package/templates/ui-vendor/src/lib/components/ui/Button/index.ts +1 -0
- package/templates/ui-vendor/src/lib/components/ui/Card/Card.svelte +49 -0
- package/templates/ui-vendor/src/lib/components/ui/Card/index.ts +1 -0
- package/templates/ui-vendor/src/lib/components/ui/Dialog/Dialog.svelte +70 -0
- package/templates/ui-vendor/src/lib/components/ui/Dialog/index.ts +1 -0
- package/templates/ui-vendor/src/lib/components/ui/FormField/FormField.svelte +53 -0
- package/templates/ui-vendor/src/lib/components/ui/FormField/index.ts +1 -0
- package/templates/ui-vendor/src/lib/components/ui/Input/Input.svelte +27 -0
- package/templates/ui-vendor/src/lib/components/ui/Input/index.ts +1 -0
- package/templates/ui-vendor/src/lib/components/ui/Separator/Separator.svelte +26 -0
- package/templates/ui-vendor/src/lib/components/ui/Separator/index.ts +1 -0
- package/templates/ui-vendor/src/lib/components/ui/Skeleton/Skeleton.svelte +40 -0
- package/templates/ui-vendor/src/lib/components/ui/Skeleton/index.ts +1 -0
- package/templates/ui-vendor/src/lib/components/ui/Switch/Switch.svelte +86 -0
- package/templates/ui-vendor/src/lib/components/ui/Switch/index.ts +1 -0
- package/templates/ui-vendor/src/lib/components/ui/Textarea/Textarea.svelte +29 -0
- 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,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,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>
|