@acms-subir/core 0.3.15 → 0.3.16

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.16
4
+
5
+ ### Patch Changes
6
+
7
+ - 180e37c: Replace Design page section autosave with an explicit Save draft button and keyboard shortcut. Preserve the selected section and language after saving, retain edits made during a save, and keep publishing separate from saving a draft.
8
+
3
9
  ## 0.3.15
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -12,7 +12,7 @@ import { defaultValue, fieldVisible, initialData, normalizeData, normalizeValue,
12
12
 
13
13
  // Pages tab of the Design screen: lay out a page from the active theme's
14
14
  // section registry (builtins + the theme's own sections). Edits stay local
15
- // autosave into the design draft (1.2s after the last edit) through
15
+ // until Save draft stages them through
16
16
  // POST /api/design; nothing here publishes, and a page's own content is
17
17
  // never touched (switching a page back to "Page content" keeps its layout).
18
18
 
@@ -76,7 +76,7 @@ export function useDesignApi() {
76
76
  }
77
77
  }, []);
78
78
  useEffect(() => { void load(); }, [load]);
79
- /** update local state after our own write (e.g. an autosave) without a refetch */
79
+ /** update local state after our own write (e.g. Save draft) without a refetch */
80
80
  const patch = useCallback((fn: (d: DesignApiState) => DesignApiState) => setData((d) => (d ? fn(d) : d)), []);
81
81
  return { data, error, loading, loadCount, reload: load, patch };
82
82
  }
@@ -196,8 +196,8 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
196
196
  return next;
197
197
  });
198
198
 
199
- // `base` is what the server holds (the staged draft); edits autosave into
200
- // it. A real reload rehydrates everything; our own autosaves only move
199
+ // `base` is what the server holds (the staged draft). A real reload
200
+ // rehydrates everything; our own saves only move
201
201
  // `base` and the revision forward, so typing is never overwritten.
202
202
  const [base, setBase] = useState<SectionPages>({});
203
203
  const revRef = useRef(0);
@@ -222,9 +222,12 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
222
222
  // eslint-disable-next-line react-hooks/exhaustive-deps
223
223
  }, [loadCount]);
224
224
 
225
- // click-to-edit from the preview
225
+ // Consume each preview click once. A save patches `data`; it must not
226
+ // replay an old click and close the section the user is editing now.
227
+ const appliedFocus = useRef<SectionFocus | null>(null);
226
228
  useEffect(() => {
227
- if (!focus || !data) return;
229
+ if (!focus || !data || appliedFocus.current === focus) return;
230
+ appliedFocus.current = focus;
228
231
  if (data.pages.some((p) => p.id === focus.pageId)) setPageId(focus.pageId);
229
232
  setOpenId(focus.sectionId);
230
233
  }, [focus, data]);
@@ -265,40 +268,44 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
265
268
  if (!res.ok) { setSaveError(await errorText(res, `Couldn't save (${res.status}).`)); return null; }
266
269
  return (await res.json()) as { revision: number };
267
270
  };
268
- // autosave: stage into the design draft 1.2s after the last edit
271
+ // Save an explicit snapshot. Edits made while the request is in flight
272
+ // remain local and dirty; another save must never silently include them.
269
273
  const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
270
- const saving$ = useRef(false);
274
+ const saveInFlight = useRef<Promise<void> | null>(null);
271
275
  const staleRef = useRef(false);
272
- const autosave = async () => {
273
- if (saving$.current) return;
274
- saving$.current = true;
275
- setSaveState('saving');
276
- const snapshot = pagesRef.current;
277
- try {
278
- const out = await post({ operation: 'stage', revision: revRef.current, patch: { sectionPages: snapshot } });
279
- if (!out) { setSaveState('error'); return; }
280
- revRef.current = out.revision;
281
- setBase(snapshot);
282
- setSaveState('saved');
283
- patch((d) => ({ ...d, revision: out.revision, draft: { ...(d.draft ?? {}), sectionPages: snapshot } }));
284
- // the preview couldn't swap (new layout / mode change): reload it now
285
- if (staleRef.current) { staleRef.current = false; onSaved?.(); }
286
- } catch {
287
- setSaveState('error');
288
- setSaveError("Couldn't reach the server. Your edits are kept here and will save when you're back online.");
289
- } finally {
290
- saving$.current = false;
291
- }
292
- };
293
- const autosaveRef = useRef(autosave);
294
- autosaveRef.current = autosave;
295
276
  const pagesRef = useRef(pages);
