smoodly 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +192 -43
  2. package/bin/smoodly.js +6 -0
  3. package/dist/admin/auth.d.ts +10 -5
  4. package/dist/admin/auth.js +14 -13
  5. package/dist/admin/client-ops.js +2 -1
  6. package/dist/admin/editor/EditorView.js +20 -6
  7. package/dist/admin/editor/InsertPicker.d.ts +14 -0
  8. package/dist/admin/editor/InsertPicker.js +37 -7
  9. package/dist/admin/editor/InspectorFields.js +1 -1
  10. package/dist/admin/editor/Outline.js +2 -2
  11. package/dist/admin/editor/PageSettings.js +6 -0
  12. package/dist/admin/editor/SeoSettings.js +1 -1
  13. package/dist/admin/editor/locale-url.d.ts +7 -4
  14. package/dist/admin/editor/locale-url.js +14 -7
  15. package/dist/admin/editor/usePageLocale.js +9 -1
  16. package/dist/admin/forms/CropEditor.d.ts +4 -2
  17. package/dist/admin/forms/CropEditor.js +6 -3
  18. package/dist/admin/forms/DatePicker.d.ts +9 -0
  19. package/dist/admin/forms/DatePicker.js +133 -0
  20. package/dist/admin/forms/FieldWidget.js +11 -5
  21. package/dist/admin/forms/ImagePreview.d.ts +9 -0
  22. package/dist/admin/forms/ImagePreview.js +11 -0
  23. package/dist/admin/forms/MediaWidget.d.ts +8 -2
  24. package/dist/admin/forms/MediaWidget.js +13 -13
  25. package/dist/admin/forms/RichtextElementCard.js +1 -1
  26. package/dist/admin/forms/RichtextImageCard.js +14 -9
  27. package/dist/admin/forms/date-picker.d.ts +44 -0
  28. package/dist/admin/forms/date-picker.js +117 -0
  29. package/dist/admin/forms/elements-context.d.ts +3 -0
  30. package/dist/admin/forms/elements-context.js +7 -2
  31. package/dist/admin/forms/time-zone-context.d.ts +7 -0
  32. package/dist/admin/forms/time-zone-context.js +15 -0
  33. package/dist/admin/forms/useRichtextEditor.js +10 -1
  34. package/dist/admin/index.d.ts +2 -0
  35. package/dist/admin/next/bridge.d.ts +2 -1
  36. package/dist/admin/next/bridge.js +35 -5
  37. package/dist/admin/next/index.d.ts +2 -0
  38. package/dist/admin/ops-impl.js +73 -1
  39. package/dist/admin/ops.d.ts +22 -2
  40. package/dist/admin/serialize.d.ts +2 -0
  41. package/dist/admin/serialize.js +1 -0
  42. package/dist/admin/shell/AdminApp.js +48 -3
  43. package/dist/admin/shell/EntriesList.js +12 -21
  44. package/dist/admin/shell/EntryForm.js +63 -5
  45. package/dist/admin/shell/GlobalForm.js +22 -2
  46. package/dist/admin/shell/GlobalList.js +4 -18
  47. package/dist/admin/shell/IconRail.d.ts +3 -1
  48. package/dist/admin/shell/IconRail.js +17 -7
  49. package/dist/admin/shell/InviteModal.d.ts +6 -0
  50. package/dist/admin/shell/InviteModal.js +28 -0
  51. package/dist/admin/shell/ListView.js +2 -2
  52. package/dist/admin/shell/LoginView.js +34 -5
  53. package/dist/admin/shell/MediaScreen.js +11 -1
  54. package/dist/admin/shell/MembersPage.d.ts +2 -1
  55. package/dist/admin/shell/MembersPage.js +90 -25
  56. package/dist/admin/shell/PageDialogs.d.ts +26 -0
  57. package/dist/admin/shell/PageDialogs.js +38 -0
  58. package/dist/admin/shell/PagesList.js +100 -70
  59. package/dist/admin/shell/RouteLink.js +1 -1
  60. package/dist/admin/shell/SchemaNotice.d.ts +4 -0
  61. package/dist/admin/shell/SchemaNotice.js +4 -0
  62. package/dist/admin/shell/SetPasswordView.d.ts +7 -0
  63. package/dist/admin/shell/SetPasswordView.js +41 -0
  64. package/dist/admin/shell/SettingsView.d.ts +5 -1
  65. package/dist/admin/shell/SettingsView.js +17 -11
  66. package/dist/admin/shell/live-url.d.ts +10 -0
  67. package/dist/admin/shell/live-url.js +19 -0
  68. package/dist/admin/shell/locale-memory.d.ts +8 -0
  69. package/dist/admin/shell/locale-memory.js +23 -0
  70. package/dist/admin/shell/members-list.d.ts +15 -1
  71. package/dist/admin/shell/members-list.js +24 -7
  72. package/dist/admin/shell/page-dialogs.d.ts +9 -0
  73. package/dist/admin/shell/page-dialogs.js +19 -0
  74. package/dist/admin/shell/router.d.ts +10 -7
  75. package/dist/admin/shell/router.js +18 -9
  76. package/dist/admin/shell/session.d.ts +6 -0
  77. package/dist/admin/shell/session.js +17 -2
  78. package/dist/admin/shell/settings.d.ts +0 -6
  79. package/dist/admin/shell/settings.js +4 -5
  80. package/dist/admin/supabase-auth.js +6 -5
  81. package/dist/admin/tree-ops.js +3 -0
  82. package/dist/admin/ui/ActionMenu.js +26 -6
  83. package/dist/admin/ui/Combobox.d.ts +5 -0
  84. package/dist/admin/ui/Combobox.js +1 -1
  85. package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
  86. package/dist/admin/ui/ConfirmDialog.js +30 -0
  87. package/dist/admin/ui/Dialog.d.ts +13 -0
  88. package/dist/admin/ui/Dialog.js +54 -0
  89. package/dist/admin/ui/HistoryModal.d.ts +2 -1
  90. package/dist/admin/ui/HistoryModal.js +7 -2
  91. package/dist/admin/ui/ResizeHandle.js +4 -4
  92. package/dist/admin/ui/confirm.d.ts +35 -0
  93. package/dist/admin/ui/confirm.js +57 -0
  94. package/dist/admin/ui/primitives.d.ts +19 -4
  95. package/dist/admin/ui/primitives.js +11 -5
  96. package/dist/admin/ui/resize.d.ts +7 -4
  97. package/dist/admin/ui/resize.js +7 -4
  98. package/dist/admin/ui/theme.d.ts +1 -1
  99. package/dist/admin/ui/theme.js +368 -203
  100. package/dist/admin/validate.d.ts +4 -0
  101. package/dist/admin/validate.js +28 -0
  102. package/dist/cli/load-collections.d.ts +2 -0
  103. package/dist/cli/load-collections.js +26 -0
  104. package/dist/cli/main.d.ts +13 -0
  105. package/dist/cli/main.js +62 -0
  106. package/dist/cli/migrate.d.ts +23 -0
  107. package/dist/cli/migrate.js +64 -0
  108. package/dist/config.d.ts +4 -0
  109. package/dist/config.js +9 -0
  110. package/dist/fields.d.ts +2 -0
  111. package/dist/fields.js +2 -0
  112. package/dist/index.d.ts +6 -3
  113. package/dist/index.js +4 -2
  114. package/dist/marks.d.ts +3 -0
  115. package/dist/marks.js +3 -0
  116. package/dist/member-store.d.ts +30 -1
  117. package/dist/member-store.js +37 -6
  118. package/dist/migrations.d.ts +37 -0
  119. package/dist/migrations.js +73 -0
  120. package/dist/permissions.d.ts +7 -0
  121. package/dist/permissions.js +19 -0
  122. package/dist/schema-version.d.ts +17 -0
  123. package/dist/schema-version.js +32 -0
  124. package/dist/sql-space.js +6 -0
  125. package/dist/sql.js +10 -0
  126. package/dist/store.d.ts +3 -0
  127. package/dist/store.js +4 -0
  128. package/dist/supabase-member-store.d.ts +8 -1
  129. package/dist/supabase-member-store.js +62 -2
  130. package/dist/supabase-store.d.ts +3 -0
  131. package/dist/supabase-store.js +23 -0
  132. package/dist/time-zone.d.ts +6 -0
  133. package/dist/time-zone.js +27 -0
  134. package/migrations/20260926120000_smoodly_v1.sql +535 -0
  135. package/package.json +13 -3
