@elytracms/studio 0.0.74 → 0.0.76

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 (51) hide show
  1. package/convex/cms.ts +166 -5
  2. package/convex/schema.ts +21 -0
  3. package/dist/components/app-views/default-modules.js +4 -4
  4. package/dist/components/app-views/default-modules.js.map +1 -1
  5. package/dist/components/cms/cms-store.d.ts +37 -2
  6. package/dist/components/cms/cms-store.js +121 -8
  7. package/dist/components/cms/cms-store.js.map +1 -1
  8. package/dist/components/cms/collection-table.js +34 -17
  9. package/dist/components/cms/collection-table.js.map +1 -1
  10. package/dist/components/cms/column-width.d.ts +8 -0
  11. package/dist/components/cms/column-width.js +8 -0
  12. package/dist/components/cms/column-width.js.map +1 -1
  13. package/dist/components/cms/doc-state.d.ts +53 -0
  14. package/dist/components/cms/doc-state.js +58 -1
  15. package/dist/components/cms/doc-state.js.map +1 -1
  16. package/dist/components/collection-view/cms-collection-view-source.js +8 -4
  17. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  18. package/dist/components/project-list.js +6 -2
  19. package/dist/components/project-list.js.map +1 -1
  20. package/dist/components/studio-project-screen.js +4 -2
  21. package/dist/components/studio-project-screen.js.map +1 -1
  22. package/dist/lib/cms-views/view-model.d.ts +29 -11
  23. package/dist/lib/cms-views/view-model.js +43 -16
  24. package/dist/lib/cms-views/view-model.js.map +1 -1
  25. package/dist/lib/collection-view/collection-view-source.d.ts +16 -5
  26. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  27. package/dist/lib/i18n/messages.de.js +4 -0
  28. package/dist/lib/i18n/messages.de.js.map +1 -1
  29. package/dist/lib/i18n/messages.en.d.ts +2 -0
  30. package/dist/lib/i18n/messages.en.js +7 -0
  31. package/dist/lib/i18n/messages.en.js.map +1 -1
  32. package/dist/lib/operations/cms-persistence.d.ts +8 -0
  33. package/dist/lib/operations/cms-persistence.js +9 -1
  34. package/dist/lib/operations/cms-persistence.js.map +1 -1
  35. package/dist/lib/operations/session-hydration.d.ts +22 -0
  36. package/dist/lib/operations/session-hydration.js +12 -0
  37. package/dist/lib/operations/session-hydration.js.map +1 -1
  38. package/dist/lib/persistence/convex-adapter.js +14 -1
  39. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  40. package/dist/router/index.d.ts +7 -0
  41. package/dist/router/index.js +12 -1
  42. package/dist/router/index.js.map +1 -1
  43. package/dist/router/navigate-once.d.ts +77 -0
  44. package/dist/router/navigate-once.js +104 -0
  45. package/dist/router/navigate-once.js.map +1 -0
  46. package/dist/router/root.js +7 -3
  47. package/dist/router/root.js.map +1 -1
  48. package/dist/ui/status-dot.d.ts +17 -6
  49. package/dist/ui/status-dot.js +9 -7
  50. package/dist/ui/status-dot.js.map +1 -1
  51. package/package.json +2 -2
