@acms-subir/core 0.3.14 → 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 +12 -0
- package/package.json +1 -1
- package/src/components/AdminLogo.astro +23 -0
- package/src/components/ThemeSectionsEditor.tsx +53 -46
- package/src/layouts/Admin.astro +2 -13
- package/src/pages/admin/login.astro +6 -8
- package/src/styles/admin.css +1 -0
- package/src/styles/theme-editor.css +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.3.15
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 8e89435: Show the published site logo and site name on the admin login page, including its dark-mode logo. Share logo rendering with the admin sidebar so both surfaces retain the framework fallback when no site logo is configured.
|
|
14
|
+
|
|
3
15
|
## 0.3.14
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { BRAND } from '../lib/brand';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
logo?: string;
|
|
6
|
+
logoDark?: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
}
|
|
9
|
+
const { logo, logoDark, size = 20 } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
{logo ? (
|
|
13
|
+
<>
|
|
14
|
+
<img class:list={['brand-logo', { 'logo-light': !!logoDark }]} src={logo} alt="" />
|
|
15
|
+
{logoDark && <img class="brand-logo logo-dark" src={logoDark} alt="" />}
|
|
16
|
+
</>
|
|
17
|
+
) : (
|
|
18
|
+
<svg width={size} height={size} viewBox="0 0 24 24" role="img" aria-label={BRAND.name}>
|
|
19
|
+
<rect x="1" y="3" width="22" height="4.4" rx="1.9" fill="currentColor" opacity="0.85" />
|
|
20
|
+
<rect x="1" y="10" width="12.5" height="4.4" rx="1.9" fill="var(--accent)" />
|
|
21
|
+
<rect x="1" y="17" width="17.5" height="4.4" rx="1.9" fill="currentColor" opacity="0.35" />
|
|
22
|
+
</svg>
|
|
23
|
+
)}
|
|
@@ -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
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -10,6 +10,7 @@ import { accessFor } from '../lib/permissions/access';
|
|
|
10
10
|
import { AUTH } from '../lib/auth/config';
|
|
11
11
|
import ClerkSession from '../components/auth/ClerkSession.astro';
|
|
12
12
|
import ThemeScript from '../components/ThemeScript.astro';
|
|
13
|
+
import AdminLogo from '../components/AdminLogo.astro';
|
|
13
14
|
import CommandPalette from '../components/CommandPalette';
|
|
14
15
|
import '../styles/admin.css';
|
|
15
16
|
|
|
@@ -185,19 +186,7 @@ const themeOpts = [
|
|
|
185
186
|
<div class="shell" id="acms-shell">
|
|
186
187
|
<aside class="sidebar" aria-label="Admin navigation">
|
|
187
188
|
<div class="brand">
|
|
188
|
-
|
|
189
|
-
{settings.siteLogo ? (
|
|
190
|
-
<>
|
|
191
|
-
<img class:list={['brand-logo', { 'logo-light': !!settings.siteLogoDark }]} src={settings.siteLogo} alt="" />
|
|
192
|
-
{settings.siteLogoDark && <img class="brand-logo logo-dark" src={settings.siteLogoDark} alt="" />}
|
|
193
|
-
</>
|
|
194
|
-
) : (
|
|
195
|
-
<svg width="20" height="20" viewBox="0 0 24 24" role="img" aria-label={BRAND.name}>
|
|
196
|
-
<rect x="1" y="3" width="22" height="4.4" rx="1.9" fill="currentColor" opacity="0.85" />
|
|
197
|
-
<rect x="1" y="10" width="12.5" height="4.4" rx="1.9" fill="var(--accent)" />
|
|
198
|
-
<rect x="1" y="17" width="17.5" height="4.4" rx="1.9" fill="currentColor" opacity="0.35" />
|
|
199
|
-
</svg>
|
|
200
|
-
)}
|
|
189
|
+
<AdminLogo logo={settings.siteLogo} logoDark={settings.siteLogoDark} />
|
|
201
190
|
<span class="brand-name" title={`v${BRAND.version} · ${BRAND.stack}`}>{siteName}</span>
|
|
202
191
|
<button class="icon-btn" type="button" id="acms-rail-toggle" aria-label="Collapse sidebar" title="Collapse sidebar">
|
|
203
192
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.side} /></svg>
|
|
@@ -7,6 +7,7 @@ import { allowLogin } from '../../lib/auth/login-limit';
|
|
|
7
7
|
import { getRepo } from '../../lib/repo';
|
|
8
8
|
import { BRAND } from '../../lib/brand';
|
|
9
9
|
import ThemeScript from '../../components/ThemeScript.astro';
|
|
10
|
+
import AdminLogo from '../../components/AdminLogo.astro';
|
|
10
11
|
import ClerkSession from '../../components/auth/ClerkSession.astro';
|
|
11
12
|
import '../../styles/admin.css';
|
|
12
13
|
|
|
@@ -55,7 +56,8 @@ const logoutAction = `/admin/logout${next ? `?next=${encodeURIComponent(next)}`
|
|
|
55
56
|
// the destination query every sign-in link on this page carries forward
|
|
56
57
|
const keep = next ? `next=${encodeURIComponent(next)}` : back ? `return=${encodeURIComponent(back)}` : '';
|
|
57
58
|
const retryHref = `/admin/login${keep ? `?${keep}` : ''}`;
|
|
58
|
-
const
|
|
59
|
+
const settings = await (await getRepo(Astro.locals)).getSettings();
|
|
60
|
+
const siteName = settings.siteTitle || BRAND.name;
|
|
59
61
|
|
|
60
62
|
let error = '';
|
|
61
63
|
if (!clerkMode && Astro.request.method === 'POST') {
|
|
@@ -105,7 +107,7 @@ if (!clerkMode && Astro.request.method === 'POST') {
|
|
|
105
107
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
106
108
|
<ThemeScript />
|
|
107
109
|
<meta name="robots" content="noindex" />
|
|
108
|
-
<title>{clerkView === 'sign-up' ? 'Accept invitation' : 'Sign in'} · {
|
|
110
|
+
<title>{clerkView === 'sign-up' ? 'Accept invitation' : 'Sign in'} · {siteName} admin</title>
|
|
109
111
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
110
112
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
111
113
|
<ClerkSession />
|
|
@@ -114,12 +116,8 @@ if (!clerkMode && Astro.request.method === 'POST') {
|
|
|
114
116
|
<div class="login-wrap">
|
|
115
117
|
<main class:list={['card card-pad login-card', { 'is-clerk': clerkMode }]}>
|
|
116
118
|
<div class="brand">
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
<rect x="1" y="10" width="12.5" height="4.4" rx="1.9" fill="var(--accent)" />
|
|
120
|
-
<rect x="1" y="17" width="17.5" height="4.4" rx="1.9" fill="currentColor" opacity="0.35" />
|
|
121
|
-
</svg>
|
|
122
|
-
<div class="brand-name">{BRAND.name}</div>
|
|
119
|
+
<AdminLogo logo={settings.siteLogo} logoDark={settings.siteLogoDark} size={26} />
|
|
120
|
+
<div class="brand-name">{siteName}</div>
|
|
123
121
|
</div>
|
|
124
122
|
|
|
125
123
|
{!clerkMode && (
|
package/src/styles/admin.css
CHANGED
|
@@ -756,6 +756,7 @@ dialog.modal[open] { animation: modal-in 0.16s ease-out; }
|
|
|
756
756
|
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: 20px; }
|
|
757
757
|
.login-card { width: 100%; max-width: 360px; }
|
|
758
758
|
.login-card .brand { justify-content: center; padding-bottom: 20px; }
|
|
759
|
+
.login-card .brand-logo { height: 26px; max-width: 128px; }
|
|
759
760
|
.login-card .brand-name { color: var(--text); flex: none; }
|
|
760
761
|
.login-wrap { background: var(--bg); }
|
|
761
762
|
.btn.block { width: 100%; justify-content: center; min-height: 38px; }
|
|
@@ -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; }
|