@pirateandfox/pagestack 0.1.0-beta.4 → 0.1.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +105 -13
  2. package/bin/invite.mjs +1 -1
  3. package/index.mjs +34 -5
  4. package/package.json +9 -4
  5. package/src/client/collection.ts +37 -0
  6. package/src/client/editor.ts +769 -182
  7. package/src/client/form.ts +84 -12
  8. package/src/client/global.ts +33 -0
  9. package/src/client/icons.ts +27 -0
  10. package/src/client/inline.ts +29 -35
  11. package/src/client/inserter.ts +59 -0
  12. package/src/client/invite.ts +1 -1
  13. package/src/client/item.ts +107 -0
  14. package/src/client/media.ts +81 -0
  15. package/src/client/pages.ts +89 -0
  16. package/src/client/picker.ts +24 -10
  17. package/src/client/preview.css +58 -16
  18. package/src/client/rich.ts +232 -0
  19. package/src/client/shell.css +200 -20
  20. package/src/client/state.ts +3 -1
  21. package/src/client/tree.ts +32 -9
  22. package/src/client/ui.ts +122 -0
  23. package/src/client/upload.ts +25 -0
  24. package/src/client/users.ts +40 -0
  25. package/src/core/auth.ts +8 -2
  26. package/src/core/frontmatter.ts +29 -0
  27. package/src/core/insert.ts +70 -0
  28. package/src/core/media.ts +37 -0
  29. package/src/core/pages.ts +52 -0
  30. package/src/core/patch.ts +2 -1
  31. package/src/core/ratelimit.ts +13 -0
  32. package/src/core/sanitize.ts +268 -0
  33. package/src/core/schema.ts +10 -1
  34. package/src/core/storage-github.ts +57 -21
  35. package/src/core/storage-local.ts +22 -5
  36. package/src/core/storage.ts +19 -3
  37. package/src/core/synced.ts +19 -0
  38. package/src/core/transform.ts +66 -0
  39. package/src/core/users.ts +34 -2
  40. package/src/routes/Nav.astro +22 -0
  41. package/src/routes/api/asset.ts +1 -1
  42. package/src/routes/api/auth.ts +7 -6
  43. package/src/routes/api/collection.ts +89 -0
  44. package/src/routes/api/global.ts +33 -0
  45. package/src/routes/api/images.ts +32 -16
  46. package/src/routes/api/page.ts +57 -14
  47. package/src/routes/api/pages.ts +39 -1
  48. package/src/routes/api/patterns.ts +68 -0
  49. package/src/routes/api/users.ts +46 -0
  50. package/src/routes/edit-collection.astro +47 -0
  51. package/src/routes/edit-global.astro +39 -0
  52. package/src/routes/edit-index.astro +40 -24
  53. package/src/routes/edit-item.astro +57 -0
  54. package/src/routes/edit-media.astro +30 -0
  55. package/src/routes/edit-page.astro +29 -19
  56. package/src/routes/edit-users.astro +43 -0
  57. package/src/routes/login.astro +2 -3
  58. package/src/routes/preview.astro +15 -5
  59. package/src/runtime.ts +70 -1
  60. package/src/server/context.ts +112 -10
  61. package/src/server/middleware.ts +79 -21
  62. package/src/server/webauthn.ts +8 -8
  63. package/src/virtual.d.ts +1 -1