@@ -117,7 +117,7 @@ function defaultView(collection, pseudo = []) {
117
117
  * layer (`documents.*` / `schema.*` commands) through {@link createCmsPersister}.
118
118
  * Failed/denied commands surface as {@link CmsStore.persistError}.
119
119
  */
120
- export function CmsProvider({ children, projectId, initialCollections, initialDocuments, initialPublishedKeys, initialTimestamps, initialLocaleConfig, }) {
120
+ export function CmsProvider({ children, projectId, initialCollections, initialDocuments, initialPublishedKeys, initialTimestamps, initialPublishTimestamps, initialChangedKeys, initialLocaleConfig, }) {
121
121
  const [documents, setDocuments] = useState(() => {
122
122
  const seeded = (initialDocuments ?? sampleDocuments).map((d) => structuredClone(d));
123
123
  // EC-320 (0.0.44 follow-up): merge any documents STAGED by the
@@ -166,6 +166,71 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
166
166
  });
167
167
  }, []);
168
168
  const documentUpdatedAt = useCallback((ref) => updatedAtByKey.get(documentKey(ref)), [updatedAtByKey]);
169
+ // EC-447: per-document FIRST-publish instants behind the `$publishedAt` column.
170
+ // Seeded from hydration (the persisted `cmsDocuments.publishedAt`); UNLIKE
171
+ // `$updatedAt` there is NO session-start fallback — an absent entry is a real "no
172
+ // publish date" (EmDash). The store re-stamps/clears it authoritatively on
173
+ // publish/unpublish (the set-once + clear semantics live in the persistence spine).
174
+ const [publishedAtByKey, setPublishedAtByKey] = useState(() => {
175
+ const map = new Map();
176
+ if (initialPublishTimestamps) {
177
+ for (const [key, value] of Object.entries(initialPublishTimestamps))
178
+ map.set(key, value);
179
+ }
180
+ return map;
181
+ });
182
+ // Adopt the AUTHORITATIVE publish instant from the reconciled record: an ISO value
183
+ // stamps it (set-once is enforced server-side, so this only ever adopts the pinned
184
+ // first-publish value); `undefined` CLEARS it (unpublish). Mirrors `stampUpdatedAt`
185
+ // but supports clearing — the column shows a date iff the doc is currently published.
186
+ const stampPublishedAt = useCallback((ref, iso) => {
187
+ setPublishedAtByKey((prev) => {
188
+ const key = documentKey(ref);
189
+ if (iso === undefined) {
190
+ if (!prev.has(key))
191
+ return prev;
192
+ const next = new Map(prev);
193
+ next.delete(key);
194
+ return next;
195
+ }
196
+ if (prev.get(key) === iso)
197
+ return prev;
198
+ const next = new Map(prev);
199
+ next.set(key, iso);
200
+ return next;
201
+ });
202
+ }, []);
203
+ const documentPublishedAt = useCallback((ref) => publishedAtByKey.get(documentKey(ref)), [publishedAtByKey]);
204
+ // EC-449: the documents whose draft carries unpublished changes — the amber
205
+ // "changed" `$state` dot. A SET like `publishedKeys` (membership = the
206
+ // materialized `hasUnpublishedChanges === true`); seeded from hydration, then
207
+ // re-stamped authoritatively from returned records — the verdict is computed
208
+ // ONCE at the write chokepoint (both adapters), never client-side per render.
209
+ const [changedKeys, setChangedKeys] = useState(() => new Set(initialChangedKeys ?? []));
210
+ // Adopt the authoritative flag: `true` adds the key; `false`/`undefined` (not
211
+ // changed / cleared at unpublish / not materialized) both remove it — the dot
212
+ // renders those identically, so the set stores no tri-state.
213
+ const stampHasUnpublishedChanges = useCallback((ref, changed) => {
214
+ setChangedKeys((prev) => {
215
+ const key = documentKey(ref);
216
+ if (prev.has(key) === (changed === true))
217
+ return prev;
218
+ const next = new Set(prev);
219
+ if (changed === true)
220
+ next.add(key);
221
+ else
222
+ next.delete(key);
223
+ return next;
224
+ });
225
+ }, []);
226
+ const documentHasUnpublishedChanges = useCallback((ref) => changedKeys.has(documentKey(ref)), [changedKeys]);
227
+ // The one adoption seam for a SAVED document record (persister queue upsert,
228
+ // inline cell edit, bulk set-field, restore): stamp the server-computed
229
+ // changed verdict for the record's ref. Referentially stable — the persister
230
+ // closes over it once.
231
+ const adoptSavedRecord = useCallback((record) => {
232
+ stampHasUnpublishedChanges({ collection: record.document.collection, id: record.document.id }, record.hasUnpublishedChanges);
233
+ }, [stampHasUnpublishedChanges]);
169
234
  const [activeViews, setActiveViews] = useState({});
170
235
  // The live table state per collection where it diverges from the active
171
236
  // view; absent key = exactly the active view's state (not dirty).
@@ -203,6 +268,10 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
203
268
  // the schema alone (above); the registry adds blocks/embed prop coverage.
204
269
  lookupManifest: studioManifestLookup(),
205
270
  onError: setPersistError,
271
+ // EC-449: adopt the server-computed `hasUnpublishedChanges` verdict from
272
+ // every queued upsert's authoritative record (saves, imports, bulk-cleaned
273
+ // drafts) — the amber list dot follows the chokepoint, never a client guess.
274
+ onDocumentSaved: adoptSavedRecord,
206
275
  });
207
276
  }
208
277
  const registry = useMemo(() => createCollectionRegistry(collections), [collections]);
@@ -282,12 +351,17 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
282
351
  // landed first, then issue the commit's single command.
283
352
  await persister.ensureBaseline();
284
353
  const { operations } = await getStudioOperations();
285
- return operations.documents.upsert({ projectId, document: next });
354
+ const result = await operations.documents.upsert({ projectId, document: next });
355
+ // EC-449: adopt the chokepoint's changed verdict (inline edits
356
+ // bypass the persister queue, so its hook doesn't see them).
357
+ if (result.ok)
358
+ adoptSavedRecord(result.value);
359
+ return result;
286
360
  },
287
361
  }
288
362
  : {}),
289
363
  }, input);
290
- }, [registry, localeConfig, projectId, stampUpdatedAt]);
364
+ }, [registry, localeConfig, projectId, stampUpdatedAt, adoptSavedRecord]);
291
365
  // Shared insert sequence (EC-326): existingIds/mint/seedDefaults (via
292
366
  // `buildNewDocument`) + optimistic append + persist, consumed by both
293
367
  // `createDocument` (always inserts) and `ensureSingletonDocument`'s empty
