@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,121 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Read-only view for the `tabs` block — tabbed container, each panel a nested
|
|
4
|
+
* BlockTuple[] rendered recursively (via the auto-import name
|
|
5
|
+
* `BlocksBlockContentRenderer`, since this view and the renderer are mutually
|
|
6
|
+
* recursive). Replaces the hand-built CSS radio-tabs (e.g. Track A / Track B
|
|
7
|
+
* rules) with an accessible WAI-ARIA tablist (roving tabindex + arrow keys).
|
|
8
|
+
* All panels stay in the DOM (v-show) so nested content is crawlable.
|
|
9
|
+
*/
|
|
10
|
+
import { useId } from 'vue';
|
|
11
|
+
import type { BlockTuple } from '@commonpub/editor';
|
|
12
|
+
|
|
13
|
+
interface TabDef { label: string; blocks: BlockTuple[] }
|
|
14
|
+
const props = defineProps<{ content: { tabs?: TabDef[]; urlKey?: string } }>();
|
|
15
|
+
|
|
16
|
+
const tabs = computed<TabDef[]>(() =>
|
|
17
|
+
(props.content.tabs ?? []).filter((t) => t && Array.isArray(t.blocks)),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// Optional deep-link: when the author sets `urlKey`, the open tab syncs to
|
|
21
|
+
// ?<urlKey>=<slug>. Router access is guarded (try/catch) so the block also works
|
|
22
|
+
// outside a Nuxt route context (tests, isolated render) — it just stays local.
|
|
23
|
+
const urlKey = computed(() => (typeof props.content.urlKey === 'string' ? props.content.urlKey.trim() : ''));
|
|
24
|
+
function safe<T>(fn: () => T): T | null { try { return fn(); } catch { return null; } }
|
|
25
|
+
const route = safe(() => useRoute());
|
|
26
|
+
const router = safe(() => useRouter());
|
|
27
|
+
function slugify(s: string): string {
|
|
28
|
+
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-+)|(-+$)/g, '');
|
|
29
|
+
}
|
|
30
|
+
function indexFromUrl(): number {
|
|
31
|
+
if (!urlKey.value || !route) return 0;
|
|
32
|
+
const v = String(route.query[urlKey.value] ?? '').toLowerCase();
|
|
33
|
+
if (!v) return 0;
|
|
34
|
+
const i = tabs.value.findIndex((t, idx) => slugify(t.label) === v || String(idx + 1) === v);
|
|
35
|
+
return i >= 0 ? i : 0;
|
|
36
|
+
}
|
|
37
|
+
const active = ref(indexFromUrl());
|
|
38
|
+
watchEffect(() => { if (active.value >= tabs.value.length) active.value = 0; });
|
|
39
|
+
|
|
40
|
+
function setActive(i: number): void {
|
|
41
|
+
active.value = i;
|
|
42
|
+
if (urlKey.value && router && route) {
|
|
43
|
+
const s = slugify(tabs.value[i]?.label ?? '') || String(i + 1);
|
|
44
|
+
if (String(route.query[urlKey.value] ?? '') !== s) {
|
|
45
|
+
void router.replace({ query: { ...route.query, [urlKey.value]: s } });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Honor back/forward + a shared deep link landing on a different tab.
|
|
50
|
+
if (route) {
|
|
51
|
+
watch(() => route.query[urlKey.value], () => {
|
|
52
|
+
if (!urlKey.value) return;
|
|
53
|
+
const i = indexFromUrl();
|
|
54
|
+
if (i !== active.value) active.value = i;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const uid = useId();
|
|
59
|
+
const tabId = (i: number): string => `${uid}-tab-${i}`;
|
|
60
|
+
const panelId = (i: number): string => `${uid}-panel-${i}`;
|
|
61
|
+
|
|
62
|
+
function onKey(e: KeyboardEvent, i: number): void {
|
|
63
|
+
const n = tabs.value.length;
|
|
64
|
+
let next: number | null = null;
|
|
65
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % n;
|
|
66
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (i - 1 + n) % n;
|
|
67
|
+
else if (e.key === 'Home') next = 0;
|
|
68
|
+
else if (e.key === 'End') next = n - 1;
|
|
69
|
+
if (next === null) return;
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
setActive(next);
|
|
72
|
+
void nextTick(() => { if (typeof document !== 'undefined') document.getElementById(tabId(next!))?.focus(); });
|
|
73
|
+
}
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<div v-if="tabs.length" class="cpub-tabs">
|
|
78
|
+
<div class="cpub-tabs-list" role="tablist">
|
|
79
|
+
<button
|
|
80
|
+
v-for="(t, i) in tabs"
|
|
81
|
+
:id="tabId(i)"
|
|
82
|
+
:key="i"
|
|
83
|
+
type="button"
|
|
84
|
+
role="tab"
|
|
85
|
+
:aria-selected="active === i"
|
|
86
|
+
:aria-controls="panelId(i)"
|
|
87
|
+
:tabindex="active === i ? 0 : -1"
|
|
88
|
+
class="cpub-tabs-tab"
|
|
89
|
+
:class="{ 'cpub-tabs-tab-active': active === i }"
|
|
90
|
+
@click="setActive(i)"
|
|
91
|
+
@keydown="onKey($event, i)"
|
|
92
|
+
>{{ t.label || `Tab ${i + 1}` }}</button>
|
|
93
|
+
</div>
|
|
94
|
+
<div
|
|
95
|
+
v-for="(t, i) in tabs"
|
|
96
|
+
v-show="active === i"
|
|
97
|
+
:id="panelId(i)"
|
|
98
|
+
:key="i"
|
|
99
|
+
role="tabpanel"
|
|
100
|
+
:aria-labelledby="tabId(i)"
|
|
101
|
+
tabindex="0"
|
|
102
|
+
class="cpub-tabs-panel"
|
|
103
|
+
>
|
|
104
|
+
<BlocksBlockContentRenderer :blocks="t.blocks" class="cpub-prose cpub-md" />
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</template>
|
|
108
|
+
|
|
109
|
+
<style scoped>
|
|
110
|
+
.cpub-tabs { margin: 0 0 18px; }
|
|
111
|
+
.cpub-tabs-list { display: flex; flex-wrap: wrap; gap: 6px; border-bottom: 2px solid var(--border); margin-bottom: 20px; }
|
|
112
|
+
.cpub-tabs-tab {
|
|
113
|
+
display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
|
|
114
|
+
font-weight: 700; font-size: 13px; color: var(--text-faint);
|
|
115
|
+
background: transparent; border: var(--border-width-default) solid transparent; border-bottom: none;
|
|
116
|
+
padding: 10px 16px; margin-bottom: -2px;
|
|
117
|
+
}
|
|
118
|
+
.cpub-tabs-tab:hover { color: var(--text); }
|
|
119
|
+
.cpub-tabs-tab-active { color: var(--accent); background: var(--accent-bg); border-color: var(--accent-border); }
|
|
120
|
+
.cpub-tabs-panel:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
121
|
+
</style>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContestBodyCanvas — the CENTER of the contest editor shell: a tab bar
|
|
4
|
+
* (Overview / Rules / Prizes) + a Write/Preview/Code switch over a SINGLE shared
|
|
5
|
+
* BlockCanvas. Presentational only — it owns no block state; the parent
|
|
6
|
+
* (ContestEditor) holds the three hoisted useBlockEditor instances and passes the
|
|
7
|
+
* currently-active one as `editor`, so the left palette and this canvas target the
|
|
8
|
+
* same body. `activeTab` + `mode` are v-modelled back to the parent (the palette
|
|
9
|
+
* needs `activeTab` to resolve which body it inserts into).
|
|
10
|
+
*
|
|
11
|
+
* Write = the canvas · Preview = the same blocks through the public view renderer ·
|
|
12
|
+
* Code = read-only BlockTuple[] JSON, all derived from the active editor.
|
|
13
|
+
*/
|
|
14
|
+
import { BlockCanvas, type BlockEditor, type BlockTypeGroup } from '@commonpub/editor/vue';
|
|
15
|
+
|
|
16
|
+
type BodyTab = 'overview' | 'rules' | 'prizes';
|
|
17
|
+
type BodyMode = 'write' | 'preview' | 'code';
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{
|
|
20
|
+
editor: BlockEditor;
|
|
21
|
+
groups: BlockTypeGroup[];
|
|
22
|
+
activeTab: BodyTab;
|
|
23
|
+
mode: BodyMode;
|
|
24
|
+
}>();
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
'update:activeTab': [tab: BodyTab];
|
|
28
|
+
'update:mode': [mode: BodyMode];
|
|
29
|
+
}>();
|
|
30
|
+
|
|
31
|
+
const TABS: { key: BodyTab; label: string; icon: string }[] = [
|
|
32
|
+
{ key: 'overview', label: 'Overview', icon: 'fa-circle-info' },
|
|
33
|
+
{ key: 'rules', label: 'Rules', icon: 'fa-file-lines' },
|
|
34
|
+
{ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' },
|
|
35
|
+
];
|
|
36
|
+
const MODES: { key: BodyMode; label: string; icon: string }[] = [
|
|
37
|
+
{ key: 'write', label: 'Write', icon: 'fa-pen' },
|
|
38
|
+
{ key: 'preview', label: 'Preview', icon: 'fa-eye' },
|
|
39
|
+
{ key: 'code', label: 'Code', icon: 'fa-code' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Live tuples for Preview/Code — recomputes when the active editor's blocks change
|
|
43
|
+
// or when the editor prop swaps on a tab switch.
|
|
44
|
+
const previewBlocks = computed<[string, Record<string, unknown>][]>(
|
|
45
|
+
() => props.editor.toBlockTuples() as [string, Record<string, unknown>][],
|
|
46
|
+
);
|
|
47
|
+
const codeJson = computed<string>(() => JSON.stringify(previewBlocks.value, null, 2));
|
|
48
|
+
|
|
49
|
+
// Roving-arrow keyboard nav for the tablist (WCAG): move selection AND DOM focus
|
|
50
|
+
// together, else focus desyncs from the active tab.
|
|
51
|
+
function focusTab(key: BodyTab): void {
|
|
52
|
+
void nextTick(() => document.getElementById(`cpub-cbc-tab-${key}`)?.focus());
|
|
53
|
+
}
|
|
54
|
+
function onTabKey(e: KeyboardEvent, key: BodyTab): void {
|
|
55
|
+
const i = TABS.findIndex((t) => t.key === key);
|
|
56
|
+
if (i < 0) return;
|
|
57
|
+
let next: BodyTab | null = null;
|
|
58
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = TABS[(i + 1) % TABS.length]!.key;
|
|
59
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = TABS[(i - 1 + TABS.length) % TABS.length]!.key;
|
|
60
|
+
else if (e.key === 'Home') next = TABS[0]!.key;
|
|
61
|
+
else if (e.key === 'End') next = TABS[TABS.length - 1]!.key;
|
|
62
|
+
if (!next) return;
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
emit('update:activeTab', next);
|
|
65
|
+
focusTab(next);
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<div class="cpub-cbc">
|
|
71
|
+
<div class="cpub-cbc-bar">
|
|
72
|
+
<div class="cpub-cbc-tablist" role="tablist" aria-label="Contest body sections">
|
|
73
|
+
<button
|
|
74
|
+
v-for="t in TABS"
|
|
75
|
+
:id="`cpub-cbc-tab-${t.key}`"
|
|
76
|
+
:key="t.key"
|
|
77
|
+
type="button"
|
|
78
|
+
role="tab"
|
|
79
|
+
:aria-selected="activeTab === t.key"
|
|
80
|
+
:tabindex="activeTab === t.key ? 0 : -1"
|
|
81
|
+
class="cpub-cbc-tab"
|
|
82
|
+
:class="{ 'cpub-cbc-tab-active': activeTab === t.key }"
|
|
83
|
+
@click="emit('update:activeTab', t.key)"
|
|
84
|
+
@keydown="onTabKey($event, t.key)"
|
|
85
|
+
>
|
|
86
|
+
<i class="fa-solid" :class="t.icon"></i> {{ t.label }}
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="cpub-cbc-mode" role="group" aria-label="Body view mode">
|
|
90
|
+
<button
|
|
91
|
+
v-for="m in MODES"
|
|
92
|
+
:key="m.key"
|
|
93
|
+
type="button"
|
|
94
|
+
class="cpub-cbc-mode-btn"
|
|
95
|
+
:class="{ 'cpub-cbc-mode-active': mode === m.key }"
|
|
96
|
+
:aria-pressed="mode === m.key"
|
|
97
|
+
@click="emit('update:mode', m.key)"
|
|
98
|
+
>
|
|
99
|
+
<i class="fa-solid" :class="m.icon"></i> {{ m.label }}
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div class="cpub-cbc-panel" role="tabpanel" tabindex="0" :aria-labelledby="`cpub-cbc-tab-${activeTab}`">
|
|
105
|
+
<!-- Overview-only lead (inline banner + cover); the parent fills the slot. -->
|
|
106
|
+
<div v-if="activeTab === 'overview' && mode === 'write'" class="cpub-cbc-lead">
|
|
107
|
+
<slot name="overview-lead" />
|
|
108
|
+
</div>
|
|
109
|
+
<!-- One canvas, keyed by tab so each body gets a clean canvas instance; the
|
|
110
|
+
underlying block state persists in the parent's hoisted editors. -->
|
|
111
|
+
<BlockCanvas v-show="mode === 'write'" :key="activeTab" :block-editor="editor" :block-types="groups" />
|
|
112
|
+
<div v-if="mode === 'preview'" class="cpub-cbc-preview">
|
|
113
|
+
<BlocksBlockContentRenderer v-if="previewBlocks.length" :blocks="previewBlocks" class="cpub-prose cpub-md" />
|
|
114
|
+
<p v-else class="cpub-cbc-empty">Nothing to preview yet. Switch to Write and add some blocks.</p>
|
|
115
|
+
</div>
|
|
116
|
+
<pre v-else-if="mode === 'code'" class="cpub-cbc-code" aria-label="Block content as JSON"><code>{{ codeJson }}</code></pre>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
120
|
+
|
|
121
|
+
<style scoped>
|
|
122
|
+
.cpub-cbc { display: flex; flex-direction: column; }
|
|
123
|
+
.cpub-cbc-bar { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; margin-bottom: var(--space-3); border-bottom: var(--border-width-default) solid var(--border); }
|
|
124
|
+
.cpub-cbc-tablist { display: flex; gap: 4px; }
|
|
125
|
+
.cpub-cbc-tab {
|
|
126
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
127
|
+
padding: 8px 14px; background: transparent; border: none; cursor: pointer;
|
|
128
|
+
font-size: var(--text-sm); font-weight: 600; color: var(--text-dim);
|
|
129
|
+
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
|
130
|
+
}
|
|
131
|
+
.cpub-cbc-tab:hover { color: var(--text); }
|
|
132
|
+
.cpub-cbc-tab-active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
133
|
+
|
|
134
|
+
.cpub-cbc-mode { display: inline-flex; margin-bottom: 6px; border: var(--border-width-default) solid var(--border); }
|
|
135
|
+
.cpub-cbc-mode-btn {
|
|
136
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
137
|
+
padding: 5px 10px; background: transparent; border: none; cursor: pointer;
|
|
138
|
+
font-size: var(--text-xs); font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em;
|
|
139
|
+
color: var(--text-faint); border-right: var(--border-width-default) solid var(--border);
|
|
140
|
+
}
|
|
141
|
+
.cpub-cbc-mode-btn:last-child { border-right: none; }
|
|
142
|
+
.cpub-cbc-mode-btn:hover { background: var(--surface2); color: var(--text-dim); }
|
|
143
|
+
.cpub-cbc-mode-active { background: var(--accent-bg); color: var(--accent); }
|
|
144
|
+
|
|
145
|
+
.cpub-cbc-panel { border: var(--border-width-default) solid var(--border); background: var(--surface); padding: var(--space-2); }
|
|
146
|
+
.cpub-cbc-lead { margin-bottom: var(--space-3); }
|
|
147
|
+
.cpub-cbc-preview { padding: var(--space-3); }
|
|
148
|
+
.cpub-cbc-empty { font-size: var(--text-sm); color: var(--text-faint); margin: 0; padding: var(--space-4) 0; text-align: center; }
|
|
149
|
+
.cpub-cbc-code {
|
|
150
|
+
margin: 0; padding: var(--space-3); overflow: auto; max-height: 60vh;
|
|
151
|
+
background: var(--surface2); color: var(--text-dim);
|
|
152
|
+
font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.6;
|
|
153
|
+
white-space: pre; tab-size: 2;
|
|
154
|
+
}
|
|
155
|
+
</style>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Reusable judging-rubric editor — ONE component for both the contest-level
|
|
4
|
+
* criteria (Judging tab) and a review stage's per-round criteria. v-model an array
|
|
5
|
+
* of `{ label, weight?, description? }`; weight is clamped 0–100 and omitted (not
|
|
6
|
+
* null) when blank, matching `contestJudgingCriterionSchema`. Pure presentational
|
|
7
|
+
* + immutable emits — no side effects, so it's trivially testable + drop-in
|
|
8
|
+
* wherever a rubric is edited (kills the prior duplicate editors).
|
|
9
|
+
*/
|
|
10
|
+
export interface ContestCriterion {
|
|
11
|
+
label: string;
|
|
12
|
+
weight?: number;
|
|
13
|
+
description?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(
|
|
17
|
+
defineProps<{
|
|
18
|
+
modelValue?: ContestCriterion[] | null;
|
|
19
|
+
/** Header label (e.g. "Judging criteria"). */
|
|
20
|
+
label?: string;
|
|
21
|
+
/** Show the summed points badge in the header. */
|
|
22
|
+
showTotal?: boolean;
|
|
23
|
+
}>(),
|
|
24
|
+
{ modelValue: () => [], label: 'Judging criteria', showTotal: true },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits<{ 'update:modelValue': [value: ContestCriterion[]] }>();
|
|
28
|
+
|
|
29
|
+
const items = computed<ContestCriterion[]>(() => props.modelValue ?? []);
|
|
30
|
+
const total = computed(() => items.value.reduce((s, c) => s + (c.weight ?? 0), 0));
|
|
31
|
+
|
|
32
|
+
function set(i: number, patch: Partial<ContestCriterion>): void {
|
|
33
|
+
emit('update:modelValue', items.value.map((c, idx) => (idx === i ? { ...c, ...patch } : c)));
|
|
34
|
+
}
|
|
35
|
+
function setWeight(i: number, raw: string): void {
|
|
36
|
+
const n = raw === '' ? undefined : Math.max(0, Math.min(100, Math.round(Number(raw))));
|
|
37
|
+
set(i, { weight: Number.isFinite(n as number) ? n : undefined });
|
|
38
|
+
}
|
|
39
|
+
function add(): void {
|
|
40
|
+
emit('update:modelValue', [...items.value, { label: '' }]);
|
|
41
|
+
}
|
|
42
|
+
function remove(i: number): void {
|
|
43
|
+
emit('update:modelValue', items.value.filter((_, idx) => idx !== i));
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<div class="cpub-criteria-editor">
|
|
49
|
+
<div class="cpub-criteria-head">
|
|
50
|
+
<span class="cpub-criteria-title">
|
|
51
|
+
{{ label }}<span v-if="showTotal && total" class="cpub-criteria-total">{{ total }} pts</span>
|
|
52
|
+
</span>
|
|
53
|
+
<button type="button" class="cpub-btn cpub-btn-sm" @click="add"><i class="fa-solid fa-plus"></i> Add</button>
|
|
54
|
+
</div>
|
|
55
|
+
<p v-if="!items.length" class="cpub-criteria-empty">No rubric yet. Judges score an overall 1–100 unless you add criteria.</p>
|
|
56
|
+
<div v-for="(c, i) in items" :key="i" class="cpub-criteria-row">
|
|
57
|
+
<div class="cpub-criteria-main">
|
|
58
|
+
<input :value="c.label" type="text" class="cpub-input" :aria-label="`Criterion ${i + 1} label`" placeholder="e.g. Technical merit" @input="set(i, { label: ($event.target as HTMLInputElement).value })" />
|
|
59
|
+
<input :value="c.weight ?? ''" type="number" min="0" max="100" class="cpub-input cpub-criteria-pts" :aria-label="`Criterion ${i + 1} points`" placeholder="pts" @input="setWeight(i, ($event.target as HTMLInputElement).value)" />
|
|
60
|
+
<button type="button" class="cpub-criteria-del" :aria-label="`Remove criterion ${i + 1}`" @click="remove(i)"><i class="fa-solid fa-xmark"></i></button>
|
|
61
|
+
</div>
|
|
62
|
+
<input :value="c.description ?? ''" type="text" class="cpub-input cpub-criteria-desc" :aria-label="`Criterion ${i + 1} description`" placeholder="What judges look for (optional)" @input="set(i, { description: ($event.target as HTMLInputElement).value || undefined })" />
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<style scoped>
|
|
68
|
+
.cpub-criteria-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: var(--space-2); }
|
|
69
|
+
.cpub-criteria-title { font-size: var(--text-xs); font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); display: inline-flex; align-items: center; gap: 8px; }
|
|
70
|
+
.cpub-criteria-total { color: var(--accent); }
|
|
71
|
+
.cpub-criteria-empty { font-size: var(--text-sm); color: var(--text-faint); margin: 0 0 var(--space-2); }
|
|
72
|
+
.cpub-criteria-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-2); }
|
|
73
|
+
.cpub-criteria-main { display: flex; align-items: center; gap: 6px; }
|
|
74
|
+
.cpub-criteria-main .cpub-input { flex: 1; }
|
|
75
|
+
.cpub-criteria-pts { max-width: 80px; flex: none !important; text-align: center; }
|
|
76
|
+
.cpub-criteria-del { background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; padding: 0 9px; flex-shrink: 0; }
|
|
77
|
+
.cpub-criteria-del:hover { border-color: var(--red-border); color: var(--red); }
|
|
78
|
+
.cpub-criteria-desc { font-size: var(--text-xs); }
|
|
79
|
+
</style>
|