package/README.md CHANGED
@@ -48,6 +48,7 @@ export default defineConfig({
48
48
  sections: './src/sections/index.ts', // exports { schemas, presets }
49
49
  siteName: 'Acme site editor',
50
50
  interactiveSelector: 'summary, .tabs__title, .map__load', // preview elements that keep their own click
51
+ reservedSlugs: blogSlugs, // URLs the site uses some other way
51
52
  auth: 'passkey', // 'passkey' | 'none-behind-proxy' | 'none' (dev only)
52
53
  storage: process.env.EDITOR === '1' ? 'github' : 'local',
53
54
  github: { repo: 'acme/site', branch: 'main', pullRequest: true },
@@ -83,6 +84,25 @@ astro build # plain static site, no editor code
83
84
 
84
85
  A page is a tree of nodes. Top-level nodes are *sections*; anything can nest (`blocks`).
85
86
  `attrs` is a passthrough for element attributes the editor never shows (class, aria-*).
87
+ `"status": "draft"` keeps a page out of the build; without it the page is published.
88
+
89
+ To let editors add pages, render them from one catch-all route. A new JSON file is then a new page,
90
+ and drafts are left out:
91
+
92
+ ```astro
93
+ ---
94
+ // src/pages/[...page].astro
95
+ import { staticPages } from '@pirateandfox/pagestack/runtime';
96
+ import SectionPage from '../components/SectionPage.astro';
97
+ export const getStaticPaths = () =>
98
+ staticPages(import.meta.glob('../content/pages/**/*.json', { eager: true }));
99
+ const { page } = Astro.props;
100
+ ---
101
+ <SectionPage page={page} />
102
+ ```
103
+
104
+ A site with its own `.astro` file per page keeps working, but pages added in the editor have no
105
+ route until the catch-all exists.
86
106
 
87
107
  ### 2. A block registry: `src/sections/index.ts`
88
108
 
@@ -133,25 +153,89 @@ renders the page inside the site's normal layout.
133
153
 
134
154
  ## Editing
135
155
 
136
- - **Sidebar form** generated from the schema; live-debounced for colours and text.
137
- - **Inline**: click text to edit it in place (plain text, or bold / italic / link for rich text);
138
- click an image to open the picker (library browse, search, upload; alt text is required).
139
- - **Structure**: tree on the left (drag to reorder sections and nested blocks), in-page toolbar
140
- (move, duplicate, delete, select parent), "+ Add section" from presets, "+ Add block" from the
141
- schema's allowed children.
142
- - **Preview**: every change POSTs the draft document to `/edit/preview/<slug>`; the server renders
143
- the full page in edit mode and the editor swaps only the sections that changed. Typically well
144
- under 20 ms server-side in dev.
156
+ Coming from the WordPress block editor:
157
+
158
+ | WordPress | PageStack |
159
+ |---|---|
160
+ | Pages list | `/edit`: search, status, add, copy, delete |
161
+ | Block inserter, Patterns | `+` or `/`: searchable blocks by category, Patterns tab, `+` between sections |
162
+ | List View | Same, with drag to reorder and to move between containers |
163
+ | Block toolbar, ⋮ menu | Move, parent, add, duplicate, delete, copy, save as pattern |
164
+ | Settings sidebar, Page / Block tabs | Same, generated from the schema |
165
+ | Rich text toolbar, links | Same; `⌘B`, `⌘I`, `⌘K`, and an HTML view |
166
+ | Save draft, Publish, Preview | Same; Preview opens the unsaved draft in a new tab |
167
+ | Revisions | The page's git history |
168
+ | Permalink | Change URL, which also adds a redirect |
169
+ | Menus, site options | Global content, at `/edit/global/<id>` |
170
+ | Patterns, synced patterns | Saved patterns; synced ones update every copy in one commit |
171
+ | Block transforms | ⋮ → Turn into… |
172
+ | `/` in an empty block | Same |
173
+ | Posts | `/edit/collection/<id>`: details form, Markdown with live preview |
174
+ | Media Library | `/edit/media` |
175
+ | Users, roles | `/edit/users`: Admin and Editor |
176
+
177
+ - **Preview**: the page renders in a frame at real device widths, scaled to fit. Every change POSTs
178
+ the draft to `/edit/preview/<slug>`; the server renders it and only changed sections are swapped.
179
+ - **Inline**: click text to edit it in place; click an image to open the picker (alt text required).
145
180
  - **Drafts** are kept in `localStorage` until saved; undo/redo is per session.
146
- - **Save** validates the document and either writes the file (local) or commits (github). A hash of
147
- the content you loaded is sent along; if someone saved first you get a 409 and a reload prompt.
181
+ - **Save** sanitises rich text, validates the document, and writes the file (local) or commits
182
+ (github). A hash of what you loaded is sent along; if someone saved first you get a 409 and a
183
+ reload prompt.
184
+
185
+ Optional exports from the `sections` module:
186
+
187
+ ```ts
188
+ // Fields on the Page tab, stored in page.layout
189
+ export const pageFields = {
190
+ footer: { type: 'select', label: 'Footer', options: [{ value: 'big', label: 'Big' }, { value: 'small', label: 'Small' }] },
191
+ };
192
+
193
+ // Starting points offered by Add page
194
+ export const templates = [{ label: 'Standard page', create: ({ title }) => ({ title, sections: [/* … */] }) }];
195
+
196
+ // JSON files edited at /edit/global/<id>
197
+ // Markdown folders edited at /edit/collection/<id>
198
+ export const collections = {
199
+ blog: {
200
+ label: 'Posts', singular: 'post', dir: 'src/content/blog', url: (slug) => `/${slug}/`,
201
+ fields: { title: { type: 'text', label: 'Title' }, draft: { type: 'boolean', label: 'Draft' }, date: { type: 'date', label: 'Date' }, tags: { type: 'tags', label: 'Tags' } },
202
+ },
203
+ };
204
+
205
+ export const globals = {
206
+ menus: {
207
+ label: 'Menus',
208
+ file: 'src/data/menus.json',
209
+ fields: {
210
+ nav: { type: 'list', label: 'Main menu', itemLabel: 'label', fields: { label: { type: 'text', label: 'Label' } } },
211
+ },
212
+ },
213
+ };
214
+ ```
215
+
216
+ Blocks can set `category` and `keywords` for the inserter. A `list` field is a repeatable group of
217
+ `fields`, with `itemLabel` naming the field that titles each row.
218
+
219
+ ## Security
220
+
221
+ Everything an editor sends is treated as untrusted. On save, every field is cleaned by its type,
222
+ and anything outside the schema is cleaned as HTML, so components can render fields with
223
+ `set:html`. Only admins can put raw HTML in a `code` field. Uploads are checked by their bytes and
224
+ SVGs are sanitised. Set `EDITOR_ORIGIN` to the public https origin in production.
225
+
226
+ With `auth: "none-behind-proxy"`, set `EDITOR_PROXY_SECRET` if the app can be reached without going
227
+ through the proxy: requests must then carry the same value in `x-editor-proxy-secret`. Otherwise the
228
+ identity headers could be forged by anyone who reaches the app directly.
229
+
230
+ The login rate limit is held in memory per server instance, so on Workers it slows guessing rather
231
+ than setting a hard limit. Passkeys are what actually stops guessing.
148
232
 
149
233
  ## Storage
150
234
 
151
235
  | `storage` | Reads | Saves | Uploads |
152
236
  |---|---|---|---|
153
237
  | `local` (dev default) | working tree | writes the JSON file (you commit) | written to `public/images/uploads/` |
154
- | `github` (build default) | Contents API on `github.branch` | one commit via the Git Data API: blobs → tree → commit → ref; with `pullRequest: true` a new `site-editor/<timestamp>-<sha>` branch + PR instead | staged in memory, served by the editor server until the next save commits them |
238
+ | `github` (build default) | Contents API on `github.branch` | one commit via the Git Data API: blobs → tree → commit → ref; with `pullRequest: true` a new `site-editor/<timestamp>-<sha>` branch + PR instead | wait on a `pagestack-uploads` branch, shown to signed-in editors, until the next save brings them along |
155
239
 
156
240
  The GitHub token (`GITHUB_TOKEN` or `EDITOR_GITHUB_TOKEN`) is read from the environment on the
157
241
  server only. A fine-grained token with *Contents: write* (and *Pull requests: write* for PR mode)
@@ -196,6 +280,9 @@ Dev only. A build with `auth: "none"` is refused.
196
280
 
197
281
  ## Deploying the editor
198
282
 
283
+ Requires Node 22.23.4+ and Astro 7.2.1+. On Cloudflare, add `@astrojs/cloudflare` as the host's
284
+ adapter and set `EDITOR_SECRET`, `EDITOR_ORIGIN` and `GITHUB_TOKEN` as secrets.
285
+
199
286
  The editor server is a normal `@astrojs/node` standalone build. Run `node dist/server/entry.mjs`
200
287
  on any small Node host (Fly, Railway, a VPS…) with `EDITOR_SECRET`, `GITHUB_TOKEN` and
201
288
  `EDITOR_ORIGIN` set, behind HTTPS. It serves the editor routes and the static pages it was built
@@ -206,7 +293,8 @@ editor response is `noindex` and `no-store`.
206
293
 
207
294
  | option | default | |
208
295
  |---|---|---|
209
- | `renderer` | required | Astro component rendering `{ page, editing }` |
296
+ | `renderer` | required, unless `pages: false` | Astro component rendering `{ page, editing }` |
297
+ | `pages` | `true` | `false` for a site with no block pages: only collections, globals and media |
210
298
  | `sections` | required | module exporting `{ schemas, presets }` |
211
299
  | `pagesDir` | `src/content/pages` | |
212
300
  | `imagesDir` / `uploadsDir` / `publicDir` | `public/images` / `public/images/uploads` / `public` | |
@@ -216,6 +304,10 @@ editor response is `noindex` and `no-store`.
216
304
  | `github` | — | `{ repo, branch, pullRequest }` |
217
305
  | `enabled` | dev, or `EDITOR=1` | |
218
306
  | `siteName`, `secretEnv` | `Site editor`, `EDITOR_SECRET` | |
307
+ | `reservedSlugs` | `[]` | URLs Add page must refuse, on top of the editor's own and `src/pages` |
308
+ | `redirectsFile` | `public/_redirects` | Change URL appends here, only if the file exists |
309
+ | `patternsFile` | `src/content/pagestack-patterns.json` | where saved patterns live |
310
+ | `clientIpHeader` | none | header with the client IP behind a proxy, for login rate limits, e.g. `cf-connecting-ip` |
219
311
 
220
312
  ## Open-source pieces used
221
313
 
package/bin/invite.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Create the first invite link without a running editor (later invites can be made from /edit).
2
+ // Create the first invite link without a running editor (later invites are made at /edit/users; CLI invites create admins).
3
3
  //
4
4
  // EDITOR_SECRET=… node packages/pagestack/bin/invite.mjs --name "Jane Doe" --email jane@example.com --origin https://example.com [--days 3]
5
5
  //
package/index.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // when EDITOR=1 (which produces a Node server build with @astrojs/node).
9
9
  import node from '@astrojs/node';
10
10
  import { fileURLToPath } from 'node:url';
11
+ import fs from 'node:fs';
11
12
  import path from 'node:path';
12
13
 
13
14
  const VIRTUAL = 'virtual:pagestack/site';
@@ -17,7 +18,8 @@ const route = (file) => new URL(`./src/routes/${file}`, import.meta.url);
17
18
 
18
19
  /**
19
20
  * @typedef {object} SectionEditorOptions
20
- * @property {string} renderer Astro component rendering a page: props { page, editing }.
21
+ * @property {string} [renderer] Astro component rendering a page: props { page, editing }. Required unless `pages: false`.
22
+ * @property {boolean} [pages] false for a site with no block pages: only collections, globals and media.
21
23
  * @property {string} sections Module exporting { schemas, presets } (see runtime.ts types).
22
24
  * @property {string} [pagesDir='src/content/pages'] Directory of <slug>.json pages.
23
25
  * @property {string} [imagesDir='public/images'] Browsable image library root.
@@ -33,6 +35,11 @@ const route = (file) => new URL(`./src/routes/${file}`, import.meta.url);
33
35
  * behaviour (accordion summaries, tab titles, load-map buttons…). Everything else is
34
36
  * intercepted so links don't navigate while editing.
35
37
  * @property {string} [secretEnv='EDITOR_SECRET'] Name of the env var holding the signing secret.
38
+ * @property {string[]} [reservedSlugs] Top-level URLs new pages may not take, beyond the host's own files in
39
+ * src/pages, which are found automatically. For dynamic routes such as blog posts.
40
+ * @property {string} [redirectsFile='public/_redirects'] When this file exists, moving a page appends a 301 to it.
41
+ * @property {string} [clientIpHeader] Header holding the client IP behind a proxy, for login rate limits (e.g. cf-connecting-ip).
42
+ * @property {string} [patternsFile='src/content/pagestack-patterns.json'] Where saved patterns are kept.
36
43
  *
37
44
  * Runtime environment (never in the repo):
38
45
  * EDITOR_SECRET >= 16 chars; signs sessions, WebAuthn challenges and invite links.
@@ -41,9 +48,19 @@ const route = (file) => new URL(`./src/routes/${file}`, import.meta.url);
41
48
  * GITHUB_TOKEN Fine-grained token with contents (+ pull requests) on the repo, github storage only.
42
49
  */
43
50
 
51
+ /** Top-level URLs taken by the host's own route files (dynamic ones can't be known here). */
52
+ function staticRoutes(srcDir) {
53
+ try {
54
+ return fs.readdirSync(path.join(srcDir, 'pages'), { withFileTypes: true })
55
+ .filter((e) => !/^[[_.]/.test(e.name) && e.name !== 'index.astro' && (e.isDirectory() || /\.(astro|md|mdx|html|js|ts)$/.test(e.name)))
56
+ .map((e) => e.name.replace(/\.[^.]+$/, ''));
57
+ } catch { return []; }
58
+ }
59
+
44
60
  /** @param {SectionEditorOptions} userOptions */
45
61
  export default function sectionEditor(userOptions) {
46
- if (!userOptions?.renderer || !userOptions?.sections) throw new Error('pagestack: `renderer` and `sections` are required');
62
+ if (!userOptions?.sections) throw new Error('pagestack: `sections` is required');
63
+ if (!userOptions.renderer && userOptions.pages !== false) throw new Error('pagestack: `renderer` is required unless `pages: false`');
47
64
  // When the site has no adapter of its own we bring @astrojs/node along and forward every
48
65
  // integration hook to it (an integration added via updateConfig() doesn't get to call
49
66
  // setAdapter() in time, so we proxy its hooks instead).
@@ -70,6 +87,8 @@ export default function sectionEditor(userOptions) {
70
87
  siteName: 'Site editor',
71
88
  secretEnv: 'EDITOR_SECRET',
72
89
  interactiveSelector: 'summary',
90
+ redirectsFile: 'public/_redirects',
91
+ patternsFile: 'src/content/pagestack-patterns.json',
73
92
  ...userOptions,
74
93
  github: userOptions.github ?? null,
75
94
  };
@@ -81,7 +100,8 @@ export default function sectionEditor(userOptions) {
81
100
  }
82
101
  const root = fileURLToPath(config.root);
83
102
  const abs = (p) => path.isAbsolute(p) ? p : path.resolve(root, p);
84
- const serial = { ...options, root, renderer: abs(options.renderer), sections: abs(options.sections), command };
103
+ const reservedSlugs = [...new Set([...(options.reservedSlugs ?? []), ...staticRoutes(fileURLToPath(config.srcDir))])];
104
+ const serial = { ...options, reservedSlugs, root, renderer: options.renderer ? abs(options.renderer) : null, sections: abs(options.sections), command };
85
105
 
86
106
  updateConfig({
87
107
  vite: {
@@ -91,7 +111,7 @@ export default function sectionEditor(userOptions) {
91
111
  load(id) {
92
112
  // The renderer (and the site layout/CSS behind it) is only imported by the preview
93
113
  // route, so the editor shell doesn't drag the whole site's CSS into its page.
94
- if (id === '\0' + VIRTUAL_RENDERER) return `export { default as Renderer } from ${JSON.stringify(serial.renderer)};`;
114
+ if (id === '\0' + VIRTUAL_RENDERER) return serial.renderer ? `export { default as Renderer } from ${JSON.stringify(serial.renderer)};` : 'export const Renderer = null;';
95
115
  if (id !== '\0' + VIRTUAL) return;
96
116
  return [
97
117
  `export * as site from ${JSON.stringify(serial.sections)};`,
@@ -102,7 +122,7 @@ export default function sectionEditor(userOptions) {
102
122
  // The package is consumed from source (.ts/.astro); make sure Vite transforms it.
103
123
  ssr: { noExternal: ['@pirateandfox/pagestack'] },
104
124
  // Pre-bundle the editor's browser deps so dev doesn't re-optimise (and reload) mid-session.
105
- optimizeDeps: { include: ['sortablejs', '@simplewebauthn/browser'] },
125
+ optimizeDeps: { include: ['sortablejs', '@simplewebauthn/browser', 'marked'] },
106
126
  },
107
127
  });
108
128
  if (!config.adapter) {
@@ -119,10 +139,19 @@ export default function sectionEditor(userOptions) {
119
139
  injectRoute({ pattern: '/edit/login', entrypoint: route('login.astro'), prerender: false });
120
140
  injectRoute({ pattern: '/edit/enroll', entrypoint: route('enroll.astro'), prerender: false });
121
141
  injectRoute({ pattern: '/edit/preview/[...slug]', entrypoint: route('preview.astro'), prerender: false });
142
+ injectRoute({ pattern: '/edit/global/[id]', entrypoint: route('edit-global.astro'), prerender: false });
143
+ injectRoute({ pattern: '/edit/collection/[id]', entrypoint: route('edit-collection.astro'), prerender: false });
144
+ injectRoute({ pattern: '/edit/collection/[id]/[...slug]', entrypoint: route('edit-item.astro'), prerender: false });
145
+ injectRoute({ pattern: '/edit/media', entrypoint: route('edit-media.astro'), prerender: false });
146
+ injectRoute({ pattern: '/edit/users', entrypoint: route('edit-users.astro'), prerender: false });
122
147
  injectRoute({ pattern: '/edit/[...slug]', entrypoint: route('edit-page.astro'), prerender: false });
123
148
  injectRoute({ pattern: '/api/editor/pages', entrypoint: route('api/pages.ts'), prerender: false });
124
149
  injectRoute({ pattern: '/api/editor/page/[...slug]', entrypoint: route('api/page.ts'), prerender: false });
150
+ injectRoute({ pattern: '/api/editor/patterns', entrypoint: route('api/patterns.ts'), prerender: false });
151
+ injectRoute({ pattern: '/api/editor/global/[id]', entrypoint: route('api/global.ts'), prerender: false });
125
152
  injectRoute({ pattern: '/api/editor/images', entrypoint: route('api/images.ts'), prerender: false });
153
+ injectRoute({ pattern: '/api/editor/collection/[id]/[...slug]', entrypoint: route('api/collection.ts'), prerender: false });
154
+ injectRoute({ pattern: '/api/editor/users', entrypoint: route('api/users.ts'), prerender: false });
126
155
  injectRoute({ pattern: '/api/editor/asset/[...path]', entrypoint: route('api/asset.ts'), prerender: false });
127
156
  injectRoute({ pattern: '/api/editor/auth/[action]', entrypoint: route('api/auth.ts'), prerender: false });
128
157
  logger.info(`enabled — storage: ${options.storage}, auth: ${options.auth}. Open /edit`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pirateandfox/pagestack",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.6",
4
4
  "description": "PageStack: visual section editor for Astro sites. Git-only, static public site, passkey login, saves are commits.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,13 +21,15 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "peerDependencies": {
24
- "astro": ">=5.0.0"
24
+ "astro": ">=7.2.1"
25
25
  },
26
26
  "dependencies": {
27
- "@astrojs/node": "10.1.1",
27
+ "@astrojs/node": "11.1.11",
28
28
  "@simplewebauthn/browser": "^13.0.0",
29
29
  "@simplewebauthn/server": "^13.0.0",
30
- "sortablejs": "^1.15.7"
30
+ "marked": "^18.1.3",
31
+ "sortablejs": "^1.15.7",
32
+ "yaml": "^2.9.1"
31
33
  },
32
34
  "devDependencies": {
33
35
  "@types/sortablejs": "^1.15.8",
@@ -50,5 +52,8 @@
50
52
  "homepage": "https://github.com/pirateandfox/pagestack#readme",
51
53
  "publishConfig": {
52
54
  "access": "public"
55
+ },
56
+ "engines": {
57
+ "node": ">=22.23.4"
53
58
  }
54
59
  }
@@ -0,0 +1,37 @@
1
+ // The list of a collection: add, delete, search.
2
+ import { slugify, uniqueSlug } from '../core/pages';
3
+ import { confirmDialog, promptDialog, toast } from './ui';
4
+
5
+ const boot = (window as any).__ASE_COLLECTION__ as { id: string; singular: string; slugs: string[]; reserved: string[] };
6
+ const api = `/api/editor/collection/${boot.id}`;
7
+
8
+ document.getElementById('ase-add-item')?.addEventListener('click', async () => {
9
+ const title = await promptDialog({ title: `Add ${boot.singular}`, label: 'Title', submit: `Add ${boot.singular}` });
10
+ if (!title) return;
11
+ const slug = uniqueSlug(slugify(title) || boot.singular, [...boot.slugs, ...boot.reserved]);
12
+ const res = await fetch(api, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title, slug }) });
13
+ const data = await res.json();
14
+ if (!res.ok) { toast(data.error || 'That did not work.', 'error'); return; }
15
+ location.href = `/edit/collection/${boot.id}/${data.slug}`;
16
+ });
17
+
18
+ document.getElementById('ase-pagelist')!.addEventListener('click', async (e) => {
19
+ const b = (e.target as HTMLElement).closest<HTMLElement>('[data-delete]');
20
+ if (!b) return;
21
+ const row = b.closest('li')!;
22
+ const name = row.querySelector('strong')!.textContent;
23
+ if (!await confirmDialog(`Delete ${boot.singular}`, `Delete "${name}"? It stays in the site's git history, so it can be recovered by a developer.`, 'Delete', 'danger')) return;
24
+ const res = await fetch(`${api}/${b.dataset.delete}`, { method: 'DELETE' });
25
+ const data = await res.json();
26
+ if (!res.ok) { toast(data.error || 'Delete failed', 'error'); return; }
27
+ row.remove();
28
+ toast(`Deleted ${name}.`, 'ok');
29
+ });
30
+
31
+ const search = document.getElementById('ase-page-search') as HTMLInputElement;
32
+ search.addEventListener('input', () => {
33
+ const q = search.value.trim().toLowerCase();
34
+ let shown = 0;
35
+ document.querySelectorAll<HTMLElement>('#ase-pagelist > li').forEach((li) => { const on = li.dataset.search!.includes(q); li.hidden = !on; if (on) shown++; });
36
+ document.getElementById('ase-no-match')!.hidden = shown > 0;
37
+ });