@@ -379,8 +453,15 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
379
453
  // value so the $updatedAt column reflects the actual write, not a guess.
380
454
  if (record.value.updatedAt)
381
455
  stampUpdatedAt(ref, record.value.updatedAt);
456
+ // EC-447: adopt the authoritative `publishedAt` — set-once on the first
457
+ // publish, absent after unpublish. Passing `undefined` CLEARS the column, so
458
+ // an unpublish reverts $publishedAt to EmDash and a republish records fresh.
459
+ stampPublishedAt(ref, record.value.publishedAt);
460
+ // EC-449: adopt the authoritative changed flag — `false` after a publish
461
+ // (the draft became the baseline), absent after an unpublish (cleared).
462
+ stampHasUnpublishedChanges(ref, record.value.hasUnpublishedChanges);
382
463
  }
383
- }, [projectId, setPublished, stampUpdatedAt]);
464
+ }, [projectId, setPublished, stampUpdatedAt, stampPublishedAt, stampHasUnpublishedChanges]);
384
465
  const publishDocument = useCallback(async (ref) => {
385
466
  setPublished(ref, true); // optimistic
386
467
  // EC-273: no clearing here — the draft is already free of conditionally-hidden
@@ -439,8 +520,12 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
439
520
  // Pure map-replace: a restore always targets a document already open in
440
521
  // the session.
441
522
  setDocuments((prev) => replaceDoc(prev, restored));
523
+ // EC-449: a restore is a save-shaped write — adopt the chokepoint's fresh
524
+ // changed verdict from the returned record (e.g. restoring a published
525
+ // doc's draft to an older version flips the dot to amber immediately).
526
+ adoptSavedRecord(result.value);
442
527
  return restored;
443
- }, [projectId]);
528
+ }, [projectId, adoptSavedRecord]);
444
529
  const validate = useCallback((doc) => validateDocument(registry, doc, {
445
530
  store,
446
531
  localeConfig,
@@ -620,7 +705,15 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
620
705
  },
621
706
  });
622
707
  }
623
- return operations.documents.upsert({ projectId, document: setFieldDoc(current) });
708
+ return operations.documents
709
+ .upsert({ projectId, document: setFieldDoc(current) })
710
+ .then((result) => {
711
+ // EC-449: adopt the chokepoint's changed verdict per set-field item
712
+ // (bulk dispatch bypasses the persister queue and its hook).
713
+ if (result.ok)
714
+ adoptSavedRecord(result.value);
715
+ return result;
716
+ });
624
717
  };
625
718
  }
626
719
  return runBulkAction({
@@ -659,7 +752,13 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
659
752
  // (`publishedKeys`) via setPublished — there is no separate flat
660
753
  // `state` mirror to keep in sync (published-ness is derived from the
661
754
  // pointer set).
662
- applyState: (ref, state) => setPublished(ref, state === 'published'),
755
+ // EC-449: either transition resolves the changed flag — publish writes
756
+ // `false` (the draft became the baseline), unpublish clears it — and
757
+ // bulk has no per-item record reconcile, so mirror that here.
758
+ applyState: (ref, state) => {
759
+ setPublished(ref, state === 'published');
760
+ stampHasUnpublishedChanges(ref, false);
761
+ },
663
762
  applyDelete: (ref) => setDocuments((prev) => removeDoc(prev, ref)),
664
763
  // EC-247: fold the new value into the session document (same
665
764
  // locale-aware path the dispatched upsert used). Not a plain
@@ -668,7 +767,17 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
668
767
  applySetField: (ref) => setDocuments((prev) => prev.map((d) => (matchesRef(d, ref) ? setFieldDoc(d) : d))),
669
768
  ...(dispatch ? { dispatch } : {}),
670
769
  }, collectionId, ids, action, setField);
671
- }, [projectId, titleOf, registry, localeConfig, activeLocale, setPublished, assetExists]);
770
+ }, [
771
+ projectId,
772
+ titleOf,
773
+ registry,
774
+ localeConfig,
775
+ activeLocale,
776
+ setPublished,
777
+ assetExists,
778
+ adoptSavedRecord,
779
+ stampHasUnpublishedChanges,
780
+ ]);
672
781
  const store$ = useMemo(() => ({
673
782
  ...(projectId !== undefined ? { projectId } : {}),
674
783
  collections,
@@ -685,6 +794,8 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
685
794
  deleteDocument,
686
795
  isPublished,
687
796
  documentUpdatedAt,
797
+ documentPublishedAt,
798
+ documentHasUnpublishedChanges,
688
799
  publishDocument,
689
800
  unpublishDocument,
690
801
  loadDocumentVersions,
@@ -738,6 +849,8 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
738
849
  deleteDocument,
739
850
  isPublished,
740
851
  documentUpdatedAt,
852
+ documentPublishedAt,
853
+ documentHasUnpublishedChanges,
741
854
  publishDocument,
742
855
  unpublishDocument,
743
856
  loadDocumentVersions,