@michaelthielemann/kestrel 1.6.0 → 2.0.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/README.md +8 -4
- package/layers/access/server/utils/grant-registry.ts +1 -1
- package/layers/access/server/utils/ip-allowlist.ts +4 -1
- package/layers/admin/app/components/BlocksBody.vue +2 -1
- package/layers/admin/app/components/CollectionEditor.vue +124 -22
- package/layers/admin/app/components/CollectionList.vue +8 -4
- package/layers/admin/app/components/EditorStatus.vue +11 -0
- package/layers/admin/app/components/SeoFields.vue +1 -0
- package/layers/admin/app/components/SingletonEditor.vue +6 -6
- package/layers/admin/app/composables/useCollectionOps.ts +15 -3
- package/layers/admin/app/composables/useEditForm.ts +6 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +9 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
- package/layers/admin/app/utils/editor-expose.ts +8 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +6 -3
- package/layers/collections/server/utils/populate-relations.ts +18 -5
- package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
- package/layers/core/modules/kestrel/index.ts +1 -0
- package/layers/core/server/api/[collection]/[id].get.ts +4 -1
- package/layers/core/server/api/[collection]/index.get.ts +7 -2
- package/layers/core/server/api/[collection]/populate-scope.test.ts +119 -0
- package/layers/core/server/api/references/broken.get.ts +7 -1
- package/layers/core/server/schema/dialect.ts +5 -3
- package/layers/core/server/schema/introspect.ts +4 -3
- package/layers/core/server/schema/sync.ts +1 -1
- package/layers/core/server/utils/crud.ts +11 -10
- package/layers/core/server/utils/kestrel-config.ts +10 -3
- package/layers/core/server/utils/populate.ts +3 -1
- package/layers/fields/server/field-registry/index.ts +2 -1
- package/layers/fields/server/field-registry/sanitize.ts +6 -3
- package/layers/media/app/components/MediaLibrary.vue +4 -1
- package/layers/media/app/components/MediaToolbar.vue +1 -1
- package/layers/media/app/components/field/Media.vue +2 -0
- package/layers/media/app/composables/useMediaLibrary.ts +2 -1
- package/layers/media/server/utils/backfill.ts +24 -12
- package/layers/media/server/utils/variants.ts +27 -12
- package/layers/public/app/pages/[...slug].vue +35 -7
- package/layers/public/app/pages/__kestrel/preview.vue +15 -3
- package/layers/public/app/utils/preview-protocol.ts +36 -0
- package/layers/public/server/api/preview.get.ts +28 -0
- package/layers/public/server/api/preview.post.ts +93 -0
- package/layers/public/server/api/publish-status.get.ts +23 -9
- package/layers/public/server/api/publish.post.ts +84 -0
- package/layers/public/server/api/route.get.ts +26 -6
- package/layers/public/server/plugins/02.register-links.ts +4 -4
- package/layers/public/server/plugins/zz.publish.ts +13 -4
- package/layers/public/server/routes/llms.txt.get.ts +5 -2
- package/layers/public/server/routes/sitemap.xml.get.ts +5 -2
- package/layers/public/server/tasks/publish/run.ts +2 -1
- package/layers/public/server/utils/link-resolve.ts +5 -2
- package/layers/public/server/utils/page-resolve.ts +23 -9
- package/layers/public/server/utils/preview-token.ts +109 -0
- package/layers/public/server/utils/publish/invalidation.ts +43 -4
- package/layers/public/server/utils/publish/pending.ts +74 -0
- package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
- package/layers/public/server/utils/publish/publish-status.ts +18 -0
- package/layers/public/server/utils/publish/publisher.ts +74 -9
- package/layers/ui/app/components/field/Datetime.vue +2 -0
- package/layers/ui/app/components/field/Repeater.vue +2 -0
- package/layers/ui/app/components/ui/Checkbox.vue +1 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
- package/layers/ui/app/components/ui/Combobox.vue +2 -0
- package/layers/ui/app/components/ui/Field.vue +1 -0
- package/layers/ui/app/components/ui/Fieldset.vue +2 -1
- package/layers/ui/app/components/ui/Icon.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +2 -0
- package/layers/ui/app/components/ui/Richtext.vue +25 -3
- package/layers/ui/app/components/ui/Select.vue +1 -0
- package/layers/ui/app/components/ui/TextInput.vue +1 -0
- package/layers/ui/app/components/ui/Textarea.vue +1 -0
- package/layers/ui/app/components/ui/TimeInput.vue +1 -0
- package/layers/ui/app/i18n/de.ts +10 -0
- package/layers/ui/app/i18n/en.ts +10 -0
- package/package.json +9 -4
- package/scripts/kestrel.mjs +48 -19
- package/scripts/lib/scaffold.mjs +39 -8
- package/templates/starter/app/blocks/Prose.vue +1 -0
package/README.md
CHANGED
|
@@ -167,6 +167,7 @@ pnpm generate # render the static public site to .output/public
|
|
|
167
167
|
pnpm test # node + happy-dom unit tests
|
|
168
168
|
pnpm test:nuxt # Nuxt-environment component tests
|
|
169
169
|
pnpm test:e2e # end-to-end tests (real dev server)
|
|
170
|
+
pnpm lint # ESLint (Nuxt-aware, generated from the playground)
|
|
170
171
|
|
|
171
172
|
pnpm db:generate # drizzle-kit: generate a migration
|
|
172
173
|
pnpm db:migrate # drizzle-kit: apply migrations
|
|
@@ -186,14 +187,17 @@ Two ways to produce the static output:
|
|
|
186
187
|
|
|
187
188
|
- **One-shot:** `pnpm generate` → `.output/public` (the classic full rebuild).
|
|
188
189
|
- **Runtime publisher (default):** a **production** run (`pnpm build && pnpm preview`, or
|
|
189
|
-
`node .output/server/index.mjs`) publishes on boot and incrementally re-publishes the affected pages
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
`node .output/server/index.mjs`) publishes on boot and incrementally re-publishes the affected pages when
|
|
191
|
+
you press **Publish** in the editor, into `output.dir` (default `.data/published`). Saving is a DB write
|
|
192
|
+
and leaves the live page alone (unpublishing and deleting still take a page down at once) — see
|
|
193
|
+
[ADR-0008](docs/architecture-decisions.md), or set `output.publishOnSave: true` for the pre-2.0 behaviour
|
|
194
|
+
where every save republished. Serve that dir with any static server, e.g. `npx serve .data/published`.
|
|
192
195
|
|
|
193
196
|
The runtime publisher is **intentionally disabled in `pnpm dev`** (a dev render would write un-hashed Vite
|
|
194
197
|
HTML), so the static files only appear on a production run. In dev you instead get the **live preview**:
|
|
195
198
|
public pages render straight from the running server, and an authenticated admin can open an unpublished
|
|
196
|
-
page at its real URL (the "open in new tab" button in the editor)
|
|
199
|
+
page at its real URL (the "open in new tab" button in the editor) — with unsaved changes that button
|
|
200
|
+
carries them along in a preview ticket rather than saving them. See
|
|
197
201
|
[static-output.md](docs/static-output.md) for the full picture.
|
|
198
202
|
|
|
199
203
|
### Simulate a production deploy locally
|
|
@@ -32,5 +32,5 @@ export function registeredGrants(): Readonly<Partial<Record<Role, Grant[]>>> {
|
|
|
32
32
|
|
|
33
33
|
/** Test helper: drop all registered grants (the registry is a module singleton). */
|
|
34
34
|
export function clearAccessGrants(): void {
|
|
35
|
-
for (const role of Object.keys(registered) as Role[])
|
|
35
|
+
for (const role of Object.keys(registered) as Role[]) Reflect.deleteProperty(registered, role)
|
|
36
36
|
}
|
|
@@ -42,7 +42,10 @@ function parseCidr(token: string): Cidr | null {
|
|
|
42
42
|
const slash = token.indexOf('/')
|
|
43
43
|
const base = ipv4ToInt(slash === -1 ? token : token.slice(0, slash))
|
|
44
44
|
if (base === null) return null
|
|
45
|
-
|
|
45
|
+
// `Number()` alone would widen a malformed prefix into a mask: '' → 0 → 0.0.0.0/0, and '0x10'/'1e1' → 16/10.
|
|
46
|
+
const bitsRaw = slash === -1 ? '32' : token.slice(slash + 1).trim()
|
|
47
|
+
if (!/^\d+$/.test(bitsRaw)) return null
|
|
48
|
+
const bits = Number(bitsRaw)
|
|
46
49
|
if (!Number.isInteger(bits) || bits < 0 || bits > 32) return null
|
|
47
50
|
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
|
|
48
51
|
return { base: (base & mask) >>> 0, mask }
|
|
@@ -56,7 +56,8 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
56
56
|
const el = e.target as HTMLElement | null
|
|
57
57
|
if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return
|
|
58
58
|
e.preventDefault()
|
|
59
|
-
|
|
59
|
+
if (isRedo) ctx.redo()
|
|
60
|
+
else ctx.undo()
|
|
60
61
|
}
|
|
61
62
|
onMounted(() => window.addEventListener('keydown', onKeydown))
|
|
62
63
|
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { localePath } from '../../../core/app/utils/locale-path'
|
|
3
|
+
import { PREVIEW_TOKEN_QUERY, PREVIEW_FALLBACK_PATH } from '../../../public/app/utils/preview-protocol'
|
|
3
4
|
import { resolveCollectionEditor } from '../utils/editor-registry'
|
|
4
5
|
import { editorFormContextKey } from '../utils/editor-form-context'
|
|
5
6
|
import '../utils/register-builtin-editors'
|
|
@@ -17,6 +18,13 @@ const saved = ref(false)
|
|
|
17
18
|
const { t } = useT()
|
|
18
19
|
const toast = useToast()
|
|
19
20
|
const f = useEditForm({ collection: props.collection, id: props.id, locale: props.localeParam, group: props.group })
|
|
21
|
+
// `useEditForm` returns its refs/computeds synchronously (only its internal `init()` is async), so this
|
|
22
|
+
// destructure is safe to read before `f.ready` resolves — pulled up here (rather than after the await
|
|
23
|
+
// below, where it originally lived) so `defineExpose` can also run before that same await.
|
|
24
|
+
const {
|
|
25
|
+
formError, saving, submit, dirty, editorType, pageLike, hasStatus,
|
|
26
|
+
savedStatus, undo, redo, canUndo, canRedo,
|
|
27
|
+
} = f
|
|
20
28
|
|
|
21
29
|
// Editor → field-widget context. MUST run BEFORE the top-level `await` below — after an await the setup
|
|
22
30
|
// instance context is gone and `provide` silently no-ops. `id`/`saved` let a widget clean up an abandoned
|
|
@@ -71,17 +79,6 @@ provide(editorFormContextKey, {
|
|
|
71
79
|
registerRevealError: (fn: () => void) => { revealError = fn },
|
|
72
80
|
})
|
|
73
81
|
|
|
74
|
-
await f.ready
|
|
75
|
-
|
|
76
|
-
const {
|
|
77
|
-
formError, saving, submit, dirty, editorType, pageLike, hasStatus,
|
|
78
|
-
savedStatus, undo, redo, canUndo, canRedo,
|
|
79
|
-
} = f
|
|
80
|
-
|
|
81
|
-
// Which editor body renders (fields · blocks · an extension type). Resolved from the registry; an
|
|
82
|
-
// unregistered type falls back to the visible EditorUnsupported panel.
|
|
83
|
-
const bodyComponent = computed(() => resolveCollectionEditor(editorType.value))
|
|
84
|
-
|
|
85
82
|
// Right dot of the editor Ampel: the live / generated state of THIS record's static page. Only a saved
|
|
86
83
|
// pageLike record has one (a non-pageLike collection produces no static page; an unsaved `new` has no row).
|
|
87
84
|
// Refreshed on mount and after each save (a save may (re)publish the page).
|
|
@@ -94,28 +91,133 @@ const liveStatus = usePublishStatus({
|
|
|
94
91
|
// Carries "poll the live status on mount" across the create→navigate remount (see usePendingPublishPoll).
|
|
95
92
|
const pendingPoll = usePendingPublishPoll()
|
|
96
93
|
|
|
94
|
+
// ---- publish + external preview -------------------------------------------------------------------
|
|
95
|
+
// Saving persists to the DB and leaves the live site alone; publishing writes the static file(s). The two
|
|
96
|
+
// are separate buttons because they are separate decisions (ADR-0008) — you can save a page a dozen times
|
|
97
|
+
// while the published version stays exactly as it was.
|
|
98
|
+
const publishing = ref(false)
|
|
99
|
+
const previewOpening = ref(false)
|
|
100
|
+
// `output.publishOnSave` turns the split off again (a save republishes, as before 2.0) — then a Publish
|
|
101
|
+
// button would have nothing to do, so the hosts hide it. Reported by /api/publish-status; unknown (a
|
|
102
|
+
// never-saved record, which does not fetch it) reads as the default, i.e. the button stays.
|
|
103
|
+
const publishOnSave = computed(() => liveStatus.data.value.publishOnSave === true)
|
|
104
|
+
const canPublish = computed(() => !publishOnSave.value)
|
|
105
|
+
|
|
106
|
+
/** Save (a publish publishes what you SEE), promote a draft, then write the output. */
|
|
107
|
+
async function publish() {
|
|
108
|
+
if (publishing.value || saving.value) return
|
|
109
|
+
publishing.value = true
|
|
110
|
+
try {
|
|
111
|
+
// Pressing Publish IS the publish intent, so a draft is promoted here rather than sending the user to
|
|
112
|
+
// the status select first. A statusless collection has nothing to promote.
|
|
113
|
+
if (hasStatus.value && f.values.status !== 'published') f.setField('status', 'published')
|
|
114
|
+
const r = await submit()
|
|
115
|
+
if (!r.ok) {
|
|
116
|
+
revealError?.()
|
|
117
|
+
toast.error(formError.value || t('editor.saveFailed'))
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
saved.value = true
|
|
121
|
+
// The saved row's id, whatever the route param says — `single`/`new` are not ids.
|
|
122
|
+
const id = (r.record as { id?: number } | null)?.id ?? Number(props.id)
|
|
123
|
+
if (!Number.isInteger(id)) return
|
|
124
|
+
const since = liveStatus.data.value.updatedAt ?? null
|
|
125
|
+
// Awaited BEFORE `saved` is emitted: on a new record that emit navigates to the record's own URL and
|
|
126
|
+
// tears this instance down, which would cut the request short.
|
|
127
|
+
const res = await $fetch<{ generates: boolean; drafts: number[] }>('/api/publish', {
|
|
128
|
+
method: 'POST', body: { collection: props.collection, id },
|
|
129
|
+
}).catch(() => null)
|
|
130
|
+
// A create navigates to the new record's URL (a full remount), so this instance is gone before its
|
|
131
|
+
// poll could run — hand the intent to the arriving one instead.
|
|
132
|
+
if (props.id === 'new') pendingPoll.value = pageLike.value && !!res?.generates
|
|
133
|
+
emit('saved', r.record)
|
|
134
|
+
if (!res) { toast.error(t('editor.publishFailed')); return }
|
|
135
|
+
if (res.drafts.length) { toast.info(t('editor.publishDraft')); return }
|
|
136
|
+
if (!res.generates) { toast.info(t('editor.publishNotGenerated')); return }
|
|
137
|
+
toast.success(t('toast.published'))
|
|
138
|
+
void liveStatus.refreshUntilSettled({ since })
|
|
139
|
+
} finally {
|
|
140
|
+
publishing.value = false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Open the record in a new tab. Unsaved edits travel as a preview TICKET (a token in the URL) instead of
|
|
146
|
+
* being written to the DB: previewing must never be an unasked-for save. A saved, unmodified record just
|
|
147
|
+
* opens its URL.
|
|
148
|
+
*
|
|
149
|
+
* The tab is opened SYNCHRONOUSLY (about:blank) and redirected once the ticket is minted — a popup opened
|
|
150
|
+
* after an await is a popup blocker's textbook case. That costs `noopener`, which the direct path keeps;
|
|
151
|
+
* the target is our own origin either way.
|
|
152
|
+
*/
|
|
153
|
+
async function openPreview() {
|
|
154
|
+
const url = previewUrl.value
|
|
155
|
+
if (!dirty.value && props.id !== 'new' && url) {
|
|
156
|
+
window.open(url, '_blank', 'noopener,noreferrer')
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const tab = window.open('', '_blank')
|
|
160
|
+
previewOpening.value = true
|
|
161
|
+
try {
|
|
162
|
+
const ticket = await $fetch<{ token: string }>('/api/preview', {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
body: {
|
|
165
|
+
collection: props.collection,
|
|
166
|
+
id: Number.isInteger(Number(props.id)) ? Number(props.id) : null, // 'new' / 'single' have no id
|
|
167
|
+
locale: f.locale.value,
|
|
168
|
+
values: f.buildBody(),
|
|
169
|
+
},
|
|
170
|
+
}).catch(() => null)
|
|
171
|
+
if (!ticket) {
|
|
172
|
+
tab?.close()
|
|
173
|
+
toast.error(t('editor.previewFailed'))
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
// No public URL (never saved, blank slug, non-pageLike) → the dedicated preview page renders the
|
|
177
|
+
// ticket in the real public app instead.
|
|
178
|
+
const base = url ?? `${PREVIEW_FALLBACK_PATH}?locale=${encodeURIComponent(f.locale.value)}`
|
|
179
|
+
const target = `${base}${base.includes('?') ? '&' : '?'}${PREVIEW_TOKEN_QUERY}=${encodeURIComponent(ticket.token)}`
|
|
180
|
+
if (tab) tab.location.replace(target)
|
|
181
|
+
else window.open(target, '_blank', 'noopener,noreferrer') // popup blocked → try once more, directly
|
|
182
|
+
} finally {
|
|
183
|
+
previewOpening.value = false
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
97
187
|
// The record-editor page renders the action toolbar (Save/Cancel/Delete + Undo/Redo) in its header: it
|
|
98
188
|
// submits this form by id (formId), reads the in-flight `saving` for the button state, drives the
|
|
99
189
|
// unsaved-changes guard off `dirty`, and the undo/redo controls off the history API exposed here.
|
|
100
190
|
// The exposed shape IS the `EditorExpose` contract (utils/editor-expose.ts) — keep them in sync.
|
|
101
|
-
|
|
191
|
+
// MUST run BEFORE the `await f.ready` below — Vue does not guarantee an async setup component's expose is
|
|
192
|
+
// wired if `defineExpose` runs after a top-level await (the parent's template ref may resolve first).
|
|
193
|
+
defineExpose({
|
|
194
|
+
dirty, saving, undo, redo, canUndo, canRedo, hasStatus, status, savedStatus, previewUrl, pageLike,
|
|
195
|
+
live: liveStatus.data, recordTitle: heading, publish, publishing, canPublish, openPreview, previewOpening,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
await f.ready
|
|
199
|
+
|
|
200
|
+
// Which editor body renders (fields · blocks · an extension type). Resolved from the registry; an
|
|
201
|
+
// unregistered type falls back to the visible EditorUnsupported panel.
|
|
202
|
+
const bodyComponent = computed(() => resolveCollectionEditor(editorType.value))
|
|
102
203
|
|
|
103
204
|
async function onSave() {
|
|
205
|
+
// Read BEFORE the save: a successful submit rebaselines, after which the saved status is the new one.
|
|
206
|
+
const wasPublished = savedStatus.value === 'published'
|
|
104
207
|
const r = await submit()
|
|
105
208
|
if (r.ok) {
|
|
106
209
|
saved.value = true
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
210
|
+
// A save publishes nothing (ADR-0008), so there is no in-flight republish to poll for — one refresh,
|
|
211
|
+
// which is what turns the right lamp to "Outdated": still live, now an older version than the record.
|
|
212
|
+
// An unpublish is the exception the write path still acts on, and its prune is enqueued the same
|
|
213
|
+
// debounced way, so that case polls until the row clears.
|
|
214
|
+
// …with one exception in each direction: an unpublish IS acted on at save time (its prune rides the same
|
|
215
|
+
// debounced queue), and with `publishOnSave` every save republishes, so both poll until the row settles.
|
|
216
|
+
const unpublished = hasStatus.value && wasPublished && status.value === 'draft'
|
|
217
|
+
const settles = unpublished || (publishOnSave.value && status.value !== 'draft')
|
|
113
218
|
const since = liveStatus.data.value.updatedAt ?? null
|
|
114
|
-
// A create navigates the editor to the new record's URL (a full remount), so this `new` instance is
|
|
115
|
-
// torn down before its poll could run — hand the intent to the arriving instance instead.
|
|
116
|
-
if (wasNew) pendingPoll.value = pageLike.value && !isDraft
|
|
117
219
|
emit('saved', r.record)
|
|
118
|
-
if (
|
|
220
|
+
if (props.id !== 'new') void (settles ? liveStatus.refreshUntilSettled({ since }) : liveStatus.refresh())
|
|
119
221
|
return
|
|
120
222
|
}
|
|
121
223
|
// Save failed — let the active body reveal the problem (the blocks body focuses the offending block /
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
|
|
3
3
|
import { sortDirection, type FilterCell } from '../utils/list-query'
|
|
4
|
-
import {
|
|
4
|
+
import type { ListColumn } from '../utils/list-columns'
|
|
5
5
|
import { PER_PAGE_OPTIONS } from '../../../core/app/utils/list-limits'
|
|
6
6
|
import type { BatchDeleteReport } from '../utils/collection-ops'
|
|
7
7
|
import { OPS_BY_KIND, DEFAULT_OP, type FilterKind, type FilterOp } from '../../../core/app/utils/filter-ops'
|
|
@@ -19,7 +19,6 @@ const collection = computed(() => props.schema.name)
|
|
|
19
19
|
const label = computed(() => resolveLocalized(props.schema.label?.singular, lang.value) ?? props.schema.name)
|
|
20
20
|
// Prefer the collection's complete per-locale "create" phrase; fall back to the generic template.
|
|
21
21
|
const newLabel = computed(() => resolveLocalized(props.schema.label?.new, lang.value) ?? t('common.new', { label: label.value }))
|
|
22
|
-
const translatable = computed(() => !!props.schema.translatable && props.schema.mode === 'multi')
|
|
23
22
|
const localeQuery = computed(() => (props.locale ? `?locale=${props.locale}` : ''))
|
|
24
23
|
|
|
25
24
|
// Configurable columns (persisted per-collection). The select + row-action columns are CHROME — fixed
|
|
@@ -56,7 +55,7 @@ watch([filterableColumns, () => JSON.stringify(filter.value)], () => {
|
|
|
56
55
|
const cm = filter.value[c.key]
|
|
57
56
|
draft[c.key] = { op: cm?.op ?? DEFAULT_OP[c.filterKind ?? 'text'], value: cm?.value ?? '' }
|
|
58
57
|
}
|
|
59
|
-
for (const key of Object.keys(draft)) if (!cols.some((c) => c.key === key))
|
|
58
|
+
for (const key of Object.keys(draft)) if (!cols.some((c) => c.key === key)) Reflect.deleteProperty(draft, key)
|
|
60
59
|
}, { immediate: true })
|
|
61
60
|
|
|
62
61
|
function opsFor(c: ListColumn): readonly FilterOp[] {
|
|
@@ -205,6 +204,9 @@ async function fetchRows() {
|
|
|
205
204
|
}
|
|
206
205
|
|
|
207
206
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
|
|
207
|
+
// The per-page `<select>` is hand-rolled directly in this template (no UiField/UiCheckbox wrapper), so the
|
|
208
|
+
// label↔control association needs an explicit pair for static a11y analysis to see it.
|
|
209
|
+
const perPageId = useId()
|
|
208
210
|
|
|
209
211
|
// Filters: edited in the Filter panel (a value change debounces; an operator change commits at once), shown
|
|
210
212
|
// as removable chips in the bar. `commitDrafts` builds a fresh clause map from the non-empty draft cells and
|
|
@@ -460,6 +462,7 @@ await fetchRows()
|
|
|
460
462
|
@click="togglePanel('columns', $event)"
|
|
461
463
|
>{{ t('list.columns') }}</UiButton>
|
|
462
464
|
<div v-if="openPanel === 'columns'" id="list-columns-panel" class="list__panel">
|
|
465
|
+
<!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiCheckbox; no `for`/`id` pair needed, invisible to static analysis -->
|
|
463
466
|
<label v-for="c in available" :key="c.key" class="list__col-row">
|
|
464
467
|
<UiCheckbox
|
|
465
468
|
class="list__col-check"
|
|
@@ -620,9 +623,10 @@ await fetchRows()
|
|
|
620
623
|
<UiButton type="button" size="sm" :disabled="page <= 1" @click="prev">{{ t('list.prev') }}</UiButton>
|
|
621
624
|
<span class="list__page">{{ t('list.page', { page, totalPages, total }) }}</span>
|
|
622
625
|
<UiButton type="button" size="sm" :disabled="page >= totalPages" @click="next">{{ t('list.next') }}</UiButton>
|
|
623
|
-
<label class="list__perpage">
|
|
626
|
+
<label class="list__perpage" :for="perPageId">
|
|
624
627
|
<span class="list__perpage-label">{{ t('list.perPage') }}</span>
|
|
625
628
|
<select
|
|
629
|
+
:id="perPageId"
|
|
626
630
|
class="list__perpage-select"
|
|
627
631
|
:value="perPage"
|
|
628
632
|
:aria-label="t('list.perPage')"
|
|
@@ -63,6 +63,11 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
|
|
|
63
63
|
if (s === 'error')
|
|
64
64
|
return { tone: 'red', word: t('editorStatus.word.error'), detail: t('editorStatus.live.error'), when: updatedAtLabel.value, error: props.live?.error ?? '' }
|
|
65
65
|
if (s === 'success') {
|
|
66
|
+
// Saved after the last publish: the file IS live, but it is an older version of this record. Saving no
|
|
67
|
+
// longer republishes (ADR-0008), so this is the normal state of a page being worked on — amber, not red.
|
|
68
|
+
if (props.live?.pending) {
|
|
69
|
+
return { tone: 'amber', word: t('editorStatus.word.outdated'), detail: t('editorStatus.live.outdated'), when: updatedAtLabel.value }
|
|
70
|
+
}
|
|
66
71
|
// Where the file actually landed — surface local vs S3 in the tooltip ("stored on S3").
|
|
67
72
|
const onS3 = (props.live?.target ?? props.live?.driver) === 's3'
|
|
68
73
|
return { tone: 'green', word: t('editorStatus.word.live'), detail: t(onS3 ? 'editorStatus.live.liveS3' : 'editorStatus.live.liveLocal'), when: updatedAtLabel.value }
|
|
@@ -71,6 +76,12 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
|
|
|
71
76
|
// static output off) → a calm neutral "Not built". Unknown (undefined) → assume a publish is in flight.
|
|
72
77
|
if (props.live?.generates === false)
|
|
73
78
|
return { tone: 'neutral', word: t('editorStatus.word.notBuilt'), detail: t('editorStatus.live.notBuilt') }
|
|
79
|
+
// Never published, and a save no longer enqueues anything (ADR-0008) — so nothing is in flight and
|
|
80
|
+
// nothing will be until someone presses Publish. Reporting progress here would point the user at a
|
|
81
|
+
// spinner instead of at the one action that changes it. With `publishOnSave` a save DOES republish, so
|
|
82
|
+
// there the in-flight reading is the right one.
|
|
83
|
+
if (props.live?.neverPublished && !props.live?.publishOnSave)
|
|
84
|
+
return { tone: 'blue', word: t('editorStatus.word.notPublished'), detail: t('editorStatus.live.notPublished') }
|
|
74
85
|
return { tone: 'amber', word: t('editorStatus.word.generating'), detail: t('editorStatus.live.pending') }
|
|
75
86
|
})
|
|
76
87
|
</script>
|
|
@@ -90,6 +90,7 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
|
|
|
90
90
|
</template>
|
|
91
91
|
</UiField>
|
|
92
92
|
|
|
93
|
+
<!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiCheckbox; no `for`/`id` pair needed, invisible to static analysis -->
|
|
93
94
|
<label class="seo-fields__noindex">
|
|
94
95
|
<UiCheckbox
|
|
95
96
|
:model-value="!!value.noindex"
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// CollectionEditor's form by id; `saving`/`dirty` come from the editor's exposed state.
|
|
5
5
|
import type { EditorExpose } from '../utils/editor-expose'
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
defineProps<{ collection: string; title: string; localeParam?: string }>()
|
|
8
8
|
|
|
9
9
|
const { t } = useT()
|
|
10
10
|
const toast = useToast()
|
|
@@ -17,10 +17,8 @@ function onSaved() {
|
|
|
17
17
|
toast.success(t('toast.saved'))
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
23
|
-
}
|
|
20
|
+
// Unsaved edits preview through a ticket instead of a save; the editor owns both paths (ADR-0008).
|
|
21
|
+
const previewTitle = computed(() => t(editorRef.value?.dirty ? 'editor.previewUnsaved' : 'editor.openInNewTab'))
|
|
24
22
|
|
|
25
23
|
// Guard in-app navigation away from unsaved changes — both record-change AND same-record `?locale`
|
|
26
24
|
// switches (the LocaleBar's singleton locale switch reuses this route record → onBeforeRouteUpdate).
|
|
@@ -35,8 +33,10 @@ useUnsavedGuard(() => editorRef.value?.dirty ?? false, () => t('editor.discardCo
|
|
|
35
33
|
<div class="singleton__actions">
|
|
36
34
|
<UiButton type="button" variant="ghost" size="sm" icon="undo" :disabled="saving || !editorRef?.canUndo" :title="t('history.undo')" :aria-label="t('history.undo')" @click="editorRef?.undo()" />
|
|
37
35
|
<UiButton type="button" variant="ghost" size="sm" icon="redo" :disabled="saving || !editorRef?.canRedo" :title="t('history.redo')" :aria-label="t('history.redo')" @click="editorRef?.redo()" />
|
|
38
|
-
<UiButton
|
|
36
|
+
<UiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
|
|
39
37
|
<UiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</UiButton>
|
|
38
|
+
<!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
|
|
39
|
+
<UiButton v-if="editorRef?.canPublish !== false" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</UiButton>
|
|
40
40
|
<EditorStatus class="singleton__ampel" :dirty="editorRef?.dirty ?? false" :saving="saving" :has-status="editorRef?.hasStatus ?? false" :status="editorRef?.status" :saved-status="editorRef?.savedStatus" :page-like="editorRef?.pageLike ?? false" :live="editorRef?.live" />
|
|
41
41
|
</div>
|
|
42
42
|
</div>
|
|
@@ -45,9 +45,21 @@ export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged
|
|
|
45
45
|
|
|
46
46
|
const confirmDelete = (ids: number[]) => bulk('delete', ids)
|
|
47
47
|
const duplicate = (ids: number[]) => bulk('duplicate', ids)
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Persist a new status and, for a publish, write the static output too (ADR-0008). Two calls because
|
|
50
|
+
* they are two things: the bulk endpoint owns the DB write (validation, all-or-nothing, write events),
|
|
51
|
+
* `/api/publish` owns the render. Unpublishing needs no second call — the write event prunes the pages
|
|
52
|
+
* on its own, since taking a page offline must never wait for a separate action.
|
|
53
|
+
*/
|
|
54
|
+
const setStatus = async (ids: number[], status: 'published' | 'draft'): Promise<BulkResult> => {
|
|
55
|
+
const res = await bulk(status === 'published' ? 'publish' : 'unpublish', ids)
|
|
56
|
+
if (status === 'published') {
|
|
57
|
+
// Best-effort: the records ARE published (the state the list shows); a failed render surfaces on the
|
|
58
|
+
// record's own status lamp, and re-pressing Publish retries it.
|
|
59
|
+
await $fetch('/api/publish', { method: 'POST', body: { collection: name(), ids } }).catch(() => null)
|
|
60
|
+
}
|
|
61
|
+
return res
|
|
62
|
+
}
|
|
51
63
|
|
|
52
64
|
return { busy, error, previewDelete, confirmDelete, duplicate, setStatus }
|
|
53
65
|
}
|
|
@@ -95,8 +95,8 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
95
95
|
function restore(snap: Record<string, unknown>) {
|
|
96
96
|
const fresh = snapshot(snap)
|
|
97
97
|
for (const k of Object.keys(fresh)) values[k] = fresh[k]
|
|
98
|
-
for (const k of Object.keys(values)) if (!(k in fresh))
|
|
99
|
-
for (const k of Object.keys(errors))
|
|
98
|
+
for (const k of Object.keys(values)) if (!(k in fresh)) Reflect.deleteProperty(values, k)
|
|
99
|
+
for (const k of Object.keys(errors)) Reflect.deleteProperty(errors, k)
|
|
100
100
|
blockErrors.value = {}
|
|
101
101
|
formError.value = ''
|
|
102
102
|
coalesceKey = ''
|
|
@@ -304,7 +304,7 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
304
304
|
// Clear the whole map, not just declared-field errors: `path`/`seo`/`status` are system columns
|
|
305
305
|
// (never in `fields.value`), so a stale server error on one of them would otherwise survive a
|
|
306
306
|
// later successful save. `validateAll` below repopulates every declared-field error fresh.
|
|
307
|
-
for (const k of Object.keys(errors))
|
|
307
|
+
for (const k of Object.keys(errors)) Reflect.deleteProperty(errors, k)
|
|
308
308
|
// Client-side validation failed: surface the same banner the server path raises, so the failure is
|
|
309
309
|
// never silent — the offending field's inline error can sit in an unmounted pane (blocks editor).
|
|
310
310
|
if (!validateAll()) {
|
|
@@ -366,6 +366,9 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
366
366
|
ready,
|
|
367
367
|
setField,
|
|
368
368
|
validateAll,
|
|
369
|
+
// The wire body a save would send — also what a preview ticket carries, so an external tab renders
|
|
370
|
+
// exactly what a save would have stored (ADR-0008).
|
|
371
|
+
buildBody,
|
|
369
372
|
submit,
|
|
370
373
|
undo,
|
|
371
374
|
redo,
|
|
@@ -9,6 +9,15 @@ export interface PublishStatusData {
|
|
|
9
9
|
updatedAt?: string | null
|
|
10
10
|
/** Which output the last attempt wrote to — `'local'` dir or `'s3'` bucket; null when no row yet. */
|
|
11
11
|
target?: 'local' | 's3' | null
|
|
12
|
+
/** The record was saved after its page was last published — the live file is an older version of it.
|
|
13
|
+
* The normal working state while editing, since a save no longer republishes (ADR-0008). */
|
|
14
|
+
pending?: boolean
|
|
15
|
+
/** The consumer opted out of the save/publish split (`output.publishOnSave`): a save republishes on its
|
|
16
|
+
* own, so the editor offers no Publish button and never reports unpublished changes. */
|
|
17
|
+
publishOnSave?: boolean
|
|
18
|
+
/** The page has no publish row at all. Distinct from "a publish is in flight": with the split on, a save
|
|
19
|
+
* enqueues nothing, so this state only ends when someone presses Publish. */
|
|
20
|
+
neverPublished?: boolean
|
|
12
21
|
/** Whether the runtime publisher actually produces files in THIS environment (prod + `output.auto`).
|
|
13
22
|
* `false` in dev / with static output off → a page can never turn "Live" here, so don't poll for it. */
|
|
14
23
|
generates?: boolean
|
|
@@ -43,12 +43,10 @@ const heading = computed(() =>
|
|
|
43
43
|
: t('editor.editRecord', { collection: singular.value, id }),
|
|
44
44
|
)
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
51
|
-
}
|
|
46
|
+
// The external-tab button always works now: with unsaved edits it opens a preview TICKET (no save, no
|
|
47
|
+
// publish), otherwise the record's own URL — so the tooltip has to say which of the two you'd get.
|
|
48
|
+
const previewTitle = computed(() => t(editorRef.value?.dirty ? 'editor.previewUnsaved' : 'editor.openInNewTab'))
|
|
49
|
+
|
|
52
50
|
const skipGuard = ref(false)
|
|
53
51
|
|
|
54
52
|
// Delete flows through the shared batch op + the same confirm dialog the list uses, so the delete logic
|
|
@@ -120,10 +118,12 @@ async function confirmDelete() {
|
|
|
120
118
|
<div class="record__actions">
|
|
121
119
|
<UiButton type="button" variant="ghost" size="sm" icon="undo" :disabled="saving || !editorRef?.canUndo" :title="t('history.undo')" :aria-label="t('history.undo')" @click="editorRef?.undo()" />
|
|
122
120
|
<UiButton type="button" variant="ghost" size="sm" icon="redo" :disabled="saving || !editorRef?.canRedo" :title="t('history.redo')" :aria-label="t('history.redo')" @click="editorRef?.redo()" />
|
|
123
|
-
<UiButton
|
|
121
|
+
<UiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
|
|
124
122
|
<UiButton type="button" variant="secondary" size="sm" icon="x" :disabled="saving" @click="toList">{{ t('common.cancel') }}</UiButton>
|
|
125
123
|
<UiButton v-if="id !== 'new'" variant="danger" size="sm" icon="trash" :loading="deleting" @click="onDelete">{{ t('common.delete') }}</UiButton>
|
|
126
124
|
<UiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</UiButton>
|
|
125
|
+
<!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
|
|
126
|
+
<UiButton v-if="editorRef?.canPublish !== false" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</UiButton>
|
|
127
127
|
<EditorStatus class="record__ampel" :dirty="editorRef?.dirty ?? false" :saving="saving" :has-status="editorRef?.hasStatus ?? false" :status="editorRef?.status" :saved-status="editorRef?.savedStatus" :page-like="editorRef?.pageLike ?? false" :live="editorRef?.live" />
|
|
128
128
|
</div>
|
|
129
129
|
</div>
|