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.
- package/README.md +192 -43
- package/bin/smoodly.js +6 -0
- package/dist/admin/auth.d.ts +10 -5
- package/dist/admin/auth.js +14 -13
- package/dist/admin/client-ops.js +2 -1
- package/dist/admin/editor/EditorView.js +20 -6
- package/dist/admin/editor/InsertPicker.d.ts +14 -0
- package/dist/admin/editor/InsertPicker.js +37 -7
- package/dist/admin/editor/InspectorFields.js +1 -1
- package/dist/admin/editor/Outline.js +2 -2
- package/dist/admin/editor/PageSettings.js +6 -0
- package/dist/admin/editor/SeoSettings.js +1 -1
- package/dist/admin/editor/locale-url.d.ts +7 -4
- package/dist/admin/editor/locale-url.js +14 -7
- package/dist/admin/editor/usePageLocale.js +9 -1
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +6 -3
- package/dist/admin/forms/DatePicker.d.ts +9 -0
- package/dist/admin/forms/DatePicker.js +133 -0
- package/dist/admin/forms/FieldWidget.js +11 -5
- package/dist/admin/forms/ImagePreview.d.ts +9 -0
- package/dist/admin/forms/ImagePreview.js +11 -0
- package/dist/admin/forms/MediaWidget.d.ts +8 -2
- package/dist/admin/forms/MediaWidget.js +13 -13
- package/dist/admin/forms/RichtextElementCard.js +1 -1
- package/dist/admin/forms/RichtextImageCard.js +14 -9
- package/dist/admin/forms/date-picker.d.ts +44 -0
- package/dist/admin/forms/date-picker.js +117 -0
- package/dist/admin/forms/elements-context.d.ts +3 -0
- package/dist/admin/forms/elements-context.js +7 -2
- package/dist/admin/forms/time-zone-context.d.ts +7 -0
- package/dist/admin/forms/time-zone-context.js +15 -0
- package/dist/admin/forms/useRichtextEditor.js +10 -1
- package/dist/admin/index.d.ts +2 -0
- package/dist/admin/next/bridge.d.ts +2 -1
- package/dist/admin/next/bridge.js +35 -5
- package/dist/admin/next/index.d.ts +2 -0
- package/dist/admin/ops-impl.js +73 -1
- package/dist/admin/ops.d.ts +22 -2
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.js +48 -3
- package/dist/admin/shell/EntriesList.js +12 -21
- package/dist/admin/shell/EntryForm.js +63 -5
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/GlobalList.js +4 -18
- package/dist/admin/shell/IconRail.d.ts +3 -1
- package/dist/admin/shell/IconRail.js +17 -7
- package/dist/admin/shell/InviteModal.d.ts +6 -0
- package/dist/admin/shell/InviteModal.js +28 -0
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/LoginView.js +34 -5
- package/dist/admin/shell/MediaScreen.js +11 -1
- package/dist/admin/shell/MembersPage.d.ts +2 -1
- package/dist/admin/shell/MembersPage.js +90 -25
- package/dist/admin/shell/PageDialogs.d.ts +26 -0
- package/dist/admin/shell/PageDialogs.js +38 -0
- package/dist/admin/shell/PagesList.js +100 -70
- package/dist/admin/shell/RouteLink.js +1 -1
- package/dist/admin/shell/SchemaNotice.d.ts +4 -0
- package/dist/admin/shell/SchemaNotice.js +4 -0
- package/dist/admin/shell/SetPasswordView.d.ts +7 -0
- package/dist/admin/shell/SetPasswordView.js +41 -0
- package/dist/admin/shell/SettingsView.d.ts +5 -1
- package/dist/admin/shell/SettingsView.js +17 -11
- package/dist/admin/shell/live-url.d.ts +10 -0
- package/dist/admin/shell/live-url.js +19 -0
- package/dist/admin/shell/locale-memory.d.ts +8 -0
- package/dist/admin/shell/locale-memory.js +23 -0
- package/dist/admin/shell/members-list.d.ts +15 -1
- package/dist/admin/shell/members-list.js +24 -7
- package/dist/admin/shell/page-dialogs.d.ts +9 -0
- package/dist/admin/shell/page-dialogs.js +19 -0
- package/dist/admin/shell/router.d.ts +10 -7
- package/dist/admin/shell/router.js +18 -9
- package/dist/admin/shell/session.d.ts +6 -0
- package/dist/admin/shell/session.js +17 -2
- package/dist/admin/shell/settings.d.ts +0 -6
- package/dist/admin/shell/settings.js +4 -5
- package/dist/admin/supabase-auth.js +6 -5
- package/dist/admin/tree-ops.js +3 -0
- package/dist/admin/ui/ActionMenu.js +26 -6
- package/dist/admin/ui/Combobox.d.ts +5 -0
- package/dist/admin/ui/Combobox.js +1 -1
- package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
- package/dist/admin/ui/ConfirmDialog.js +30 -0
- package/dist/admin/ui/Dialog.d.ts +13 -0
- package/dist/admin/ui/Dialog.js +54 -0
- package/dist/admin/ui/HistoryModal.d.ts +2 -1
- package/dist/admin/ui/HistoryModal.js +7 -2
- package/dist/admin/ui/ResizeHandle.js +4 -4
- package/dist/admin/ui/confirm.d.ts +35 -0
- package/dist/admin/ui/confirm.js +57 -0
- package/dist/admin/ui/primitives.d.ts +19 -4
- package/dist/admin/ui/primitives.js +11 -5
- package/dist/admin/ui/resize.d.ts +7 -4
- package/dist/admin/ui/resize.js +7 -4
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +368 -203
- package/dist/admin/validate.d.ts +4 -0
- package/dist/admin/validate.js +28 -0
- package/dist/cli/load-collections.d.ts +2 -0
- package/dist/cli/load-collections.js +26 -0
- package/dist/cli/main.d.ts +13 -0
- package/dist/cli/main.js +62 -0
- package/dist/cli/migrate.d.ts +23 -0
- package/dist/cli/migrate.js +64 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +9 -0
- package/dist/fields.d.ts +2 -0
- package/dist/fields.js +2 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- package/dist/marks.d.ts +3 -0
- package/dist/marks.js +3 -0
- package/dist/member-store.d.ts +30 -1
- package/dist/member-store.js +37 -6
- package/dist/migrations.d.ts +37 -0
- package/dist/migrations.js +73 -0
- package/dist/permissions.d.ts +7 -0
- package/dist/permissions.js +19 -0
- package/dist/schema-version.d.ts +17 -0
- package/dist/schema-version.js +32 -0
- package/dist/sql-space.js +6 -0
- package/dist/sql.js +10 -0
- package/dist/store.d.ts +3 -0
- package/dist/store.js +4 -0
- package/dist/supabase-member-store.d.ts +8 -1
- package/dist/supabase-member-store.js +62 -2
- package/dist/supabase-store.d.ts +3 -0
- package/dist/supabase-store.js +23 -0
- package/dist/time-zone.d.ts +6 -0
- package/dist/time-zone.js +27 -0
- package/migrations/20260926120000_smoodly_v1.sql +535 -0
- 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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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 (!
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
53
|
-
|
|
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", "
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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;
|