@@ -15,7 +15,10 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
15
15
  // publish control. A draft may be incomplete; publish validates it and
16
16
  // lists the fields in an amber banner. The safe-delete guard's message
17
17
  // renders verbatim — it is product surface.
18
+ import { Eye } from "lucide-react";
18
19
  import { useEffect, useMemo, useRef, useState } from "react";
20
+ import { stillFailing, validateFields } from "../validate.js";
21
+ import { elementSchemas } from "../../richtext-walk.js";
19
22
  import { FieldWidget } from "../forms/FieldWidget.js";
20
23
  import { revealField } from "../forms/reveal.js";
21
24
  import { useRefOptions } from "../forms/useRefOptions.js";
@@ -32,6 +35,8 @@ import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-lis
32
35
  import { useAdminHref } from "./base.js";
33
36
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
34
37
  import { navigate, useLeaveGuard } from "./router.js";
38
+ import { LIVE_URL_CONFIRM, liveUrlChanges } from "./live-url.js";
39
+ import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
35
40
  /** A stable empty map for the hooks that run before the "no such collection" return. */
36
41
  const NO_FIELDS = {};
37
42
  export function EntryForm({ ops, registry, collection, id }) {
@@ -59,10 +64,28 @@ export function EntryForm({ ops, registry, collection, id }) {
59
64
  const formRef = useRef(null);
60
65
  const fieldsRef = useRef(fields);
61
66
  fieldsRef.current = fields;
67
+ const recordRef = useRef(record);
68
+ recordRef.current = record;
69
+ /** The slug the editor already confirmed as a live-URL change, so the next autosave does not ask again. */
70
+ const confirmedSlug = useRef(null);
62
71
  // The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
63
72
  const { saveState, mark, flush } = useDraftAutosave({
64
73
  read: () => fieldsRef.current,
65
74
  persist: async (value) => {
75
+ // A published entry's slug is a live URL: one confirm at the save, Cancel restores it (members management §5).
76
+ const row = recordRef.current?.locales[locale];
77
+ const stored = row?.slug ?? null;
78
+ const next = typeof value.slug === "string" ? value.slug : null;
79
+ if (next !== confirmedSlug.current && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
80
+ const current = recordRef.current;
81
+ const subject = current && meta ? { name: entryTitle(current, meta, registry, locale), path: slugPrefix && stored ? `${slugPrefix}${stored}` : null } : undefined;
82
+ if (await confirm({ ...LIVE_URL_CONFIRM, subject }))
83
+ confirmedSlug.current = next;
84
+ else {
85
+ value = { ...value, slug: stored };
86
+ setFields((f) => ({ ...(f ?? {}), slug: stored }));
87
+ }
88
+ }
66
89
  const r = await ops.entries.save(collection, id, locale, value);
67
90
  if (r.ok)
68
91
  setRecord(r.data);
@@ -110,6 +133,22 @@ export function EntryForm({ ops, registry, collection, id }) {
110
133
  return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
111
134
  const row = record?.locales[locale];
112
135
  const status = row ? localeStatusOf(row) : null;
136
+ // A refused publish's field errors clear one by one as the fields are fixed: every change revalidates
137
+ // (the same rules the op applies) and keeps only what still fails; the notice's list shrinks with them.
138
+ const elementDescs = useMemo(() => elementSchemas(registry.elements), [registry]);
139
+ const hasFieldErrors = Object.keys(fieldErrors).length > 0;
140
+ useEffect(() => {
141
+ if (!hasFieldErrors || !fields || !meta)
142
+ return;
143
+ const fresh = validateFields(fields, meta.fields, elementDescs);
144
+ setFieldErrors((prev) => stillFailing(prev, fresh));
145
+ setNotice((n) => {
146
+ if (!n?.fields)
147
+ return n;
148
+ const left = n.fields.filter((f) => fresh.some((e) => e.field === f.field));
149
+ return left.length === 0 ? null : left.length === n.fields.length ? n : { ...n, fields: left };
150
+ });
151
+ }, [fields, hasFieldErrors, meta, elementDescs]);
113
152
  /** An op's failure onto the screen: amber with the fields for a validation result, red otherwise. */
114
153
  const fail = (r) => {
115
154
  if (r.code === "validation" && r.fields && r.fields.length > 0) {
@@ -163,7 +202,7 @@ export function EntryForm({ ops, registry, collection, id }) {
163
202
  };
164
203
  /** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload. */
165
204
  const discard = async () => {
166
- if (!window.confirm("Discard your changes and restore the published version?"))
205
+ if (!(await confirm({ ...DISCARD_CONFIRM, subject: entrySubject() })))
167
206
  return;
168
207
  setNotice(null);
169
208
  setBusy("discard");
@@ -273,7 +312,14 @@ export function EntryForm({ ops, registry, collection, id }) {
273
312
  const remaining = Object.keys(record.locales).filter((l) => l !== locale);
274
313
  if (remaining.length === 0)
275
314
  return;
276
- if (!window.confirm(`Remove this entry from ${locale}? Its ${locale} fields and history are deleted.`))
315
+ const ok = await confirm({
316
+ title: `Remove this entry from ${locale}?`,
317
+ body: `Its ${locale} fields and history are deleted. Other languages are not affected.`,
318
+ confirmLabel: `Remove from ${locale}`,
319
+ destructive: true,
320
+ subject: entrySubject(),
321
+ });
322
+ if (!ok)
277
323
  return;
278
324
  let result;
279
325
  try {
@@ -290,11 +336,18 @@ export function EntryForm({ ops, registry, collection, id }) {
290
336
  goToLocale(remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0]);
291
337
  };
292
338
  const destroy = async () => {
293
- if (!window.confirm(multilingual ? "Delete this entry in every language?" : "Delete this entry?"))
339
+ const ok = await confirm({
340
+ title: "Delete this entry?",
341
+ body: `The entry${multilingual ? " in every language" : ""} and its history are removed. Anything referencing it loses the reference. This cannot be undone.`,
342
+ confirmLabel: "Delete entry",
343
+ destructive: true,
344
+ subject: entrySubject(),
345
+ });
346
+ if (!ok)
294
347
  return;
295
348
  const r = await ops.entries.delete(collection, id);
296
349
  if (r.ok)
297
- navigate(adminHref(`/${collection}`), { force: true });
350
+ void navigate(adminHref(`/${collection}`), { force: true });
298
351
  else
299
352
  fail(r);
300
353
  };
@@ -302,6 +355,11 @@ export function EntryForm({ ops, registry, collection, id }) {
302
355
  // The mount's path in the ACTIVE locale prefixes the slug; no path, no prefix.
303
356
  const base = meta.path?.[locale];
304
357
  const slugPrefix = base ? `/${base}/` : undefined;
358
+ /** The confirm dialogs' context chip: the entry's title and, on a routed collection, its URL. */
359
+ const entrySubject = () => {
360
+ const slug = record?.locales[locale]?.slug;
361
+ return record ? { name: heading, path: slugPrefix && slug ? `${slugPrefix}${slug}` : null } : undefined;
362
+ };
305
363
  const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
306
364
  const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
307
365
  setFields((f) => ({ ...(f ?? {}), [key]: value }));
@@ -316,6 +374,6 @@ export function EntryForm({ ops, registry, collection, id }) {
316
374
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
317
375
  },
318
376
  { label: heading },
319
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { href: urls[locale], target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
377
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
320
378
  revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (Object.entries(meta.fields).map(widget)) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
321
379
  }
@@ -12,6 +12,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
12
12
  // validates the draft and lists the fields in an amber banner. "Used on
13
13
  // N pages" is the refs index's answer.
14
14
  import { useEffect, useMemo, useRef, useState } from "react";
15
+ import { stillFailing, validateNode } from "../validate.js";
16
+ import { elementSchemas } from "../../richtext-walk.js";
15
17
  import { CSS_KEY, SETTINGS_KEY, STYLES_KEY } from "../../global.js";
16
18
  import { tabsFor } from "../forms/tabs.js";
17
19
  import { revealField } from "../forms/reveal.js";
@@ -28,6 +30,7 @@ import { useAdminHref } from "./base.js";
28
30
  import { GlobalFields } from "./GlobalFields.js";
29
31
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
30
32
  import { useLeaveGuard } from "./router.js";
33
+ import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
31
34
  export function GlobalForm({ ops, registry, name }) {
32
35
  const adminHref = useAdminHref();
33
36
  const item = registry.globals.find((s) => s.name === name);
@@ -101,6 +104,23 @@ export function GlobalForm({ ops, registry, name }) {
101
104
  else
102
105
  setNotice({ tone: "error", text: r.message });
103
106
  };
107
+ // A refused publish's field errors clear as the fields are fixed: every change revalidates the way the
108
+ // op does (a visual item like a node: fields, `$settings` under `settings.`, `$css`) and keeps what still fails.
109
+ const elementDescs = useMemo(() => elementSchemas(registry.elements), [registry]);
110
+ const hasFieldErrors = Object.keys(fieldErrors).length > 0;
111
+ useEffect(() => {
112
+ if (!hasFieldErrors || !fields || !item)
113
+ return;
114
+ const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...values } = fields;
115
+ const fresh = validateNode({ fields: values, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: item.section !== undefined ? item.settings : undefined }, elementDescs);
116
+ setFieldErrors((prev) => stillFailing(prev, fresh));
117
+ setNotice((n) => {
118
+ if (!n?.fields)
119
+ return n;
120
+ const left = n.fields.filter((f) => fresh.some((e) => e.field === f.field));
121
+ return left.length === 0 ? null : left.length === n.fields.length ? n : { ...n, fields: left };
122
+ });
123
+ }, [fields, hasFieldErrors, item, elementDescs]);
104
124
  const publish = async () => {
105
125
  setNotice(null);
106
126
  setBusy("publish");
@@ -143,7 +163,7 @@ export function GlobalForm({ ops, registry, name }) {
143
163
  fail(r);
144
164
  };
145
165
  const discard = async () => {
146
- if (!window.confirm("Discard your changes and restore the published version?"))
166
+ if (!(await confirm({ ...DISCARD_CONFIRM, subject: { name: item.title } })))
147
167
  return;
148
168
  setNotice(null);
149
169
  setBusy("discard");
@@ -235,7 +255,7 @@ export function GlobalForm({ ops, registry, name }) {
235
255
  };
236
256
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
237
257
  {
238
- label: "Globals", href: adminHref("/globals"),
258
+ label: "Site", href: adminHref("/globals"),
239
259
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
240
260
  },
241
261
  { label: item.title },
@@ -14,30 +14,16 @@ import { LocalePicker } from "../ui/LocaleSwitcher.js";
14
14
  import { relativeTime } from "../ui/format.js";
15
15
  import { useAdminHref } from "./base.js";
16
16
  import { ErrorPane } from "./ErrorPane.js";
17
- const LOCALE_KEY = "smoodly.entries.locale"; // the same choice the collection lists remember
18
- function rememberedLocale(registry) {
19
- try {
20
- const stored = window.sessionStorage.getItem(LOCALE_KEY);
21
- if (stored && registry.locales.supported.includes(stored))
22
- return stored;
23
- }
24
- catch {
25
- // storage unavailable — the default is fine
26
- }
27
- return registry.locales.default;
28
- }
17
+ import { rememberLocale, rememberedLocale } from "./locale-memory.js";
29
18
  export function GlobalList({ ops, registry }) {
30
19
  const adminHref = useAdminHref();
31
20
  const [items, setItems] = useState(null);
32
21
  const [error, setError] = useState(null);
33
- const [locale, setLocale] = useState(() => rememberedLocale(registry));
22
+ const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
34
23
  const multilingual = registry.locales.supported.length > 1;
35
24
  const pickLocale = (next) => {
36
25
  setLocale(next);
37
- try {
38
- window.sessionStorage.setItem(LOCALE_KEY, next);
39
- }
40
- catch { /* see rememberedLocale */ }
26
+ rememberLocale(next);
41
27
  };
42
28
  const load = useCallback(() => {
43
29
  ops.globals.list()
@@ -48,7 +34,7 @@ export function GlobalList({ ops, registry }) {
48
34
  if (error)
49
35
  return _jsx(ErrorPane, { message: error });
50
36
  const rows = items ? globalRows(items, registry, locale) : [];
51
- return (_jsx(ListView, { title: "Globals", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
37
+ return (_jsx(ListView, { title: "Site", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
52
38
  { label: "Title", width: "minmax(160px,1fr)" },
53
39
  { label: "Kind", width: "90px" },
54
40
  { label: "Updated", width: "minmax(110px,160px)" },
@@ -1,7 +1,9 @@
1
1
  import type { AdminRegistry } from "../serialize.ts";
2
- export declare function IconRail({ registry, active, email, onSignOut }: {
2
+ export declare function IconRail({ registry, active, email, settings, onSignOut }: {
3
3
  registry: AdminRegistry;
4
4
  active: string;
5
5
  email?: string;
6
+ /** Whether the Settings tile shows — `can(role, "settings")` in the shell. */
7
+ settings?: boolean;
6
8
  onSignOut: () => void;
7
9
  }): import("react").JSX.Element;
@@ -10,7 +10,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  // Collections — one rail item whose flyout lists every collection — then
11
11
  // Media, Globals (the global content items, when the registry has any;
12
12
  // the route is /globals), and Settings (the Settings area, spec
13
- // 2026-09-25; the route is /settings). The user avatar is pinned to the bottom and opens the same
13
+ // 2026-09-25; the route is /settings; admins only, so the shell passes
14
+ // `settings` false for an editor). The user avatar is pinned to the bottom and opens the same
14
15
  // flyout with the theme toggle and Sign out, which the old nav's footer
15
16
  // held.
16
17
  //
@@ -25,11 +26,13 @@ import { RouteLink } from "./RouteLink.js";
25
26
  /** Lucide at the rail's size and stroke (the tile's CSS restates both). */
26
27
  const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
27
28
  /** The app icon, inlined: a package can't ship a file into the host app's
28
- * /public. Brand blue on white in both themes, so no --logo-filter. */
29
+ * /public. design_handoff_smoodly_neutral/assets/smoodly-logo.svg — the
30
+ * outer and inner shapes take --logo-outer / --logo-inner (white over
31
+ * navy on the frame), so a look decides its colours. */
29
32
  function AppIcon() {
30
- return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 89 86", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { fill: "#0038B1", d: "M0 51.5552C0 24.3141 25.0505 0 52.5493 0H52.7761C74.4707 0 89 14.6336 89 34.445C89 61.6862 64.4034 86 36.0066 86C14.0946 86 0 70.9165 0 51.5552Z" }), _jsx("path", { fill: "#FFFFFF", d: "M23.25 46.1188C23.25 33.0027 34.8806 21.2959 47.6479 21.2959H47.7532C57.8257 21.2959 64.5714 28.3417 64.5714 37.8805C64.5714 50.9966 53.1516 62.7033 39.9674 62.7033C29.7939 62.7033 23.25 55.4409 23.25 46.1188Z" })] }));
33
+ return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 28 27", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { style: { fill: "var(--logo-outer)" }, d: "M0 16.1859C0 7.6335 7.88106 0 16.5324 0H16.6037C23.429 0 28 4.59428 28 10.8141C28 19.3666 20.2618 27 11.3279 27C4.43425 27 0 22.2645 0 16.1859Z" }), _jsx("path", { style: { fill: "var(--logo-inner)" }, d: "M7 14.7932C7 10.6754 10.6591 7 14.6757 7H14.7089C17.8777 7 20 9.21206 20 12.2068C20 16.3247 16.4072 20 12.2594 20C9.05876 20 7 17.7199 7 14.7932Z" })] }));
31
34
  }
32
- export function IconRail({ registry, active, email, onSignOut }) {
35
+ export function IconRail({ registry, active, email, settings = true, onSignOut }) {
33
36
  const adminHref = useAdminHref();
34
37
  const root = useRef(null);
35
38
  const uid = useId();
@@ -43,14 +46,21 @@ export function IconRail({ registry, active, email, onSignOut }) {
43
46
  const close = () => setOpen(null);
44
47
  const hoverLeave = () => setHovered(false);
45
48
  // A click open moves focus into the menu; a pointer down outside closes it.
49
+ // Outside means outside the menu and its trigger — the rest of the rail
50
+ // counts too (2026-09-26); the trigger is left to its own toggle.
46
51
  useEffect(() => {
47
52
  if (!open || !root.current)
48
53
  return;
49
54
  if (!hovered)
50
55
  root.current.querySelector(`#${CSS.escape(menuId(open))} [role=menuitem]`)?.focus();
51
56
  const onPointerDown = (e) => {
52
- if (e.target instanceof Node && !root.current?.contains(e.target))
53
- close();
57
+ if (!(e.target instanceof Node))
58
+ return;
59
+ const menu = root.current?.querySelector(`#${CSS.escape(menuId(open))}`);
60
+ const trigger = root.current?.querySelector(`[aria-controls="${menuId(open)}"]`);
61
+ if (menu?.contains(e.target) || trigger?.contains(e.target))
62
+ return;
63
+ close();
54
64
  };
55
65
  document.addEventListener("pointerdown", onPointerDown);
56
66
  return () => document.removeEventListener("pointerdown", onPointerDown);
@@ -78,5 +88,5 @@ export function IconRail({ registry, active, email, onSignOut }) {
78
88
  const collectionsOpen = open === "collections" || (hovered && open === null);
79
89
  const name = email?.split("@")[0] ?? "Editor";
80
90
  const initials = name.slice(0, 2).toUpperCase();
81
- return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Globals", _jsx(Globe, { ...ICON }), adminHref("/globals")), link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
91
+ return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
82
92
  }
@@ -0,0 +1,6 @@
1
+ import type { InviteInput } from "../../member-store.ts";
2
+ export declare function InviteModal({ onClose, onInvite }: {
3
+ onClose(): void;
4
+ /** Resolves to an error message, or null when the invite landed (the caller closes). */
5
+ onInvite(input: Omit<InviteInput, "redirectTo">): Promise<string | null>;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,28 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // packages/smoodly/src/admin/shell/InviteModal.tsx
4
+ // Invite a member (spec 2026-09-25 members management §4): an email, a
5
+ // role, Send. The role is a radio list with a line under each option
6
+ // saying what it may do — the one place the difference is explained in
7
+ // the admin (the Roles page was dropped 2026-09-25). The op's error
8
+ // shows inside; success closes it and the page reports what happened.
9
+ import { useState } from "react";
10
+ import { MEMBER_ROLES } from "../../permissions.js";
11
+ import { Modal } from "../ui/Modal.js";
12
+ import { Banner, Button, Field } from "../ui/primitives.js";
13
+ import { ROLE_DESCRIPTION, roleLabel } from "./members-list.js";
14
+ export function InviteModal({ onClose, onInvite }) {
15
+ const [email, setEmail] = useState("");
16
+ const [role, setRole] = useState("editor");
17
+ const [error, setError] = useState(null);
18
+ const [busy, setBusy] = useState(false);
19
+ const submit = async (e) => {
20
+ e.preventDefault();
21
+ setBusy(true);
22
+ setError(await onInvite({ email: email.trim(), role }));
23
+ setBusy(false);
24
+ };
25
+ // Editor first: the default, and the one most invites want.
26
+ const roles = [...MEMBER_ROLES].reverse();
27
+ return (_jsx(Modal, { title: "Invite a member", width: 420, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", required: true, autoFocus: true, value: email, onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Role", asDiv: true, children: _jsx("div", { className: "sm-choices", role: "radiogroup", children: roles.map((r) => (_jsxs("label", { className: "sm-choice sm-choice--desc", children: [_jsx("input", { type: "radio", name: "role", value: r, checked: role === r, onChange: () => setRole(r) }), _jsxs("span", { children: [_jsx("span", { className: "sm-choice__label", children: roleLabel(r) }), _jsx("span", { className: "sm-choice__desc", children: ROLE_DESCRIPTION[r] })] })] }, r))) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 }, children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", className: "sm-btn sm-btn--primary", disabled: busy, children: busy ? "Sending…" : "Send invite" })] })] }) }));
28
+ }
@@ -6,10 +6,10 @@ import { navigate } from "./router.js";
6
6
  export function ListView({ title, titleTrailing, actions, notice, columns, rows, empty, sortable }) {
7
7
  const widths = sortable ? ["24px", ...columns.map((c) => c.width)] : columns.map((c) => c.width);
8
8
  const grid = { gridTemplateColumns: widths.join(" ") };
9
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), titleTrailing] }), right: actions }), notice, _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table", children: [_jsxs("div", { className: "sm-table__head", style: grid, children: [sortable && _jsx("span", { "aria-hidden": "true" }), columns.map((c) => _jsx("span", { children: c.label }, c.label))] }), _jsxs("div", { className: "sm-table__body", ref: sortable?.hook.containerRef, children: [rows.map((r, i) => {
9
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), titleTrailing] }), right: actions }), notice && _jsx("div", { className: "sm-listnotice", children: notice }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table", children: [_jsxs("div", { className: "sm-table__head", style: grid, children: [sortable && _jsx("span", { "aria-hidden": "true" }), columns.map((c) => _jsx("span", { children: c.label }, c.label))] }), _jsxs("div", { className: "sm-table__body", ref: sortable?.hook.containerRef, children: [rows.map((r, i) => {
10
10
  const clickable = Boolean(r.href || r.onClick);
11
11
  const href = r.href;
12
- const go = r.onClick ?? (href ? () => { navigate(href); } : undefined);
12
+ const go = r.onClick ?? (href ? () => { void navigate(href); } : undefined);
13
13
  return (_jsxs("div", { style: { ...grid, ...sortable?.hook.rowStyle(i) }, role: clickable ? "button" : undefined, tabIndex: clickable ? 0 : undefined, className: cx("sm-row", clickable && "sm-row--clickable", sortable?.hook.rowClass(i)), onClick: go, onKeyDown: (e) => { if (go && (e.key === "Enter" || e.key === " ")) {
14
14
  e.preventDefault();
15
15
  go();
@@ -4,10 +4,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // Google/GitHub when the auth project has them. Errors come from
5
5
  // Supabase Auth verbatim. Being an editor is checked by the server on
6
6
  // the first op — a signed-in non-editor sees that message in the shell.
7
+ // Forgot password (members management §6) mails a reset link; the reply
8
+ // is the same whatever the email, so the form reveals no membership.
7
9
  import { useState } from "react";
8
10
  import { Banner, Button, Field, Logo } from "../ui/primitives.js";
9
11
  const LABELS = { google: "Continue with Google", github: "Continue with GitHub" };
12
+ const CARD = {
13
+ width: 320, display: "flex", flexDirection: "column", gap: 14,
14
+ background: "var(--surface)", padding: 28, borderRadius: "var(--radius-card-lg)",
15
+ border: "var(--menu-border)", boxShadow: "var(--shadow-page)",
16
+ };
10
17
  export function LoginView({ client, providers = [] }) {
18
+ const [mode, setMode] = useState("signin");
11
19
  const [email, setEmail] = useState("");
12
20
  const [password, setPassword] = useState("");
13
21
  const [error, setError] = useState(null);
@@ -28,6 +36,20 @@ export function LoginView({ client, providers = [] }) {
28
36
  setBusy(false);
29
37
  }
30
38
  };
39
+ const forgot = async (e) => {
40
+ e.preventDefault();
41
+ setBusy(true);
42
+ setError(null);
43
+ try {
44
+ // The answer is not shown: the form must not reveal whether an email is registered.
45
+ await client.auth.resetPasswordForEmail(email, { redirectTo: window.location.origin + window.location.pathname });
46
+ }
47
+ catch { /* same */ }
48
+ finally {
49
+ setBusy(false);
50
+ setMode("sent");
51
+ }
52
+ };
31
53
  const oauth = async (provider) => {
32
54
  setBusy(true);
33
55
  setError(null);
@@ -43,9 +65,16 @@ export function LoginView({ client, providers = [] }) {
43
65
  setBusy(false);
44
66
  }
45
67
  };
46
- return (_jsx("div", { style: { minHeight: "100vh", display: "grid", placeItems: "center", background: "var(--bg)" }, children: _jsxs("form", { onSubmit: submit, style: {
47
- width: 320, display: "flex", flexDirection: "column", gap: 14,
48
- background: "var(--surface)", padding: 28, borderRadius: "var(--radius-card-lg)",
49
- border: "var(--menu-border)", boxShadow: "var(--shadow-page)",
50
- }, children: [_jsx(Logo, { height: 20 }), _jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", value: email, required: true, autoComplete: "email", onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Password", children: _jsx("input", { className: "sm-input", type: "password", value: password, required: true, autoComplete: "current-password", onChange: (e) => setPassword(e.target.value) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsx("button", { type: "submit", disabled: busy, className: "sm-btn sm-btn--primary sm-btn--lg", children: busy ? "Signing in…" : "Sign in" }), providers.map((p) => (_jsx(Button, { variant: "secondary", size: "lg", disabled: busy, onClick: () => oauth(p), children: LABELS[p] }, p)))] }) }));
68
+ const back = (_jsx(Button, { variant: "secondary", size: "lg", disabled: busy, onClick: () => { setError(null); setMode("signin"); }, children: "Back to sign in" }));
69
+ let card;
70
+ if (mode === "sent") {
71
+ card = (_jsxs("div", { style: CARD, children: [_jsx(Logo, { height: 20 }), _jsx("span", { children: "Check your email for a link to choose a new password." }), back] }));
72
+ }
73
+ else if (mode === "forgot") {
74
+ card = (_jsxs("form", { onSubmit: forgot, style: CARD, children: [_jsx(Logo, { height: 20 }), _jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", value: email, required: true, autoComplete: "email", autoFocus: true, onChange: (e) => setEmail(e.target.value) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsx("button", { type: "submit", disabled: busy, className: "sm-btn sm-btn--primary sm-btn--lg", children: busy ? "Sending…" : "Send reset link" }), back] }));
75
+ }
76
+ else {
77
+ card = (_jsxs("form", { onSubmit: submit, style: CARD, children: [_jsx(Logo, { height: 20 }), _jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", value: email, required: true, autoComplete: "email", onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Password", children: _jsx("input", { className: "sm-input", type: "password", value: password, required: true, autoComplete: "current-password", onChange: (e) => setPassword(e.target.value) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsx("button", { type: "submit", disabled: busy, className: "sm-btn sm-btn--primary sm-btn--lg", children: busy ? "Signing in…" : "Sign in" }), providers.map((p) => (_jsx(Button, { variant: "secondary", size: "lg", disabled: busy, onClick: () => oauth(p), children: LABELS[p] }, p))), _jsx("button", { type: "button", className: "sm-link", disabled: busy, onClick: () => { setError(null); setMode("forgot"); }, style: { background: "transparent", border: 0, color: "var(--text-muted)", cursor: "pointer", font: "inherit", padding: 0 }, children: "Forgot password?" })] }));
78
+ }
79
+ return (_jsx("div", { style: { minHeight: "100vh", display: "grid", placeItems: "center", background: "var(--bg)" }, children: card }));
51
80
  }
@@ -9,6 +9,7 @@ import { useAssets } from "../forms/assets-context.js";
9
9
  import { uploadFile } from "../forms/upload.js";
10
10
  import { MediaGrid } from "../forms/MediaGrid.js";
11
11
  import { Banner, Button, Field, Segmented, TopBar } from "../ui/primitives.js";
12
+ import { confirm } from "../ui/confirm.js";
12
13
  import { RouteLink } from "./RouteLink.js";
13
14
  import { KIND_FILTERS, acceptFor, formatBytes, kindOfFilter, usageLabel } from "./media-list.js";
14
15
  const META = ["title", "alt", "caption", "credit"];
@@ -70,7 +71,16 @@ export function MediaScreen({ initialItems = [], initialSelected = null, initial
70
71
  load();
71
72
  };
72
73
  const remove = async () => {
73
- if (!selected || !window.confirm(`Delete "${selected.title ?? selected.filename}"? This cannot be undone.`))
74
+ if (!selected)
75
+ return;
76
+ const ok = await confirm({
77
+ title: "Delete this file?",
78
+ body: "The file is removed from the media library and from storage. This cannot be undone.",
79
+ confirmLabel: "Delete file",
80
+ destructive: true,
81
+ subject: { name: selected.title ?? selected.filename },
82
+ });
83
+ if (!ok)
74
84
  return;
75
85
  const r = await assets.ops.delete(selected.id);
76
86
  if (!r.ok) {
@@ -1,5 +1,6 @@
1
1
  import type { AdminOps, MembersListResult } from "../ops.ts";
2
- export declare function MembersPage({ ops, initial }: {
2
+ export declare function MembersPage({ ops, userId, initial }: {
3
3
  ops: AdminOps;
4
+ userId: string;
4
5
  initial?: MembersListResult | null;
5
6
  }): import("react").JSX.Element;