@pirateandfox/pagestack 0.1.0-beta.4 → 0.1.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -12
- package/bin/invite.mjs +1 -1
- package/index.mjs +29 -2
- package/package.json +9 -4
- package/src/client/collection.ts +37 -0
- package/src/client/editor.ts +769 -182
- package/src/client/form.ts +84 -12
- package/src/client/global.ts +33 -0
- package/src/client/icons.ts +27 -0
- package/src/client/inline.ts +29 -35
- package/src/client/inserter.ts +59 -0
- package/src/client/invite.ts +1 -1
- package/src/client/item.ts +107 -0
- package/src/client/media.ts +81 -0
- package/src/client/pages.ts +89 -0
- package/src/client/picker.ts +24 -10
- package/src/client/preview.css +58 -16
- package/src/client/rich.ts +232 -0
- package/src/client/shell.css +200 -20
- package/src/client/state.ts +3 -1
- package/src/client/tree.ts +32 -9
- package/src/client/ui.ts +122 -0
- package/src/client/upload.ts +25 -0
- package/src/client/users.ts +40 -0
- package/src/core/auth.ts +8 -2
- package/src/core/frontmatter.ts +29 -0
- package/src/core/insert.ts +70 -0
- package/src/core/media.ts +37 -0
- package/src/core/pages.ts +52 -0
- package/src/core/patch.ts +2 -1
- package/src/core/ratelimit.ts +13 -0
- package/src/core/sanitize.ts +268 -0
- package/src/core/schema.ts +10 -1
- package/src/core/storage-github.ts +57 -21
- package/src/core/storage-local.ts +22 -5
- package/src/core/storage.ts +19 -3
- package/src/core/synced.ts +19 -0
- package/src/core/transform.ts +66 -0
- package/src/core/users.ts +34 -2
- package/src/routes/Nav.astro +22 -0
- package/src/routes/api/asset.ts +1 -1
- package/src/routes/api/auth.ts +7 -6
- package/src/routes/api/collection.ts +85 -0
- package/src/routes/api/global.ts +33 -0
- package/src/routes/api/images.ts +32 -16
- package/src/routes/api/page.ts +57 -14
- package/src/routes/api/pages.ts +38 -1
- package/src/routes/api/patterns.ts +68 -0
- package/src/routes/api/users.ts +46 -0
- package/src/routes/edit-collection.astro +47 -0
- package/src/routes/edit-global.astro +39 -0
- package/src/routes/edit-index.astro +36 -24
- package/src/routes/edit-item.astro +57 -0
- package/src/routes/edit-media.astro +30 -0
- package/src/routes/edit-page.astro +29 -19
- package/src/routes/edit-users.astro +43 -0
- package/src/routes/login.astro +2 -3
- package/src/routes/preview.astro +14 -5
- package/src/runtime.ts +68 -1
- package/src/server/context.ts +111 -9
- package/src/server/middleware.ts +78 -21
- package/src/server/webauthn.ts +8 -8
- 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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
147
|
-
|
|
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 |
|
|
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
|
|
@@ -216,6 +303,10 @@ editor response is `noindex` and `no-store`.
|
|
|
216
303
|
| `github` | — | `{ repo, branch, pullRequest }` |
|
|
217
304
|
| `enabled` | dev, or `EDITOR=1` | |
|
|
218
305
|
| `siteName`, `secretEnv` | `Site editor`, `EDITOR_SECRET` | |
|
|
306
|
+
| `reservedSlugs` | `[]` | URLs Add page must refuse, on top of the editor's own and `src/pages` |
|
|
307
|
+
| `redirectsFile` | `public/_redirects` | Change URL appends here, only if the file exists |
|
|
308
|
+
| `patternsFile` | `src/content/pagestack-patterns.json` | where saved patterns live |
|
|
309
|
+
| `clientIpHeader` | none | header with the client IP behind a proxy, for login rate limits, e.g. `cf-connecting-ip` |
|
|
219
310
|
|
|
220
311
|
## Open-source pieces used
|
|
221
312
|
|
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
|
|
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';
|
|
@@ -33,6 +34,11 @@ const route = (file) => new URL(`./src/routes/${file}`, import.meta.url);
|
|
|
33
34
|
* behaviour (accordion summaries, tab titles, load-map buttons…). Everything else is
|
|
34
35
|
* intercepted so links don't navigate while editing.
|
|
35
36
|
* @property {string} [secretEnv='EDITOR_SECRET'] Name of the env var holding the signing secret.
|
|
37
|
+
* @property {string[]} [reservedSlugs] Top-level URLs new pages may not take, beyond the host's own files in
|
|
38
|
+
* src/pages, which are found automatically. For dynamic routes such as blog posts.
|
|
39
|
+
* @property {string} [redirectsFile='public/_redirects'] When this file exists, moving a page appends a 301 to it.
|
|
40
|
+
* @property {string} [clientIpHeader] Header holding the client IP behind a proxy, for login rate limits (e.g. cf-connecting-ip).
|
|
41
|
+
* @property {string} [patternsFile='src/content/pagestack-patterns.json'] Where saved patterns are kept.
|
|
36
42
|
*
|
|
37
43
|
* Runtime environment (never in the repo):
|
|
38
44
|
* EDITOR_SECRET >= 16 chars; signs sessions, WebAuthn challenges and invite links.
|
|
@@ -41,6 +47,15 @@ const route = (file) => new URL(`./src/routes/${file}`, import.meta.url);
|
|
|
41
47
|
* GITHUB_TOKEN Fine-grained token with contents (+ pull requests) on the repo, github storage only.
|
|
42
48
|
*/
|
|
43
49
|
|
|
50
|
+
/** Top-level URLs taken by the host's own route files (dynamic ones can't be known here). */
|
|
51
|
+
function staticRoutes(srcDir) {
|
|
52
|
+
try {
|
|
53
|
+
return fs.readdirSync(path.join(srcDir, 'pages'), { withFileTypes: true })
|
|
54
|
+
.filter((e) => !/^[[_.]/.test(e.name) && e.name !== 'index.astro' && (e.isDirectory() || /\.(astro|md|mdx|html|js|ts)$/.test(e.name)))
|
|
55
|
+
.map((e) => e.name.replace(/\.[^.]+$/, ''));
|
|
56
|
+
} catch { return []; }
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
/** @param {SectionEditorOptions} userOptions */
|
|
45
60
|
export default function sectionEditor(userOptions) {
|
|
46
61
|
if (!userOptions?.renderer || !userOptions?.sections) throw new Error('pagestack: `renderer` and `sections` are required');
|
|
@@ -70,6 +85,8 @@ export default function sectionEditor(userOptions) {
|
|
|
70
85
|
siteName: 'Site editor',
|
|
71
86
|
secretEnv: 'EDITOR_SECRET',
|
|
72
87
|
interactiveSelector: 'summary',
|
|
88
|
+
redirectsFile: 'public/_redirects',
|
|
89
|
+
patternsFile: 'src/content/pagestack-patterns.json',
|
|
73
90
|
...userOptions,
|
|
74
91
|
github: userOptions.github ?? null,
|
|
75
92
|
};
|
|
@@ -81,7 +98,8 @@ export default function sectionEditor(userOptions) {
|
|
|
81
98
|
}
|
|
82
99
|
const root = fileURLToPath(config.root);
|
|
83
100
|
const abs = (p) => path.isAbsolute(p) ? p : path.resolve(root, p);
|
|
84
|
-
const
|
|
101
|
+
const reservedSlugs = [...new Set([...(options.reservedSlugs ?? []), ...staticRoutes(fileURLToPath(config.srcDir))])];
|
|
102
|
+
const serial = { ...options, reservedSlugs, root, renderer: abs(options.renderer), sections: abs(options.sections), command };
|
|
85
103
|
|
|
86
104
|
updateConfig({
|
|
87
105
|
vite: {
|
|
@@ -102,7 +120,7 @@ export default function sectionEditor(userOptions) {
|
|
|
102
120
|
// The package is consumed from source (.ts/.astro); make sure Vite transforms it.
|
|
103
121
|
ssr: { noExternal: ['@pirateandfox/pagestack'] },
|
|
104
122
|
// Pre-bundle the editor's browser deps so dev doesn't re-optimise (and reload) mid-session.
|
|
105
|
-
optimizeDeps: { include: ['sortablejs', '@simplewebauthn/browser'] },
|
|
123
|
+
optimizeDeps: { include: ['sortablejs', '@simplewebauthn/browser', 'marked'] },
|
|
106
124
|
},
|
|
107
125
|
});
|
|
108
126
|
if (!config.adapter) {
|
|
@@ -119,10 +137,19 @@ export default function sectionEditor(userOptions) {
|
|
|
119
137
|
injectRoute({ pattern: '/edit/login', entrypoint: route('login.astro'), prerender: false });
|
|
120
138
|
injectRoute({ pattern: '/edit/enroll', entrypoint: route('enroll.astro'), prerender: false });
|
|
121
139
|
injectRoute({ pattern: '/edit/preview/[...slug]', entrypoint: route('preview.astro'), prerender: false });
|
|
140
|
+
injectRoute({ pattern: '/edit/global/[id]', entrypoint: route('edit-global.astro'), prerender: false });
|
|
141
|
+
injectRoute({ pattern: '/edit/collection/[id]', entrypoint: route('edit-collection.astro'), prerender: false });
|
|
142
|
+
injectRoute({ pattern: '/edit/collection/[id]/[...slug]', entrypoint: route('edit-item.astro'), prerender: false });
|
|
143
|
+
injectRoute({ pattern: '/edit/media', entrypoint: route('edit-media.astro'), prerender: false });
|
|
144
|
+
injectRoute({ pattern: '/edit/users', entrypoint: route('edit-users.astro'), prerender: false });
|
|
122
145
|
injectRoute({ pattern: '/edit/[...slug]', entrypoint: route('edit-page.astro'), prerender: false });
|
|
123
146
|
injectRoute({ pattern: '/api/editor/pages', entrypoint: route('api/pages.ts'), prerender: false });
|
|
124
147
|
injectRoute({ pattern: '/api/editor/page/[...slug]', entrypoint: route('api/page.ts'), prerender: false });
|
|
148
|
+
injectRoute({ pattern: '/api/editor/patterns', entrypoint: route('api/patterns.ts'), prerender: false });
|
|
149
|
+
injectRoute({ pattern: '/api/editor/global/[id]', entrypoint: route('api/global.ts'), prerender: false });
|
|
125
150
|
injectRoute({ pattern: '/api/editor/images', entrypoint: route('api/images.ts'), prerender: false });
|
|
151
|
+
injectRoute({ pattern: '/api/editor/collection/[id]/[...slug]', entrypoint: route('api/collection.ts'), prerender: false });
|
|
152
|
+
injectRoute({ pattern: '/api/editor/users', entrypoint: route('api/users.ts'), prerender: false });
|
|
126
153
|
injectRoute({ pattern: '/api/editor/asset/[...path]', entrypoint: route('api/asset.ts'), prerender: false });
|
|
127
154
|
injectRoute({ pattern: '/api/editor/auth/[action]', entrypoint: route('api/auth.ts'), prerender: false });
|
|
128
155
|
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.
|
|
3
|
+
"version": "0.1.0-beta.5",
|
|
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": ">=
|
|
24
|
+
"astro": ">=7.2.1"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@astrojs/node": "
|
|
27
|
+
"@astrojs/node": "11.1.11",
|
|
28
28
|
"@simplewebauthn/browser": "^13.0.0",
|
|
29
29
|
"@simplewebauthn/server": "^13.0.0",
|
|
30
|
-
"
|
|
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
|
+
});
|