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 +4 -2
- package/package.json +1 -1
- package/templates/adapters/node/docker-compose.prod.yml +23 -0
- package/templates/base/AGENTS.md +46 -3
- package/templates/base/src/lib/server/auth.ts +5 -0
- package/templates/base/src/lib/server/db/schema.ts +68 -68
- package/templates/base/src/lib/zero/mutators.ts +7 -2
- package/templates/base/src/routes/(app)/+page.svelte +112 -12
- package/templates/base/src/routes/(public)/sign-in/+page.svelte +42 -4
- package/templates/i18n/lingui.config.ts +5 -1
- package/templates/i18n/src/lib/LocaleSwitcher.svelte +51 -0
- package/templates/i18n/src/lib/i18n.ts +36 -4
- package/templates/i18n/src/lib/server/emails/magic-link.ts +67 -0
- package/templates/i18n/src/lib/server/notifications/definitions.ts +12 -0
- package/templates/i18n/src/locales/en.po +81 -0
- package/templates/i18n/src/locales/fi.po +87 -0
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:(
|
|
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
|
-
|
|
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
|
@@ -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:
|
package/templates/base/AGENTS.md
CHANGED
|
@@ -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
|
-
- **
|
|
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
|
|
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(
|
|
6
|
-
name: text(
|
|
7
|
-
email: text(
|
|
8
|
-
emailVerified: boolean(
|
|
9
|
-
image: text(
|
|
10
|
-
createdAt: timestamp(
|
|
11
|
-
updatedAt: timestamp(
|
|
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(
|
|
16
|
-
expiresAt: timestamp(
|
|
17
|
-
token: text(
|
|
18
|
-
ipAddress: text(
|
|
19
|
-
userAgent: text(
|
|
20
|
-
userId: text(
|
|
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(
|
|
24
|
-
updatedAt: timestamp(
|
|
23
|
+
createdAt: timestamp().notNull().defaultNow(),
|
|
24
|
+
updatedAt: timestamp().notNull().defaultNow(),
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
export const account = pgTable("account", {
|
|
28
|
-
id: text(
|
|
29
|
-
accountId: text(
|
|
30
|
-
providerId: text(
|
|
31
|
-
userId: text(
|
|
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(
|
|
35
|
-
refreshToken: text(
|
|
36
|
-
idToken: text(
|
|
37
|
-
accessTokenExpiresAt: timestamp(
|
|
38
|
-
refreshTokenExpiresAt: timestamp(
|
|
39
|
-
scope: text(
|
|
40
|
-
password: text(
|
|
41
|
-
createdAt: timestamp(
|
|
42
|
-
updatedAt: timestamp(
|
|
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(
|
|
47
|
-
identifier: text(
|
|
48
|
-
value: text(
|
|
49
|
-
expiresAt: timestamp(
|
|
50
|
-
createdAt: timestamp(
|
|
51
|
-
updatedAt: timestamp(
|
|
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(
|
|
56
|
+
id: text()
|
|
57
57
|
.primaryKey()
|
|
58
58
|
.$defaultFn(() => crypto.randomUUID()),
|
|
59
|
-
userId: text(
|
|
60
|
-
type: text(
|
|
61
|
-
channel: text(
|
|
62
|
-
status: text(
|
|
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(
|
|
66
|
-
error: text(
|
|
67
|
-
retryOf: text(
|
|
68
|
-
retriesLeft: integer(
|
|
69
|
-
recipientEmail: text(
|
|
70
|
-
externalId: text(
|
|
71
|
-
createdAt: timestamp(
|
|
72
|
-
updatedAt: timestamp(
|
|
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(
|
|
76
|
+
id: text()
|
|
77
77
|
.primaryKey()
|
|
78
78
|
.$defaultFn(() => crypto.randomUUID()),
|
|
79
|
-
userId: text(
|
|
79
|
+
userId: text()
|
|
80
80
|
.notNull()
|
|
81
81
|
.references(() => user.id),
|
|
82
|
-
type: text(
|
|
83
|
-
title: text(
|
|
84
|
-
body: text(
|
|
85
|
-
url: text(
|
|
86
|
-
icon: text(
|
|
87
|
-
read: boolean(
|
|
88
|
-
createdAt: timestamp(
|
|
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(
|
|
92
|
+
id: text()
|
|
93
93
|
.primaryKey()
|
|
94
94
|
.$defaultFn(() => crypto.randomUUID()),
|
|
95
|
-
userId: text(
|
|
95
|
+
userId: text()
|
|
96
96
|
.notNull()
|
|
97
97
|
.references(() => user.id),
|
|
98
|
-
notificationType: text(
|
|
99
|
-
channel: text(
|
|
100
|
-
enabled: boolean(
|
|
101
|
-
updatedAt: timestamp(
|
|
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(
|
|
108
|
-
title: text(
|
|
109
|
-
content: text(
|
|
110
|
-
userId: text(
|
|
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(
|
|
114
|
-
updatedAt: timestamp(
|
|
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
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
import
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
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"
|