@commonpub/layer 0.82.0 → 0.83.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/components/AppToast.vue +1 -1
- package/components/ContentAvatar.vue +98 -0
- package/components/CpubCriteriaBar.vue +88 -0
- package/components/CpubDateTimeField.vue +73 -0
- package/components/CpubMarkdown.vue +3 -1
- package/components/FormatToggle.vue +2 -2
- package/components/ImageUpload.vue +5 -8
- package/components/MirrorDetailModal.vue +3 -1
- package/components/MirrorRequestApproveModal.vue +3 -1
- package/components/ProductEditModal.vue +184 -0
- package/components/RemoteFollowDialog.vue +2 -2
- package/components/SearchSidebar.vue +14 -21
- package/components/ShareToHubModal.vue +3 -1
- package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
- package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
- package/components/blocks/BlockCompareColumnsView.vue +92 -0
- package/components/blocks/BlockContentRenderer.vue +17 -0
- package/components/blocks/BlockCriteriaBarView.vue +25 -0
- package/components/blocks/BlockGalleryView.vue +5 -0
- package/components/blocks/BlockHtmlView.vue +26 -0
- package/components/blocks/BlockImageView.vue +4 -0
- package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
- package/components/blocks/BlockRoadmapView.vue +84 -0
- package/components/blocks/BlockSponsorsView.vue +89 -0
- package/components/blocks/BlockTableView.vue +49 -0
- package/components/blocks/BlockTabsView.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +155 -0
- package/components/contest/ContestCriteriaEditor.vue +79 -0
- package/components/contest/ContestEditor.vue +948 -0
- package/components/contest/ContestEntries.vue +1 -1
- package/components/contest/ContestEntryPrivateData.vue +126 -0
- package/components/contest/ContestHero.vue +114 -186
- package/components/contest/ContestJudgeManager.vue +6 -4
- package/components/contest/ContestJudgingCriteria.vue +5 -21
- package/components/contest/ContestPrizes.vue +8 -1
- package/components/contest/ContestProposalForm.vue +88 -0
- package/components/contest/ContestRules.vue +8 -1
- package/components/contest/ContestSidebar.vue +8 -2
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +3 -2
- package/components/contest/ContestSubmissionField.vue +141 -0
- package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
- package/components/contest/blocks/ContestTabPanel.vue +27 -0
- package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
- package/components/contest/blocks/HtmlBlock.vue +61 -0
- package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
- package/components/contest/blocks/RoadmapBlock.vue +127 -0
- package/components/contest/blocks/SponsorsBlock.vue +127 -0
- package/components/contest/blocks/TableBlock.vue +101 -0
- package/components/contest/blocks/TabsBlock.vue +168 -0
- package/components/editors/ArticleEditor.vue +9 -16
- package/components/editors/ExplainerEditor.vue +8 -5
- package/components/editors/ProjectEditor.vue +13 -10
- package/components/homepage/CustomHtmlSection.vue +11 -2
- package/components/hub/HubProducts.vue +4 -2
- package/components/nav/NavDropdown.vue +1 -5
- package/components/nav/NavLink.vue +2 -0
- package/components/views/ArticleView.vue +3 -56
- package/components/views/ExplainerView.vue +4 -0
- package/components/views/ProjectView.vue +83 -245
- package/composables/useContestEditor.ts +388 -0
- package/composables/useDocsPageTree.ts +154 -0
- package/composables/useDocsSiteSettings.ts +107 -0
- package/composables/useEditorAutosave.ts +131 -0
- package/composables/useEngagement.ts +13 -6
- package/composables/useFeatures.ts +9 -1
- package/composables/useFileUpload.ts +60 -0
- package/composables/useProfileContent.ts +84 -0
- package/composables/useSanitize.ts +38 -4
- package/composables/useScrollSpy.ts +87 -0
- package/layouts/admin.vue +41 -19
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +9 -9
- package/pages/[type]/index.vue +6 -1
- package/pages/admin/api-keys.vue +13 -3
- package/pages/admin/features.vue +2 -0
- package/pages/admin/federation.vue +1 -1
- package/pages/admin/layouts/[id].vue +30 -2
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +1 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -769
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +93 -7
- package/pages/contests/[slug]/judge.vue +49 -26
- package/pages/contests/create.vue +5 -466
- package/pages/contests/index.vue +7 -2
- package/pages/cookies.vue +1 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
- package/pages/docs/[siteSlug]/edit.vue +93 -231
- package/pages/events/[slug]/edit.vue +20 -20
- package/pages/events/create.vue +18 -18
- package/pages/events/index.vue +7 -2
- package/pages/hubs/[slug]/index.vue +34 -9
- package/pages/hubs/[slug]/invites.vue +312 -0
- package/pages/hubs/[slug]/members.vue +128 -0
- package/pages/hubs/[slug]/posts/[postId].vue +2 -2
- package/pages/hubs/index.vue +6 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
- package/pages/learn/index.vue +8 -1
- package/pages/messages/index.vue +1 -1
- package/pages/mirror/[id].vue +1 -1
- package/pages/products/[slug].vue +55 -2
- package/pages/products/index.vue +6 -1
- package/pages/settings/account.vue +8 -8
- package/pages/settings/profile.vue +23 -14
- package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
- package/pages/u/[username]/followers.vue +11 -3
- package/pages/u/[username]/following.vue +10 -8
- package/pages/u/[username]/index.vue +73 -7
- package/pages/videos/index.vue +13 -10
- package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
- package/server/api/admin/api-keys/[id].delete.ts +2 -2
- package/server/api/admin/api-keys/index.get.ts +1 -0
- package/server/api/admin/api-keys/index.post.ts +1 -0
- package/server/api/admin/federation/refederate.post.ts +18 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
- package/server/api/admin/layouts/[id].delete.ts +1 -4
- package/server/api/admin/layouts/[id].get.ts +1 -4
- package/server/api/admin/layouts/[id].put.ts +1 -4
- package/server/api/auth/federated/login.post.ts +12 -5
- package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
- package/server/api/content/[id]/build.get.ts +11 -0
- package/server/api/content/[id]/report.post.ts +2 -0
- package/server/api/content/[id]/versions.get.ts +15 -0
- package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
- package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
- package/server/api/contests/[slug]/export.get.ts +43 -0
- package/server/api/contests/[slug]/judge.post.ts +8 -2
- package/server/api/contests/[slug]/proposal.post.ts +36 -0
- package/server/api/contests/[slug]/user-search.get.ts +30 -0
- package/server/api/contests/index.post.ts +1 -1
- package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
- package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
- package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
- package/server/api/docs/[siteSlug]/search.get.ts +7 -1
- package/server/api/events/[slug]/attendees.get.ts +10 -0
- package/server/api/events/[slug].get.ts +9 -0
- package/server/api/events/index.get.ts +8 -1
- package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
- package/server/api/federation/content/[id]/build.get.ts +10 -0
- package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
- package/server/api/hubs/[slug]/invites.get.ts +5 -3
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
- package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
- package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
- package/server/api/hubs/[slug]/requests.get.ts +20 -0
- package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
- package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
- package/server/api/products/[id].delete.ts +22 -2
- package/server/api/registry/ping.post.ts +17 -3
- package/server/api/search/index.get.ts +5 -3
- package/server/api/social/bookmark.get.ts +1 -0
- package/server/api/social/bookmark.post.ts +1 -0
- package/server/api/social/bookmarks.get.ts +1 -0
- package/server/api/social/comments/[id].delete.ts +1 -0
- package/server/api/social/comments.get.ts +1 -0
- package/server/api/social/comments.post.ts +1 -0
- package/server/api/social/like.get.ts +1 -0
- package/server/api/social/like.post.ts +1 -0
- package/server/api/users/[username]/content.get.ts +15 -3
- package/server/api/users/[username]/follow.delete.ts +1 -0
- package/server/api/users/[username]/follow.post.ts +1 -0
- package/server/api/users/[username]/followers.get.ts +2 -1
- package/server/api/users/[username]/following.get.ts +2 -1
- package/server/middleware/content-ap.ts +8 -3
- package/server/middleware/csrf.ts +93 -0
- package/server/plugins/federation-hub-sync.ts +48 -17
- package/server/plugins/notification-email.ts +22 -3
- package/server/routes/hubs/[slug]/inbox.ts +13 -1
- package/server/routes/inbox.ts +14 -1
- package/server/routes/users/[username]/inbox.ts +13 -1
- package/server/utils/inbox.ts +7 -2
- package/server/utils/validate.ts +22 -0
- package/theme/base.css +5 -0
- package/theme/prose.css +20 -0
- package/theme/stoa-dark.css +4 -0
- package/types/contestBlocks.ts +122 -0
- package/utils/contestBlocks.ts +107 -0
- package/utils/contestBody.ts +25 -0
- package/utils/contestStages.ts +62 -0
- package/utils/contestSubmission.ts +97 -0
- package/utils/datetime.ts +45 -0
- package/utils/projectBlocks.ts +162 -0
- package/components/editors/BlogEditor.vue +0 -648
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useEditorAutosave — debounced save engine for block-editor pages.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from docs/[siteSlug]/edit.vue (session 205), which had grown
|
|
5
|
+
* its own inline autosave: a debounce timer, a status state machine, the
|
|
6
|
+
* Cmd+S shortcut, and a beforeunload guard all hand-wired in setup(). This
|
|
7
|
+
* pulls that engine into one tested unit so the page only has to supply a
|
|
8
|
+
* `persist()` callback and call `markDirty()` from its watchers.
|
|
9
|
+
*
|
|
10
|
+
* Why not reuse useLayoutAutoSave? That composable is a *leading*-debounce
|
|
11
|
+
* watcher (it fires `debounceMs` after the dirty flag first flips true and
|
|
12
|
+
* does NOT re-arm on continued edits) and deliberately owns nothing else —
|
|
13
|
+
* the layout editor keeps its status/shortcuts elsewhere. The docs editor
|
|
14
|
+
* needs a *trailing* debounce (save once the user pauses), plus the status
|
|
15
|
+
* machine + Cmd+S + unsaved-changes guard. Different semantics, different
|
|
16
|
+
* surface; consolidating the three autosave call-sites is its own follow-up.
|
|
17
|
+
*
|
|
18
|
+
* Router-coupled navigation guards (onBeforeRouteLeave) stay in the page —
|
|
19
|
+
* this composable is intentionally router-free so it unit-tests without a
|
|
20
|
+
* router instance.
|
|
21
|
+
*/
|
|
22
|
+
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue';
|
|
23
|
+
|
|
24
|
+
export type AutosaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
|
25
|
+
|
|
26
|
+
export interface UseEditorAutosaveOptions {
|
|
27
|
+
/** Performs the actual persistence (the PUT + any refresh). Must throw on failure. */
|
|
28
|
+
persist: () => Promise<void>;
|
|
29
|
+
/** Gate — autosave and manual save only run when this returns true (e.g. a page is selected). Default: always. */
|
|
30
|
+
canSave?: () => boolean;
|
|
31
|
+
/** Trailing-debounce window in ms. Default 5000 (docs editor saves once the user pauses for 5s). */
|
|
32
|
+
debounceMs?: number;
|
|
33
|
+
/** Called with the thrown error when a save fails — the page wires this to a toast. */
|
|
34
|
+
onError?: (err: unknown) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseEditorAutosave {
|
|
38
|
+
/** True when there are unsaved changes. */
|
|
39
|
+
isDirty: Ref<boolean>;
|
|
40
|
+
/** When true, markDirty() is a no-op — set during content load so programmatic edits do not mark dirty. */
|
|
41
|
+
isLoading: Ref<boolean>;
|
|
42
|
+
/** Save-lifecycle status for the UI (status dot + label). */
|
|
43
|
+
status: Ref<AutosaveStatus>;
|
|
44
|
+
/** True while a save is in flight. */
|
|
45
|
+
saving: Ref<boolean>;
|
|
46
|
+
/** Mark dirty and (re)arm the trailing-debounce timer. No-op while isLoading. */
|
|
47
|
+
markDirty: () => void;
|
|
48
|
+
/** Cancel any pending timer and persist immediately. Used by Cmd+S, the Save button, and save-before-switch. */
|
|
49
|
+
saveNow: () => Promise<void>;
|
|
50
|
+
/** Clear dirty + reset status to idle — call after loading a freshly-selected page. */
|
|
51
|
+
reset: () => void;
|
|
52
|
+
/** Cancel a pending autosave without saving. */
|
|
53
|
+
cancel: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useEditorAutosave(opts: UseEditorAutosaveOptions): UseEditorAutosave {
|
|
57
|
+
const debounceMs = opts.debounceMs ?? 5000;
|
|
58
|
+
const canSave = opts.canSave ?? ((): boolean => true);
|
|
59
|
+
|
|
60
|
+
const isDirty = ref(false);
|
|
61
|
+
const isLoading = ref(false);
|
|
62
|
+
const status = ref<AutosaveStatus>('idle');
|
|
63
|
+
const saving = ref(false);
|
|
64
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
65
|
+
|
|
66
|
+
function cancel(): void {
|
|
67
|
+
if (timer !== null) {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
timer = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function saveNow(): Promise<void> {
|
|
74
|
+
if (!canSave()) return;
|
|
75
|
+
if (saving.value) return; // re-entrancy guard — never fire two PUTs for one change
|
|
76
|
+
cancel();
|
|
77
|
+
saving.value = true;
|
|
78
|
+
status.value = 'saving';
|
|
79
|
+
try {
|
|
80
|
+
await opts.persist();
|
|
81
|
+
isDirty.value = false;
|
|
82
|
+
status.value = 'saved';
|
|
83
|
+
} catch (err: unknown) {
|
|
84
|
+
// Leave isDirty true — the change is still unsaved and should retry/save again.
|
|
85
|
+
status.value = 'error';
|
|
86
|
+
opts.onError?.(err);
|
|
87
|
+
} finally {
|
|
88
|
+
saving.value = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function markDirty(): void {
|
|
93
|
+
if (isLoading.value) return;
|
|
94
|
+
isDirty.value = true;
|
|
95
|
+
cancel();
|
|
96
|
+
timer = setTimeout(() => {
|
|
97
|
+
timer = null;
|
|
98
|
+
if (isDirty.value && canSave()) void saveNow();
|
|
99
|
+
}, debounceMs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function reset(): void {
|
|
103
|
+
cancel();
|
|
104
|
+
isDirty.value = false;
|
|
105
|
+
status.value = 'idle';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
109
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
void saveNow();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function onBeforeUnload(e: BeforeUnloadEvent): void {
|
|
116
|
+
if (isDirty.value) e.preventDefault();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
onMounted(() => {
|
|
120
|
+
if (typeof document !== 'undefined') document.addEventListener('keydown', onKeydown);
|
|
121
|
+
if (typeof window !== 'undefined') window.addEventListener('beforeunload', onBeforeUnload);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
onBeforeUnmount(() => {
|
|
125
|
+
cancel();
|
|
126
|
+
if (typeof document !== 'undefined') document.removeEventListener('keydown', onKeydown);
|
|
127
|
+
if (typeof window !== 'undefined') window.removeEventListener('beforeunload', onBeforeUnload);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return { isDirty, isLoading, status, saving, markDirty, saveNow, reset, cancel };
|
|
131
|
+
}
|
|
@@ -150,16 +150,23 @@ export function useEngagement(opts: EngagementOptions) {
|
|
|
150
150
|
|
|
151
151
|
async function share(): Promise<void> {
|
|
152
152
|
if (!contentId.value) return;
|
|
153
|
+
const url = window.location.href;
|
|
153
154
|
if (navigator.share) {
|
|
154
155
|
try {
|
|
155
|
-
await navigator.share({
|
|
156
|
-
url: window.location.href,
|
|
157
|
-
});
|
|
156
|
+
await navigator.share({ url });
|
|
158
157
|
} catch {
|
|
159
|
-
// User cancelled or
|
|
158
|
+
// User cancelled or share unavailable — nothing to report.
|
|
160
159
|
}
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// No Web Share API (desktop / non-secure context): copy + confirm so the
|
|
163
|
+
// button isn't a silent no-op.
|
|
164
|
+
const toast = useToast();
|
|
165
|
+
try {
|
|
166
|
+
await navigator.clipboard.writeText(url);
|
|
167
|
+
toast.success('Link copied to clipboard');
|
|
168
|
+
} catch {
|
|
169
|
+
toast.error('Could not copy the link');
|
|
163
170
|
}
|
|
164
171
|
}
|
|
165
172
|
|
|
@@ -20,6 +20,11 @@ export interface FeatureFlags {
|
|
|
20
20
|
/** Per-stage submission artifacts for multi-round contests. Default ON;
|
|
21
21
|
* inert until a stage defines a submissionTemplate. */
|
|
22
22
|
contestStageSubmissions: boolean;
|
|
23
|
+
/** Form-first proposal submissions + draft placeholder project (Phase 4). Default OFF. */
|
|
24
|
+
contestProposals: boolean;
|
|
25
|
+
/** Offer PII field types in the submission-form builder (Phase 4). Default OFF.
|
|
26
|
+
* PII access is always gated server-side by `contest.pii.read`. */
|
|
27
|
+
contestPii: boolean;
|
|
23
28
|
events: boolean;
|
|
24
29
|
learning: boolean;
|
|
25
30
|
explainers: boolean;
|
|
@@ -68,7 +73,8 @@ let hydrated = false;
|
|
|
68
73
|
// `flags.value.X` access would crash at runtime.
|
|
69
74
|
export const DEFAULT_FLAGS: FeatureFlags = {
|
|
70
75
|
content: true, social: true, hubs: true, docs: true, video: true,
|
|
71
|
-
contests: false, contestStageSubmissions: true,
|
|
76
|
+
contests: false, contestStageSubmissions: true, contestProposals: false, contestPii: false,
|
|
77
|
+
events: false, learning: true, explainers: true,
|
|
72
78
|
editorial: true, federation: false, admin: false, themeStudio: true, emailNotifications: false,
|
|
73
79
|
publicApi: false, contentImport: true,
|
|
74
80
|
layoutEngine: false,
|
|
@@ -169,6 +175,8 @@ export function useFeatures() {
|
|
|
169
175
|
video: computed(() => flags.value.video),
|
|
170
176
|
contests: computed(() => flags.value.contests),
|
|
171
177
|
contestStageSubmissions: computed(() => flags.value.contestStageSubmissions),
|
|
178
|
+
contestProposals: computed(() => flags.value.contestProposals),
|
|
179
|
+
contestPii: computed(() => flags.value.contestPii),
|
|
172
180
|
events: computed(() => flags.value.events),
|
|
173
181
|
learning: computed(() => flags.value.learning),
|
|
174
182
|
explainers: computed(() => flags.value.explainers),
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFileUpload — single source of truth for the `/api/files/upload` POST.
|
|
3
|
+
*
|
|
4
|
+
* Eight call sites used to copy-paste the same `FormData` + `$fetch` dance
|
|
5
|
+
* (build FormData, append `file`, optionally append `purpose`, POST as
|
|
6
|
+
* multipart). They diverged only in the response shape they read back
|
|
7
|
+
* (`{ url }`, plus optional `originalName`/`size`/`sizeBytes`/`mimeType`/
|
|
8
|
+
* `width`/`height`) and in how they handle errors. This composable owns the
|
|
9
|
+
* request; callers keep their own success/error handling and pick the response
|
|
10
|
+
* shape via the `T` type parameter.
|
|
11
|
+
*
|
|
12
|
+
* Behaviour is identical to the previous inline code: same endpoint, same
|
|
13
|
+
* field names (`file`, optional `purpose`), same multipart body, errors
|
|
14
|
+
* propagate to the caller (no swallowing here).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Every upload response includes at least the stored URL. */
|
|
18
|
+
export interface FileUploadResult {
|
|
19
|
+
url: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseFileUpload {
|
|
23
|
+
/**
|
|
24
|
+
* POST a file to `/api/files/upload` as multipart FormData.
|
|
25
|
+
*
|
|
26
|
+
* @param file the File/Blob to upload (appended as the `file` field).
|
|
27
|
+
* @param purpose optional upload purpose (e.g. 'avatar', 'banner', 'cover',
|
|
28
|
+
* 'content'); appended as the `purpose` field when provided.
|
|
29
|
+
* Omit it to match the legacy hub-post upload, which sent none.
|
|
30
|
+
* @returns the parsed JSON response, typed by the caller via `T`.
|
|
31
|
+
*/
|
|
32
|
+
uploadFile: <T extends FileUploadResult = FileUploadResult>(
|
|
33
|
+
file: Blob,
|
|
34
|
+
purpose?: string,
|
|
35
|
+
) => Promise<T>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useFileUpload(): UseFileUpload {
|
|
39
|
+
async function uploadFile<T extends FileUploadResult = FileUploadResult>(
|
|
40
|
+
file: Blob,
|
|
41
|
+
purpose?: string,
|
|
42
|
+
): Promise<T> {
|
|
43
|
+
const formData = new FormData();
|
|
44
|
+
formData.append('file', file);
|
|
45
|
+
if (purpose !== undefined) {
|
|
46
|
+
formData.append('purpose', purpose);
|
|
47
|
+
}
|
|
48
|
+
// Keep the `<T>` generic (it bounds the request type so Nuxt's route-scoring
|
|
49
|
+
// typegen doesn't recurse over the whole route union) but cast the result: the
|
|
50
|
+
// typed overload otherwise mis-resolves to the GET response shape in some apps'
|
|
51
|
+
// route typegen (a Nuxt fragility any new GET route can perturb). The upload
|
|
52
|
+
// endpoint returns a FileUploadResult, so the cast is sound.
|
|
53
|
+
return (await $fetch<T>('/api/files/upload', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
body: formData,
|
|
56
|
+
})) as unknown as T;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { uploadFile };
|
|
60
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Serialized, ContentListItem } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-tab keyset content loader for a user profile.
|
|
5
|
+
*
|
|
6
|
+
* The profile page used to fetch one page of all the user's published content
|
|
7
|
+
* and split it into tabs client-side, so a tab with many items was silently
|
|
8
|
+
* truncated and an owner never saw their drafts. This loads the active tab's
|
|
9
|
+
* slice from `GET /api/users/[username]/content` with keyset pagination (the
|
|
10
|
+
* server endpoint orders by publishedAt DESC NULLS LAST, id DESC) and pages by
|
|
11
|
+
* the opaque `nextCursor` — the same dup-safe strategy as useContentFeed, but
|
|
12
|
+
* pointed at the per-user endpoint (kept separate so the homepage feed loader
|
|
13
|
+
* stays untouched).
|
|
14
|
+
*
|
|
15
|
+
* The caller passes a reactive query ({ type?, drafts?, limit }). When the query
|
|
16
|
+
* identity changes (tab switch) the feed re-fetches from the first page and the
|
|
17
|
+
* accumulator resets. Draft visibility is enforced server-side from the
|
|
18
|
+
* authenticated viewer; `drafts: 'true'` is only a request.
|
|
19
|
+
*/
|
|
20
|
+
export type ProfileFeedItem = Serialized<ContentListItem>;
|
|
21
|
+
|
|
22
|
+
type ProfileQuery = Record<string, unknown> & { limit?: number };
|
|
23
|
+
|
|
24
|
+
// Shallow wire type. Items are Array<Record<string, unknown>> (not the deep
|
|
25
|
+
// Serialized<…> mapped type) to avoid TS2589 "excessively deep" through
|
|
26
|
+
// useFetch's own generic machinery under the consumer apps' stricter typecheck;
|
|
27
|
+
// we cast once to ProfileFeedItem[] at the `items` boundary callers read.
|
|
28
|
+
interface ProfileFeedResponse { items: Array<Record<string, unknown>>; nextCursor?: string | null }
|
|
29
|
+
|
|
30
|
+
export function useProfileContent(username: string, query: Ref<ProfileQuery> | ComputedRef<ProfileQuery>) {
|
|
31
|
+
const toast = useToast();
|
|
32
|
+
|
|
33
|
+
const endpoint = `/api/users/${username}/content`;
|
|
34
|
+
// @ts-ignore TS2589: parameterising useFetch with the deep Serialized type blows
|
|
35
|
+
// the instantiation depth under the consumer apps' stricter typecheck. Runtime is
|
|
36
|
+
// unaffected; `data` is read through the typed `page` computed below.
|
|
37
|
+
const { data, pending } = useFetch(endpoint, { query, watch: [query] });
|
|
38
|
+
const page = computed<ProfileFeedResponse | null>(() => (data.value as ProfileFeedResponse | null) ?? null);
|
|
39
|
+
|
|
40
|
+
// Accumulator for load-more pages, kept separate from the useFetch payload so a
|
|
41
|
+
// tab switch cleanly replaces the list.
|
|
42
|
+
const extra = ref<Array<Record<string, unknown>>>([]);
|
|
43
|
+
const cursor = ref<string | null>(null);
|
|
44
|
+
const loadingMore = ref(false);
|
|
45
|
+
const exhausted = ref(false);
|
|
46
|
+
|
|
47
|
+
const items = computed<ProfileFeedItem[]>(
|
|
48
|
+
() => [...(page.value?.items ?? []), ...extra.value] as ProfileFeedItem[],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Reset pagination whenever the tab/filter changes.
|
|
52
|
+
watch(query, () => {
|
|
53
|
+
extra.value = [];
|
|
54
|
+
cursor.value = null;
|
|
55
|
+
exhausted.value = false;
|
|
56
|
+
}, { deep: true });
|
|
57
|
+
|
|
58
|
+
const canLoadMore = computed(() => {
|
|
59
|
+
if (exhausted.value) return false;
|
|
60
|
+
// The next cursor is whatever the last load-more returned, else the first page's.
|
|
61
|
+
return (cursor.value ?? page.value?.nextCursor ?? null) != null;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function loadMore(): Promise<void> {
|
|
65
|
+
if (loadingMore.value || !canLoadMore.value) return;
|
|
66
|
+
const next = cursor.value ?? page.value?.nextCursor ?? null;
|
|
67
|
+
if (!next) return;
|
|
68
|
+
loadingMore.value = true;
|
|
69
|
+
try {
|
|
70
|
+
const res = await $fetch<ProfileFeedResponse>(endpoint, {
|
|
71
|
+
query: { ...query.value, cursor: next },
|
|
72
|
+
});
|
|
73
|
+
extra.value.push(...res.items);
|
|
74
|
+
cursor.value = res.nextCursor ?? null;
|
|
75
|
+
if (!res.nextCursor) exhausted.value = true;
|
|
76
|
+
} catch {
|
|
77
|
+
toast.error('Failed to load more');
|
|
78
|
+
} finally {
|
|
79
|
+
loadingMore.value = false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { items, pending, loadMore, canLoadMore, loadingMore };
|
|
84
|
+
}
|
|
@@ -174,15 +174,49 @@ const RICH_URL_ATTRS = new Set(['href', 'src', 'xlink:href']);
|
|
|
174
174
|
// CSS constructs that can fetch, exfiltrate, or execute — stripped per-declaration.
|
|
175
175
|
const STYLE_DECL_BLOCKLIST = /expression\s*\(|javascript:|vbscript:|behavior\s*:|-moz-binding|@import|url\s*\(/i;
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
// Color neutralization (opt-in, for dark-mode-safe author HTML). A declaration of
|
|
178
|
+
// one of these properties whose value is a HARDCODED color literal (hex / rgb()/
|
|
179
|
+
// hsl() / common named) is dropped so the themed `.cpub-md-html` baseline shows
|
|
180
|
+
// through — but theme-adaptive values (var(), currentColor, inherit, transparent)
|
|
181
|
+
// are KEPT, so an author who writes `color: var(--text)` is respected.
|
|
182
|
+
const COLOR_PROPS = new Set(['color', 'background', 'background-color', 'border-color', 'outline-color']);
|
|
183
|
+
const LITERAL_COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?)\s*\(|\b(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|gold|maroon|navy|teal|olive|lime|aqua|cyan|magenta|brown|beige|ivory|crimson|coral|salmon|khaki|indigo|violet)\b/i;
|
|
184
|
+
const ADAPTIVE_COLOR = /var\(|currentcolor|inherit|transparent|initial|unset/i;
|
|
185
|
+
|
|
186
|
+
function isClashingColorDecl(decl: string): boolean {
|
|
187
|
+
const ci = decl.indexOf(':');
|
|
188
|
+
if (ci <= 0) return false;
|
|
189
|
+
const prop = decl.slice(0, ci).trim().toLowerCase();
|
|
190
|
+
if (!COLOR_PROPS.has(prop)) return false;
|
|
191
|
+
const val = decl.slice(ci + 1);
|
|
192
|
+
if (ADAPTIVE_COLOR.test(val)) return false;
|
|
193
|
+
return LITERAL_COLOR.test(val);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function sanitizeStyleAttr(value: string, neutralizeColors = false): string {
|
|
178
197
|
return value
|
|
179
198
|
.split(';')
|
|
180
|
-
.filter((decl) =>
|
|
199
|
+
.filter((decl) => {
|
|
200
|
+
if (!decl.trim() || STYLE_DECL_BLOCKLIST.test(decl)) return false;
|
|
201
|
+
if (neutralizeColors && isClashingColorDecl(decl)) return false;
|
|
202
|
+
return true;
|
|
203
|
+
})
|
|
181
204
|
.join(';');
|
|
182
205
|
}
|
|
183
206
|
|
|
207
|
+
export interface SanitizeRichOptions {
|
|
208
|
+
/**
|
|
209
|
+
* Drop inline hardcoded color/background literals so the themed render baseline
|
|
210
|
+
* (dark-safe) shows through. Theme-adaptive values (var(), currentColor) are
|
|
211
|
+
* kept. Off by default (general-purpose rendering preserves author colors);
|
|
212
|
+
* on for the contest "Full HTML" fields so a pasted light-mode document stays
|
|
213
|
+
* readable in dark mode.
|
|
214
|
+
*/
|
|
215
|
+
neutralizeColors?: boolean;
|
|
216
|
+
}
|
|
217
|
+
|
|
184
218
|
/** Sanitize author HTML for "full HTML" rendering — permissive but script-free. */
|
|
185
|
-
export function sanitizeRichHtml(html: string): string {
|
|
219
|
+
export function sanitizeRichHtml(html: string, opts: SanitizeRichOptions = {}): string {
|
|
186
220
|
if (!html || typeof html !== 'string') return '';
|
|
187
221
|
|
|
188
222
|
let result = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
@@ -219,7 +253,7 @@ export function sanitizeRichHtml(html: string): string {
|
|
|
219
253
|
if (RICH_URL_ATTRS.has(name) && !isSafeUrl(value)) continue;
|
|
220
254
|
|
|
221
255
|
if (name === 'style') {
|
|
222
|
-
const cleaned = sanitizeStyleAttr(value);
|
|
256
|
+
const cleaned = sanitizeStyleAttr(value, opts.neutralizeColors);
|
|
223
257
|
if (cleaned) safeAttrs.push(`style="${escapeAttrValue(cleaned)}"`);
|
|
224
258
|
continue;
|
|
225
259
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useScrollSpy — TOC scroll-spy (active-heading highlight) + smooth scroll.
|
|
3
|
+
*
|
|
4
|
+
* Consolidates two divergent, each-buggy copies of the same IntersectionObserver
|
|
5
|
+
* pattern: ProjectView.vue (which never re-observed, so the highlight went stale
|
|
6
|
+
* when content changed) and the docs viewer (which re-observed but leaked an
|
|
7
|
+
* observer every time because its instance was function-local and never
|
|
8
|
+
* disconnected). This single source:
|
|
9
|
+
* - re-observes whenever `source` changes (fixes the stale highlight), and
|
|
10
|
+
* - disconnects the previous observer before each re-observe and on unmount
|
|
11
|
+
* (fixes the leak).
|
|
12
|
+
*
|
|
13
|
+
* Element discovery and `rootMargin` stay per-surface (one finds elements by TOC
|
|
14
|
+
* id, the other by CSS selector; the active band differs), so callers pass those
|
|
15
|
+
* in. `scrollTo` honours prefers-reduced-motion (the docs viewer uses native
|
|
16
|
+
* anchor links instead and simply ignores it).
|
|
17
|
+
*/
|
|
18
|
+
import { onMounted, onUnmounted, ref, watch, nextTick, type Ref, type WatchSource } from 'vue';
|
|
19
|
+
|
|
20
|
+
export interface UseScrollSpyOptions {
|
|
21
|
+
/** Resolve the heading elements to observe. Called on mount + on each `source` change. */
|
|
22
|
+
getHeadingElements: () => HTMLElement[];
|
|
23
|
+
/** Reactive trigger; when it changes, the observer is rebuilt (e.g. the TOC array or rendered page). */
|
|
24
|
+
source: WatchSource;
|
|
25
|
+
/** IntersectionObserver rootMargin — the active band, tuned per surface. */
|
|
26
|
+
rootMargin?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseScrollSpy {
|
|
30
|
+
/** Id of the heading currently in the active band (or set by scrollTo). */
|
|
31
|
+
activeId: Ref<string>;
|
|
32
|
+
/** Smooth-scroll to a heading by id and mark it active (honours reduced-motion). */
|
|
33
|
+
scrollTo: (id: string) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_ROOT_MARGIN = '-80px 0px -70% 0px';
|
|
37
|
+
|
|
38
|
+
export function useScrollSpy(options: UseScrollSpyOptions): UseScrollSpy {
|
|
39
|
+
const activeId = ref('');
|
|
40
|
+
let observer: IntersectionObserver | null = null;
|
|
41
|
+
|
|
42
|
+
function observe(): void {
|
|
43
|
+
if (typeof IntersectionObserver === 'undefined') return; // SSR / unsupported
|
|
44
|
+
observer?.disconnect(); // never leak the previous observer
|
|
45
|
+
const els = options.getHeadingElements();
|
|
46
|
+
if (!els.length) {
|
|
47
|
+
observer = null;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
observer = new IntersectionObserver(
|
|
51
|
+
(entries) => {
|
|
52
|
+
// Topmost heading in the band wins (entries arrive in document order).
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (entry.isIntersecting) {
|
|
55
|
+
activeId.value = entry.target.id;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{ rootMargin: options.rootMargin ?? DEFAULT_ROOT_MARGIN, threshold: 0 },
|
|
61
|
+
);
|
|
62
|
+
for (const el of els) observer.observe(el);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function scrollTo(id: string): void {
|
|
66
|
+
const el = document.getElementById(id);
|
|
67
|
+
if (!el) return;
|
|
68
|
+
// CSS scroll-behavior is reduced-motion-gated, but the JS `smooth` option
|
|
69
|
+
// ignores that, so honour the preference explicitly.
|
|
70
|
+
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
71
|
+
el.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
|
|
72
|
+
activeId.value = id;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let stopWatch: (() => void) | null = null;
|
|
76
|
+
onMounted(() => {
|
|
77
|
+
// immediate → initial observe after first paint; subsequent → re-observe on change.
|
|
78
|
+
stopWatch = watch(options.source, () => nextTick(observe), { immediate: true });
|
|
79
|
+
});
|
|
80
|
+
onUnmounted(() => {
|
|
81
|
+
stopWatch?.();
|
|
82
|
+
observer?.disconnect();
|
|
83
|
+
observer = null;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return { activeId, scrollTo };
|
|
87
|
+
}
|
package/layouts/admin.vue
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
const { isAdmin } = useAuth();
|
|
3
|
-
const { admin: adminEnabled, layoutEngine } = useFeatures();
|
|
3
|
+
const { admin: adminEnabled, layoutEngine, publicApi } = useFeatures();
|
|
4
4
|
const runtimeConfig = useRuntimeConfig();
|
|
5
5
|
const siteName = computed(() => (runtimeConfig.public.siteName as string) || 'CommonPub');
|
|
6
6
|
|
|
7
|
+
// Each nav link is gated on the permission key its target route enforces via
|
|
8
|
+
// requirePermission (verified against the server routes). With the RBAC flag
|
|
9
|
+
// OFF, useCan returns true for admins (admin floor) and false otherwise, so the
|
|
10
|
+
// visible chrome is unchanged today; flipping the flag later drives the UI.
|
|
11
|
+
const canDashboard = useCan('audit.read'); // /admin → /api/admin/stats
|
|
12
|
+
const canUsers = useCan('users.read'); // /admin/users
|
|
13
|
+
const canRoles = useCan('roles.manage'); // /admin/roles
|
|
14
|
+
const canContent = useCan('content.moderate'); // /admin/content
|
|
15
|
+
const canCategories = useCan('categories.manage'); // /admin/categories + video-categories
|
|
16
|
+
const canReports = useCan('reports.review'); // /admin/reports
|
|
17
|
+
const canAudit = useCan('audit.read'); // /admin/audit
|
|
18
|
+
const canTheme = useCan('theme.manage'); // /admin/theme
|
|
19
|
+
const canLayout = useCan('layout.manage'); // /admin/homepage + /admin/layouts
|
|
20
|
+
const canNavigation = useCan('navigation.manage'); // /admin/navigation
|
|
21
|
+
const canSettings = useCan('settings.manage'); // /admin/features + /admin/settings
|
|
22
|
+
const canFederation = useCan('federation.manage'); // /admin/federation
|
|
23
|
+
const canApiKeys = useCan('apikeys.manage'); // /admin/api-keys
|
|
24
|
+
|
|
7
25
|
// Sidebar state (desktop collapse + mobile drawer) — see useAdminSidebar.ts.
|
|
8
26
|
// Editor routes (/admin/layouts/[id], /admin/theme/edit/[id]) auto-collapse
|
|
9
27
|
// so the editor canvas gets more horizontal room; user can override per visit.
|
|
@@ -57,52 +75,56 @@ const { desktopCollapsed, mobileOpen, toggleDesktop, toggleMobile, closeMobile }
|
|
|
57
75
|
"Dashboard, link", the icon alone has no accessible name.
|
|
58
76
|
`title` attr only set when collapsed → visual tooltip on hover.
|
|
59
77
|
-->
|
|
60
|
-
<NuxtLink to="/admin" class="admin-nav-link" :title="desktopCollapsed ? 'Dashboard' : undefined" @click="closeMobile">
|
|
78
|
+
<NuxtLink v-if="canDashboard" to="/admin" class="admin-nav-link" :title="desktopCollapsed ? 'Dashboard' : undefined" @click="closeMobile">
|
|
61
79
|
<i class="fa-solid fa-gauge"></i><span class="admin-nav-label">Dashboard</span>
|
|
62
80
|
</NuxtLink>
|
|
63
|
-
<NuxtLink to="/admin/users" class="admin-nav-link" :title="desktopCollapsed ? 'Users' : undefined" @click="closeMobile">
|
|
81
|
+
<NuxtLink v-if="canUsers" to="/admin/users" class="admin-nav-link" :title="desktopCollapsed ? 'Users' : undefined" @click="closeMobile">
|
|
64
82
|
<i class="fa-solid fa-users"></i><span class="admin-nav-label">Users</span>
|
|
65
83
|
</NuxtLink>
|
|
66
|
-
<NuxtLink to="/admin/roles" class="admin-nav-link" :title="desktopCollapsed ? 'Roles' : undefined" @click="closeMobile">
|
|
84
|
+
<NuxtLink v-if="canRoles" to="/admin/roles" class="admin-nav-link" :title="desktopCollapsed ? 'Roles' : undefined" @click="closeMobile">
|
|
67
85
|
<i class="fa-solid fa-user-shield"></i><span class="admin-nav-label">Roles</span>
|
|
68
86
|
</NuxtLink>
|
|
69
|
-
<NuxtLink to="/admin/content" class="admin-nav-link" :title="desktopCollapsed ? 'Content' : undefined" @click="closeMobile">
|
|
87
|
+
<NuxtLink v-if="canContent" to="/admin/content" class="admin-nav-link" :title="desktopCollapsed ? 'Content' : undefined" @click="closeMobile">
|
|
70
88
|
<i class="fa-solid fa-newspaper"></i><span class="admin-nav-label">Content</span>
|
|
71
89
|
</NuxtLink>
|
|
72
|
-
<NuxtLink to="/admin/categories" class="admin-nav-link" :title="desktopCollapsed ? 'Categories' : undefined" @click="closeMobile">
|
|
90
|
+
<NuxtLink v-if="canCategories" to="/admin/categories" class="admin-nav-link" :title="desktopCollapsed ? 'Categories' : undefined" @click="closeMobile">
|
|
73
91
|
<i class="fa-solid fa-tags"></i><span class="admin-nav-label">Categories</span>
|
|
74
92
|
</NuxtLink>
|
|
75
|
-
<NuxtLink to="/admin/
|
|
93
|
+
<NuxtLink v-if="canCategories" to="/admin/video-categories" class="admin-nav-link" :title="desktopCollapsed ? 'Video Categories' : undefined" @click="closeMobile">
|
|
94
|
+
<i class="fa-solid fa-film"></i><span class="admin-nav-label">Video Categories</span>
|
|
95
|
+
</NuxtLink>
|
|
96
|
+
<NuxtLink v-if="canReports" to="/admin/reports" class="admin-nav-link" :title="desktopCollapsed ? 'Reports' : undefined" @click="closeMobile">
|
|
76
97
|
<i class="fa-solid fa-flag"></i><span class="admin-nav-label">Reports</span>
|
|
77
98
|
</NuxtLink>
|
|
78
|
-
<NuxtLink to="/admin/audit" class="admin-nav-link" :title="desktopCollapsed ? 'Audit Log' : undefined" @click="closeMobile">
|
|
99
|
+
<NuxtLink v-if="canAudit" to="/admin/audit" class="admin-nav-link" :title="desktopCollapsed ? 'Audit Log' : undefined" @click="closeMobile">
|
|
79
100
|
<i class="fa-solid fa-clipboard-list"></i><span class="admin-nav-label">Audit Log</span>
|
|
80
101
|
</NuxtLink>
|
|
81
|
-
<NuxtLink to="/admin/theme" class="admin-nav-link" :title="desktopCollapsed ? 'Theme' : undefined" @click="closeMobile">
|
|
102
|
+
<NuxtLink v-if="canTheme" to="/admin/theme" class="admin-nav-link" :title="desktopCollapsed ? 'Theme' : undefined" @click="closeMobile">
|
|
82
103
|
<i class="fa-solid fa-palette"></i><span class="admin-nav-label">Theme</span>
|
|
83
104
|
</NuxtLink>
|
|
84
|
-
<NuxtLink to="/admin/homepage" class="admin-nav-link" :title="desktopCollapsed ? 'Homepage' : undefined" @click="closeMobile">
|
|
105
|
+
<NuxtLink v-if="canLayout" to="/admin/homepage" class="admin-nav-link" :title="desktopCollapsed ? 'Homepage' : undefined" @click="closeMobile">
|
|
85
106
|
<i class="fa-solid fa-house"></i><span class="admin-nav-label">Homepage</span>
|
|
86
107
|
</NuxtLink>
|
|
87
|
-
<!-- Layouts editor — gated on layoutEngine feature flag (CLAUDE.md rule #2)
|
|
88
|
-
Stays invisible until the operator flips the flag,
|
|
89
|
-
the legacy /admin/homepage editor and Navigation.
|
|
90
|
-
|
|
108
|
+
<!-- Layouts editor — gated on layoutEngine feature flag (CLAUDE.md rule #2)
|
|
109
|
+
AND layout.manage. Stays invisible until the operator flips the flag,
|
|
110
|
+
then appears between the legacy /admin/homepage editor and Navigation.
|
|
111
|
+
Phase 3a, session 160 audit. -->
|
|
112
|
+
<NuxtLink v-if="layoutEngine && canLayout" to="/admin/layouts" class="admin-nav-link" :title="desktopCollapsed ? 'Layouts' : undefined" @click="closeMobile">
|
|
91
113
|
<i class="fa-solid fa-table-cells-large"></i><span class="admin-nav-label">Layouts</span>
|
|
92
114
|
</NuxtLink>
|
|
93
|
-
<NuxtLink to="/admin/navigation" class="admin-nav-link" :title="desktopCollapsed ? 'Navigation' : undefined" @click="closeMobile">
|
|
115
|
+
<NuxtLink v-if="canNavigation" to="/admin/navigation" class="admin-nav-link" :title="desktopCollapsed ? 'Navigation' : undefined" @click="closeMobile">
|
|
94
116
|
<i class="fa-solid fa-bars"></i><span class="admin-nav-label">Navigation</span>
|
|
95
117
|
</NuxtLink>
|
|
96
|
-
<NuxtLink to="/admin/features" class="admin-nav-link" :title="desktopCollapsed ? 'Features' : undefined" @click="closeMobile">
|
|
118
|
+
<NuxtLink v-if="canSettings" to="/admin/features" class="admin-nav-link" :title="desktopCollapsed ? 'Features' : undefined" @click="closeMobile">
|
|
97
119
|
<i class="fa-solid fa-toggle-on"></i><span class="admin-nav-label">Features</span>
|
|
98
120
|
</NuxtLink>
|
|
99
|
-
<NuxtLink to="/admin/federation" class="admin-nav-link" :title="desktopCollapsed ? 'Federation' : undefined" @click="closeMobile">
|
|
121
|
+
<NuxtLink v-if="canFederation" to="/admin/federation" class="admin-nav-link" :title="desktopCollapsed ? 'Federation' : undefined" @click="closeMobile">
|
|
100
122
|
<i class="fa-solid fa-globe"></i><span class="admin-nav-label">Federation</span>
|
|
101
123
|
</NuxtLink>
|
|
102
|
-
<NuxtLink to="/admin/api-keys" class="admin-nav-link" :title="desktopCollapsed ? 'API Keys' : undefined" @click="closeMobile">
|
|
124
|
+
<NuxtLink v-if="publicApi && canApiKeys" to="/admin/api-keys" class="admin-nav-link" :title="desktopCollapsed ? 'API Keys' : undefined" @click="closeMobile">
|
|
103
125
|
<i class="fa-solid fa-key"></i><span class="admin-nav-label">API Keys</span>
|
|
104
126
|
</NuxtLink>
|
|
105
|
-
<NuxtLink to="/admin/settings" class="admin-nav-link" :title="desktopCollapsed ? 'Settings' : undefined" @click="closeMobile">
|
|
127
|
+
<NuxtLink v-if="canSettings" to="/admin/settings" class="admin-nav-link" :title="desktopCollapsed ? 'Settings' : undefined" @click="closeMobile">
|
|
106
128
|
<i class="fa-solid fa-gear"></i><span class="admin-nav-label">Settings</span>
|
|
107
129
|
</NuxtLink>
|
|
108
130
|
</nav>
|