296
277
  pagesRef.current = pages;
297
- useEffect(() => {
298
- if (!data || !dirty || conflict) return;
299
- const t = setTimeout(() => void autosaveRef.current(), 1200);
300
- return () => clearTimeout(t);
301
- }, [pages, dirty, conflict, data, saveState]);
278
+ const saveDraft = (): Promise<void> => {
279
+ if (saveInFlight.current) return saveInFlight.current;
280
+ if (conflict || JSON.stringify(pagesRef.current) === JSON.stringify(baseRef.current)) return Promise.resolve();
281
+ const task = (async () => {
282
+ setSaveState('saving');
283
+ setSaveError('');
284
+ setFlash('');
285
+ const snapshot = pagesRef.current;
286
+ try {
287
+ const out = await post({ operation: 'stage', revision: revRef.current, patch: { sectionPages: snapshot } });
288
+ if (!out) { setSaveState('error'); return; }
289
+ revRef.current = out.revision;
290
+ // Flush callers need the acknowledged snapshot immediately, before
291
+ // React commits the state update (Publish must not lose the click).
292
+ baseRef.current = snapshot;
293
+ setBase(snapshot);
294
+ setSaveState('saved');
295
+ patch((d) => ({ ...d, revision: out.revision, draft: { ...(d.draft ?? {}), sectionPages: snapshot } }));
296
+ if (staleRef.current) { staleRef.current = false; onSaved?.(); }
297
+ } catch {
298
+ setSaveState('error');
299
+ setSaveError("Couldn't reach the server. Your edits are kept here. Try Save draft again when you're back online.");
300
+ } finally {
301
+ saveInFlight.current = null;
302
+ }
303
+ })();
304
+ saveInFlight.current = task;
305
+ return task;
306
+ };
307
+ const saveDraftRef = useRef(saveDraft);
308
+ saveDraftRef.current = saveDraft;
302
309
 
303
310
  // live preview: render the unsaved layout ~0.35s after you pause typing
304
311
  const liveSent = useRef('');
@@ -397,15 +404,16 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
397
404
  // eslint-disable-next-line react-hooks/exhaustive-deps
398
405
  }, [pages, lang, i18nInfo?.drafts]);
399
406
 
400
- // ⌘Z / ⌘⇧Z (Ctrl+Y) while editing sections; rich text keeps its own undo
407
+ // Save from any section field; rich text keeps its own undo shortcuts.
401
408
  const rootRef = useRef<HTMLDivElement>(null);
