@glw907/cairn-cms 0.5.0 → 0.6.0-rc.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/dist/auth/crypto.d.ts +13 -0
- package/dist/auth/crypto.d.ts.map +1 -0
- package/dist/auth/crypto.js +31 -0
- package/dist/auth/store.d.ts +41 -0
- package/dist/auth/store.d.ts.map +1 -0
- package/dist/auth/store.js +115 -0
- package/dist/auth/types.d.ts +25 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +1 -0
- package/dist/components/AdminLayout.svelte +58 -108
- package/dist/components/AdminLayout.svelte.d.ts +14 -9
- package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
- package/dist/components/ComponentPalette.svelte +50 -0
- package/dist/components/ComponentPalette.svelte.d.ts +16 -0
- package/dist/components/ComponentPalette.svelte.d.ts.map +1 -0
- package/dist/components/ConceptList.svelte +81 -0
- package/dist/components/ConceptList.svelte.d.ts +13 -0
- package/dist/components/ConceptList.svelte.d.ts.map +1 -0
- package/dist/components/ConfirmPage.svelte +23 -20
- package/dist/components/ConfirmPage.svelte.d.ts +6 -0
- package/dist/components/ConfirmPage.svelte.d.ts.map +1 -1
- package/dist/components/EditPage.svelte +160 -103
- package/dist/components/EditPage.svelte.d.ts +17 -7
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/LoginPage.svelte +42 -52
- package/dist/components/LoginPage.svelte.d.ts +12 -0
- package/dist/components/LoginPage.svelte.d.ts.map +1 -1
- package/dist/components/ManageEditors.svelte +81 -0
- package/dist/components/ManageEditors.svelte.d.ts +24 -0
- package/dist/components/ManageEditors.svelte.d.ts.map +1 -0
- package/dist/components/MarkdownEditor.svelte +81 -0
- package/dist/components/MarkdownEditor.svelte.d.ts +20 -0
- package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -0
- package/dist/components/NavTree.svelte +138 -0
- package/dist/components/NavTree.svelte.d.ts +17 -0
- package/dist/components/NavTree.svelte.d.ts.map +1 -0
- package/dist/components/cairn-admin.css +42 -0
- package/dist/components/index.d.ts +5 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +7 -4
- package/dist/content/compose.d.ts +7 -0
- package/dist/content/compose.d.ts.map +1 -0
- package/dist/content/compose.js +32 -0
- package/dist/content/concepts.d.ts +17 -0
- package/dist/content/concepts.d.ts.map +1 -0
- package/dist/content/concepts.js +41 -0
- package/dist/content/frontmatter.d.ts +18 -0
- package/dist/content/frontmatter.d.ts.map +1 -0
- package/dist/content/frontmatter.js +58 -0
- package/dist/content/ids.d.ts +17 -0
- package/dist/content/ids.d.ts.map +1 -0
- package/dist/content/ids.js +33 -0
- package/dist/content/types.d.ts +210 -0
- package/dist/content/types.d.ts.map +1 -0
- package/dist/content/types.js +1 -0
- package/dist/content/validate.d.ts +13 -0
- package/dist/content/validate.d.ts.map +1 -0
- package/dist/content/validate.js +45 -0
- package/dist/email.d.ts +25 -12
- package/dist/email.d.ts.map +1 -1
- package/dist/email.js +24 -24
- package/dist/env.d.ts +24 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +29 -0
- package/dist/github/credentials.d.ts +12 -0
- package/dist/github/credentials.d.ts.map +1 -0
- package/dist/github/credentials.js +11 -0
- package/dist/github/repo.d.ts +49 -0
- package/dist/github/repo.d.ts.map +1 -0
- package/dist/github/repo.js +123 -0
- package/dist/github/signing.d.ts +17 -0
- package/dist/github/signing.d.ts.map +1 -0
- package/dist/github/signing.js +79 -0
- package/dist/github/types.d.ts +35 -0
- package/dist/github/types.d.ts.map +1 -0
- package/dist/github/types.js +19 -0
- package/dist/index.d.ts +27 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -8
- package/dist/nav/site-config.d.ts +50 -0
- package/dist/nav/site-config.d.ts.map +1 -0
- package/dist/nav/site-config.js +100 -0
- package/dist/render/glyph.d.ts +1 -1
- package/dist/render/glyph.d.ts.map +1 -1
- package/dist/render/index.d.ts +5 -5
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/index.js +6 -6
- package/dist/render/pipeline.d.ts +3 -3
- package/dist/render/pipeline.d.ts.map +1 -1
- package/dist/render/pipeline.js +4 -4
- package/dist/render/registry.d.ts +6 -4
- package/dist/render/registry.d.ts.map +1 -1
- package/dist/render/registry.js +8 -6
- package/dist/render/rehype-dispatch.d.ts +1 -1
- package/dist/render/rehype-dispatch.d.ts.map +1 -1
- package/dist/render/remark-directives.d.ts +1 -1
- package/dist/render/remark-directives.d.ts.map +1 -1
- package/dist/render/sanitize.d.ts +8 -0
- package/dist/render/sanitize.d.ts.map +1 -0
- package/dist/render/sanitize.js +26 -0
- package/dist/sveltekit/auth-routes.d.ts +23 -0
- package/dist/sveltekit/auth-routes.d.ts.map +1 -0
- package/dist/sveltekit/auth-routes.js +85 -0
- package/dist/sveltekit/content-routes.d.ts +80 -0
- package/dist/sveltekit/content-routes.d.ts.map +1 -0
- package/dist/sveltekit/content-routes.js +183 -0
- package/dist/sveltekit/editors-routes.d.ts +24 -0
- package/dist/sveltekit/editors-routes.d.ts.map +1 -0
- package/dist/sveltekit/editors-routes.js +73 -0
- package/dist/sveltekit/guard.d.ts +9 -0
- package/dist/sveltekit/guard.d.ts.map +1 -0
- package/dist/sveltekit/guard.js +43 -0
- package/dist/sveltekit/health.d.ts +19 -0
- package/dist/sveltekit/health.d.ts.map +1 -0
- package/dist/sveltekit/health.js +12 -0
- package/dist/sveltekit/index.d.ts +9 -83
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +8 -149
- package/dist/sveltekit/nav-routes.d.ts +30 -0
- package/dist/sveltekit/nav-routes.d.ts.map +1 -0
- package/dist/sveltekit/nav-routes.js +103 -0
- package/dist/sveltekit/types.d.ts +32 -0
- package/dist/sveltekit/types.d.ts.map +1 -0
- package/dist/sveltekit/types.js +1 -0
- package/package.json +38 -58
- package/src/lib/auth/crypto.ts +37 -0
- package/src/lib/auth/store.ts +158 -0
- package/src/lib/auth/types.ts +27 -0
- package/src/lib/components/AdminLayout.svelte +58 -108
- package/src/lib/components/ComponentPalette.svelte +50 -0
- package/src/lib/components/ConceptList.svelte +81 -0
- package/src/lib/components/ConfirmPage.svelte +23 -20
- package/src/lib/components/EditPage.svelte +160 -103
- package/src/lib/components/LoginPage.svelte +42 -52
- package/src/lib/components/ManageEditors.svelte +81 -0
- package/src/lib/components/MarkdownEditor.svelte +81 -0
- package/src/lib/components/NavTree.svelte +138 -0
- package/src/lib/components/cairn-admin.css +42 -0
- package/src/lib/components/index.ts +7 -4
- package/src/lib/content/compose.ts +39 -0
- package/src/lib/content/concepts.ts +57 -0
- package/src/lib/content/frontmatter.ts +71 -0
- package/src/lib/content/ids.ts +38 -0
- package/src/lib/content/types.ts +235 -0
- package/src/lib/content/validate.ts +51 -0
- package/src/lib/email.ts +52 -38
- package/src/lib/env.ts +32 -0
- package/src/lib/github/credentials.ts +27 -0
- package/src/lib/github/repo.ts +138 -0
- package/src/lib/github/signing.ts +97 -0
- package/src/lib/github/types.ts +46 -0
- package/src/lib/index.ts +86 -8
- package/src/lib/nav/site-config.ts +124 -0
- package/src/lib/render/glyph.ts +6 -6
- package/src/lib/render/index.ts +6 -6
- package/src/lib/render/pipeline.ts +22 -22
- package/src/lib/render/registry.ts +33 -26
- package/src/lib/render/rehype-dispatch.ts +47 -47
- package/src/lib/render/remark-directives.ts +46 -46
- package/src/lib/render/sanitize.ts +27 -0
- package/src/lib/sveltekit/auth-routes.ts +107 -0
- package/src/lib/sveltekit/content-routes.ts +261 -0
- package/src/lib/sveltekit/editors-routes.ts +82 -0
- package/src/lib/sveltekit/guard.ts +47 -0
- package/src/lib/sveltekit/health.ts +24 -0
- package/src/lib/sveltekit/index.ts +19 -235
- package/src/lib/sveltekit/nav-routes.ts +139 -0
- package/src/lib/sveltekit/types.ts +33 -0
- package/dist/adapter.d.ts +0 -69
- package/dist/adapter.d.ts.map +0 -1
- package/dist/adapter.js +0 -30
- package/dist/auth/admins.d.ts +0 -33
- package/dist/auth/admins.d.ts.map +0 -1
- package/dist/auth/admins.js +0 -90
- package/dist/auth/config.d.ts +0 -2097
- package/dist/auth/config.d.ts.map +0 -1
- package/dist/auth/config.js +0 -78
- package/dist/auth/guard.d.ts +0 -34
- package/dist/auth/guard.d.ts.map +0 -1
- package/dist/auth/guard.js +0 -47
- package/dist/auth/index.d.ts +0 -4
- package/dist/auth/index.d.ts.map +0 -1
- package/dist/auth/index.js +0 -6
- package/dist/auth/schema.d.ts +0 -750
- package/dist/auth/schema.d.ts.map +0 -1
- package/dist/auth/schema.js +0 -93
- package/dist/carta.d.ts +0 -39
- package/dist/carta.d.ts.map +0 -1
- package/dist/carta.js +0 -30
- package/dist/components/AdminList.svelte +0 -33
- package/dist/components/AdminList.svelte.d.ts +0 -10
- package/dist/components/AdminList.svelte.d.ts.map +0 -1
- package/dist/components/ManageAdmins.svelte +0 -84
- package/dist/components/ManageAdmins.svelte.d.ts +0 -10
- package/dist/components/ManageAdmins.svelte.d.ts.map +0 -1
- package/dist/content.d.ts +0 -3
- package/dist/content.d.ts.map +0 -1
- package/dist/content.js +0 -10
- package/dist/github.d.ts +0 -72
- package/dist/github.d.ts.map +0 -1
- package/dist/github.js +0 -171
- package/dist/utils.d.ts +0 -3
- package/dist/utils.d.ts.map +0 -1
- package/dist/utils.js +0 -11
- package/src/lib/adapter.ts +0 -119
- package/src/lib/auth/admins.ts +0 -106
- package/src/lib/auth/config.ts +0 -108
- package/src/lib/auth/guard.ts +0 -60
- package/src/lib/auth/index.ts +0 -6
- package/src/lib/auth/schema.ts +0 -112
- package/src/lib/carta.ts +0 -59
- package/src/lib/components/AdminList.svelte +0 -33
- package/src/lib/components/ManageAdmins.svelte +0 -84
- package/src/lib/content.ts +0 -11
- package/src/lib/github.ts +0 -220
- package/src/lib/utils.ts +0 -12
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The session cookie name. */
|
|
2
|
+
export declare const COOKIE_NAME = "cairn_session";
|
|
3
|
+
/** Magic-link tokens live 10 minutes. */
|
|
4
|
+
export declare const TOKEN_TTL_MS: number;
|
|
5
|
+
/** Sessions live 30 days. */
|
|
6
|
+
export declare const SESSION_TTL_MS: number;
|
|
7
|
+
/** A fresh 256-bit magic-link token, url-safe. */
|
|
8
|
+
export declare function generateToken(): string;
|
|
9
|
+
/** A fresh 256-bit session id, url-safe. */
|
|
10
|
+
export declare function generateSessionId(): string;
|
|
11
|
+
/** The lowercase hex SHA-256 of a token, for storage and lookup. */
|
|
12
|
+
export declare function hashToken(token: string): Promise<string>;
|
|
13
|
+
//# sourceMappingURL=crypto.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../src/lib/auth/crypto.ts"],"names":[],"mappings":"AAIA,+BAA+B;AAC/B,eAAO,MAAM,WAAW,kBAAkB,CAAC;AAE3C,yCAAyC;AACzC,eAAO,MAAM,YAAY,QAAiB,CAAC;AAE3C,6BAA6B;AAC7B,eAAO,MAAM,cAAc,QAA2B,CAAC;AAUvD,kDAAkD;AAClD,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED,4CAA4C;AAC5C,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED,oEAAoE;AACpE,wBAAsB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAI9D"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Token and session-id generation plus SHA-256 token hashing, on Web Crypto so the
|
|
2
|
+
// code runs unchanged in workerd. The store keeps only the hash of a token, never the
|
|
3
|
+
// token itself (spec 7.1).
|
|
4
|
+
/** The session cookie name. */
|
|
5
|
+
export const COOKIE_NAME = 'cairn_session';
|
|
6
|
+
/** Magic-link tokens live 10 minutes. */
|
|
7
|
+
export const TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
8
|
+
/** Sessions live 30 days. */
|
|
9
|
+
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
10
|
+
function randomBase64Url(byteLength = 32) {
|
|
11
|
+
const bytes = new Uint8Array(byteLength);
|
|
12
|
+
crypto.getRandomValues(bytes);
|
|
13
|
+
let binary = '';
|
|
14
|
+
for (const b of bytes)
|
|
15
|
+
binary += String.fromCharCode(b);
|
|
16
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
17
|
+
}
|
|
18
|
+
/** A fresh 256-bit magic-link token, url-safe. */
|
|
19
|
+
export function generateToken() {
|
|
20
|
+
return randomBase64Url(32);
|
|
21
|
+
}
|
|
22
|
+
/** A fresh 256-bit session id, url-safe. */
|
|
23
|
+
export function generateSessionId() {
|
|
24
|
+
return randomBase64Url(32);
|
|
25
|
+
}
|
|
26
|
+
/** The lowercase hex SHA-256 of a token, for storage and lookup. */
|
|
27
|
+
export async function hashToken(token) {
|
|
28
|
+
const data = new TextEncoder().encode(token);
|
|
29
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
30
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
2
|
+
import type { Editor, Role } from './types.js';
|
|
3
|
+
/** Look an email up in the allowlist. */
|
|
4
|
+
export declare function findEditor(db: D1Database, email: string): Promise<Editor | null>;
|
|
5
|
+
/** Replace any prior token for this email with a fresh one, atomically. */
|
|
6
|
+
export declare function issueToken(db: D1Database, email: string, tokenHash: string, expiresAt: number, now: number): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Consume a token in one atomic statement. A returned email means the token was present and
|
|
9
|
+
* unexpired and is now gone, so the link is single-use by construction on strongly-consistent D1.
|
|
10
|
+
*/
|
|
11
|
+
export declare function consumeToken(db: D1Database, tokenHash: string, now: number): Promise<string | null>;
|
|
12
|
+
/** Create a session row. */
|
|
13
|
+
export declare function createSession(db: D1Database, id: string, email: string, expiresAt: number, now: number): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a session to its editor, joining `editor` so the role is read live. An expired
|
|
16
|
+
* session or a removed editor resolves to null, which revokes access on the next request.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveSession(db: D1Database, id: string, now: number): Promise<Editor | null>;
|
|
19
|
+
/** Delete a session (logout). */
|
|
20
|
+
export declare function deleteSession(db: D1Database, id: string): Promise<void>;
|
|
21
|
+
/** The full allowlist, sorted by email. */
|
|
22
|
+
export declare function listEditors(db: D1Database): Promise<Editor[]>;
|
|
23
|
+
/** Add an editor to the allowlist. */
|
|
24
|
+
export declare function insertEditor(db: D1Database, email: string, displayName: string, role: Role, now: number): Promise<void>;
|
|
25
|
+
/** Remove an editor and cut their live access (sessions and any pending token go too). */
|
|
26
|
+
export declare function deleteEditor(db: D1Database, email: string): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Remove an owner only if another owner remains. The count is part of the DELETE, so two
|
|
29
|
+
* concurrent removals cannot both pass a separate check and strand the allowlist at zero
|
|
30
|
+
* owners. Returns false (and writes nothing) when this is the last owner. On success the
|
|
31
|
+
* editor's sessions and pending token go too.
|
|
32
|
+
*/
|
|
33
|
+
export declare function removeOwnerIfNotLast(db: D1Database, email: string): Promise<boolean>;
|
|
34
|
+
/** Change an editor's role. The guard reads the new role on the next request. */
|
|
35
|
+
export declare function setEditorRole(db: D1Database, email: string, role: Role): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Demote an owner to editor only if another owner remains, in one atomic statement (see
|
|
38
|
+
* `removeOwnerIfNotLast`). Returns false (and writes nothing) when this is the last owner.
|
|
39
|
+
*/
|
|
40
|
+
export declare function demoteOwnerIfNotLast(db: D1Database, email: string): Promise<boolean>;
|
|
41
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/lib/auth/store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAQ/C,yCAAyC;AACzC,wBAAsB,UAAU,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAMtF;AAED,2EAA2E;AAC3E,wBAAsB,UAAU,CAC9B,EAAE,EAAE,UAAU,EACd,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAMzG;AAED,4BAA4B;AAC5B,wBAAsB,aAAa,CACjC,EAAE,EAAE,UAAU,EACd,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAKf;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAUpG;AAED,iCAAiC;AACjC,wBAAsB,aAAa,CAAC,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7E;AAED,2CAA2C;AAC3C,wBAAsB,WAAW,CAAC,EAAE,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAKnE;AAED,sCAAsC;AACtC,wBAAsB,YAAY,CAChC,EAAE,EAAE,UAAU,EACd,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,IAAI,EACV,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,0FAA0F;AAC1F,wBAAsB,YAAY,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM/E;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAe1F;AAED,iFAAiF;AACjF,wBAAsB,aAAa,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAE5F;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU1F"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
function toEditor(row) {
|
|
2
|
+
return { email: row.email, displayName: row.display_name, role: row.role };
|
|
3
|
+
}
|
|
4
|
+
/** Look an email up in the allowlist. */
|
|
5
|
+
export async function findEditor(db, email) {
|
|
6
|
+
const row = await db
|
|
7
|
+
.prepare('SELECT email, display_name, role FROM editor WHERE email = ?')
|
|
8
|
+
.bind(email)
|
|
9
|
+
.first();
|
|
10
|
+
return row ? toEditor(row) : null;
|
|
11
|
+
}
|
|
12
|
+
/** Replace any prior token for this email with a fresh one, atomically. */
|
|
13
|
+
export async function issueToken(db, email, tokenHash, expiresAt, now) {
|
|
14
|
+
await db.batch([
|
|
15
|
+
db.prepare('DELETE FROM magic_token WHERE email = ?').bind(email),
|
|
16
|
+
db
|
|
17
|
+
.prepare('INSERT INTO magic_token (token_hash, email, expires_at, created_at) VALUES (?, ?, ?, ?)')
|
|
18
|
+
.bind(tokenHash, email, expiresAt, now),
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Consume a token in one atomic statement. A returned email means the token was present and
|
|
23
|
+
* unexpired and is now gone, so the link is single-use by construction on strongly-consistent D1.
|
|
24
|
+
*/
|
|
25
|
+
export async function consumeToken(db, tokenHash, now) {
|
|
26
|
+
const row = await db
|
|
27
|
+
.prepare('DELETE FROM magic_token WHERE token_hash = ? AND expires_at > ? RETURNING email')
|
|
28
|
+
.bind(tokenHash, now)
|
|
29
|
+
.first();
|
|
30
|
+
return row?.email ?? null;
|
|
31
|
+
}
|
|
32
|
+
/** Create a session row. */
|
|
33
|
+
export async function createSession(db, id, email, expiresAt, now) {
|
|
34
|
+
await db
|
|
35
|
+
.prepare('INSERT INTO session (id, email, expires_at, created_at) VALUES (?, ?, ?, ?)')
|
|
36
|
+
.bind(id, email, expiresAt, now)
|
|
37
|
+
.run();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a session to its editor, joining `editor` so the role is read live. An expired
|
|
41
|
+
* session or a removed editor resolves to null, which revokes access on the next request.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveSession(db, id, now) {
|
|
44
|
+
const row = await db
|
|
45
|
+
.prepare(`SELECT e.email AS email, e.display_name AS display_name, e.role AS role
|
|
46
|
+
FROM session s JOIN editor e ON e.email = s.email
|
|
47
|
+
WHERE s.id = ? AND s.expires_at > ?`)
|
|
48
|
+
.bind(id, now)
|
|
49
|
+
.first();
|
|
50
|
+
return row ? toEditor(row) : null;
|
|
51
|
+
}
|
|
52
|
+
/** Delete a session (logout). */
|
|
53
|
+
export async function deleteSession(db, id) {
|
|
54
|
+
await db.prepare('DELETE FROM session WHERE id = ?').bind(id).run();
|
|
55
|
+
}
|
|
56
|
+
/** The full allowlist, sorted by email. */
|
|
57
|
+
export async function listEditors(db) {
|
|
58
|
+
const { results } = await db
|
|
59
|
+
.prepare('SELECT email, display_name, role FROM editor ORDER BY email')
|
|
60
|
+
.all();
|
|
61
|
+
return results.map(toEditor);
|
|
62
|
+
}
|
|
63
|
+
/** Add an editor to the allowlist. */
|
|
64
|
+
export async function insertEditor(db, email, displayName, role, now) {
|
|
65
|
+
await db
|
|
66
|
+
.prepare('INSERT INTO editor (email, display_name, role, created_at) VALUES (?, ?, ?, ?)')
|
|
67
|
+
.bind(email, displayName, role, now)
|
|
68
|
+
.run();
|
|
69
|
+
}
|
|
70
|
+
/** Remove an editor and cut their live access (sessions and any pending token go too). */
|
|
71
|
+
export async function deleteEditor(db, email) {
|
|
72
|
+
await db.batch([
|
|
73
|
+
db.prepare('DELETE FROM session WHERE email = ?').bind(email),
|
|
74
|
+
db.prepare('DELETE FROM magic_token WHERE email = ?').bind(email),
|
|
75
|
+
db.prepare('DELETE FROM editor WHERE email = ?').bind(email),
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Remove an owner only if another owner remains. The count is part of the DELETE, so two
|
|
80
|
+
* concurrent removals cannot both pass a separate check and strand the allowlist at zero
|
|
81
|
+
* owners. Returns false (and writes nothing) when this is the last owner. On success the
|
|
82
|
+
* editor's sessions and pending token go too.
|
|
83
|
+
*/
|
|
84
|
+
export async function removeOwnerIfNotLast(db, email) {
|
|
85
|
+
const res = await db
|
|
86
|
+
.prepare(`DELETE FROM editor
|
|
87
|
+
WHERE email = ? AND role = 'owner'
|
|
88
|
+
AND (SELECT COUNT(*) FROM editor WHERE role = 'owner') > 1`)
|
|
89
|
+
.bind(email)
|
|
90
|
+
.run();
|
|
91
|
+
if (res.meta.changes !== 1)
|
|
92
|
+
return false;
|
|
93
|
+
await db.batch([
|
|
94
|
+
db.prepare('DELETE FROM session WHERE email = ?').bind(email),
|
|
95
|
+
db.prepare('DELETE FROM magic_token WHERE email = ?').bind(email),
|
|
96
|
+
]);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
/** Change an editor's role. The guard reads the new role on the next request. */
|
|
100
|
+
export async function setEditorRole(db, email, role) {
|
|
101
|
+
await db.prepare('UPDATE editor SET role = ? WHERE email = ?').bind(role, email).run();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Demote an owner to editor only if another owner remains, in one atomic statement (see
|
|
105
|
+
* `removeOwnerIfNotLast`). Returns false (and writes nothing) when this is the last owner.
|
|
106
|
+
*/
|
|
107
|
+
export async function demoteOwnerIfNotLast(db, email) {
|
|
108
|
+
const res = await db
|
|
109
|
+
.prepare(`UPDATE editor SET role = 'editor'
|
|
110
|
+
WHERE email = ? AND role = 'owner'
|
|
111
|
+
AND (SELECT COUNT(*) FROM editor WHERE role = 'owner') > 1`)
|
|
112
|
+
.bind(email)
|
|
113
|
+
.run();
|
|
114
|
+
return res.meta.changes === 1;
|
|
115
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
2
|
+
export type Role = 'owner' | 'editor';
|
|
3
|
+
/** The session shape the whole admin reads: guard, loads, content fns, manage-editors. */
|
|
4
|
+
export interface Editor {
|
|
5
|
+
email: string;
|
|
6
|
+
displayName: string;
|
|
7
|
+
role: Role;
|
|
8
|
+
}
|
|
9
|
+
/** Worker bindings and vars the auth layer reads; a structural subset of `Platform.env`. */
|
|
10
|
+
export interface AuthEnv {
|
|
11
|
+
AUTH_DB?: D1Database;
|
|
12
|
+
/** Canonical origin for confirmation links, never read from a request header (spec 7.1, risk H3). */
|
|
13
|
+
PUBLIC_ORIGIN?: string;
|
|
14
|
+
/** Cloudflare Email Sending binding. */
|
|
15
|
+
EMAIL?: {
|
|
16
|
+
send(message: {
|
|
17
|
+
to: string;
|
|
18
|
+
from: string;
|
|
19
|
+
subject: string;
|
|
20
|
+
html: string;
|
|
21
|
+
text: string;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/auth/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAE5D,MAAM,MAAM,IAAI,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEtC,0FAA0F;AAC1F,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,IAAI,CAAC;CACZ;AAED,4FAA4F;AAC5F,MAAM,WAAW,OAAO;IACtB,OAAO,CAAC,EAAE,UAAU,CAAC;IACrB,qGAAqG;IACrG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,wCAAwC;IACxC,KAAK,CAAC,EAAE;QACN,IAAI,CAAC,OAAO,EAAE;YACZ,EAAE,EAAE,MAAM,CAAC;YACX,IAAI,EAAE,MAAM,CAAC;YACb,OAAO,EAAE,MAAM,CAAC;YAChB,IAAI,EAAE,MAAM,CAAC;YACb,IAAI,EAAE,MAAM,CAAC;SACd,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KACnB,CAAC;CACH"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,130 +1,80 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The admin shell: a DaisyUI drawer-and-navbar that wraps every authed admin page. The nav is
|
|
4
|
+
data-driven from the enabled concepts and role-gated (owners see the manage-editors entry). The
|
|
5
|
+
root sets `data-theme="cairn-admin"` and imports the self-contained Warm Stone theme, so the
|
|
6
|
+
admin looks identical on every host regardless of the site's own theme.
|
|
7
|
+
-->
|
|
1
8
|
<script lang="ts">
|
|
2
|
-
// Neutral admin chrome, shared across sites so the tool looks identical everywhere (only the
|
|
3
|
-
// adapter's siteName varies). When signed in it's a responsive DaisyUI drawer+navbar shell
|
|
4
|
-
// (`drawer lg:drawer-open`, sidebar pinned on desktop, slide-over + hamburger on mobile),
|
|
5
|
-
// patterned on scosman/CMSaasStarter's `(admin)/(menu)` layout. The nav is data-driven and
|
|
6
|
-
// role-gated, so a new surface is one entry in `nav` (plus its route + component). Signed out
|
|
7
|
-
// (the login page lives under this layout) it falls back to a minimal centered shell.
|
|
8
|
-
// Each site's `admin/+layout.svelte` is a one-line shim that forwards `data` + `children`.
|
|
9
9
|
import type { Snippet } from 'svelte';
|
|
10
|
-
import type {
|
|
10
|
+
import type { LayoutData } from '../sveltekit/content-routes.js';
|
|
11
|
+
import './cairn-admin.css';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
data,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
data: { siteName: string; user: CairnUser | null; pathname: string };
|
|
13
|
+
interface Props {
|
|
14
|
+
/** The layout load's data: site name, user, nav concepts, active path, owner capability. */
|
|
15
|
+
data: LayoutData;
|
|
16
|
+
/** The page body. */
|
|
17
17
|
children: Snippet;
|
|
18
|
-
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let { data, children }: Props = $props();
|
|
19
21
|
|
|
20
22
|
interface NavItem {
|
|
21
23
|
href: string;
|
|
22
24
|
label: string;
|
|
23
|
-
icon: Snippet;
|
|
24
|
-
active: boolean;
|
|
25
|
-
/** Owner-only surface; hidden from regular editors. */
|
|
26
25
|
owner?: boolean;
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
const
|
|
30
|
-
{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
icon: contentIcon,
|
|
34
|
-
active: data.pathname === '/admin' || data.pathname.startsWith('/admin/edit'),
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
href: '/admin/admins',
|
|
38
|
-
label: 'Editors',
|
|
39
|
-
icon: editorsIcon,
|
|
40
|
-
owner: true,
|
|
41
|
-
active: data.pathname.startsWith('/admin/admins'),
|
|
42
|
-
},
|
|
28
|
+
const navItems: NavItem[] = $derived([
|
|
29
|
+
...data.concepts.map((c) => ({ href: `/admin/${c.id}`, label: c.label })),
|
|
30
|
+
...(data.navLabel ? [{ href: '/admin/nav', label: data.navLabel }] : []),
|
|
31
|
+
{ href: '/admin/editors', label: 'Editors', owner: true },
|
|
43
32
|
]);
|
|
44
|
-
const visibleNav = $derived(nav.filter((item) => !item.owner || data.user?.role === 'owner'));
|
|
45
33
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
34
|
+
const visibleNav = $derived(navItems.filter((item) => !item.owner || data.canManageEditors));
|
|
35
|
+
|
|
36
|
+
function isActive(href: string): boolean {
|
|
37
|
+
return data.pathname === href || data.pathname.startsWith(`${href}/`);
|
|
50
38
|
}
|
|
51
39
|
</script>
|
|
52
40
|
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
56
|
-
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
57
|
-
</svg>
|
|
58
|
-
{/snippet}
|
|
59
|
-
|
|
60
|
-
{#snippet editorsIcon()}
|
|
61
|
-
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
62
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
63
|
-
d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-1.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3.5-2.1" />
|
|
64
|
-
</svg>
|
|
65
|
-
{/snippet}
|
|
41
|
+
<div data-theme="cairn-admin" class="drawer lg:drawer-open min-h-screen bg-base-200 text-base-content">
|
|
42
|
+
<input id="cairn-drawer" type="checkbox" class="drawer-toggle" />
|
|
66
43
|
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<div class="drawer-content">
|
|
76
|
-
<!-- Mobile top bar; the desktop sidebar replaces this at lg. -->
|
|
77
|
-
<div class="navbar bg-base-100 lg:hidden">
|
|
78
|
-
<div class="flex-1">
|
|
79
|
-
<span class="px-2 text-xl font-bold">{data.siteName} CMS</span>
|
|
80
|
-
</div>
|
|
81
|
-
<div class="flex-none">
|
|
82
|
-
<label for="admin-drawer" class="btn btn-square btn-ghost" aria-label="Open menu">
|
|
83
|
-
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
84
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
|
|
85
|
-
</svg>
|
|
86
|
-
</label>
|
|
87
|
-
</div>
|
|
44
|
+
<div class="drawer-content flex flex-col">
|
|
45
|
+
<div class="navbar bg-base-100 border-b border-base-300">
|
|
46
|
+
<div class="flex-none lg:hidden">
|
|
47
|
+
<label for="cairn-drawer" aria-label="Open menu" class="btn btn-square btn-ghost">
|
|
48
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
49
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
|
50
|
+
</svg>
|
|
51
|
+
</label>
|
|
88
52
|
</div>
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
{@render children()}
|
|
92
|
-
</main>
|
|
53
|
+
<div class="flex-1 px-2 font-semibold">{data.siteName}</div>
|
|
54
|
+
<div class="flex-none px-2 text-sm text-[var(--color-muted)]">{data.user.displayName}</div>
|
|
93
55
|
</div>
|
|
94
56
|
|
|
95
|
-
<
|
|
96
|
-
<label for="admin-drawer" class="drawer-overlay" aria-label="Close menu"></label>
|
|
97
|
-
<div class="flex min-h-full w-80 flex-col bg-base-100 lg:border-r lg:border-base-300">
|
|
98
|
-
<ul class="menu menu-lg grow p-4">
|
|
99
|
-
<li class="menu-title flex flex-row items-center text-xl font-bold text-base-content">
|
|
100
|
-
<span class="grow">{data.siteName} CMS</span>
|
|
101
|
-
<label for="admin-drawer" class="ml-3 cursor-pointer lg:hidden" aria-label="Close menu">✕</label>
|
|
102
|
-
</li>
|
|
103
|
-
{#each visibleNav as item (item.href)}
|
|
104
|
-
<li>
|
|
105
|
-
<a href={item.href} class={item.active ? 'active' : ''} onclick={closeDrawer}>
|
|
106
|
-
{@render item.icon()}
|
|
107
|
-
{item.label}
|
|
108
|
-
</a>
|
|
109
|
-
</li>
|
|
110
|
-
{/each}
|
|
111
|
-
</ul>
|
|
112
|
-
|
|
113
|
-
<div class="border-t border-base-300 p-4">
|
|
114
|
-
<p class="text-sm font-medium">{data.user.name}</p>
|
|
115
|
-
<p class="text-xs opacity-60">{data.user.email}</p>
|
|
116
|
-
<form method="POST" action="/admin/auth/logout" class="mt-3">
|
|
117
|
-
<button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">Sign out</button>
|
|
118
|
-
</form>
|
|
119
|
-
</div>
|
|
120
|
-
</div>
|
|
121
|
-
</div>
|
|
122
|
-
</div>
|
|
123
|
-
{:else}
|
|
124
|
-
<!-- Signed out (login page): no nav, just a centered surface. -->
|
|
125
|
-
<div class="min-h-screen bg-base-200" data-pagefind-ignore>
|
|
126
|
-
<div class="mx-auto max-w-3xl px-4 py-8">
|
|
57
|
+
<main class="flex-1 p-4 lg:p-8">
|
|
127
58
|
{@render children()}
|
|
128
|
-
</
|
|
59
|
+
</main>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="drawer-side">
|
|
63
|
+
<label for="cairn-drawer" aria-label="Close menu" class="drawer-overlay"></label>
|
|
64
|
+
<nav class="bg-base-100 min-h-full w-64 border-r border-base-300 p-4" aria-label="Site content">
|
|
65
|
+
<div class="menu-title mb-2 px-2 text-xs uppercase tracking-wide text-[var(--color-muted)]">Content</div>
|
|
66
|
+
<ul class="menu menu-lg w-full">
|
|
67
|
+
{#each visibleNav as item (item.href)}
|
|
68
|
+
<li>
|
|
69
|
+
<a href={item.href} class:menu-active={isActive(item.href)} aria-current={isActive(item.href) ? 'page' : undefined}>
|
|
70
|
+
{item.label}
|
|
71
|
+
</a>
|
|
72
|
+
</li>
|
|
73
|
+
{/each}
|
|
74
|
+
</ul>
|
|
75
|
+
<form method="POST" action="/admin/auth/logout" class="mt-6 px-2">
|
|
76
|
+
<button type="submit" class="btn btn-ghost btn-sm btn-block">Sign out</button>
|
|
77
|
+
</form>
|
|
78
|
+
</nav>
|
|
129
79
|
</div>
|
|
130
|
-
|
|
80
|
+
</div>
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
};
|
|
2
|
+
import type { LayoutData } from '../sveltekit/content-routes.js';
|
|
3
|
+
import './cairn-admin.css';
|
|
4
|
+
interface Props {
|
|
5
|
+
/** The layout load's data: site name, user, nav concepts, active path, owner capability. */
|
|
6
|
+
data: LayoutData;
|
|
7
|
+
/** The page body. */
|
|
9
8
|
children: Snippet;
|
|
10
|
-
}
|
|
11
|
-
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The admin shell: a DaisyUI drawer-and-navbar that wraps every authed admin page. The nav is
|
|
12
|
+
* data-driven from the enabled concepts and role-gated (owners see the manage-editors entry). The
|
|
13
|
+
* root sets `data-theme="cairn-admin"` and imports the self-contained Warm Stone theme, so the
|
|
14
|
+
* admin looks identical on every host regardless of the site's own theme.
|
|
15
|
+
*/
|
|
16
|
+
declare const AdminLayout: import("svelte").Component<Props, {}, "">;
|
|
12
17
|
type AdminLayout = ReturnType<typeof AdminLayout>;
|
|
13
18
|
export default AdminLayout;
|
|
14
19
|
//# sourceMappingURL=AdminLayout.svelte.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AdminLayout.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminLayout.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"AdminLayout.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminLayout.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACjE,OAAO,mBAAmB,CAAC;AAGzB,UAAU,KAAK;IACb,4FAA4F;IAC5F,IAAI,EAAE,UAAU,CAAC;IACjB,qBAAqB;IACrB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAyEH;;;;;GAKG;AACH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The insert-component palette: a dropdown listing the site's registered directive components
|
|
4
|
+
(seam 3). Picking one inserts its template at the cursor through the editor's insert callback.
|
|
5
|
+
Renders nothing when the site configures no registry.
|
|
6
|
+
-->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import type { ComponentRegistry } from '../render/registry.js';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** The site's component registry; the palette derives its catalog from it. */
|
|
12
|
+
registry?: ComponentRegistry;
|
|
13
|
+
/** Insert a template at the editor's cursor. */
|
|
14
|
+
insert: (template: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let { registry, insert }: Props = $props();
|
|
18
|
+
|
|
19
|
+
const defs = $derived(registry?.defs ?? []);
|
|
20
|
+
let open = $state(false);
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
{#if defs.length > 0}
|
|
24
|
+
<div
|
|
25
|
+
class="dropdown"
|
|
26
|
+
class:dropdown-open={open}
|
|
27
|
+
role="presentation"
|
|
28
|
+
onkeydown={(e) => { if (e.key === 'Escape') open = false; }}
|
|
29
|
+
>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
class="btn btn-sm btn-ghost"
|
|
33
|
+
aria-haspopup="listbox"
|
|
34
|
+
aria-expanded={open}
|
|
35
|
+
onclick={() => (open = !open)}
|
|
36
|
+
>Insert</button>
|
|
37
|
+
<ul class="dropdown-content menu rounded-box border border-base-300 bg-base-100 z-10 w-56 shadow" role="listbox">
|
|
38
|
+
{#each defs as def (def.name)}
|
|
39
|
+
<li role="option" aria-selected={false}>
|
|
40
|
+
<button type="button" onclick={() => { insert(def.insertTemplate); open = false; }}>
|
|
41
|
+
<span class="flex flex-col items-start">
|
|
42
|
+
<span class="font-medium">{def.label}</span>
|
|
43
|
+
<span class="text-xs text-[var(--color-muted)]">{def.description}</span>
|
|
44
|
+
</span>
|
|
45
|
+
</button>
|
|
46
|
+
</li>
|
|
47
|
+
{/each}
|
|
48
|
+
</ul>
|
|
49
|
+
</div>
|
|
50
|
+
{/if}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ComponentRegistry } from '../render/registry.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** The site's component registry; the palette derives its catalog from it. */
|
|
4
|
+
registry?: ComponentRegistry;
|
|
5
|
+
/** Insert a template at the editor's cursor. */
|
|
6
|
+
insert: (template: string) => void;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The insert-component palette: a dropdown listing the site's registered directive components
|
|
10
|
+
* (seam 3). Picking one inserts its template at the cursor through the editor's insert callback.
|
|
11
|
+
* Renders nothing when the site configures no registry.
|
|
12
|
+
*/
|
|
13
|
+
declare const ComponentPalette: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type ComponentPalette = ReturnType<typeof ComponentPalette>;
|
|
15
|
+
export default ComponentPalette;
|
|
16
|
+
//# sourceMappingURL=ComponentPalette.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ComponentPalette.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ComponentPalette.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAG7D,UAAU,KAAK;IACb,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,gDAAgD;IAChD,MAAM,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAgCH;;;;GAIG;AACH,QAAA,MAAM,gBAAgB,2CAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
One concept's list view: every entry as a link to its editor, with title, date, and a draft badge,
|
|
4
|
+
plus a new-entry form. The slug auto-derives from the title until the author edits the slug field.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { slugify } from '../content/ids.js';
|
|
8
|
+
import type { ListData } from '../sveltekit/content-routes.js';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** The list load's data: the concept, its entries, and any inline or form errors. */
|
|
12
|
+
data: ListData;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let { data }: Props = $props();
|
|
16
|
+
|
|
17
|
+
let title = $state('');
|
|
18
|
+
let slug = $state('');
|
|
19
|
+
let slugEdited = $state(false);
|
|
20
|
+
|
|
21
|
+
const derivedSlug = $derived(slugEdited ? slug : slugify(title));
|
|
22
|
+
const slugPlaceholder = $derived(data.dated ? '2026-05-my-entry' : 'about-us');
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<header class="mb-4 flex items-center justify-between">
|
|
26
|
+
<h1 class="text-xl font-semibold">{data.label}</h1>
|
|
27
|
+
</header>
|
|
28
|
+
|
|
29
|
+
{#if data.formError}
|
|
30
|
+
<div role="alert" class="alert alert-error mb-4 text-sm">{data.formError}</div>
|
|
31
|
+
{/if}
|
|
32
|
+
|
|
33
|
+
{#if data.error}
|
|
34
|
+
<div role="alert" class="alert alert-warning mb-4 text-sm">{data.error}</div>
|
|
35
|
+
{/if}
|
|
36
|
+
|
|
37
|
+
<div class="rounded-box border border-base-300 bg-base-100 mb-6">
|
|
38
|
+
{#if data.entries.length === 0}
|
|
39
|
+
<p class="p-4 text-sm opacity-70">No entries yet.</p>
|
|
40
|
+
{:else}
|
|
41
|
+
<ul class="menu w-full">
|
|
42
|
+
{#each data.entries as entry (entry.id)}
|
|
43
|
+
<li>
|
|
44
|
+
<a href={`/admin/${data.conceptId}/${entry.id}`} class="flex items-center justify-between">
|
|
45
|
+
<span>{entry.title}</span>
|
|
46
|
+
<span class="flex items-center gap-2 text-xs text-[var(--color-muted)]">
|
|
47
|
+
{#if entry.date}<span>{entry.date}</span>{/if}
|
|
48
|
+
{#if entry.draft}<span class="badge badge-warning badge-sm">Draft</span>{/if}
|
|
49
|
+
</span>
|
|
50
|
+
</a>
|
|
51
|
+
</li>
|
|
52
|
+
{/each}
|
|
53
|
+
</ul>
|
|
54
|
+
{/if}
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<form method="POST" action="?/create" class="rounded-box border border-base-300 bg-base-100 flex flex-col gap-3 p-4">
|
|
58
|
+
<h2 class="text-sm font-semibold">New entry</h2>
|
|
59
|
+
<label class="flex flex-col gap-1">
|
|
60
|
+
<span class="text-sm font-medium">Title</span>
|
|
61
|
+
<input class="input" name="title" aria-label="Title" bind:value={title} required />
|
|
62
|
+
</label>
|
|
63
|
+
<label class="flex flex-col gap-1">
|
|
64
|
+
<span class="text-sm font-medium">Slug</span>
|
|
65
|
+
<input
|
|
66
|
+
class="input"
|
|
67
|
+
name="slug"
|
|
68
|
+
aria-label="Slug"
|
|
69
|
+
placeholder={slugPlaceholder}
|
|
70
|
+
value={derivedSlug}
|
|
71
|
+
oninput={(e) => { slugEdited = true; slug = e.currentTarget.value; }}
|
|
72
|
+
/>
|
|
73
|
+
</label>
|
|
74
|
+
{#if data.dated}
|
|
75
|
+
<label class="flex flex-col gap-1">
|
|
76
|
+
<span class="text-sm font-medium">Date</span>
|
|
77
|
+
<input class="input" type="date" name="date" aria-label="Date" />
|
|
78
|
+
</label>
|
|
79
|
+
{/if}
|
|
80
|
+
<button type="submit" class="btn btn-primary self-start">Create</button>
|
|
81
|
+
</form>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ListData } from '../sveltekit/content-routes.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** The list load's data: the concept, its entries, and any inline or form errors. */
|
|
4
|
+
data: ListData;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* One concept's list view: every entry as a link to its editor, with title, date, and a draft badge,
|
|
8
|
+
* plus a new-entry form. The slug auto-derives from the title until the author edits the slug field.
|
|
9
|
+
*/
|
|
10
|
+
declare const ConceptList: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type ConceptList = ReturnType<typeof ConceptList>;
|
|
12
|
+
export default ConceptList;
|
|
13
|
+
//# sourceMappingURL=ConceptList.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ConceptList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ConceptList.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAG7D,UAAU,KAAK;IACb,qFAAqF;IACrF,IAAI,EAAE,QAAQ,CAAC;CAChB;AAsEH;;;GAGG;AACH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
|