create-mikstack 0.1.23 → 0.1.25

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/dist/index.js CHANGED
@@ -145,11 +145,13 @@ async function runPrompts(projectName, packageManager) {
145
145
 
146
146
  //#endregion
147
147
  //#region src/template-engine.ts
148
- const CONDITIONAL_BLOCK_RE = /^[^\S\n]*(?:\/\/|#|<!--)[^\S\n]*\{\{#if:(\w+)\}\}[^\S\n]*(?:-->)?[^\S\n]*\n([\s\S]*?)^[^\S\n]*(?:\/\/|#|<!--)[^\S\n]*\{\{\/if:\1\}\}[^\S\n]*(?:-->)?[^\S\n]*\n?/gm;
148
+ const CONDITIONAL_BLOCK_RE = /^[^\S\n]*(?:\/\/|#|<!--)[^\S\n]*\{\{#if:(!?\w+)\}\}[^\S\n]*(?:-->)?[^\S\n]*\n([\s\S]*?)^[^\S\n]*(?:\/\/|#|<!--)[^\S\n]*\{\{\/if:\1\}\}[^\S\n]*(?:-->)?[^\S\n]*\n?/gm;
149
149
  const VARIABLE_RE = /\{\{(\w+)\}\}/g;
150
150
  function renderTemplate(content, context) {
151
151
  let result = content.replace(CONDITIONAL_BLOCK_RE, (_match, name, body) => {
152
- if (context[name]) return body;
152
+ const negated = name.startsWith("!");
153
+ const value = context[negated ? name.slice(1) : name];
154
+ if (negated ? !value : value) return body;
153
155
  return "";
154
156
  });
155
157
  result = result.replace(VARIABLE_RE, (_match, name) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mikstack",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,26 @@ services:
10
10
  volumes:
11
11
  - pgdata:/var/lib/postgresql/data
12
12
 
13
+ zero-cache:
14
+ image: rocicorp/zero:${ZERO_VERSION}
15
+ restart: always
16
+ ports:
17
+ - 4848:4848
18
+ environment:
19
+ ZERO_UPSTREAM_DB: postgres://root:${POSTGRES_PASSWORD}@db:5432/{{projectName}}
20
+ ZERO_CVR_DB: postgres://root:${POSTGRES_PASSWORD}@db:5432/{{projectName}}_cvr
21
+ ZERO_CHANGE_DB: postgres://root:${POSTGRES_PASSWORD}@db:5432/{{projectName}}_cdb
22
+ ZERO_REPLICA_FILE: /data/sync-replica.db
23
+ ZERO_PUSH_URL: http://app:3000/api/zero/mutate
24
+ ZERO_QUERY_URL: http://app:3000/api/zero/get-queries
25
+ ZERO_QUERY_FORWARD_COOKIES: "true"
26
+ ZERO_MUTATE_FORWARD_COOKIES: "true"
27
+ ZERO_AUTH_SECRET: ${BETTER_AUTH_SECRET}
28
+ volumes:
29
+ - zero-data:/data
30
+ depends_on:
31
+ - db
32
+
13
33
  app:
14
34
  build: .
15
35
  restart: always
@@ -20,8 +40,11 @@ services:
20
40
  BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
21
41
  BETTER_AUTH_URL: ${BETTER_AUTH_URL}
22
42
  ORIGIN: ${BETTER_AUTH_URL}
43
+ PUBLIC_SERVER: http://zero-cache:4848
23
44
  depends_on:
24
45
  - db
46
+ - zero-cache
25
47
 
26
48
  volumes:
27
49
  pgdata:
50
+ zero-data:
@@ -8,10 +8,14 @@ SvelteKit app with Drizzle ORM, Zero (real-time sync), and better-auth.
8
8
  - **Database**: PostgreSQL via Drizzle ORM
9
9
  - **Real-time**: Zero (@rocicorp/zero) for client-side sync
10
10
  - **Auth**: better-auth with magic link
11
- - **Styling**: See `src/app.css`
11
+ - **UI**: @mikstack/ui components (Button, Input, FormField, Textarea, Separator)
12
+ - **Styling**: Design tokens in `src/app.css` — oklch colors, spacing/radius variables, dark mode via `prefers-color-scheme`
12
13
  <!-- {{#if:i18n}} -->
13
14
  - **i18n**: @mikstack/svelte-lingui (Lingui-based)
14
15
  <!-- {{/if:i18n}} -->
16
+ <!-- {{#if:testing}} -->
17
+ - **Testing**: Vitest + @testcontainers/postgresql for integration tests
18
+ <!-- {{/if:testing}} -->
15
19
 
16
20
  ## Key Patterns
17
21
 
@@ -28,11 +32,19 @@ const data = $derived(query.data);
28
32
  await z.mutate(mutators.myMutation(args));
29
33
  ```
30
34
 
35
+ Mutators verify ownership before update/delete via `tx.run()`:
36
+
37
+ ```typescript
38
+ const entity = await tx.run(zql.note.where("id", args.id).one());
39
+ if (!entity || entity.userId !== ctx.userID) return;
40
+ ```
41
+
31
42
  ### Auth
32
43
 
33
44
  Server: `src/lib/server/auth.ts` (better-auth config)
34
45
  Client: `src/lib/auth-client.ts` (magic link + client helpers)
35
46
  Session is available in `locals.user` and `locals.session` via `src/hooks.server.ts`.
47
+ Routes under `(app)/` require authentication — unauthenticated requests redirect to `/sign-in`.
36
48
 
37
49
  ### Forms (@mikstack/form)
38
50
 
@@ -85,9 +97,23 @@ Email delivery tracking and retries are handled automatically.
85
97
 
86
98
  ### Database
87
99
 
88
- Schema: `src/lib/server/db/schema.ts`
100
+ Schema: `src/lib/server/db/schema.ts` (uses `casing: "snake_case"` — no explicit column names needed)
89
101
  Connection: `src/lib/server/db/index.ts` (lazy-initialized via Proxy)
90
102
 
103
+ <!-- {{#if:testing}} -->
104
+ ### Testing
105
+
106
+ Test utils: `src/lib/server/db/test-utils.ts` — `createTestDatabase()` spins up a PostgreSQL container via @testcontainers/postgresql.
107
+
108
+ ```typescript
109
+ import { createTestDatabase, stopTestDatabase, type TestDatabase } from "$lib/server/db/test-utils";
110
+
111
+ let testDb: TestDatabase;
112
+ beforeAll(async () => { testDb = await createTestDatabase(); });
113
+ afterAll(async () => { await stopTestDatabase(testDb); });
114
+ ```
115
+
116
+ <!-- {{/if:testing}} -->
91
117
  <!-- {{#if:i18n}} -->
92
118
  ### i18n
93
119
 
@@ -95,7 +121,7 @@ Setup: `src/lib/i18n.ts` — initialized in root layout
95
121
  Config: `lingui.config.ts`
96
122
  Catalogs: `src/locales/{locale}.po`
97
123
 
98
- Use `useLingui()` for translations, `<T>` component for rich text:
124
+ Use `useLingui()` for translations in Svelte components:
99
125
 
100
126
  ```svelte
101
127
  <script lang="ts">
@@ -106,9 +132,23 @@ Use `useLingui()` for translations, `<T>` component for rich text:
106
132
  <h1>{t`Hello world`}</h1>
107
133
  ```
108
134
 
135
+ Use `<T>` component for rich text with embedded elements:
136
+
137
+ ```svelte
138
+ <T>Read the <a href="/docs">documentation</a></T>
139
+ ```
140
+
141
+ The extractor and Svelte preprocessor handle the transformation automatically.
142
+
109
143
  Run `{{pmRun}} i18n:extract` to extract messages into `.po` catalogs.
110
144
  <!-- {{/if:i18n}} -->
111
145
 
146
+ ### Deployment
147
+
148
+ - `Dockerfile` — multi-stage build for the Node adapter
149
+ - `docker-compose.yml` — local dev (PostgreSQL + zero-cache)
150
+ - `docker-compose.prod.yml` — production (PostgreSQL + zero-cache + app)
151
+
112
152
  ## Commands
113
153
 
114
154
  - `{{pmRun}} dev` — start dev server
@@ -121,3 +161,6 @@ Run `{{pmRun}} i18n:extract` to extract messages into `.po` catalogs.
121
161
  <!-- {{#if:i18n}} -->
122
162
  - `{{pmRun}} i18n:extract` — extract messages to .po catalogs
123
163
  <!-- {{/if:i18n}} -->
164
+ <!-- {{#if:testing}} -->
165
+ - `{{pmRun}} test` — run tests (requires Docker for testcontainers)
166
+ <!-- {{/if:testing}} -->
@@ -24,7 +24,12 @@ function createAuth() {
24
24
  await notif.send({
25
25
  type: "magic-link",
26
26
  recipientEmail: email,
27
+ // {{#if:i18n}}
28
+ data: { url, locale: getRequestEvent().cookies.get("locale") },
29
+ // {{/if:i18n}}
30
+ // {{#if:!i18n}}
27
31
  data: { url },
32
+ // {{/if:!i18n}}
28
33
  });
29
34
  },
30
35
  }),
@@ -2,114 +2,114 @@ import { boolean, integer, jsonb, pgTable, text, timestamp } from "drizzle-orm/p
2
2
 
3
3
  // better-auth tables (managed by better-auth, do not insert/update directly)
4
4
  export const user = pgTable("user", {
5
- id: text("id").primaryKey(),
6
- name: text("name").notNull(),
7
- email: text("email").notNull().unique(),
8
- emailVerified: boolean("email_verified").notNull().default(false),
9
- image: text("image"),
10
- createdAt: timestamp("created_at").notNull().defaultNow(),
11
- updatedAt: timestamp("updated_at").notNull().defaultNow(),
5
+ id: text().primaryKey(),
6
+ name: text().notNull(),
7
+ email: text().notNull().unique(),
8
+ emailVerified: boolean().notNull().default(false),
9
+ image: text(),
10
+ createdAt: timestamp().notNull().defaultNow(),
11
+ updatedAt: timestamp().notNull().defaultNow(),
12
12
  });
13
13
 
14
14
  export const session = pgTable("session", {
15
- id: text("id").primaryKey(),
16
- expiresAt: timestamp("expires_at").notNull(),
17
- token: text("token").notNull().unique(),
18
- ipAddress: text("ip_address"),
19
- userAgent: text("user_agent"),
20
- userId: text("user_id")
15
+ id: text().primaryKey(),
16
+ expiresAt: timestamp().notNull(),
17
+ token: text().notNull().unique(),
18
+ ipAddress: text(),
19
+ userAgent: text(),
20
+ userId: text()
21
21
  .notNull()
22
22
  .references(() => user.id),
23
- createdAt: timestamp("created_at").notNull().defaultNow(),
24
- updatedAt: timestamp("updated_at").notNull().defaultNow(),
23
+ createdAt: timestamp().notNull().defaultNow(),
24
+ updatedAt: timestamp().notNull().defaultNow(),
25
25
  });
26
26
 
27
27
  export const account = pgTable("account", {
28
- id: text("id").primaryKey(),
29
- accountId: text("account_id").notNull(),
30
- providerId: text("provider_id").notNull(),
31
- userId: text("user_id")
28
+ id: text().primaryKey(),
29
+ accountId: text().notNull(),
30
+ providerId: text().notNull(),
31
+ userId: text()
32
32
  .notNull()
33
33
  .references(() => user.id),
34
- accessToken: text("access_token"),
35
- refreshToken: text("refresh_token"),
36
- idToken: text("id_token"),
37
- accessTokenExpiresAt: timestamp("access_token_expires_at"),
38
- refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
39
- scope: text("scope"),
40
- password: text("password"),
41
- createdAt: timestamp("created_at").notNull().defaultNow(),
42
- updatedAt: timestamp("updated_at").notNull().defaultNow(),
34
+ accessToken: text(),
35
+ refreshToken: text(),
36
+ idToken: text(),
37
+ accessTokenExpiresAt: timestamp(),
38
+ refreshTokenExpiresAt: timestamp(),
39
+ scope: text(),
40
+ password: text(),
41
+ createdAt: timestamp().notNull().defaultNow(),
42
+ updatedAt: timestamp().notNull().defaultNow(),
43
43
  });
44
44
 
45
45
  export const verification = pgTable("verification", {
46
- id: text("id").primaryKey(),
47
- identifier: text("identifier").notNull(),
48
- value: text("value").notNull(),
49
- expiresAt: timestamp("expires_at").notNull(),
50
- createdAt: timestamp("created_at").notNull().defaultNow(),
51
- updatedAt: timestamp("updated_at").notNull().defaultNow(),
46
+ id: text().primaryKey(),
47
+ identifier: text().notNull(),
48
+ value: text().notNull(),
49
+ expiresAt: timestamp().notNull(),
50
+ createdAt: timestamp().notNull().defaultNow(),
51
+ updatedAt: timestamp().notNull().defaultNow(),
52
52
  });
53
53
 
54
54
  // Notification tables (managed by @mikstack/notifications)
55
55
  export const notificationDelivery = pgTable("notification_delivery", {
56
- id: text("id")
56
+ id: text()
57
57
  .primaryKey()
58
58
  .$defaultFn(() => crypto.randomUUID()),
59
- userId: text("user_id").references(() => user.id),
60
- type: text("type").notNull(),
61
- channel: text("channel").notNull(),
62
- status: text("status", { enum: ["pending", "sent", "delivered", "failed"] })
59
+ userId: text().references(() => user.id),
60
+ type: text().notNull(),
61
+ channel: text().notNull(),
62
+ status: text({ enum: ["pending", "sent", "delivered", "failed"] })
63
63
  .notNull()
64
64
  .default("pending"),
65
- content: jsonb("content"),
66
- error: text("error"),
67
- retryOf: text("retry_of"),
68
- retriesLeft: integer("retries_left").notNull().default(0),
69
- recipientEmail: text("recipient_email"),
70
- externalId: text("external_id"),
71
- createdAt: timestamp("created_at").notNull().defaultNow(),
72
- updatedAt: timestamp("updated_at").notNull().defaultNow(),
65
+ content: jsonb(),
66
+ error: text(),
67
+ retryOf: text(),
68
+ retriesLeft: integer().notNull().default(0),
69
+ recipientEmail: text(),
70
+ externalId: text(),
71
+ createdAt: timestamp().notNull().defaultNow(),
72
+ updatedAt: timestamp().notNull().defaultNow(),
73
73
  });
74
74
 
75
75
  export const inAppNotification = pgTable("in_app_notification", {
76
- id: text("id")
76
+ id: text()
77
77
  .primaryKey()
78
78
  .$defaultFn(() => crypto.randomUUID()),
79
- userId: text("user_id")
79
+ userId: text()
80
80
  .notNull()
81
81
  .references(() => user.id),
82
- type: text("type").notNull(),
83
- title: text("title").notNull(),
84
- body: text("body"),
85
- url: text("url"),
86
- icon: text("icon"),
87
- read: boolean("read").notNull().default(false),
88
- createdAt: timestamp("created_at").notNull().defaultNow(),
82
+ type: text().notNull(),
83
+ title: text().notNull(),
84
+ body: text(),
85
+ url: text(),
86
+ icon: text(),
87
+ read: boolean().notNull().default(false),
88
+ createdAt: timestamp().notNull().defaultNow(),
89
89
  });
90
90
 
91
91
  export const notificationPreference = pgTable("notification_preference", {
92
- id: text("id")
92
+ id: text()
93
93
  .primaryKey()
94
94
  .$defaultFn(() => crypto.randomUUID()),
95
- userId: text("user_id")
95
+ userId: text()
96
96
  .notNull()
97
97
  .references(() => user.id),
98
- notificationType: text("notification_type").notNull(),
99
- channel: text("channel").notNull(),
100
- enabled: boolean("enabled").notNull(),
101
- updatedAt: timestamp("updated_at").notNull().defaultNow(),
98
+ notificationType: text().notNull(),
99
+ channel: text().notNull(),
100
+ enabled: boolean().notNull(),
101
+ updatedAt: timestamp().notNull().defaultNow(),
102
102
  });
103
103
 
104
104
  // Application tables
105
105
 
106
106
  export const note = pgTable("note", {
107
- id: text("id").primaryKey(),
108
- title: text("title").notNull(),
109
- content: text("content").notNull().default(""),
110
- userId: text("user_id")
107
+ id: text().primaryKey(),
108
+ title: text().notNull(),
109
+ content: text().notNull().default(""),
110
+ userId: text()
111
111
  .notNull()
112
112
  .references(() => user.id),
113
- createdAt: timestamp("created_at").notNull(),
114
- updatedAt: timestamp("updated_at").notNull(),
113
+ createdAt: timestamp().notNull(),
114
+ updatedAt: timestamp().notNull(),
115
115
  });
@@ -1,5 +1,6 @@
1
1
  import { defineMutators, defineMutator } from "@rocicorp/zero";
2
2
  import * as v from "valibot";
3
+ import { zql } from "./schema";
3
4
 
4
5
  export const mutators = defineMutators({
5
6
  note: {
@@ -19,7 +20,9 @@ export const mutators = defineMutators({
19
20
  ),
20
21
  update: defineMutator(
21
22
  v.object({ id: v.string(), title: v.string(), content: v.string() }),
22
- async ({ tx, args }) => {
23
+ async ({ tx, ctx, args }) => {
24
+ const note = await tx.run(zql.note.where("id", args.id).one());
25
+ if (!note || note.userId !== ctx.userID) return;
23
26
  await tx.mutate.note.update({
24
27
  id: args.id,
25
28
  title: args.title,
@@ -28,7 +31,9 @@ export const mutators = defineMutators({
28
31
  });
29
32
  },
30
33
  ),
31
- delete: defineMutator(v.object({ id: v.string() }), async ({ tx, args }) => {
34
+ delete: defineMutator(v.object({ id: v.string() }), async ({ tx, ctx, args }) => {
35
+ const note = await tx.run(zql.note.where("id", args.id).one());
36
+ if (!note || note.userId !== ctx.userID) return;
32
37
  await tx.mutate.note.delete({ id: args.id });
33
38
  }),
34
39
  },
@@ -1,10 +1,10 @@
1
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";
2
+ import NotePencilIcon from "phosphor-svelte/lib/NotePencilIcon";
3
+ import PencilSimpleIcon from "phosphor-svelte/lib/PencilSimpleIcon";
4
+ import PlusIcon from "phosphor-svelte/lib/PlusIcon";
5
+ import SignOutIcon from "phosphor-svelte/lib/SignOutIcon";
6
+ import TrashIcon from "phosphor-svelte/lib/TrashIcon";
7
+ import XIcon from "phosphor-svelte/lib/XIcon";
8
8
  import Button from "{{uiPrefix}}/Button";
9
9
  import FormField from "{{uiPrefix}}/FormField";
10
10
  import Input from "{{uiPrefix}}/Input";
@@ -18,6 +18,11 @@
18
18
  import { get_z } from "$lib/z.svelte";
19
19
  import { queries } from "$lib/zero/queries";
20
20
  import { mutators } from "$lib/zero/mutators";
21
+ // {{#if:i18n}}
22
+ import { useLingui } from "@mikstack/svelte-lingui";
23
+ import LocaleSwitcher from "$lib/LocaleSwitcher.svelte";
24
+ const { t } = useLingui();
25
+ // {{/if:i18n}}
21
26
 
22
27
  let { data } = $props();
23
28
 
@@ -39,7 +44,12 @@
39
44
  let editingId = $state<string | null>(null);
40
45
 
41
46
  const noteSchema = v.object({
47
+ // {{#if:i18n}}
48
+ title: v.pipe(v.string(), v.minLength(1, t`Title is required`)),
49
+ // {{/if:i18n}}
50
+ // {{#if:!i18n}}
42
51
  title: v.pipe(v.string(), v.minLength(1, "Title is required")),
52
+ // {{/if:!i18n}}
43
53
  content: v.string(),
44
54
  });
45
55
 
@@ -103,13 +113,26 @@
103
113
  <div class="container">
104
114
  <header class="header">
105
115
  <div class="header-title">
106
- <NotePencil size={24} weight="duotone" />
116
+ <NotePencilIcon size={24} weight="duotone" />
117
+ <!-- {{#if:i18n}} -->
118
+ <h1>{t`Notes`}</h1>
119
+ <!-- {{/if:i18n}} -->
120
+ <!-- {{#if:!i18n}} -->
107
121
  <h1>Notes</h1>
122
+ <!-- {{/if:!i18n}} -->
108
123
  </div>
109
124
  <div class="header-actions">
125
+ <!-- {{#if:i18n}} -->
126
+ <LocaleSwitcher />
127
+ <!-- {{/if:i18n}} -->
110
128
  <span class="email">{data.user.email}</span>
111
129
  <Button variant="ghost" onclick={signOut}>
112
- <SignOut size={16} weight="bold" /> Sign out
130
+ <!-- {{#if:i18n}} -->
131
+ <SignOutIcon size={16} weight="bold" /> {t`Sign out`}
132
+ <!-- {{/if:i18n}} -->
133
+ <!-- {{#if:!i18n}} -->
134
+ <SignOutIcon size={16} weight="bold" /> Sign out
135
+ <!-- {{/if:!i18n}} -->
113
136
  </Button>
114
137
  </div>
115
138
  </header>
@@ -117,13 +140,28 @@
117
140
  <Separator />
118
141
 
119
142
  <section>
143
+ <!-- {{#if:i18n}} -->
144
+ <h2>{t`New note`}</h2>
145
+ <!-- {{/if:i18n}} -->
146
+ <!-- {{#if:!i18n}} -->
120
147
  <h2>New note</h2>
148
+ <!-- {{/if:!i18n}} -->
121
149
  <form id={createNoteForm.id} onsubmit={createNoteForm.onsubmit} onkeydown={submitOnModEnter} class="note-form">
122
150
  <FormField for={createNoteForm.fields.title.as("text").id}>
123
151
  {#snippet label(attrs)}
152
+ <!-- {{#if:i18n}} -->
153
+ <label {...attrs}>{t`Title`}</label>
154
+ <!-- {{/if:i18n}} -->
155
+ <!-- {{#if:!i18n}} -->
124
156
  <label {...attrs}>Title</label>
157
+ <!-- {{/if:!i18n}} -->
125
158
  {/snippet}
159
+ <!-- {{#if:i18n}} -->
160
+ <Input {...createNoteForm.fields.title.as("text")} placeholder={t`Note title`} />
161
+ <!-- {{/if:i18n}} -->
162
+ <!-- {{#if:!i18n}} -->
126
163
  <Input {...createNoteForm.fields.title.as("text")} placeholder="Note title" />
164
+ <!-- {{/if:!i18n}} -->
127
165
  {#snippet error(attrs)}
128
166
  {#each createNoteForm.fields.title.issues() as issue (issue.message)}
129
167
  <p {...attrs}>{issue.message}</p>
@@ -133,8 +171,24 @@
133
171
 
134
172
  <FormField for={`${createNoteForm.id}-content`}>
135
173
  {#snippet label(attrs)}
174
+ <!-- {{#if:i18n}} -->
175
+ <label {...attrs}>{t`Content`}</label>
176
+ <!-- {{/if:i18n}} -->
177
+ <!-- {{#if:!i18n}} -->
136
178
  <label {...attrs}>Content</label>
179
+ <!-- {{/if:!i18n}} -->
137
180
  {/snippet}
181
+ <!-- {{#if:i18n}} -->
182
+ <Textarea
183
+ name={createNoteForm.fields.content.name()}
184
+ id="{createNoteForm.id}-content"
185
+ oninput={(e) =>
186
+ createNoteForm.fields.content.set((e.target as HTMLTextAreaElement).value)}
187
+ value={createNoteForm.fields.content.value() as string}
188
+ placeholder={t`Write something...`}
189
+ />
190
+ <!-- {{/if:i18n}} -->
191
+ <!-- {{#if:!i18n}} -->
138
192
  <Textarea
139
193
  name={createNoteForm.fields.content.name()}
140
194
  id="{createNoteForm.id}-content"
@@ -143,6 +197,7 @@
143
197
  value={createNoteForm.fields.content.value() as string}
144
198
  placeholder="Write something..."
145
199
  />
200
+ <!-- {{/if:!i18n}} -->
146
201
  </FormField>
147
202
 
148
203
  {#if createNoteForm.error}
@@ -150,8 +205,13 @@
150
205
  {/if}
151
206
 
152
207
  <Button type="submit" disabled={createNoteForm.pending}>
153
- <Plus size={16} weight="bold" />
208
+ <PlusIcon size={16} weight="bold" />
209
+ <!-- {{#if:i18n}} -->
210
+ {createNoteForm.pending ? t`Creating...` : t`Create note`}
211
+ <!-- {{/if:i18n}} -->
212
+ <!-- {{#if:!i18n}} -->
154
213
  {createNoteForm.pending ? "Creating..." : "Create note"}
214
+ <!-- {{/if:!i18n}} -->
155
215
  {#if !isMobile}<kbd>{modLabel}+Enter</kbd>{/if}
156
216
  </Button>
157
217
  </form>
@@ -160,9 +220,19 @@
160
220
  <Separator />
161
221
 
162
222
  <section>
223
+ <!-- {{#if:i18n}} -->
224
+ <h2>{t`Your notes`}</h2>
225
+ <!-- {{/if:i18n}} -->
226
+ <!-- {{#if:!i18n}} -->
163
227
  <h2>Your notes</h2>
228
+ <!-- {{/if:!i18n}} -->
164
229
  {#if notes.length === 0}
230
+ <!-- {{#if:i18n}} -->
231
+ <p class="empty">{t`No notes yet. Create one above!`}</p>
232
+ <!-- {{/if:i18n}} -->
233
+ <!-- {{#if:!i18n}} -->
165
234
  <p class="empty">No notes yet. Create one above!</p>
235
+ <!-- {{/if:!i18n}} -->
166
236
  {:else}
167
237
  <ul class="note-list">
168
238
  {#each notes as note (note.id)}
@@ -171,7 +241,12 @@
171
241
  <form id={editForm.id} onsubmit={editForm.onsubmit} onkeydown={submitOnModEnter} class="note-form">
172
242
  <FormField for={editForm.fields.title.as("text").id}>
173
243
  {#snippet label(attrs)}
244
+ <!-- {{#if:i18n}} -->
245
+ <label {...attrs}>{t`Title`}</label>
246
+ <!-- {{/if:i18n}} -->
247
+ <!-- {{#if:!i18n}} -->
174
248
  <label {...attrs}>Title</label>
249
+ <!-- {{/if:!i18n}} -->
175
250
  {/snippet}
176
251
  <Input {...editForm.fields.title.as("text")} />
177
252
  {#snippet error(attrs)}
@@ -183,7 +258,12 @@
183
258
 
184
259
  <FormField for={`${editForm.id}-content`}>
185
260
  {#snippet label(attrs)}
261
+ <!-- {{#if:i18n}} -->
262
+ <label {...attrs}>{t`Content`}</label>
263
+ <!-- {{/if:i18n}} -->
264
+ <!-- {{#if:!i18n}} -->
186
265
  <label {...attrs}>Content</label>
266
+ <!-- {{/if:!i18n}} -->
187
267
  {/snippet}
188
268
  <Textarea
189
269
  name={editForm.fields.content.name()}
@@ -201,11 +281,21 @@
201
281
 
202
282
  <div class="actions">
203
283
  <Button type="submit" disabled={editForm.pending}>
284
+ <!-- {{#if:i18n}} -->
285
+ {editForm.pending ? t`Saving...` : t`Save`}
286
+ <!-- {{/if:i18n}} -->
287
+ <!-- {{#if:!i18n}} -->
204
288
  {editForm.pending ? "Saving..." : "Save"}
289
+ <!-- {{/if:!i18n}} -->
205
290
  {#if !isMobile}<kbd>{modLabel}+Enter</kbd>{/if}
206
291
  </Button>
207
292
  <Button variant="ghost" type="button" onclick={cancelEdit}>
208
- <X size={16} weight="bold" /> Cancel
293
+ <!-- {{#if:i18n}} -->
294
+ <XIcon size={16} weight="bold" /> {t`Cancel`}
295
+ <!-- {{/if:i18n}} -->
296
+ <!-- {{#if:!i18n}} -->
297
+ <XIcon size={16} weight="bold" /> Cancel
298
+ <!-- {{/if:!i18n}} -->
209
299
  </Button>
210
300
  </div>
211
301
  </form>
@@ -218,10 +308,20 @@
218
308
  </div>
219
309
  <div class="actions">
220
310
  <Button variant="ghost" onclick={() => startEdit(note)}>
221
- <PencilSimple size={16} /> Edit
311
+ <!-- {{#if:i18n}} -->
312
+ <PencilSimpleIcon size={16} /> {t`Edit`}
313
+ <!-- {{/if:i18n}} -->
314
+ <!-- {{#if:!i18n}} -->
315
+ <PencilSimpleIcon size={16} /> Edit
316
+ <!-- {{/if:!i18n}} -->
222
317
  </Button>
223
318
  <Button variant="danger" onclick={() => deleteNote(note.id)}>
224
- <Trash size={16} /> Delete
319
+ <!-- {{#if:i18n}} -->
320
+ <TrashIcon size={16} /> {t`Delete`}
321
+ <!-- {{/if:i18n}} -->
322
+ <!-- {{#if:!i18n}} -->
323
+ <TrashIcon size={16} /> Delete
324
+ <!-- {{/if:!i18n}} -->
225
325
  </Button>
226
326
  </div>
227
327
  {/if}
@@ -1,16 +1,26 @@
1
1
  <script lang="ts">
2
- import EnvelopeSimple from "phosphor-svelte/lib/EnvelopeSimple";
3
- import CheckCircle from "phosphor-svelte/lib/CheckCircle";
2
+ import EnvelopeSimpleIcon from "phosphor-svelte/lib/EnvelopeSimpleIcon";
3
+ import CheckCircleIcon from "phosphor-svelte/lib/CheckCircleIcon";
4
4
  import Button from "{{uiPrefix}}/Button";
5
5
  import FormField from "{{uiPrefix}}/FormField";
6
6
  import Input from "{{uiPrefix}}/Input";
7
7
  import { createForm } from "@mikstack/form";
8
8
  import * as v from "valibot";
9
9
  import { authClient } from "$lib/auth-client";
10
+ // {{#if:i18n}}
11
+ import { useLingui } from "@mikstack/svelte-lingui";
12
+ import LocaleSwitcher from "$lib/LocaleSwitcher.svelte";
13
+ const { t } = useLingui();
14
+ // {{/if:i18n}}
10
15
 
11
16
  const form = createForm({
12
17
  schema: v.object({
18
+ // {{#if:i18n}}
19
+ email: v.pipe(v.string(), v.email(t`Please enter a valid email address`)),
20
+ // {{/if:i18n}}
21
+ // {{#if:!i18n}}
13
22
  email: v.pipe(v.string(), v.email("Please enter a valid email address")),
23
+ // {{/if:!i18n}}
14
24
  }),
15
25
  initialValues: { email: "" },
16
26
  async onSubmit(data) {
@@ -27,18 +37,36 @@
27
37
  </script>
28
38
 
29
39
  <div class="sign-in">
40
+ <!-- {{#if:i18n}} -->
41
+ <div class="locale-bar">
42
+ <LocaleSwitcher />
43
+ </div>
44
+ <h1>{t`Sign in to {{projectName}}`}</h1>
45
+ <!-- {{/if:i18n}} -->
46
+ <!-- {{#if:!i18n}} -->
30
47
  <h1>Sign in to {{projectName}}</h1>
48
+ <!-- {{/if:!i18n}} -->
31
49
 
32
50
  {#if form.result}
33
51
  <div class="success">
34
- <CheckCircle size={24} weight="duotone" />
52
+ <CheckCircleIcon size={24} weight="duotone" />
53
+ <!-- {{#if:i18n}} -->
54
+ <p>{t`Check your email for a magic link to sign in.`}</p>
55
+ <!-- {{/if:i18n}} -->
56
+ <!-- {{#if:!i18n}} -->
35
57
  <p>Check your email for a magic link to sign in.</p>
58
+ <!-- {{/if:!i18n}} -->
36
59
  </div>
37
60
  {:else}
38
61
  <form id={form.id} onsubmit={form.onsubmit} class="sign-in-form">
39
62
  <FormField for={emailField.as("email").id}>
40
63
  {#snippet label(attrs)}
64
+ <!-- {{#if:i18n}} -->
65
+ <label {...attrs}>{t`Email`}</label>
66
+ <!-- {{/if:i18n}} -->
67
+ <!-- {{#if:!i18n}} -->
41
68
  <label {...attrs}>Email</label>
69
+ <!-- {{/if:!i18n}} -->
42
70
  {/snippet}
43
71
  <Input {...emailField.as("email")} placeholder="you@example.com" />
44
72
  {#snippet error(attrs)}
@@ -53,8 +81,13 @@
53
81
  {/if}
54
82
 
55
83
  <Button type="submit" disabled={form.pending}>
56
- <EnvelopeSimple size={16} weight="bold" />
84
+ <EnvelopeSimpleIcon size={16} weight="bold" />
85
+ <!-- {{#if:i18n}} -->
86
+ {form.pending ? t`Sending magic link...` : t`Sign in with magic link`}
87
+ <!-- {{/if:i18n}} -->
88
+ <!-- {{#if:!i18n}} -->
57
89
  {form.pending ? "Sending magic link..." : "Sign in with magic link"}
90
+ <!-- {{/if:!i18n}} -->
58
91
  </Button>
59
92
  </form>
60
93
  {/if}
@@ -70,6 +103,11 @@
70
103
  gap: var(--space-5);
71
104
  }
72
105
 
106
+ .locale-bar {
107
+ display: flex;
108
+ justify-content: flex-end;
109
+ }
110
+
73
111
  h1 {
74
112
  font-size: var(--text-2xl);
75
113
  }
@@ -2,7 +2,7 @@ import { extractor } from "@mikstack/svelte-lingui/extractor";
2
2
  import type { LinguiConfig } from "@lingui/conf";
3
3
 
4
4
  const config: LinguiConfig = {
5
- locales: ["en"],
5
+ locales: ["en", "fi"],
6
6
  sourceLocale: "en",
7
7
  catalogs: [
8
8
  {
@@ -11,6 +11,10 @@ const config: LinguiConfig = {
11
11
  },
12
12
  ],
13
13
  extractors: [extractor],
14
+ format: "po",
15
+ formatOptions: {
16
+ lineNumbers: false,
17
+ },
14
18
  };
15
19
 
16
20
  export default config;
@@ -0,0 +1,51 @@
1
+ <script lang="ts">
2
+ import { locales, getLocale, setLocale } from "$lib/i18n";
3
+
4
+ let currentLocale = $state(getLocale());
5
+
6
+ function switchLocale(locale: string) {
7
+ setLocale(locale);
8
+ document.cookie = `locale=${locale};path=/;max-age=31536000;samesite=lax`;
9
+ currentLocale = locale;
10
+ }
11
+ </script>
12
+
13
+ <div class="locale-switcher">
14
+ {#each locales as locale (locale)}
15
+ <button
16
+ class:active={locale === currentLocale}
17
+ disabled={locale === currentLocale}
18
+ onclick={() => switchLocale(locale)}
19
+ >
20
+ {locale.toUpperCase()}
21
+ </button>
22
+ {/each}
23
+ </div>
24
+
25
+ <style>
26
+ .locale-switcher {
27
+ display: flex;
28
+ gap: var(--space-1);
29
+ }
30
+
31
+ button {
32
+ padding: var(--space-1) var(--space-2);
33
+ font-size: var(--text-xs);
34
+ font-weight: 500;
35
+ border: 1px solid transparent;
36
+ border-radius: var(--radius-sm);
37
+ background: none;
38
+ color: var(--text-2);
39
+ cursor: pointer;
40
+ }
41
+
42
+ button:hover:not(:disabled) {
43
+ background-color: var(--surface-2);
44
+ }
45
+
46
+ button.active {
47
+ color: var(--text-1);
48
+ font-weight: 700;
49
+ cursor: default;
50
+ }
51
+ </style>
@@ -1,10 +1,42 @@
1
- import { setupI18n } from "@lingui/core";
1
+ import { setupI18n, type I18n } from "@lingui/core";
2
2
  import { setI18n } from "@mikstack/svelte-lingui";
3
- import { messages } from "../locales/en.po";
3
+ import { messages as enMessages } from "../locales/en.po";
4
+ import { messages as fiMessages } from "../locales/fi.po";
5
+
6
+ const allMessages: Record<string, typeof enMessages> = {
7
+ en: enMessages,
8
+ fi: fiMessages,
9
+ };
4
10
 
5
11
  const i18n = setupI18n();
6
12
 
7
- export function initI18n(): void {
8
- i18n.loadAndActivate({ locale: "en", messages });
13
+ export function initI18n(locale = "en"): void {
14
+ i18n.loadAndActivate({ locale, messages: allMessages[locale] });
9
15
  setI18n(i18n);
10
16
  }
17
+
18
+ export function setLocale(locale: string): void {
19
+ const messages = allMessages[locale];
20
+ if (messages) {
21
+ i18n.loadAndActivate({ locale, messages });
22
+ }
23
+ }
24
+
25
+ export function getLocale(): string {
26
+ return i18n.locale;
27
+ }
28
+
29
+ export const locales = Object.keys(allMessages);
30
+
31
+ /**
32
+ * Create a standalone i18n instance for server-side use (e.g. emails).
33
+ * Each call returns a fresh instance — safe for concurrent requests.
34
+ */
35
+ export function createServerI18n(locale = "en"): I18n {
36
+ const serverI18n = setupI18n();
37
+ serverI18n.loadAndActivate({
38
+ locale,
39
+ messages: allMessages[locale] ?? allMessages["en"],
40
+ });
41
+ return serverI18n;
42
+ }
@@ -0,0 +1,67 @@
1
+ import { html, body, section, text, button, render } from "@mikstack/email";
2
+ import { msg } from "@mikstack/svelte-lingui";
3
+ import { createServerI18n } from "$lib/i18n";
4
+
5
+ export function magicLinkEmail(url: string, locale = "en") {
6
+ const i18n = createServerI18n(locale);
7
+ const _ = (d: { id: string; message: string }) => i18n._(d) as string;
8
+
9
+ const email = html(
10
+ body(
11
+ [
12
+ section(
13
+ [
14
+ text(_(msg`Sign in to {{projectName}}`), {
15
+ fontSize: 24,
16
+ fontWeight: "bold",
17
+ color: "#111827",
18
+ marginBottom: 40,
19
+ marginTop: 40,
20
+ }),
21
+ button(_(msg`Sign in`), {
22
+ href: url,
23
+ backgroundColor: "#111827",
24
+ color: "#ffffff",
25
+ fontSize: 14,
26
+ fontWeight: "bold",
27
+ padding: [12, 34],
28
+ borderRadius: 6,
29
+ marginBottom: 16,
30
+ }),
31
+ text(_(msg`Or, copy and paste this temporary login URL:`), {
32
+ fontSize: 14,
33
+ lineHeight: 1.7,
34
+ color: "#111827",
35
+ marginTop: 24,
36
+ marginBottom: 14,
37
+ }),
38
+ text(url, {
39
+ fontSize: 13,
40
+ color: "#111827",
41
+ backgroundColor: "#f4f4f4",
42
+ borderRadius: 6,
43
+ padding: [16, 24],
44
+ border: "1px solid #eee",
45
+ }),
46
+ text(_(msg`If you didn't try to sign in, you can safely ignore this email.`), {
47
+ fontSize: 14,
48
+ lineHeight: 1.7,
49
+ color: "#ababab",
50
+ marginTop: 14,
51
+ marginBottom: 38,
52
+ }),
53
+ ],
54
+ { padding: [0, 24] },
55
+ ),
56
+ ],
57
+ { maxWidth: 480, backgroundColor: "#ffffff" },
58
+ ),
59
+ { lang: locale },
60
+ );
61
+
62
+ return {
63
+ subject: _(msg`Sign in to {{projectName}}`),
64
+ html: render(email),
65
+ text: render(email, { plainText: true }),
66
+ };
67
+ }
@@ -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; locale?: string }) => magicLinkEmail(data.url, data.locale),
10
+ },
11
+ }),
12
+ } as const;
@@ -1,6 +1,87 @@
1
1
  msgid ""
