@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
|
@@ -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
|
-
//
|
|
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.
|
|
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)
|
|
200
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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>
|
|
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' :
|
|
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
|
-
|
|
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
|
-
|
|
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; }
|