@commonpub/layer 0.23.3 → 0.25.0
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 +41 -12
- package/components/LayoutRow.vue +944 -0
- package/components/LayoutSection.vue +1028 -0
- package/components/LayoutSlot.vue +104 -162
- package/components/PageFrame.vue +116 -0
- package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
- package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
- package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
- package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
- package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
- package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
- package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
- package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
- package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
- package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
- package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
- package/components/blocks/BlockDividerView.vue +52 -2
- package/components/homepage/ContentGridSection.vue +23 -1
- package/components/homepage/HeroSection.vue +69 -8
- package/components/sections/SectionCta.vue +175 -0
- package/components/sections/SectionLearning.vue +232 -0
- package/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useFeatures.ts +32 -5
- package/composables/useLayout.ts +46 -43
- package/composables/useLayoutAnnouncer.ts +332 -0
- package/composables/useLayoutAutoSave.ts +117 -0
- package/composables/useLayoutDrag.ts +290 -0
- package/composables/useLayoutEditor.ts +593 -0
- package/composables/useLayoutHistory.ts +583 -0
- package/composables/useLayoutHotkeys.ts +366 -0
- package/composables/useLayoutResize.ts +783 -0
- package/layouts/admin.vue +137 -24
- package/middleware/admin-layouts.ts +29 -0
- package/nuxt.config.ts +14 -0
- package/package.json +8 -5
- package/pages/[...customPath].vue +154 -0
- package/pages/admin/homepage.vue +46 -0
- package/pages/admin/index.vue +16 -0
- package/pages/admin/layouts/[id].vue +1110 -0
- package/pages/admin/layouts/index.vue +356 -0
- package/pages/explore.vue +16 -6
- package/sections/builtin/content-feed.ts +18 -29
- package/sections/builtin/contests.ts +30 -0
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +36 -0
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +29 -0
- package/sections/builtin/embed.ts +31 -0
- package/sections/builtin/gallery.ts +29 -0
- package/sections/builtin/heading.ts +14 -19
- package/sections/builtin/hero.ts +16 -51
- package/sections/builtin/hubs.ts +30 -0
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +30 -0
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +35 -0
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +38 -7
- package/server/api/admin/homepage/sections.put.ts +52 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
- package/server/api/admin/layouts/[id].delete.ts +33 -1
- package/server/api/admin/layouts/[id].put.ts +78 -0
- package/server/api/admin/layouts/index.post.ts +60 -4
- package/server/api/admin/layouts/migrate-homepage.post.ts +68 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- package/server/plugins/feature-flags-prime.ts +39 -0
- package/server/utils/layoutCache.ts +37 -1
- package/server/utils/validateSectionConfigs.ts +123 -0
- package/theme/base.css +1 -0
- package/components/sections/SectionContentFeed.vue +0 -160
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionImage.vue +0 -104
- package/components/sections/SectionParagraph.vue +0 -55
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* <AdminLayoutsPaletteTile> — a single section in the palette.
|
|
4
|
+
*
|
|
5
|
+
* Phase 3b/A: each tile owns its own `makeDraggable` template ref so
|
|
6
|
+
* the palette can host N tiles, each a drag source. Mirrors the
|
|
7
|
+
* <LayoutRow> per-iteration pattern: dnd-kit composables run per
|
|
8
|
+
* component setup; the natural fit is one component instance per drag
|
|
9
|
+
* source.
|
|
10
|
+
*
|
|
11
|
+
* The tile drags a `paletteDragPayload(def)` envelope (kind:
|
|
12
|
+
* 'palette-section-spec'). On drop into a row, the row's makeDroppable
|
|
13
|
+
* onDrop hands the payload to `dispatchSectionDrop`, which mints a
|
|
14
|
+
* fresh section via `createSectionFromSpec`. Source + drop handler
|
|
15
|
+
* stay in lockstep through the shared `useLayoutDrag` module.
|
|
16
|
+
*
|
|
17
|
+
* Visual identical to the inline tile this replaces — same class names,
|
|
18
|
+
* same data attributes, same hover treatment. Only addition: cursor:
|
|
19
|
+
* grab when wired (per `feedback-visual-editor-ux-patterns` cursor-as-
|
|
20
|
+
* contract — grab is a PROMISE the drag actually works, and now it does).
|
|
21
|
+
*/
|
|
22
|
+
import { ref } from 'vue';
|
|
23
|
+
import { makeDraggable } from '@vue-dnd-kit/core';
|
|
24
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
25
|
+
import { paletteDragPayload } from '../../../composables/useLayoutDrag';
|
|
26
|
+
|
|
27
|
+
const props = defineProps<{
|
|
28
|
+
section: SectionDefinition;
|
|
29
|
+
}>();
|
|
30
|
+
|
|
31
|
+
const tileRef = ref<HTMLElement | null>(null);
|
|
32
|
+
|
|
33
|
+
// dnd-kit's payload factory takes [index, items]. A palette tile is
|
|
34
|
+
// effectively a single-item list (you can't "rearrange items within a
|
|
35
|
+
// palette tile") so index=0 + items=[envelope] is the canonical shape.
|
|
36
|
+
//
|
|
37
|
+
// The factory is invoked per drag-tick, so referencing `props.section`
|
|
38
|
+
// directly is fine — `paletteDragPayload` shallow-clones defaultConfig
|
|
39
|
+
// into the envelope so subsequent drags are independent.
|
|
40
|
+
makeDraggable(
|
|
41
|
+
tileRef,
|
|
42
|
+
{
|
|
43
|
+
groups: ['section'],
|
|
44
|
+
},
|
|
45
|
+
() => [0, [paletteDragPayload(props.section)]],
|
|
46
|
+
);
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<li
|
|
51
|
+
ref="tileRef"
|
|
52
|
+
class="cpub-admin-layouts-palette-tile"
|
|
53
|
+
:data-section-type="section.type"
|
|
54
|
+
:data-section-status="section.status ?? 'stable'"
|
|
55
|
+
:aria-label="`Drag to insert ${section.name} (${section.type}) section`"
|
|
56
|
+
tabindex="0"
|
|
57
|
+
>
|
|
58
|
+
<i :class="['cpub-admin-layouts-palette-tile-icon', section.icon]"></i>
|
|
59
|
+
<div class="cpub-admin-layouts-palette-tile-body">
|
|
60
|
+
<span class="cpub-admin-layouts-palette-tile-name">{{ section.name }}</span>
|
|
61
|
+
<span class="cpub-admin-layouts-palette-tile-desc">{{ section.description }}</span>
|
|
62
|
+
</div>
|
|
63
|
+
<span
|
|
64
|
+
v-if="section.status === 'beta'"
|
|
65
|
+
class="cpub-admin-layouts-palette-tile-badge"
|
|
66
|
+
>beta</span>
|
|
67
|
+
</li>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<style scoped>
|
|
71
|
+
/*
|
|
72
|
+
* Tile chrome — visual treatment moves here from AdminLayoutsPalette
|
|
73
|
+
* (Vue scoped styles are component-instance hashed). The palette's
|
|
74
|
+
* own scoped styles handle the surrounding layout (group titles,
|
|
75
|
+
* scroll container) — these handle the tile body itself.
|
|
76
|
+
*/
|
|
77
|
+
.cpub-admin-layouts-palette-tile {
|
|
78
|
+
display: flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
gap: var(--space-3);
|
|
81
|
+
padding: var(--space-2) var(--space-3);
|
|
82
|
+
background: var(--surface2);
|
|
83
|
+
border: 1px solid var(--border2);
|
|
84
|
+
user-select: none;
|
|
85
|
+
/*
|
|
86
|
+
* cursor: grab. The drag is now wired — this isn't a UI lie. Per
|
|
87
|
+
* `feedback-visual-editor-ux-patterns` cursor-as-contract:
|
|
88
|
+
* `grab` reads "you can pick me up". A tile WITHOUT makeDraggable
|
|
89
|
+
* should stay `cursor: default`; this one has it, so the cursor
|
|
90
|
+
* promises something true.
|
|
91
|
+
*/
|
|
92
|
+
cursor: grab;
|
|
93
|
+
}
|
|
94
|
+
.cpub-admin-layouts-palette-tile:active {
|
|
95
|
+
/* `cursor: grabbing` during active drag — second half of the
|
|
96
|
+
cursor-as-contract pattern. Pointer-state-driven (CSS :active fires
|
|
97
|
+
on mousedown) is fine since dnd-kit's activation defaults are
|
|
98
|
+
pointer-down + small distance. */
|
|
99
|
+
cursor: grabbing;
|
|
100
|
+
}
|
|
101
|
+
.cpub-admin-layouts-palette-tile:hover {
|
|
102
|
+
border-color: var(--border);
|
|
103
|
+
background: var(--surface);
|
|
104
|
+
}
|
|
105
|
+
.cpub-admin-layouts-palette-tile:focus-visible {
|
|
106
|
+
/* Keyboard-only focus ring — matches the editor's accent treatment
|
|
107
|
+
elsewhere. WCAG 2.4.7 Focus Visible (AA). */
|
|
108
|
+
outline: 2px solid var(--accent);
|
|
109
|
+
outline-offset: 2px;
|
|
110
|
+
}
|
|
111
|
+
.cpub-admin-layouts-palette-tile[data-section-status='deprecated'] { opacity: 0.5; }
|
|
112
|
+
|
|
113
|
+
.cpub-admin-layouts-palette-tile-icon {
|
|
114
|
+
flex: 0 0 auto;
|
|
115
|
+
width: 20px;
|
|
116
|
+
height: 20px;
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
justify-content: center;
|
|
120
|
+
color: var(--text-dim);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.cpub-admin-layouts-palette-tile-body {
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
gap: 1px;
|
|
127
|
+
flex: 1;
|
|
128
|
+
min-width: 0;
|
|
129
|
+
}
|
|
130
|
+
.cpub-admin-layouts-palette-tile-name { font-size: var(--text-sm); color: var(--text); }
|
|
131
|
+
.cpub-admin-layouts-palette-tile-desc {
|
|
132
|
+
font-size: var(--text-xs);
|
|
133
|
+
color: var(--text-faint);
|
|
134
|
+
white-space: nowrap;
|
|
135
|
+
overflow: hidden;
|
|
136
|
+
text-overflow: ellipsis;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.cpub-admin-layouts-palette-tile-badge {
|
|
140
|
+
font-family: var(--font-mono);
|
|
141
|
+
font-size: 10px;
|
|
142
|
+
text-transform: uppercase;
|
|
143
|
+
letter-spacing: var(--tracking-wide);
|
|
144
|
+
padding: 1px var(--space-1);
|
|
145
|
+
background: var(--accent-bg, var(--surface));
|
|
146
|
+
color: var(--accent);
|
|
147
|
+
border: 1px solid var(--accent);
|
|
148
|
+
}
|
|
149
|
+
</style>
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Editor toolbar (Phase 3a.5).
|
|
4
|
+
*
|
|
5
|
+
* Sticky top strip: breadcrumb back to /admin/layouts + layout name +
|
|
6
|
+
* state pill + viewport segmented control + save-status text +
|
|
7
|
+
* Save (manual) + Publish buttons.
|
|
8
|
+
*
|
|
9
|
+
* Viewport is a UI-only concern (translates to a max-width cap on the
|
|
10
|
+
* canvas) — the actual responsive resolution happens at runtime via
|
|
11
|
+
* the section's `responsive.{sm,md,lg}` colSpans. Picking "mobile"
|
|
12
|
+
* here doesn't mutate the layout; it just previews the reflow.
|
|
13
|
+
*
|
|
14
|
+
* Save indicator strings match docs/plans/layout-and-pages.md §7.13:
|
|
15
|
+
* - 'Saved' (saved, no dirt)
|
|
16
|
+
* - 'Saving…' (in flight)
|
|
17
|
+
* - 'Unsaved changes' (dirty, idle)
|
|
18
|
+
* - 'Save failed' (last save errored)
|
|
19
|
+
* - 'Conflict' (last save returned 409 — caller surfaces a modal)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { onMounted, onBeforeUnmount, ref } from 'vue';
|
|
23
|
+
|
|
24
|
+
const props = defineProps<{
|
|
25
|
+
/** Layout name (read from draft so renames show live). */
|
|
26
|
+
layoutName: string;
|
|
27
|
+
/** Draft state pill — draft vs published. */
|
|
28
|
+
state: 'draft' | 'published';
|
|
29
|
+
/** Current viewport — drives the segmented control's pressed state. */
|
|
30
|
+
viewport: 'mobile' | 'tablet' | 'desktop';
|
|
31
|
+
/** Save status — drives the indicator text + color. */
|
|
32
|
+
saveStatus: 'idle' | 'saving' | 'saved' | 'error' | 'conflict';
|
|
33
|
+
/** True when draft != original. */
|
|
34
|
+
dirty: boolean;
|
|
35
|
+
/** Friendly error text (only shown when saveStatus==='error'). */
|
|
36
|
+
errorMessage: string | null;
|
|
37
|
+
/** ISO timestamp of the last successful save — drives "Saved · 2m ago". */
|
|
38
|
+
lastSavedAt?: string | null;
|
|
39
|
+
/** Phase 3b/B — undo/redo button enablement + tooltip labels. */
|
|
40
|
+
canUndo?: boolean;
|
|
41
|
+
canRedo?: boolean;
|
|
42
|
+
/** Label of the command that undo/redo will apply — shown as the
|
|
43
|
+
* button's title attribute ("Undo: move hero"). null when stack
|
|
44
|
+
* is empty (button is disabled then). */
|
|
45
|
+
undoLabel?: string | null;
|
|
46
|
+
redoLabel?: string | null;
|
|
47
|
+
}>();
|
|
48
|
+
|
|
49
|
+
// Relative-time string for the save indicator. Updates every 30s so the
|
|
50
|
+
// "Saved · 5s ago" indicator stays current without manual refresh.
|
|
51
|
+
// Per UX research synthesis (session 160 audit): the relative time IS
|
|
52
|
+
// the trust signal — a bare "Saved" without context erodes user
|
|
53
|
+
// confidence ("did my LAST edit save, or one from earlier?").
|
|
54
|
+
const now = ref<number>(Date.now());
|
|
55
|
+
let nowTimer: ReturnType<typeof setInterval> | null = null;
|
|
56
|
+
onMounted(() => {
|
|
57
|
+
nowTimer = setInterval(() => { now.value = Date.now(); }, 30_000);
|
|
58
|
+
});
|
|
59
|
+
onBeforeUnmount(() => {
|
|
60
|
+
if (nowTimer) clearInterval(nowTimer);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function relativeTime(iso: string | null | undefined, ref: number): string {
|
|
64
|
+
if (!iso) return '';
|
|
65
|
+
const t = new Date(iso).getTime();
|
|
66
|
+
if (Number.isNaN(t)) return '';
|
|
67
|
+
const deltaSec = Math.max(0, Math.round((ref - t) / 1000));
|
|
68
|
+
if (deltaSec < 5) return 'just now';
|
|
69
|
+
if (deltaSec < 60) return `${deltaSec}s ago`;
|
|
70
|
+
const m = Math.round(deltaSec / 60);
|
|
71
|
+
if (m < 60) return `${m}m ago`;
|
|
72
|
+
const h = Math.round(m / 60);
|
|
73
|
+
if (h < 24) return `${h}h ago`;
|
|
74
|
+
return new Date(iso).toLocaleDateString();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const savedAgo = computed<string>(() => relativeTime(props.lastSavedAt, now.value));
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Effective state for the pill — adopts the Strapi 3-state model:
|
|
81
|
+
* - 'draft': never been published
|
|
82
|
+
* - 'published': live, no pending changes
|
|
83
|
+
* - 'modified': live, has un-published draft edits
|
|
84
|
+
* The Modified state is the one most CMSs hide. Surfacing it explicitly
|
|
85
|
+
* gives the admin a clear mental model of "what's live vs what's drafted."
|
|
86
|
+
* Per UX research synthesis (session 160 audit).
|
|
87
|
+
*/
|
|
88
|
+
const effectiveState = computed<'draft' | 'published' | 'modified'>(() => {
|
|
89
|
+
if (props.state === 'published' && props.dirty) return 'modified';
|
|
90
|
+
return props.state;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const STATE_LABELS: Record<'draft' | 'published' | 'modified', string> = {
|
|
94
|
+
draft: 'draft',
|
|
95
|
+
published: 'published',
|
|
96
|
+
modified: 'modified',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Publish button copy adapts to context per the Strapi model. */
|
|
100
|
+
const publishLabel = computed<string>(() => {
|
|
101
|
+
if (effectiveState.value === 'modified') return 'Publish changes';
|
|
102
|
+
if (effectiveState.value === 'published') return 'Republish';
|
|
103
|
+
return 'Publish';
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** Publish is disabled when there's nothing meaningful to publish — i.e.,
|
|
107
|
+
* the layout is already live and there are no draft edits. (Republish
|
|
108
|
+
* still works, but only via the explicit menu in a later phase.) */
|
|
109
|
+
const publishDisabled = computed<boolean>(() => {
|
|
110
|
+
return props.saveStatus === 'saving' || effectiveState.value === 'published';
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const emit = defineEmits<{
|
|
114
|
+
(e: 'update:viewport', value: 'mobile' | 'tablet' | 'desktop'): void;
|
|
115
|
+
(e: 'save'): void;
|
|
116
|
+
(e: 'publish'): void;
|
|
117
|
+
(e: 'discard'): void;
|
|
118
|
+
(e: 'undo'): void;
|
|
119
|
+
(e: 'redo'): void;
|
|
120
|
+
}>();
|
|
121
|
+
|
|
122
|
+
/* Phase 3b/B — undo/redo button copy. Title attribute shows the
|
|
123
|
+
* specific label of the next command ("Undo: move hero") — this is
|
|
124
|
+
* the discoverable counterpart to the hotkey + a confirmation that the
|
|
125
|
+
* stack actually holds something meaningful before the user clicks. */
|
|
126
|
+
const undoTitle = computed<string>(() =>
|
|
127
|
+
props.canUndo && props.undoLabel ? `Undo: ${props.undoLabel} (Cmd+Z)` : 'Undo (Cmd+Z)',
|
|
128
|
+
);
|
|
129
|
+
const redoTitle = computed<string>(() =>
|
|
130
|
+
props.canRedo && props.redoLabel ? `Redo: ${props.redoLabel} (Cmd+Shift+Z)` : 'Redo (Cmd+Shift+Z)',
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const indicatorText = computed<string>(() => {
|
|
134
|
+
if (props.saveStatus === 'saving') return 'Saving…';
|
|
135
|
+
if (props.saveStatus === 'conflict') return 'Conflict';
|
|
136
|
+
if (props.saveStatus === 'error') return props.errorMessage ?? 'Save failed';
|
|
137
|
+
if (props.dirty) return 'Unsaved changes';
|
|
138
|
+
// Saved + relative time — "Saved · 2m ago" is the trust signal
|
|
139
|
+
if (props.lastSavedAt) return savedAgo.value ? `Saved · ${savedAgo.value}` : 'Saved';
|
|
140
|
+
if (props.saveStatus === 'saved') return 'Saved';
|
|
141
|
+
return '';
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const indicatorTone = computed<'neutral' | 'pending' | 'success' | 'error'>(() => {
|
|
145
|
+
if (props.saveStatus === 'saving') return 'pending';
|
|
146
|
+
if (props.saveStatus === 'error' || props.saveStatus === 'conflict') return 'error';
|
|
147
|
+
if (props.saveStatus === 'saved' && !props.dirty) return 'success';
|
|
148
|
+
return 'neutral';
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const VIEWPORTS: Array<{ value: 'mobile' | 'tablet' | 'desktop'; icon: string; label: string }> = [
|
|
152
|
+
{ value: 'mobile', icon: 'fa-solid fa-mobile-screen', label: 'Mobile' },
|
|
153
|
+
{ value: 'tablet', icon: 'fa-solid fa-tablet-screen-button', label: 'Tablet' },
|
|
154
|
+
{ value: 'desktop', icon: 'fa-solid fa-display', label: 'Desktop' },
|
|
155
|
+
];
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<template>
|
|
159
|
+
<header class="cpub-admin-layouts-toolbar" role="toolbar" aria-label="Editor toolbar">
|
|
160
|
+
<NuxtLink to="/admin/layouts" class="cpub-admin-layouts-toolbar-back">
|
|
161
|
+
<i class="fa-solid fa-chevron-left"></i>
|
|
162
|
+
<span>Layouts</span>
|
|
163
|
+
</NuxtLink>
|
|
164
|
+
|
|
165
|
+
<div class="cpub-admin-layouts-toolbar-title">
|
|
166
|
+
<span class="cpub-admin-layouts-toolbar-name">{{ layoutName || '—' }}</span>
|
|
167
|
+
<span
|
|
168
|
+
class="cpub-admin-layouts-toolbar-state"
|
|
169
|
+
:data-state="effectiveState"
|
|
170
|
+
>{{ STATE_LABELS[effectiveState] }}</span>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<!--
|
|
174
|
+
Session 164 polish: palette/inspector toggles MOVED to edge tabs on the
|
|
175
|
+
panels themselves (see pages/admin/layouts/[id].vue body). The toolbar
|
|
176
|
+
previously hosted these buttons, but the placement was non-obvious —
|
|
177
|
+
collapsing made it unclear where to re-open. The edge tabs at the
|
|
178
|
+
panel/canvas boundary follow the Notion/Linear convention: when
|
|
179
|
+
expanded they sit at the panel's outer edge; when collapsed they sit
|
|
180
|
+
at the screen edge inviting expansion. The icon (« / ») tells the
|
|
181
|
+
direction.
|
|
182
|
+
-->
|
|
183
|
+
<div
|
|
184
|
+
class="cpub-admin-layouts-toolbar-viewport"
|
|
185
|
+
role="radiogroup"
|
|
186
|
+
aria-label="Preview viewport"
|
|
187
|
+
>
|
|
188
|
+
<button
|
|
189
|
+
v-for="vp in VIEWPORTS"
|
|
190
|
+
:key="vp.value"
|
|
191
|
+
type="button"
|
|
192
|
+
class="cpub-admin-layouts-toolbar-viewport-btn"
|
|
193
|
+
:aria-label="vp.label"
|
|
194
|
+
:aria-pressed="viewport === vp.value"
|
|
195
|
+
:data-active="viewport === vp.value"
|
|
196
|
+
@click="emit('update:viewport', vp.value)"
|
|
197
|
+
>
|
|
198
|
+
<i :class="vp.icon"></i>
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<!--
|
|
203
|
+
Phase 3b/B — undo / redo. Plan §7.12 toolbar mockup shows '⤺ ⤻'
|
|
204
|
+
between viewport and save indicator. Tooltip carries the next
|
|
205
|
+
command's specific label ("Undo: move hero") so the discoverable
|
|
206
|
+
affordance answers "what will Cmd+Z do?" without taking action.
|
|
207
|
+
Disabled state when stack is empty in that direction; keyboard
|
|
208
|
+
hotkeys (Cmd+Z / Cmd+Shift+Z) live independently in useLayoutHotkeys.
|
|
209
|
+
-->
|
|
210
|
+
<div
|
|
211
|
+
class="cpub-admin-layouts-toolbar-history"
|
|
212
|
+
role="group"
|
|
213
|
+
aria-label="Undo and redo"
|
|
214
|
+
>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
class="cpub-admin-layouts-toolbar-panel-btn"
|
|
218
|
+
:aria-label="undoTitle"
|
|
219
|
+
:title="undoTitle"
|
|
220
|
+
:disabled="!canUndo"
|
|
221
|
+
@click="emit('undo')"
|
|
222
|
+
>
|
|
223
|
+
<i class="fa-solid fa-rotate-left" aria-hidden="true"></i>
|
|
224
|
+
</button>
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
class="cpub-admin-layouts-toolbar-panel-btn"
|
|
228
|
+
:aria-label="redoTitle"
|
|
229
|
+
:title="redoTitle"
|
|
230
|
+
:disabled="!canRedo"
|
|
231
|
+
@click="emit('redo')"
|
|
232
|
+
>
|
|
233
|
+
<i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div
|
|
238
|
+
class="cpub-admin-layouts-toolbar-indicator"
|
|
239
|
+
:data-tone="indicatorTone"
|
|
240
|
+
role="status"
|
|
241
|
+
aria-live="polite"
|
|
242
|
+
>
|
|
243
|
+
<i v-if="saveStatus === 'saving'" class="fa-solid fa-circle-notch fa-spin"></i>
|
|
244
|
+
<i v-else-if="saveStatus === 'saved' && !dirty" class="fa-solid fa-check"></i>
|
|
245
|
+
<i v-else-if="saveStatus === 'error' || saveStatus === 'conflict'" class="fa-solid fa-triangle-exclamation"></i>
|
|
246
|
+
<span>{{ indicatorText }}</span>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div class="cpub-admin-layouts-toolbar-actions">
|
|
250
|
+
<!-- R4 audit P2 fix: Discard button wires useLayoutEditor.discard().
|
|
251
|
+
Enabled only when dirty; emits 'discard' for the parent page to
|
|
252
|
+
confirm + invoke. Previously discard() was implemented but
|
|
253
|
+
unwired — admin's only revert path was page refresh. -->
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
class="cpub-admin-layouts-toolbar-btn"
|
|
257
|
+
:disabled="!dirty || saveStatus === 'saving'"
|
|
258
|
+
@click="emit('discard')"
|
|
259
|
+
title="Discard unsaved changes"
|
|
260
|
+
>
|
|
261
|
+
<i class="fa-solid fa-rotate-left"></i>
|
|
262
|
+
<span>Discard</span>
|
|
263
|
+
</button>
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
class="cpub-admin-layouts-toolbar-btn"
|
|
267
|
+
:disabled="!dirty || saveStatus === 'saving'"
|
|
268
|
+
@click="emit('save')"
|
|
269
|
+
>
|
|
270
|
+
<i class="fa-solid fa-floppy-disk"></i>
|
|
271
|
+
<span>Save</span>
|
|
272
|
+
</button>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
class="cpub-admin-layouts-toolbar-btn cpub-admin-layouts-toolbar-btn--primary"
|
|
276
|
+
:disabled="publishDisabled"
|
|
277
|
+
@click="emit('publish')"
|
|
278
|
+
>
|
|
279
|
+
<i class="fa-solid fa-cloud-arrow-up"></i>
|
|
280
|
+
<span>{{ publishLabel }}</span>
|
|
281
|
+
</button>
|
|
282
|
+
</div>
|
|
283
|
+
</header>
|
|
284
|
+
</template>
|
|
285
|
+
|
|
286
|
+
<style scoped>
|
|
287
|
+
.cpub-admin-layouts-toolbar {
|
|
288
|
+
display: flex;
|
|
289
|
+
align-items: center;
|
|
290
|
+
gap: var(--space-4);
|
|
291
|
+
padding: var(--space-2) var(--space-4);
|
|
292
|
+
background: var(--surface);
|
|
293
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
294
|
+
flex: 0 0 auto;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.cpub-admin-layouts-toolbar-back {
|
|
298
|
+
display: inline-flex;
|
|
299
|
+
align-items: center;
|
|
300
|
+
gap: var(--space-1);
|
|
301
|
+
color: var(--text-dim);
|
|
302
|
+
text-decoration: none;
|
|
303
|
+
font-family: var(--font-mono);
|
|
304
|
+
font-size: var(--text-xs);
|
|
305
|
+
text-transform: uppercase;
|
|
306
|
+
letter-spacing: var(--tracking-wide);
|
|
307
|
+
}
|
|
308
|
+
.cpub-admin-layouts-toolbar-back:hover { color: var(--accent); }
|
|
309
|
+
.cpub-admin-layouts-toolbar-back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
310
|
+
|
|
311
|
+
.cpub-admin-layouts-toolbar-title {
|
|
312
|
+
display: inline-flex;
|
|
313
|
+
align-items: center;
|
|
314
|
+
gap: var(--space-2);
|
|
315
|
+
flex: 1;
|
|
316
|
+
min-width: 0;
|
|
317
|
+
}
|
|
318
|
+
.cpub-admin-layouts-toolbar-name {
|
|
319
|
+
font-size: var(--text-base);
|
|
320
|
+
color: var(--text);
|
|
321
|
+
overflow: hidden;
|
|
322
|
+
text-overflow: ellipsis;
|
|
323
|
+
white-space: nowrap;
|
|
324
|
+
}
|
|
325
|
+
.cpub-admin-layouts-toolbar-state {
|
|
326
|
+
display: inline-block;
|
|
327
|
+
padding: 2px var(--space-2);
|
|
328
|
+
font-family: var(--font-mono);
|
|
329
|
+
font-size: 10px;
|
|
330
|
+
text-transform: uppercase;
|
|
331
|
+
letter-spacing: var(--tracking-wide);
|
|
332
|
+
border: 1px solid var(--border2);
|
|
333
|
+
color: var(--text-dim);
|
|
334
|
+
}
|
|
335
|
+
.cpub-admin-layouts-toolbar-state[data-state='published'] {
|
|
336
|
+
color: var(--accent);
|
|
337
|
+
border-color: var(--accent);
|
|
338
|
+
}
|
|
339
|
+
/* "modified" pill: yellow border + tint background, but theme-safe
|
|
340
|
+
text color (var(--text)) for WCAG contrast. The raw --yellow token
|
|
341
|
+
(#f59e0b) is 2.07:1 on white — fails both AA text (4.5:1) and
|
|
342
|
+
non-text UI (3:1). Pairing border+tint with --text gives the visual
|
|
343
|
+
signal (warning) without the contrast failure. Per session 160
|
|
344
|
+
audit catch. */
|
|
345
|
+
.cpub-admin-layouts-toolbar-state[data-state='modified'] {
|
|
346
|
+
color: var(--text);
|
|
347
|
+
background: var(--yellow-bg);
|
|
348
|
+
border-color: var(--yellow);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* Panel toggles (palette + inspector hide/show — session 161 canvas-squish fix).
|
|
352
|
+
Same 28×28 minimum target as viewport buttons (WCAG 2.5.8 AA buffer).
|
|
353
|
+
Visually similar but bordered alone (not grouped) so they don't read as
|
|
354
|
+
part of the viewport segmented control. */
|
|
355
|
+
.cpub-admin-layouts-toolbar-panel-btn {
|
|
356
|
+
display: inline-flex;
|
|
357
|
+
align-items: center;
|
|
358
|
+
justify-content: center;
|
|
359
|
+
min-width: 28px;
|
|
360
|
+
min-height: 28px;
|
|
361
|
+
padding: var(--space-1) var(--space-2);
|
|
362
|
+
background: transparent;
|
|
363
|
+
border: 1px solid var(--border2);
|
|
364
|
+
color: var(--text-dim);
|
|
365
|
+
cursor: pointer;
|
|
366
|
+
font-size: var(--text-sm);
|
|
367
|
+
transition: color var(--transition-default), border-color var(--transition-default), background var(--transition-default);
|
|
368
|
+
}
|
|
369
|
+
.cpub-admin-layouts-toolbar-panel-btn:hover {
|
|
370
|
+
color: var(--accent);
|
|
371
|
+
border-color: var(--accent);
|
|
372
|
+
background: var(--accent-bg);
|
|
373
|
+
}
|
|
374
|
+
.cpub-admin-layouts-toolbar-panel-btn:focus-visible {
|
|
375
|
+
outline: 2px solid var(--accent);
|
|
376
|
+
outline-offset: 2px;
|
|
377
|
+
}
|
|
378
|
+
/* When the panel is hidden (aria-pressed=false), give the button a
|
|
379
|
+
subtle "active" tint so the admin sees it's holding a non-default
|
|
380
|
+
state. Pressed=true means "the panel is visible", which IS default,
|
|
381
|
+
so no tint needed. */
|
|
382
|
+
.cpub-admin-layouts-toolbar-panel-btn[aria-pressed='false'] {
|
|
383
|
+
color: var(--accent);
|
|
384
|
+
border-color: var(--accent);
|
|
385
|
+
background: var(--accent-bg);
|
|
386
|
+
}
|
|
387
|
+
/* Disabled state for undo/redo when stack is empty in that direction.
|
|
388
|
+
The aria-pressed treatment above is for toggle buttons; history
|
|
389
|
+
buttons have no pressed state, so the disabled rule wins on overlap. */
|
|
390
|
+
.cpub-admin-layouts-toolbar-panel-btn:disabled {
|
|
391
|
+
opacity: 0.4;
|
|
392
|
+
cursor: not-allowed;
|
|
393
|
+
color: var(--text-dim);
|
|
394
|
+
border-color: var(--border2);
|
|
395
|
+
background: transparent;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* Group the undo + redo buttons visually so they read as a pair (matches
|
|
399
|
+
the viewport segmented control's grouping discipline). */
|
|
400
|
+
.cpub-admin-layouts-toolbar-history {
|
|
401
|
+
display: inline-flex;
|
|
402
|
+
gap: var(--space-1);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.cpub-admin-layouts-toolbar-viewport {
|
|
406
|
+
display: inline-flex;
|
|
407
|
+
background: var(--surface2);
|
|
408
|
+
border: 1px solid var(--border2);
|
|
409
|
+
}
|
|
410
|
+
.cpub-admin-layouts-toolbar-viewport-btn {
|
|
411
|
+
display: inline-flex;
|
|
412
|
+
align-items: center;
|
|
413
|
+
justify-content: center;
|
|
414
|
+
/* WCAG 2.5.8 AA — 24×24 minimum target. We use 28×28 (the GitHub
|
|
415
|
+
Primer "small" segmented-control spec) as a buffer + comfort
|
|
416
|
+
adjustment. The visible icon stays ~14px; padding pads the hit area. */
|
|
417
|
+
min-width: 28px;
|
|
418
|
+
min-height: 28px;
|
|
419
|
+
padding: var(--space-1) var(--space-2);
|
|
420
|
+
background: transparent;
|
|
421
|
+
border: 0;
|
|
422
|
+
color: var(--text-dim);
|
|
423
|
+
cursor: pointer;
|
|
424
|
+
font-size: var(--text-sm);
|
|
425
|
+
}
|
|
426
|
+
.cpub-admin-layouts-toolbar-viewport-btn:hover { color: var(--text); }
|
|
427
|
+
.cpub-admin-layouts-toolbar-viewport-btn:focus-visible {
|
|
428
|
+
outline: 2px solid var(--accent);
|
|
429
|
+
outline-offset: -2px;
|
|
430
|
+
}
|
|
431
|
+
.cpub-admin-layouts-toolbar-viewport-btn[data-active='true'] {
|
|
432
|
+
background: var(--surface);
|
|
433
|
+
color: var(--accent);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.cpub-admin-layouts-toolbar-indicator {
|
|
437
|
+
display: inline-flex;
|
|
438
|
+
align-items: center;
|
|
439
|
+
gap: var(--space-1);
|
|
440
|
+
font-family: var(--font-mono);
|
|
441
|
+
font-size: var(--text-xs);
|
|
442
|
+
text-transform: uppercase;
|
|
443
|
+
letter-spacing: var(--tracking-wide);
|
|
444
|
+
min-width: 140px;
|
|
445
|
+
}
|
|
446
|
+
.cpub-admin-layouts-toolbar-indicator[data-tone='neutral'] { color: var(--text-dim); }
|
|
447
|
+
.cpub-admin-layouts-toolbar-indicator[data-tone='pending'] { color: var(--text-dim); }
|
|
448
|
+
.cpub-admin-layouts-toolbar-indicator[data-tone='success'] { color: var(--accent); }
|
|
449
|
+
.cpub-admin-layouts-toolbar-indicator[data-tone='error'] { color: var(--red); }
|
|
450
|
+
|
|
451
|
+
.cpub-admin-layouts-toolbar-actions { display: inline-flex; gap: var(--space-2); }
|
|
452
|
+
|
|
453
|
+
.cpub-admin-layouts-toolbar-btn {
|
|
454
|
+
display: inline-flex;
|
|
455
|
+
align-items: center;
|
|
456
|
+
gap: var(--space-1);
|
|
457
|
+
padding: var(--space-1) var(--space-3);
|
|
458
|
+
background: var(--surface);
|
|
459
|
+
border: var(--border-width-default) solid var(--border);
|
|
460
|
+
color: var(--text);
|
|
461
|
+
font-family: var(--font-mono);
|
|
462
|
+
font-size: var(--text-xs);
|
|
463
|
+
text-transform: uppercase;
|
|
464
|
+
letter-spacing: var(--tracking-wide);
|
|
465
|
+
cursor: pointer;
|
|
466
|
+
}
|
|
467
|
+
.cpub-admin-layouts-toolbar-btn:hover:not(:disabled) {
|
|
468
|
+
background: var(--surface2);
|
|
469
|
+
border-color: var(--accent);
|
|
470
|
+
}
|
|
471
|
+
.cpub-admin-layouts-toolbar-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
472
|
+
.cpub-admin-layouts-toolbar-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
473
|
+
.cpub-admin-layouts-toolbar-btn--primary {
|
|
474
|
+
background: var(--accent);
|
|
475
|
+
color: var(--surface);
|
|
476
|
+
border-color: var(--accent);
|
|
477
|
+
}
|
|
478
|
+
.cpub-admin-layouts-toolbar-btn--primary:hover:not(:disabled) {
|
|
479
|
+
background: var(--accent);
|
|
480
|
+
filter: brightness(1.1);
|
|
481
|
+
color: var(--surface);
|
|
482
|
+
}
|
|
483
|
+
</style>
|
|
@@ -1,11 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Divider block — `<hr>` with optional variant + vertical-spacing.
|
|
4
|
+
*
|
|
5
|
+
* Existing in-article use (BlockContentRenderer) passes nothing → keeps
|
|
6
|
+
* the default solid line with the stock 36px margin (preserved exactly
|
|
7
|
+
* via the `data-spacing-y='md'` rule below).
|
|
8
|
+
*
|
|
9
|
+
* Layout-engine use (Stage E.1) passes
|
|
10
|
+
* `content: { variant: 'solid'|'dashed'|'dotted'|'accent',
|
|
11
|
+
* spacingY: 'sm'|'md'|'lg'|'xl' }`
|
|
12
|
+
* to customise. Variants applied via data-attrs (`var(--*)` only).
|
|
13
|
+
*/
|
|
14
|
+
import { computed } from 'vue';
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
content?: Record<string, unknown>;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
const variant = computed(() => {
|
|
21
|
+
const v = props.content?.variant;
|
|
22
|
+
return v === 'dashed' || v === 'dotted' || v === 'accent' ? v : 'solid';
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const spacingY = computed(() => {
|
|
26
|
+
const s = props.content?.spacingY;
|
|
27
|
+
return s === 'sm' || s === 'lg' || s === 'xl' ? s : 'md';
|
|
28
|
+
});
|
|
29
|
+
</script>
|
|
30
|
+
|
|
1
31
|
<template>
|
|
2
|
-
<hr
|
|
32
|
+
<hr
|
|
33
|
+
class="cpub-block-divider"
|
|
34
|
+
:data-variant="variant"
|
|
35
|
+
:data-spacing-y="spacingY"
|
|
36
|
+
/>
|
|
3
37
|
</template>
|
|
4
38
|
|
|
5
39
|
<style scoped>
|
|
6
40
|
.cpub-block-divider {
|
|
7
41
|
border: none;
|
|
8
42
|
border-top: var(--border-width-default) solid var(--border);
|
|
9
|
-
margin: 36px 0;
|
|
43
|
+
margin: 36px 0; /* default — preserved for existing block callers */
|
|
10
44
|
}
|
|
45
|
+
|
|
46
|
+
/* variants */
|
|
47
|
+
.cpub-block-divider[data-variant='dashed'] { border-top-style: dashed; }
|
|
48
|
+
.cpub-block-divider[data-variant='dotted'] { border-top-style: dotted; }
|
|
49
|
+
.cpub-block-divider[data-variant='accent'] {
|
|
50
|
+
border-top-color: var(--accent);
|
|
51
|
+
border-top-width: 2px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* vertical spacing — explicit overrides for layout-engine sections.
|
|
55
|
+
`md` keeps the legacy 36px margin so existing block callers (passing
|
|
56
|
+
no content prop, defaulting to 'md') see no visual change. */
|
|
57
|
+
.cpub-block-divider[data-spacing-y='sm'] { margin: 16px 0; }
|
|
58
|
+
.cpub-block-divider[data-spacing-y='md'] { margin: 36px 0; }
|
|
59
|
+
.cpub-block-divider[data-spacing-y='lg'] { margin: 64px 0; }
|
|
60
|
+
.cpub-block-divider[data-spacing-y='xl'] { margin: 96px 0; }
|
|
11
61
|
</style>
|