2
2
  msgstr ""
3
+ "Project-Id-Version: {{projectName}}\n"
3
4
  "Language: en\n"
5
+ "Language-Team: English, United States\n"
6
+ "Plural-Forms: nplurals=2; plural=(n != 1);\n"
4
7
  "MIME-Version: 1.0\n"
5
8
  "Content-Type: text/plain; charset=UTF-8\n"
6
9
  "Content-Transfer-Encoding: 8bit\n"
10
+
11
+ msgid "Cancel"
12
+ msgstr "Cancel"
13
+
14
+ msgid "Check your email for a magic link to sign in."
15
+ msgstr "Check your email for a magic link to sign in."
16
+
17
+ msgid "Content"
18
+ msgstr "Content"
19
+
20
+ msgid "Create note"
21
+ msgstr "Create note"
22
+
23
+ msgid "Creating..."
24
+ msgstr "Creating..."
25
+
26
+ msgid "Delete"
27
+ msgstr "Delete"
28
+
29
+ msgid "Edit"
30
+ msgstr "Edit"
31
+
32
+ msgid "If you didn't try to sign in, you can safely ignore this email."
33
+ msgstr "If you didn't try to sign in, you can safely ignore this email."
34
+
35
+ msgid "Email"
36
+ msgstr "Email"
37
+
38
+ msgid "New note"
39
+ msgstr "New note"
40
+
41
+ msgid "No notes yet. Create one above!"
42
+ msgstr "No notes yet. Create one above!"
43
+
44
+ msgid "Note title"
45
+ msgstr "Note title"
46
+
47
+ msgid "Notes"
48
+ msgstr "Notes"
49
+
50
+ msgid "Or, copy and paste this temporary login URL:"
51
+ msgstr "Or, copy and paste this temporary login URL:"
52
+
53
+ msgid "Please enter a valid email address"
54
+ msgstr "Please enter a valid email address"
55
+
56
+ msgid "Save"
57
+ msgstr "Save"
58
+
59
+ msgid "Saving..."
60
+ msgstr "Saving..."
61
+
62
+ msgid "Sending magic link..."
63
+ msgstr "Sending magic link..."
64
+
65
+ msgid "Sign in"
66
+ msgstr "Sign in"
67
+
68
+ msgid "Sign in to {{projectName}}"
69
+ msgstr "Sign in to {{projectName}}"
70
+
71
+ msgid "Sign in with magic link"
72
+ msgstr "Sign in with magic link"
73
+
74
+ msgid "Sign out"
75
+ msgstr "Sign out"
76
+
77
+ msgid "Title"
78
+ msgstr "Title"
79
+
80
+ msgid "Title is required"
81
+ msgstr "Title is required"
82
+
83
+ msgid "Write something..."
84
+ msgstr "Write something..."
85
+
86
+ msgid "Your notes"
87
+ msgstr "Your notes"
@@ -0,0 +1,87 @@
1
+ msgid ""
2
+ msgstr ""
3
+ "Project-Id-Version: {{projectName}}\n"
4
+ "Language: fi\n"
5
+ "Language-Team: Finnish\n"
6
+ "Plural-Forms: nplurals=2; plural=(n != 1);\n"
7
+ "MIME-Version: 1.0\n"
8
+ "Content-Type: text/plain; charset=UTF-8\n"
9
+ "Content-Transfer-Encoding: 8bit\n"
10
+
11
+ msgid "Cancel"
12
+ msgstr "Peruuta"
13
+
14
+ msgid "Check your email for a magic link to sign in."
15
+ msgstr "Tarkista sähköpostisi kirjautumislinkin varalta."
16
+
17
+ msgid "Content"
18
+ msgstr "Sisältö"
19
+
20
+ msgid "Create note"
21
+ msgstr "Luo muistiinpano"
22
+
23
+ msgid "Creating..."
24
+ msgstr "Luodaan..."
25
+
26
+ msgid "Delete"
27
+ msgstr "Poista"
28
+
29
+ msgid "Edit"
30
+ msgstr "Muokkaa"
31
+
32
+ msgid "If you didn't try to sign in, you can safely ignore this email."
33
+ msgstr "Jos et yrittänyt kirjautua, voit jättää tämän sähköpostin huomiotta."
34
+
35
+ msgid "Email"
36
+ msgstr "Sähköposti"
37
+
38
+ msgid "New note"
39
+ msgstr "Uusi muistiinpano"
40
+
41
+ msgid "No notes yet. Create one above!"
42
+ msgstr "Ei vielä muistiinpanoja. Luo ensimmäinen yllä!"
43
+
44
+ msgid "Note title"
45
+ msgstr "Muistiinpanon otsikko"
46
+
47
+ msgid "Notes"
48
+ msgstr "Muistiinpanot"
49
+
50
+ msgid "Or, copy and paste this temporary login URL:"
51
+ msgstr "Tai kopioi ja liitä tämä väliaikainen kirjautumislinkki:"
52
+
53
+ msgid "Please enter a valid email address"
54
+ msgstr "Syötä kelvollinen sähköpostiosoite"
55
+
56
+ msgid "Save"
57
+ msgstr "Tallenna"
58
+
59
+ msgid "Saving..."
60
+ msgstr "Tallennetaan..."
61
+
62
+ msgid "Sending magic link..."
63
+ msgstr "Lähetetään kirjautumislinkkiä..."
64
+
65
+ msgid "Sign in"
66
+ msgstr "Kirjaudu"
67
+
68
+ msgid "Sign in to {{projectName}}"
69
+ msgstr "Kirjaudu palveluun {{projectName}}"
70
+
71
+ msgid "Sign in with magic link"
72
+ msgstr "Kirjaudu kirjautumislinkillä"
73
+
74
+ msgid "Sign out"
75
+ msgstr "Kirjaudu ulos"
76
+
77
+ msgid "Title"
78
+ msgstr "Otsikko"
79
+
80
+ msgid "Title is required"
81
+ msgstr "Otsikko on pakollinen"
82
+
83
+ msgid "Write something..."
84
+ msgstr "Kirjoita jotain..."
85
+
86
+ msgid "Your notes"
87
+ msgstr "Muistiinpanosi"