402
409
  useEffect(() => {
403
410
  const onKey = (e: KeyboardEvent) => {
404
411
  if (!(e.metaKey || e.ctrlKey) || !rootRef.current || rootRef.current.offsetParent === null) return;
405
412
  const t = e.target as HTMLElement | null;
406
- if (t?.isContentEditable) return;
407
413
  if (t && t !== document.body && !rootRef.current.contains(t)) return;
408
414
  const k = e.key.toLowerCase();
415
+ if (k === 's') { e.preventDefault(); void saveDraftRef.current(); return; }
416
+ if (t?.isContentEditable) return;
409
417
  if (k === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
410
418
  else if ((k === 'z' && e.shiftKey) || k === 'y') { e.preventDefault(); redo(); }
411
419
  };
@@ -545,8 +553,7 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
545
553
  };
546
554
  /** save now (the Save button, and before publishing/upgrading) */
547
555
  const flush = async (): Promise<boolean> => {
548
- if (!dirty) return true;
549
- await autosaveRef.current();
556
+ await saveDraftRef.current();
550
557
  return JSON.stringify(pagesRef.current) === JSON.stringify(baseRef.current);
551
558
  };
552
559
  if (flushRef) flushRef.current = flush;
@@ -633,13 +640,13 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
633
640
  <div className="te" ref={rootRef}>
634
641
  <p className="te-intro">
635
642
  Build a page from your theme's sections. Sections marked <b>coded</b> come from your theme's code: their look
636
- changes in code, their content changes here. Edits save to a private draft as you type; visitors see them after you <b>Publish</b>.
643
+ changes in code, their content changes here. Click <b>Save draft</b> to keep your edits; visitors see them after you <b>Publish</b>.
637
644
  </p>
638
645
  <details className="te-help-panel">
639
646
  <summary>How editing works</summary>
640
647
  <ul>
641
648
  <li><b>Click anything in the preview</b> to open its section here. The preview updates as you type.</li>
642
- <li><b>Your changes save themselves</b> to a draft. Undo with ⌘Z (Ctrl+Z), redo with ⌘⇧Z.</li>
649
+ <li><b>Save draft</b> (⌘S / Ctrl+S) keeps your changes without publishing them. Undo with ⌘Z (Ctrl+Z), redo with ⌘⇧Z.</li>
643
650
  <li><b>Publish</b> (top of this screen) makes every change live. <b>Publish this section</b> sends just one section and leaves the rest in the draft.</li>
644
651
  <li><b>Linked</b> sections (like upcoming events) fill themselves from your calendar or posts, so there's nothing to update here.</li>
645
652
  <li>Fields marked in red need fixing before that section can be published.</li>
@@ -788,7 +795,7 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
788
795
  {s.variant !== 'default' && s.variant}
789
796
  {s.hidden && ' · hidden'}
790
797
  {!def && ' · unavailable'}
791
- {unsaved && ' · saving'}
798
+ {unsaved && (saveState === 'saving' ? ' · saving' : ' · unsaved')}
792
799
  {(s.showFrom || s.showUntil) && ' · scheduled'}
793
800
  </span>
794
801
  {issueCount > 0 && <span className="te-issue-mark">{issueCount} to fix</span>}
@@ -977,13 +984,13 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
977
984
  {saveError && <p className="flash error te-alert" role="alert">{saveError}</p>}
978
985
 
979
986
  <div className="te-savebar">
980
- <span className={`te-savestate ${saveState === 'error' ? 'is-dirty' : ''}`} aria-live="polite">
981
- {flash || (saveState === 'error' ? 'Not saved yet' : dirty || saveState === 'saving' ? 'Saving draft…' : saveState === 'saved' ? 'Draft saved · publish when ready' : 'All changes saved')}
987
+ <span className={`te-savestate ${dirty || saveState === 'error' ? 'is-dirty' : ''}`} aria-live="polite">
988
+ {flash || (saveState === 'error' ? 'Not saved yet' : saveState === 'saving' ? 'Saving draft…' : dirty ? 'Unsaved changes' : saveState === 'saved' ? 'Draft saved · publish when ready' : 'All changes saved')}
982
989
  {totalIssues > 0 && <> · <button type="button" className="te-issue-link" onClick={() => setOpenId(firstIssueId)}>{totalIssues} {totalIssues === 1 ? 'field needs' : 'fields need'} fixing before publishing</button></>}
983
990
  </span>
984
991
  <button className="btn sm ghost" type="button" onClick={undo} disabled={!past.current.length} title="Undo (⌘Z)">Undo</button>
985
992
  <button className="btn sm ghost" type="button" onClick={redo} disabled={!future.current.length} title="Redo (⌘⇧Z)">Redo</button>
986
- {saveState === 'error' && <button className="btn sm" type="button" onClick={() => void autosaveRef.current()} disabled={conflict}>Try saving again</button>}
993
+ <button className="btn sm primary" type="button" onClick={() => void saveDraftRef.current()} disabled={!dirty || saveState === 'saving' || saving || conflict}>{saveState === 'saving' ? 'Saving…' : 'Save draft'}</button>
987
994
  </div>
988
995
 
989
996
  <ImagePicker
@@ -82,7 +82,8 @@
82
82
  .te-add .btn { margin-bottom: 1px; }
83
83
  .te-savebar {
84
84
  display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap;
85
- margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line);
85
+ position: sticky; bottom: 0; z-index: 2; background: var(--panel);
86
+ margin-top: 16px; padding: 12px 0; border-top: 1px solid var(--line);
86
87
  }
87
88
  .te-savestate { margin-right: auto; font-size: 12px; color: var(--faint); }
88
89
  .te-savestate.is-dirty { color: var(--amber); font-weight: 600; }