smoodly 0.0.25 → 0.0.26
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 +32 -0
- package/dist/admin/editor/EditorView.js +81 -17
- package/dist/admin/editor/autosave.d.ts +4 -1
- package/dist/admin/editor/autosave.js +24 -1
- package/dist/admin/editor/global-edit.d.ts +5 -1
- package/dist/admin/editor/global-edit.js +15 -5
- package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
- package/dist/admin/editor/useGlobalEdit.js +1 -1
- package/dist/admin/next/create-admin.d.ts +4 -0
- package/dist/admin/next/create-admin.js +3 -1
- package/dist/admin/next/index.d.ts +1 -0
- package/dist/admin/ops-impl.js +70 -24
- package/dist/admin/ops.d.ts +22 -12
- package/dist/admin/shell/AdminApp.d.ts +3 -1
- package/dist/admin/shell/AdminApp.js +20 -2
- package/dist/admin/shell/EntriesList.js +5 -1
- package/dist/admin/shell/EntryForm.js +44 -7
- package/dist/admin/shell/GlobalForm.js +46 -7
- package/dist/admin/shell/GlobalList.js +6 -2
- package/dist/admin/shell/PagesList.js +5 -1
- package/dist/admin/shell/presence-context.d.ts +9 -0
- package/dist/admin/shell/presence-context.js +35 -0
- package/dist/admin/shell/presence-realtime.d.ts +3 -0
- package/dist/admin/shell/presence-realtime.js +22 -0
- package/dist/admin/shell/presence.d.ts +51 -0
- package/dist/admin/shell/presence.js +90 -0
- package/dist/admin/shell/stale.d.ts +28 -0
- package/dist/admin/shell/stale.js +17 -0
- package/dist/admin/ui/LockIcon.d.ts +4 -0
- package/dist/admin/ui/LockIcon.js +12 -0
- package/dist/admin/ui/PresenceLine.d.ts +6 -0
- package/dist/admin/ui/PresenceLine.js +11 -0
- package/dist/admin/ui/StaleBanner.d.ts +5 -0
- package/dist/admin/ui/StaleBanner.js +9 -0
- package/dist/admin/ui/primitives.js +1 -0
- package/dist/admin/ui/theme.js +4 -0
- package/dist/draft-token.d.ts +31 -0
- package/dist/draft-token.js +41 -0
- package/dist/entry-store.d.ts +11 -8
- package/dist/entry-store.js +24 -10
- package/dist/env.d.ts +9 -0
- package/dist/env.js +19 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/sql-space.js +11 -0
- package/dist/sql.js +11 -0
- package/dist/store.d.ts +11 -8
- package/dist/store.js +26 -8
- package/dist/supabase-entry-store.d.ts +15 -5
- package/dist/supabase-entry-store.js +87 -22
- package/dist/supabase-store.d.ts +15 -5
- package/dist/supabase-store.js +60 -20
- package/migrations/20260926130000_smoodly_v2.sql +26 -0
- package/package.json +1 -1
package/dist/admin/ops-impl.js
CHANGED
|
@@ -12,10 +12,15 @@ import { SETTINGS_KEY, CSS_KEY } from "../global.js";
|
|
|
12
12
|
import { withDefaults } from "../field-defaults.js";
|
|
13
13
|
import { elementSchemas } from "../richtext-walk.js";
|
|
14
14
|
import { SCHEMA_VERSION } from "../schema-version.js";
|
|
15
|
+
import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
|
|
15
16
|
const ok = (data) => ({ ok: true, data });
|
|
16
17
|
/** A history row without its content (spec 2026-09-13 history §6). */
|
|
17
18
|
const summary = ({ id, savedAt }) => ({ id, savedAt });
|
|
18
19
|
const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
|
|
20
|
+
/** The client's token is untrusted data: undefined skips, null is "no draft", the shape is the token, anything else is refused. */
|
|
21
|
+
const BAD_TOKEN = Symbol("bad token");
|
|
22
|
+
const tokenArg = (v) => v === undefined ? undefined : v === null ? null : isDraftToken(v) ? v : BAD_TOKEN;
|
|
23
|
+
const badToken = () => err("validation", "smoodly: malformed draft token.");
|
|
19
24
|
/** Own-property, non-array plain object — same shape guard the tree checks
|
|
20
25
|
* below and `runAdminOp`'s envelope check both lean on. */
|
|
21
26
|
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -42,6 +47,9 @@ function isPageTree(tree) {
|
|
|
42
47
|
}
|
|
43
48
|
/** Store throws are stringly-typed; classify the known guards, default to internal. */
|
|
44
49
|
const classify = (e) => {
|
|
50
|
+
// The guard's refusal (spec 2026-09-26 concurrency §2): typed, so it never depends on message matching.
|
|
51
|
+
if (e instanceof StaleDraftError)
|
|
52
|
+
return { ok: false, code: "conflict", stale: true, by: e.by, at: e.at, message: e.message };
|
|
45
53
|
const message = e instanceof Error ? e.message : String(e);
|
|
46
54
|
if (message.includes("already exists in locale"))
|
|
47
55
|
return err("conflict", message);
|
|
@@ -324,20 +332,28 @@ export function createAdminOps(deps) {
|
|
|
324
332
|
expireTree();
|
|
325
333
|
return ok(created);
|
|
326
334
|
}),
|
|
327
|
-
saveDraft: (id, locale, tree) => guard(async () => {
|
|
335
|
+
saveDraft: (id, locale, tree, expected) => guard(async () => {
|
|
328
336
|
const unsupported = supportedLocale(locale);
|
|
329
337
|
if (unsupported)
|
|
330
338
|
return unsupported;
|
|
331
339
|
if (!isPageTree(tree))
|
|
332
340
|
return err("validation", "smoodly: malformed page tree.");
|
|
341
|
+
const token = tokenArg(expected);
|
|
342
|
+
if (token === BAD_TOKEN)
|
|
343
|
+
return badToken();
|
|
333
344
|
if (!(await pages.getPage(id)))
|
|
334
345
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
335
|
-
|
|
346
|
+
const version = await pages.saveDraft(id, locale, tree, by, token);
|
|
347
|
+
const after = await pages.getPage(id);
|
|
348
|
+
return ok({ versionId: version.id, draft: after?.locales[locale] ? draftToken(after.locales[locale]) : null });
|
|
336
349
|
}),
|
|
337
|
-
publish: (id, locale) => guard(async () => {
|
|
350
|
+
publish: (id, locale, expected) => guard(async () => {
|
|
338
351
|
const unsupported = supportedLocale(locale);
|
|
339
352
|
if (unsupported)
|
|
340
353
|
return unsupported;
|
|
354
|
+
const token = tokenArg(expected);
|
|
355
|
+
if (token === BAD_TOKEN)
|
|
356
|
+
return badToken();
|
|
341
357
|
const record = await pages.getPage(id);
|
|
342
358
|
if (!record)
|
|
343
359
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
@@ -373,7 +389,7 @@ export function createAdminOps(deps) {
|
|
|
373
389
|
if (fieldErrors.length > 0) {
|
|
374
390
|
return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
|
|
375
391
|
}
|
|
376
|
-
const published = await pages.publish(id, locale, by);
|
|
392
|
+
const published = await pages.publish(id, locale, by, token);
|
|
377
393
|
// The parent's cached unit lists its published children.
|
|
378
394
|
expirePages([published.id, published.parentId]);
|
|
379
395
|
expireTree();
|
|
@@ -398,10 +414,13 @@ export function createAdminOps(deps) {
|
|
|
398
414
|
expireTree();
|
|
399
415
|
return ok(off);
|
|
400
416
|
}),
|
|
401
|
-
discardDraft: (id, locale) => guard(async () => {
|
|
417
|
+
discardDraft: (id, locale, expected) => guard(async () => {
|
|
402
418
|
const unsupported = supportedLocale(locale);
|
|
403
419
|
if (unsupported)
|
|
404
420
|
return unsupported;
|
|
421
|
+
const token = tokenArg(expected);
|
|
422
|
+
if (token === BAD_TOKEN)
|
|
423
|
+
return badToken();
|
|
405
424
|
const record = await pages.getPage(id);
|
|
406
425
|
if (!record)
|
|
407
426
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
@@ -411,7 +430,7 @@ export function createAdminOps(deps) {
|
|
|
411
430
|
if (!row.publishedVersionId) {
|
|
412
431
|
return err("blocked", `smoodly: "${nameOf(record)}" has nothing published in ${locale} — there is no version to restore.`);
|
|
413
432
|
}
|
|
414
|
-
const restored = await pages.discardDraft(id, locale, by);
|
|
433
|
+
const restored = await pages.discardDraft(id, locale, by, token);
|
|
415
434
|
expirePages([restored.id]); // the draft-mode render of this page changed
|
|
416
435
|
return ok(restored);
|
|
417
436
|
}),
|
|
@@ -423,10 +442,13 @@ export function createAdminOps(deps) {
|
|
|
423
442
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
424
443
|
return ok((await pages.listVersions(id, locale)).map(summary));
|
|
425
444
|
}),
|
|
426
|
-
restore: (id, locale, versionId) => guard(async () => {
|
|
445
|
+
restore: (id, locale, versionId, expected) => guard(async () => {
|
|
427
446
|
const unsupported = supportedLocale(locale);
|
|
428
447
|
if (unsupported)
|
|
429
448
|
return unsupported;
|
|
449
|
+
const token = tokenArg(expected);
|
|
450
|
+
if (token === BAD_TOKEN)
|
|
451
|
+
return badToken();
|
|
430
452
|
const record = await pages.getPage(id);
|
|
431
453
|
if (!record)
|
|
432
454
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
@@ -438,7 +460,7 @@ export function createAdminOps(deps) {
|
|
|
438
460
|
if (!version || version.pageId !== id || version.locale !== locale) {
|
|
439
461
|
return err("not_found", `smoodly: no version "${String(versionId)}" of this page in ${locale}.`);
|
|
440
462
|
}
|
|
441
|
-
const restored = await pages.restore(id, locale, versionId, by);
|
|
463
|
+
const restored = await pages.restore(id, locale, versionId, by, token);
|
|
442
464
|
expirePages([restored.id]); // the draft-mode render of this page changed
|
|
443
465
|
return ok(restored);
|
|
444
466
|
}),
|
|
@@ -598,22 +620,28 @@ export function createAdminOps(deps) {
|
|
|
598
620
|
expireCollection(collection);
|
|
599
621
|
return ok(created);
|
|
600
622
|
}),
|
|
601
|
-
save: (collection, id, locale, fields) => guard(async () => {
|
|
623
|
+
save: (collection, id, locale, fields, expected) => guard(async () => {
|
|
602
624
|
const unsupported = supportedLocale(locale);
|
|
603
625
|
if (unsupported)
|
|
604
626
|
return unsupported;
|
|
627
|
+
const token = tokenArg(expected);
|
|
628
|
+
if (token === BAD_TOKEN)
|
|
629
|
+
return badToken();
|
|
605
630
|
const descriptors = collectionFields(collection);
|
|
606
631
|
if (!descriptors)
|
|
607
632
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
608
|
-
const entry = await entries.update(collection, id, locale, fields, by);
|
|
633
|
+
const entry = await entries.update(collection, id, locale, fields, by, token);
|
|
609
634
|
await fanOut(id);
|
|
610
635
|
expireCollection(collection);
|
|
611
636
|
return ok(entry);
|
|
612
637
|
}),
|
|
613
|
-
setStatus: (collection, id, locale, status) => guard(async () => {
|
|
638
|
+
setStatus: (collection, id, locale, status, expected) => guard(async () => {
|
|
614
639
|
const unsupported = supportedLocale(locale);
|
|
615
640
|
if (unsupported)
|
|
616
641
|
return unsupported;
|
|
642
|
+
const token = tokenArg(expected);
|
|
643
|
+
if (token === BAD_TOKEN)
|
|
644
|
+
return badToken();
|
|
617
645
|
if (status === "published") {
|
|
618
646
|
// A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
|
|
619
647
|
const descriptors = collectionFields(collection);
|
|
@@ -628,16 +656,19 @@ export function createAdminOps(deps) {
|
|
|
628
656
|
if (fieldErrors.length > 0)
|
|
629
657
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
630
658
|
}
|
|
631
|
-
const entry = await entries.setStatus(collection, id, locale, status, by);
|
|
659
|
+
const entry = await entries.setStatus(collection, id, locale, status, by, token);
|
|
632
660
|
await fanOut(id);
|
|
633
661
|
expireCollection(collection);
|
|
634
662
|
return ok(entry);
|
|
635
663
|
}),
|
|
636
|
-
discardDraft: (collection, id, locale) => guard(async () => {
|
|
664
|
+
discardDraft: (collection, id, locale, expected) => guard(async () => {
|
|
637
665
|
const unsupported = supportedLocale(locale);
|
|
638
666
|
if (unsupported)
|
|
639
667
|
return unsupported;
|
|
640
|
-
const
|
|
668
|
+
const token = tokenArg(expected);
|
|
669
|
+
if (token === BAD_TOKEN)
|
|
670
|
+
return badToken();
|
|
671
|
+
const restored = await entries.discardDraft(collection, id, locale, by, token);
|
|
641
672
|
await fanOut(id);
|
|
642
673
|
expireCollection(collection);
|
|
643
674
|
return ok(restored);
|
|
@@ -657,15 +688,18 @@ export function createAdminOps(deps) {
|
|
|
657
688
|
}
|
|
658
689
|
return ok({ ...summary(version), fields: version.fields });
|
|
659
690
|
}),
|
|
660
|
-
restore: (collection, id, locale, versionId) => guard(async () => {
|
|
691
|
+
restore: (collection, id, locale, versionId, expected) => guard(async () => {
|
|
661
692
|
const unsupported = supportedLocale(locale);
|
|
662
693
|
if (unsupported)
|
|
663
694
|
return unsupported;
|
|
695
|
+
const token = tokenArg(expected);
|
|
696
|
+
if (token === BAD_TOKEN)
|
|
697
|
+
return badToken();
|
|
664
698
|
const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
|
|
665
699
|
if (!version || version.entryId !== id || version.locale !== locale) {
|
|
666
700
|
return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}", locale ${locale}.`);
|
|
667
701
|
}
|
|
668
|
-
const restored = await entries.restore(collection, id, locale, versionId, by);
|
|
702
|
+
const restored = await entries.restore(collection, id, locale, versionId, by, token);
|
|
669
703
|
await fanOut(id);
|
|
670
704
|
expireCollection(collection);
|
|
671
705
|
return ok(restored);
|
|
@@ -720,25 +754,31 @@ export function createAdminOps(deps) {
|
|
|
720
754
|
const usage = await entries.referencesTo(name, record.id);
|
|
721
755
|
return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
|
|
722
756
|
}),
|
|
723
|
-
save: (name, locale, fields) => guard(async () => {
|
|
757
|
+
save: (name, locale, fields, expected) => guard(async () => {
|
|
724
758
|
const unsupported = supportedLocale(locale);
|
|
725
759
|
if (unsupported)
|
|
726
760
|
return unsupported;
|
|
761
|
+
const token = tokenArg(expected);
|
|
762
|
+
if (token === BAD_TOKEN)
|
|
763
|
+
return badToken();
|
|
727
764
|
const item = globalItem(name);
|
|
728
765
|
if (!item)
|
|
729
766
|
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
730
767
|
const record = await globalRecord(name);
|
|
731
768
|
if (!record)
|
|
732
769
|
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
733
|
-
const saved = await entries.update(name, record.id, locale, fields, by);
|
|
770
|
+
const saved = await entries.update(name, record.id, locale, fields, by, token);
|
|
734
771
|
// fanOut expires `global:<name>` itself — this row IS the item's.
|
|
735
772
|
await fanOut(record.id);
|
|
736
773
|
return ok(saved);
|
|
737
774
|
}),
|
|
738
|
-
setStatus: (name, locale, status) => guard(async () => {
|
|
775
|
+
setStatus: (name, locale, status, expected) => guard(async () => {
|
|
739
776
|
const unsupported = supportedLocale(locale);
|
|
740
777
|
if (unsupported)
|
|
741
778
|
return unsupported;
|
|
779
|
+
const token = tokenArg(expected);
|
|
780
|
+
if (token === BAD_TOKEN)
|
|
781
|
+
return badToken();
|
|
742
782
|
const item = globalItem(name);
|
|
743
783
|
if (!item)
|
|
744
784
|
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
@@ -757,21 +797,24 @@ export function createAdminOps(deps) {
|
|
|
757
797
|
if (fieldErrors.length > 0)
|
|
758
798
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
759
799
|
}
|
|
760
|
-
const changed = await entries.setStatus(name, record.id, locale, status, by);
|
|
800
|
+
const changed = await entries.setStatus(name, record.id, locale, status, by, token);
|
|
761
801
|
// see save: the fan-out carries the item's own tag.
|
|
762
802
|
await fanOut(record.id);
|
|
763
803
|
return ok(changed);
|
|
764
804
|
}),
|
|
765
|
-
discardDraft: (name, locale) => guard(async () => {
|
|
805
|
+
discardDraft: (name, locale, expected) => guard(async () => {
|
|
766
806
|
const unsupported = supportedLocale(locale);
|
|
767
807
|
if (unsupported)
|
|
768
808
|
return unsupported;
|
|
809
|
+
const token = tokenArg(expected);
|
|
810
|
+
if (token === BAD_TOKEN)
|
|
811
|
+
return badToken();
|
|
769
812
|
if (!globalItem(name))
|
|
770
813
|
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
771
814
|
const record = await globalRecord(name);
|
|
772
815
|
if (!record)
|
|
773
816
|
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
774
|
-
const restored = await entries.discardDraft(name, record.id, locale, by);
|
|
817
|
+
const restored = await entries.discardDraft(name, record.id, locale, by, token);
|
|
775
818
|
await fanOut(record.id);
|
|
776
819
|
return ok(restored);
|
|
777
820
|
}),
|
|
@@ -798,10 +841,13 @@ export function createAdminOps(deps) {
|
|
|
798
841
|
}
|
|
799
842
|
return ok({ ...summary(version), fields: version.fields });
|
|
800
843
|
}),
|
|
801
|
-
restore: (name, locale, versionId) => guard(async () => {
|
|
844
|
+
restore: (name, locale, versionId, expected) => guard(async () => {
|
|
802
845
|
const unsupported = supportedLocale(locale);
|
|
803
846
|
if (unsupported)
|
|
804
847
|
return unsupported;
|
|
848
|
+
const token = tokenArg(expected);
|
|
849
|
+
if (token === BAD_TOKEN)
|
|
850
|
+
return badToken();
|
|
805
851
|
if (!globalItem(name))
|
|
806
852
|
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
807
853
|
const record = await globalRecord(name);
|
|
@@ -811,7 +857,7 @@ export function createAdminOps(deps) {
|
|
|
811
857
|
if (!version || version.entryId !== record.id || version.locale !== locale) {
|
|
812
858
|
return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}" in ${locale}.`);
|
|
813
859
|
}
|
|
814
|
-
const restored = await entries.restore(name, record.id, locale, versionId, by);
|
|
860
|
+
const restored = await entries.restore(name, record.id, locale, versionId, by, token);
|
|
815
861
|
await fanOut(record.id);
|
|
816
862
|
return ok(restored);
|
|
817
863
|
}),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -6,11 +6,17 @@ import type { AssetPage, AssetQuery, FinishInput, UploadTicket } from "../asset-
|
|
|
6
6
|
import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
|
|
7
7
|
import type { MemberRole } from "../permissions.ts";
|
|
8
8
|
import type { FieldError } from "./validate.ts";
|
|
9
|
+
import type { DraftToken } from "../draft-token.ts";
|
|
9
10
|
export type OpError = {
|
|
10
11
|
ok: false;
|
|
11
12
|
code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized";
|
|
12
13
|
message: string;
|
|
13
14
|
fields?: FieldError[];
|
|
15
|
+
/** A `conflict` from the token guard (spec 2026-09-26 concurrency §2): the draft moved since the client loaded it. */
|
|
16
|
+
stale?: true;
|
|
17
|
+
/** Who wrote it last and when — the freeze notice's two facts. */
|
|
18
|
+
by?: string | null;
|
|
19
|
+
at?: number;
|
|
14
20
|
};
|
|
15
21
|
export type OpResult<T> = {
|
|
16
22
|
ok: true;
|
|
@@ -110,14 +116,16 @@ export type AdminOps = {
|
|
|
110
116
|
/** not_found only when the page is missing; an absent locale comes back with `draft: null`. */
|
|
111
117
|
get(id: string, locale: string): Promise<OpResult<PageGetResult>>;
|
|
112
118
|
create(input: PageCreateInput): Promise<OpResult<PageRecord>>;
|
|
113
|
-
|
|
119
|
+
/** `expected` is the token the client last saw (spec 2026-09-26 concurrency §1); `undefined` skips the check — the admin always sends one. */
|
|
120
|
+
saveDraft(id: string, locale: string, tree: PageTree, expected?: DraftToken | null): Promise<OpResult<{
|
|
114
121
|
versionId: string;
|
|
122
|
+
draft: DraftToken | null;
|
|
115
123
|
}>>;
|
|
116
|
-
publish(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
124
|
+
publish(id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
|
|
117
125
|
/** Takes the locale off the site; status draft, pointers kept. Never the site root or a fixed node (spec 2026-09-13 publish flow §5). */
|
|
118
126
|
unpublish(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
119
127
|
/** The draft back to the published version (§4); `blocked` when nothing is published in the locale. */
|
|
120
|
-
discardDraft(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
128
|
+
discardDraft(id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
|
|
121
129
|
delete(id: string): Promise<OpResult<null>>;
|
|
122
130
|
rename(id: string, patch: {
|
|
123
131
|
locale: string;
|
|
@@ -139,7 +147,7 @@ export type AdminOps = {
|
|
|
139
147
|
/** The locale's versions newest first, without their trees. */
|
|
140
148
|
listVersions(id: string, locale: string): Promise<OpResult<VersionSummary[]>>;
|
|
141
149
|
/** The version's tree becomes the draft (spec 2026-09-13 history §6); `not_found` for a version of another page or locale, `blocked` on a folder. */
|
|
142
|
-
restore(id: string, locale: string, versionId: string): Promise<OpResult<PageRecord>>;
|
|
150
|
+
restore(id: string, locale: string, versionId: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
|
|
143
151
|
};
|
|
144
152
|
entries: {
|
|
145
153
|
list(collection: string): Promise<OpResult<EntryRecord[]>>;
|
|
@@ -150,10 +158,11 @@ export type AdminOps = {
|
|
|
150
158
|
/** not_found only when the entry is missing; an absent locale comes back with `fields: null`. */
|
|
151
159
|
get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
|
|
152
160
|
create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
|
|
153
|
-
|
|
154
|
-
|
|
161
|
+
/** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
|
|
162
|
+
save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
163
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
155
164
|
/** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
|
|
156
|
-
discardDraft(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
165
|
+
discardDraft(collection: string, id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
157
166
|
delete(collection: string, id: string): Promise<OpResult<null>>;
|
|
158
167
|
move(collection: string, id: string, to: {
|
|
159
168
|
parentId: string | null;
|
|
@@ -170,19 +179,20 @@ export type AdminOps = {
|
|
|
170
179
|
/** One snapshot's fields; `not_found` unless it belongs to `id`. */
|
|
171
180
|
getVersion(collection: string, id: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
|
|
172
181
|
/** The snapshot becomes the draft (§6); `not_found` for a foreign version. Fans out like a save. */
|
|
173
|
-
restore(collection: string, id: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
|
|
182
|
+
restore(collection: string, id: string, locale: string, versionId: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
174
183
|
};
|
|
175
184
|
globals: {
|
|
176
185
|
/** Materializes every registered item in every supported locale, then lists them in registry order. */
|
|
177
186
|
list(): Promise<OpResult<GlobalListItem[]>>;
|
|
178
187
|
get(name: string, locale: string): Promise<OpResult<GlobalGetResult>>;
|
|
179
|
-
|
|
180
|
-
|
|
188
|
+
/** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
|
|
189
|
+
save(name: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
190
|
+
setStatus(name: string, locale: string, status: "draft" | "published", expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
181
191
|
/** The item's live row back to its published snapshot (§4); `blocked` before the first publish. */
|
|
182
|
-
discardDraft(name: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
192
|
+
discardDraft(name: string, locale: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
183
193
|
listVersions(name: string, locale: string): Promise<OpResult<VersionSummary[]>>;
|
|
184
194
|
getVersion(name: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
|
|
185
|
-
restore(name: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
|
|
195
|
+
restore(name: string, locale: string, versionId: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
186
196
|
};
|
|
187
197
|
assets: {
|
|
188
198
|
/** Ready assets, newest first, paged by cursor; `kind` and `search` filter. */
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { AdminOpAction } from "../ops.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
3
|
import { type AuthConfig } from "./session.ts";
|
|
4
|
-
|
|
4
|
+
import type { ContentConfig } from "../../env.ts";
|
|
5
|
+
export declare function AdminApp({ registry, op, auth, content }: {
|
|
5
6
|
registry: AdminRegistry;
|
|
6
7
|
op: AdminOpAction;
|
|
7
8
|
auth: AuthConfig;
|
|
9
|
+
content?: ContentConfig;
|
|
8
10
|
}): import("react").JSX.Element;
|
|
@@ -37,7 +37,10 @@ import { ElementsProvider } from "../forms/elements-context.js";
|
|
|
37
37
|
import { TimeZoneProvider } from "../forms/time-zone-context.js";
|
|
38
38
|
import { SchemaNotice } from "./SchemaNotice.js";
|
|
39
39
|
import { schemaBehind } from "../../schema-version.js";
|
|
40
|
-
|
|
40
|
+
import { createPresence } from "./presence.js";
|
|
41
|
+
import { connectRealtime } from "./presence-realtime.js";
|
|
42
|
+
import { PresenceProvider } from "./presence-context.js";
|
|
43
|
+
export function AdminApp({ registry, op, auth, content }) {
|
|
41
44
|
const session = useAdminSession(auth);
|
|
42
45
|
const [ops] = useState(() => makeClientOps(op, session.token));
|
|
43
46
|
const route = useRoute(registry.adminPath);
|
|
@@ -78,6 +81,21 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
78
81
|
return () => { live = false; };
|
|
79
82
|
}, [signedIn, ops]);
|
|
80
83
|
const userId = session.state.status === "signed_in" ? session.state.session.user.id : "";
|
|
84
|
+
// Presence (spec 2026-09-26 concurrency §5): one channel per signed-in session,
|
|
85
|
+
// joined with the content project's public config; absent config = presence off.
|
|
86
|
+
const [presence, setPresence] = useState(null);
|
|
87
|
+
const contentUrl = content?.url, contentKey = content?.key, contentSpace = content?.spaceId;
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!signedIn || !contentUrl || !contentKey || !contentSpace || !email) {
|
|
90
|
+
setPresence(null);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const config = { url: contentUrl, key: contentKey, spaceId: contentSpace };
|
|
94
|
+
const p = createPresence({ email, connect: () => connectRealtime(config, email) });
|
|
95
|
+
p.join();
|
|
96
|
+
setPresence(p);
|
|
97
|
+
return () => { p.dispose(); setPresence(null); };
|
|
98
|
+
}, [signedIn, contentUrl, contentKey, contentSpace, email]);
|
|
81
99
|
if (session.state.status === "loading") {
|
|
82
100
|
return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
|
|
83
101
|
}
|
|
@@ -111,5 +129,5 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
111
129
|
main = _jsx(MediaScreen, {});
|
|
112
130
|
else
|
|
113
131
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
114
|
-
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null && can(role, "settings"), onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }));
|
|
132
|
+
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null && can(role, "settings"), onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
|
|
115
133
|
}
|
|
@@ -17,12 +17,16 @@ import { navigate } from "./router.js";
|
|
|
17
17
|
import { confirm } from "../ui/confirm.js";
|
|
18
18
|
import { ErrorPane } from "./ErrorPane.js";
|
|
19
19
|
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
20
|
+
import { usePresenceState } from "./presence-context.js";
|
|
21
|
+
import { editorsOf } from "./presence.js";
|
|
22
|
+
import { LockIcon } from "../ui/LockIcon.js";
|
|
20
23
|
export function EntriesList({ ops, registry, collection }) {
|
|
21
24
|
const adminHref = useAdminHref();
|
|
22
25
|
const meta = registry.collections.find((c) => c.name === collection);
|
|
23
26
|
const [records, setRecords] = useState(null);
|
|
24
27
|
const [error, setError] = useState(null);
|
|
25
28
|
const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
|
|
29
|
+
const { others } = usePresenceState();
|
|
26
30
|
const multilingual = registry.locales.supported.length > 1;
|
|
27
31
|
const pickLocale = (next) => {
|
|
28
32
|
setLocale(next);
|
|
@@ -113,7 +117,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
113
117
|
key: row.id,
|
|
114
118
|
onClick: () => (row.present ? open(row.id) : void addLocale(row)),
|
|
115
119
|
cells: [
|
|
116
|
-
|
|
120
|
+
_jsxs("span", { className: "sm-cell sm-cell--title", style: dim, children: [row.title, _jsx(LockIcon, { names: editorsOf(others, { kind: "entry", id: row.id, locale }) })] }, "t"),
|
|
117
121
|
row.present
|
|
118
122
|
? _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u")
|
|
119
123
|
: _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "u"),
|
|
@@ -37,6 +37,12 @@ import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
|
37
37
|
import { navigate, useLeaveGuard } from "./router.js";
|
|
38
38
|
import { LIVE_URL_CONFIRM, liveUrlChanges } from "./live-url.js";
|
|
39
39
|
import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
40
|
+
import { draftToken } from "../../draft-token.js";
|
|
41
|
+
import { isStale, staleInfoOf } from "./stale.js";
|
|
42
|
+
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
43
|
+
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
|
|
44
|
+
import { editorsOf } from "./presence.js";
|
|
45
|
+
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
40
46
|
/** A stable empty map for the hooks that run before the "no such collection" return. */
|
|
41
47
|
const NO_FIELDS = {};
|
|
42
48
|
export function EntryForm({ ops, registry, collection, id }) {
|
|
@@ -61,6 +67,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
61
67
|
const [published, setPublished] = useState(false);
|
|
62
68
|
const [history, setHistory] = useState(null);
|
|
63
69
|
const [snapshot, setSnapshot] = useState(null);
|
|
70
|
+
const [stale, setStale] = useState(null);
|
|
64
71
|
const formRef = useRef(null);
|
|
65
72
|
const fieldsRef = useRef(fields);
|
|
66
73
|
fieldsRef.current = fields;
|
|
@@ -69,7 +76,14 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
69
76
|
/** The slug the editor already confirmed as a live-URL change, so the next autosave does not ask again. */
|
|
70
77
|
const confirmedSlug = useRef(null);
|
|
71
78
|
// The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
|
|
72
|
-
|
|
79
|
+
/** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
|
|
80
|
+
const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
|
|
81
|
+
// Presence (spec 2026-09-26 concurrency §5): this tab on this row, the others on it, every ack announced.
|
|
82
|
+
const target = { kind: "entry", id, locale };
|
|
83
|
+
useTrackPresence(target);
|
|
84
|
+
const { others } = usePresenceState();
|
|
85
|
+
const presence = usePresence();
|
|
86
|
+
const { saveState, mark, flush, reset } = useDraftAutosave({
|
|
73
87
|
read: () => fieldsRef.current,
|
|
74
88
|
persist: async (value) => {
|
|
75
89
|
// A published entry's slug is a live URL: one confirm at the save, Cancel restores it (members management §5).
|
|
@@ -86,9 +100,13 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
86
100
|
setFields((f) => ({ ...(f ?? {}), slug: stored }));
|
|
87
101
|
}
|
|
88
102
|
}
|
|
89
|
-
const r = await ops.entries.save(collection, id, locale, value);
|
|
90
|
-
if (r.ok)
|
|
103
|
+
const r = await ops.entries.save(collection, id, locale, value, token());
|
|
104
|
+
if (r.ok) {
|
|
91
105
|
setRecord(r.data);
|
|
106
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
107
|
+
}
|
|
108
|
+
else if (isStale(r))
|
|
109
|
+
setStale(staleInfoOf(r));
|
|
92
110
|
return r;
|
|
93
111
|
},
|
|
94
112
|
onSaved: () => setDirty(false),
|
|
@@ -113,6 +131,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
113
131
|
setFields(null);
|
|
114
132
|
setFieldErrors({});
|
|
115
133
|
setDirty(false);
|
|
134
|
+
setStale(null);
|
|
116
135
|
setTouched(false);
|
|
117
136
|
ops.entries.get(collection, id, locale).then((r) => {
|
|
118
137
|
if (cancelled)
|
|
@@ -151,6 +170,10 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
151
170
|
}, [fields, hasFieldErrors, meta, elementDescs]);
|
|
152
171
|
/** An op's failure onto the screen: amber with the fields for a validation result, red otherwise. */
|
|
153
172
|
const fail = (r) => {
|
|
173
|
+
if (isStale(r)) {
|
|
174
|
+
setStale(staleInfoOf(r));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
154
177
|
if (r.code === "validation" && r.fields && r.fields.length > 0) {
|
|
155
178
|
setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
|
|
156
179
|
setNotice({ tone: "warn", text: r.message, fields: r.fields });
|
|
@@ -158,6 +181,17 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
158
181
|
else
|
|
159
182
|
setNotice({ tone: "error", text: r.message });
|
|
160
183
|
};
|
|
184
|
+
/** Load their version (spec 2026-09-26 concurrency §4): drop the frozen state and re-get through the ops seam. */
|
|
185
|
+
const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
|
|
186
|
+
// Someone else saved this row (§5): clean → their version silently; unsaved edits → the freeze at once.
|
|
187
|
+
useSavedElsewhere(target, (event) => {
|
|
188
|
+
if (stale)
|
|
189
|
+
return;
|
|
190
|
+
if (dirty || saveState !== "saved")
|
|
191
|
+
setStale({ by: event.email, at: Date.now() });
|
|
192
|
+
else
|
|
193
|
+
setReloadKey((k) => k + 1);
|
|
194
|
+
});
|
|
161
195
|
/** Publish what is on screen: the pending edit is flushed first, and a failed flush cancels the publish. */
|
|
162
196
|
const publish = async () => {
|
|
163
197
|
setNotice(null);
|
|
@@ -169,7 +203,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
169
203
|
}
|
|
170
204
|
let r;
|
|
171
205
|
try {
|
|
172
|
-
r = await ops.entries.setStatus(collection, id, locale, "published");
|
|
206
|
+
r = await ops.entries.setStatus(collection, id, locale, "published", token());
|
|
173
207
|
}
|
|
174
208
|
catch {
|
|
175
209
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -180,6 +214,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
180
214
|
return;
|
|
181
215
|
}
|
|
182
216
|
setRecord(r.data);
|
|
217
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
183
218
|
setFieldErrors({});
|
|
184
219
|
setPublished(true);
|
|
185
220
|
setTimeout(() => setPublished(false), 1600);
|
|
@@ -213,7 +248,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
213
248
|
}
|
|
214
249
|
let r;
|
|
215
250
|
try {
|
|
216
|
-
r = await ops.entries.discardDraft(collection, id, locale);
|
|
251
|
+
r = await ops.entries.discardDraft(collection, id, locale, token());
|
|
217
252
|
}
|
|
218
253
|
catch {
|
|
219
254
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -224,6 +259,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
224
259
|
return;
|
|
225
260
|
}
|
|
226
261
|
setRecord(r.data);
|
|
262
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
227
263
|
setReloadKey((k) => k + 1);
|
|
228
264
|
setNotice({ tone: "ok", text: "Changes discarded." });
|
|
229
265
|
};
|
|
@@ -251,12 +287,13 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
251
287
|
fail(r);
|
|
252
288
|
};
|
|
253
289
|
const restore = async (versionId) => {
|
|
254
|
-
const r = await ops.entries.restore(collection, id, locale, versionId);
|
|
290
|
+
const r = await ops.entries.restore(collection, id, locale, versionId, token());
|
|
255
291
|
if (!r.ok) {
|
|
256
292
|
fail(r);
|
|
257
293
|
return false;
|
|
258
294
|
}
|
|
259
295
|
setRecord(r.data);
|
|
296
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
260
297
|
setReloadKey((k) => k + 1);
|
|
261
298
|
setNotice({ tone: "ok", text: "Version restored." });
|
|
262
299
|
return true;
|
|
@@ -374,6 +411,6 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
374
411
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
|
|
375
412
|
},
|
|
376
413
|
{ label: heading },
|
|
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)
|
|
414
|
+
] }) }), 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" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, 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)
|
|
378
415
|
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" })] }))] })] })] }));
|
|
379
416
|
}
|