@commonpub/layer 0.81.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 +11 -3
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +54 -20
- 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/useAuth.ts +13 -0
- package/composables/useCan.ts +23 -0
- 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 +43 -18
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +8 -8
- 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/roles.vue +286 -0
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +81 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -764
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +97 -8
- 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/admin/permissions.get.ts +14 -0
- package/server/api/admin/roles/[id]/index.delete.ts +25 -0
- package/server/api/admin/roles/[id]/index.put.ts +24 -0
- package/server/api/admin/roles/index.get.ts +10 -0
- package/server/api/admin/roles/index.post.ts +27 -0
- package/server/api/admin/users/[id]/role.put.ts +20 -1
- package/server/api/admin/users/[id]/roles.get.ts +10 -0
- package/server/api/admin/users/[id]/roles.put.ts +17 -0
- 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]/advance.post.ts +10 -5
- 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]/index.get.ts +10 -2
- package/server/api/contests/[slug]/index.put.ts +11 -2
- 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]/stakeholders/index.post.ts +12 -3
- package/server/api/contests/[slug]/transition.post.ts +8 -3
- 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/me.get.ts +7 -0
- 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,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Edit component for the `judgesShowcase` contest block (avatar + name + title +
|
|
4
|
+
* bio cards). Provided to BlockCanvas via BLOCK_COMPONENTS_KEY by the contest
|
|
5
|
+
* editor (2e). Follows the house block-edit contract: `content` in, `update` out,
|
|
6
|
+
* immutable list ops. Avatars are URLs here; 2e can swap to <ImageUpload>.
|
|
7
|
+
*/
|
|
8
|
+
import type { JudgeShowcaseEntry, JudgesShowcaseContent } from '../../../types/contestBlocks';
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
11
|
+
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
12
|
+
|
|
13
|
+
const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading : ''));
|
|
14
|
+
const judges = computed<JudgeShowcaseEntry[]>(() =>
|
|
15
|
+
Array.isArray(props.content.judges) ? (props.content.judges as JudgeShowcaseEntry[]) : [],
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
function commit(next: Partial<JudgesShowcaseContent>): void {
|
|
19
|
+
emit('update', { heading: heading.value || undefined, judges: judges.value, ...next });
|
|
20
|
+
}
|
|
21
|
+
function setHeading(v: string): void {
|
|
22
|
+
commit({ heading: v || undefined });
|
|
23
|
+
}
|
|
24
|
+
function addJudge(): void {
|
|
25
|
+
commit({ judges: [...judges.value, { name: '' }] });
|
|
26
|
+
}
|
|
27
|
+
function setJudge(i: number, field: keyof JudgeShowcaseEntry, v: string): void {
|
|
28
|
+
commit({ judges: judges.value.map((j, idx) => (idx === i ? { ...j, [field]: v || undefined } : j)) });
|
|
29
|
+
}
|
|
30
|
+
function removeJudge(i: number): void {
|
|
31
|
+
commit({ judges: judges.value.filter((_, idx) => idx !== i) });
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div class="cpub-jedit">
|
|
37
|
+
<div class="cpub-jedit-header">
|
|
38
|
+
<div class="cpub-jedit-icon"><i class="fa-solid fa-user-group"></i></div>
|
|
39
|
+
<span class="cpub-jedit-title">Judges Showcase</span>
|
|
40
|
+
<span class="cpub-jedit-count">{{ judges.length }} {{ judges.length === 1 ? 'person' : 'people' }}</span>
|
|
41
|
+
<button type="button" class="cpub-jedit-add" @click="addJudge"><i class="fa-solid fa-plus"></i> Add person</button>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="cpub-jedit-body">
|
|
45
|
+
<input
|
|
46
|
+
class="cpub-jedit-input cpub-jedit-heading"
|
|
47
|
+
type="text"
|
|
48
|
+
:value="heading"
|
|
49
|
+
placeholder="Section heading (optional), e.g. Meet the Judges"
|
|
50
|
+
aria-label="Showcase heading"
|
|
51
|
+
@input="setHeading(($event.target as HTMLInputElement).value)"
|
|
52
|
+
/>
|
|
53
|
+
|
|
54
|
+
<div v-for="(j, i) in judges" :key="i" class="cpub-jedit-row">
|
|
55
|
+
<div class="cpub-jedit-row-main">
|
|
56
|
+
<input class="cpub-jedit-input" type="text" :value="j.name" placeholder="Name" :aria-label="`Person ${i + 1} name`" @input="setJudge(i, 'name', ($event.target as HTMLInputElement).value)" />
|
|
57
|
+
<input class="cpub-jedit-input" type="text" :value="j.title ?? ''" placeholder="Title / affiliation" :aria-label="`Person ${i + 1} title`" @input="setJudge(i, 'title', ($event.target as HTMLInputElement).value)" />
|
|
58
|
+
<button type="button" class="cpub-jedit-remove" :aria-label="`Remove person ${i + 1}`" @click="removeJudge(i)"><i class="fa-solid fa-xmark"></i></button>
|
|
59
|
+
</div>
|
|
60
|
+
<input class="cpub-jedit-input" type="url" :value="j.avatarUrl ?? ''" placeholder="Avatar image URL (https://…)" :aria-label="`Person ${i + 1} avatar URL`" @input="setJudge(i, 'avatarUrl', ($event.target as HTMLInputElement).value)" />
|
|
61
|
+
<input class="cpub-jedit-input" type="url" :value="j.link ?? ''" placeholder="Profile / link (https://…, optional)" :aria-label="`Person ${i + 1} link`" @input="setJudge(i, 'link', ($event.target as HTMLInputElement).value)" />
|
|
62
|
+
<textarea class="cpub-jedit-input cpub-jedit-bio" rows="2" :value="j.bio ?? ''" placeholder="Short bio (optional)" :aria-label="`Person ${i + 1} bio`" @input="setJudge(i, 'bio', ($event.target as HTMLTextAreaElement).value)" />
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div v-if="!judges.length" class="cpub-jedit-empty" @click="addJudge">
|
|
66
|
+
<i class="fa-solid fa-plus"></i> Add the first judge or mentor
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
71
|
+
|
|
72
|
+
<style scoped>
|
|
73
|
+
.cpub-jedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
|
|
74
|
+
.cpub-jedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
|
|
75
|
+
.cpub-jedit-icon { font-size: 12px; color: var(--accent); }
|
|
76
|
+
.cpub-jedit-title { font-size: 12px; font-weight: 600; }
|
|
77
|
+
.cpub-jedit-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
|
|
78
|
+
.cpub-jedit-add { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 4px; margin-left: 8px; }
|
|
79
|
+
.cpub-jedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
|
|
80
|
+
|
|
81
|
+
.cpub-jedit-body { padding: 10px 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
82
|
+
.cpub-jedit-input { width: 100%; padding: 6px 8px; font-size: 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
|
|
83
|
+
.cpub-jedit-input:focus { border-color: var(--accent); }
|
|
84
|
+
.cpub-jedit-input::placeholder { color: var(--text-faint); }
|
|
85
|
+
.cpub-jedit-heading { font-weight: 600; }
|
|
86
|
+
.cpub-jedit-bio { resize: vertical; font-family: inherit; }
|
|
87
|
+
|
|
88
|
+
.cpub-jedit-row { border: var(--border-width-default) dashed var(--border2); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
|
89
|
+
.cpub-jedit-row-main { display: flex; gap: 6px; }
|
|
90
|
+
.cpub-jedit-row-main .cpub-jedit-input { flex: 1; }
|
|
91
|
+
.cpub-jedit-remove { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; padding: 0 8px; flex-shrink: 0; }
|
|
92
|
+
.cpub-jedit-remove:hover { border-color: var(--red-border); color: var(--red); }
|
|
93
|
+
|
|
94
|
+
.cpub-jedit-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; border: var(--border-width-default) dashed var(--border2); }
|
|
95
|
+
.cpub-jedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
|
96
|
+
</style>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Edit component for the `roadmap` block — a schedule timeline. House block-edit
|
|
4
|
+
* contract: `content` in, `update` out, immutable list ops. The contest editor
|
|
5
|
+
* provides a roadmap derived from the live stages/schedule under CONTEST_SCHEDULE_KEY,
|
|
6
|
+
* so this offers a one-click "Pull from schedule" seed; from there each milestone
|
|
7
|
+
* (date, title, blurb, badge, tone) is freely edited and reordered. Provided via
|
|
8
|
+
* BLOCK_COMPONENTS_KEY.
|
|
9
|
+
*/
|
|
10
|
+
import { inject } from 'vue';
|
|
11
|
+
import { CONTEST_SCHEDULE_KEY } from '../../../utils/contestBlocks';
|
|
12
|
+
import type { RoadmapItem, RoadmapContent, RoadmapTone } from '../../../types/contestBlocks';
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
15
|
+
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
16
|
+
|
|
17
|
+
const TONES: { value: RoadmapTone; label: string }[] = [
|
|
18
|
+
{ value: 'default', label: 'Default' },
|
|
19
|
+
{ value: 'accent', label: 'Accent' },
|
|
20
|
+
{ value: 'highlight', label: 'Highlight' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// The contest editor's live schedule (custom stages, else the core flow).
|
|
24
|
+
const schedule = inject(CONTEST_SCHEDULE_KEY, null);
|
|
25
|
+
const canPull = computed(() => !!schedule?.value?.length);
|
|
26
|
+
|
|
27
|
+
const eyebrow = computed(() => (typeof props.content.eyebrow === 'string' ? props.content.eyebrow : ''));
|
|
28
|
+
const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading : ''));
|
|
29
|
+
const items = computed<RoadmapItem[]>(() => (Array.isArray(props.content.items) ? (props.content.items as RoadmapItem[]) : []));
|
|
30
|
+
|
|
31
|
+
function commit(next: Partial<RoadmapContent>): void {
|
|
32
|
+
emit('update', { eyebrow: eyebrow.value || undefined, heading: heading.value || undefined, items: items.value, ...next });
|
|
33
|
+
}
|
|
34
|
+
function addItem(): void {
|
|
35
|
+
commit({ items: [...items.value, { title: '', tone: 'default' }] });
|
|
36
|
+
}
|
|
37
|
+
function setItem(i: number, field: keyof RoadmapItem, value: string): void {
|
|
38
|
+
commit({ items: items.value.map((it, idx) => (idx === i ? { ...it, [field]: value || undefined } : it)) });
|
|
39
|
+
}
|
|
40
|
+
function removeItem(i: number): void {
|
|
41
|
+
commit({ items: items.value.filter((_, idx) => idx !== i) });
|
|
42
|
+
}
|
|
43
|
+
function move(i: number, dir: -1 | 1): void {
|
|
44
|
+
const j = i + dir;
|
|
45
|
+
if (j < 0 || j >= items.value.length) return;
|
|
46
|
+
const next = [...items.value];
|
|
47
|
+
[next[i], next[j]] = [next[j]!, next[i]!];
|
|
48
|
+
commit({ items: next });
|
|
49
|
+
}
|
|
50
|
+
function pullFromSchedule(): void {
|
|
51
|
+
commit({ items: (schedule?.value ?? []).map((it) => ({ ...it })) });
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<div class="cpub-rmedit">
|
|
57
|
+
<div class="cpub-rmedit-header">
|
|
58
|
+
<div class="cpub-rmedit-icon"><i class="fa-solid fa-timeline"></i></div>
|
|
59
|
+
<span class="cpub-rmedit-title">Roadmap</span>
|
|
60
|
+
<span class="cpub-rmedit-count">{{ items.length }} {{ items.length === 1 ? 'milestone' : 'milestones' }}</span>
|
|
61
|
+
<button v-if="canPull" type="button" class="cpub-rmedit-add" title="Seed from this contest's stages / schedule" @click="pullFromSchedule"><i class="fa-solid fa-wand-magic-sparkles"></i> Pull from schedule</button>
|
|
62
|
+
<button type="button" class="cpub-rmedit-add" @click="addItem"><i class="fa-solid fa-plus"></i> Add milestone</button>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div class="cpub-rmedit-body">
|
|
66
|
+
<input class="cpub-rmedit-input" type="text" :value="eyebrow" placeholder="Eyebrow (optional), e.g. Key dates, 2026" aria-label="Roadmap eyebrow" @input="commit({ eyebrow: ($event.target as HTMLInputElement).value || undefined })" />
|
|
67
|
+
<input class="cpub-rmedit-input cpub-rmedit-heading" type="text" :value="heading" placeholder="Heading (optional), e.g. The 18-week roadmap" aria-label="Roadmap heading" @input="commit({ heading: ($event.target as HTMLInputElement).value || undefined })" />
|
|
68
|
+
|
|
69
|
+
<div v-for="(it, i) in items" :key="i" class="cpub-rmedit-row" :class="`cpub-rmedit-${it.tone ?? 'default'}`">
|
|
70
|
+
<div class="cpub-rmedit-rowtop">
|
|
71
|
+
<input class="cpub-rmedit-input cpub-rmedit-date" type="text" :value="it.date ?? ''" placeholder="Date, e.g. Jun 30" :aria-label="`Milestone ${i + 1} date`" @input="setItem(i, 'date', ($event.target as HTMLInputElement).value)" />
|
|
72
|
+
<input class="cpub-rmedit-input cpub-rmedit-badge" type="text" :value="it.badge ?? ''" placeholder="Badge (optional)" :aria-label="`Milestone ${i + 1} badge`" @input="setItem(i, 'badge', ($event.target as HTMLInputElement).value)" />
|
|
73
|
+
<select class="cpub-rmedit-input cpub-rmedit-tone" :value="it.tone ?? 'default'" :aria-label="`Milestone ${i + 1} style`" @change="setItem(i, 'tone', ($event.target as HTMLSelectElement).value)">
|
|
74
|
+
<option v-for="t in TONES" :key="t.value" :value="t.value">{{ t.label }}</option>
|
|
75
|
+
</select>
|
|
76
|
+
<div class="cpub-rmedit-moves">
|
|
77
|
+
<button type="button" class="cpub-rmedit-move" :disabled="i === 0" :aria-label="`Move milestone ${i + 1} up`" @click="move(i, -1)"><i class="fa-solid fa-chevron-up"></i></button>
|
|
78
|
+
<button type="button" class="cpub-rmedit-move" :disabled="i === items.length - 1" :aria-label="`Move milestone ${i + 1} down`" @click="move(i, 1)"><i class="fa-solid fa-chevron-down"></i></button>
|
|
79
|
+
</div>
|
|
80
|
+
<button type="button" class="cpub-rmedit-remove" :aria-label="`Remove milestone ${i + 1}`" @click="removeItem(i)"><i class="fa-solid fa-xmark"></i></button>
|
|
81
|
+
</div>
|
|
82
|
+
<input class="cpub-rmedit-input" type="text" :value="it.title" placeholder="Title" :aria-label="`Milestone ${i + 1} title`" @input="setItem(i, 'title', ($event.target as HTMLInputElement).value)" />
|
|
83
|
+
<textarea class="cpub-rmedit-input cpub-rmedit-desc" rows="2" :value="it.description ?? ''" placeholder="Description (optional)" :aria-label="`Milestone ${i + 1} description`" @input="setItem(i, 'description', ($event.target as HTMLTextAreaElement).value)" />
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div v-if="!items.length" class="cpub-rmedit-empty" @click="canPull ? pullFromSchedule() : addItem()">
|
|
87
|
+
<i class="fa-solid fa-plus"></i> {{ canPull ? 'Pull milestones from the schedule' : 'Add the first milestone' }}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</template>
|
|
92
|
+
|
|
93
|
+
<style scoped>
|
|
94
|
+
.cpub-rmedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
|
|
95
|
+
.cpub-rmedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); flex-wrap: wrap; }
|
|
96
|
+
.cpub-rmedit-icon { font-size: 12px; color: var(--accent); }
|
|
97
|
+
.cpub-rmedit-title { font-size: 12px; font-weight: 600; }
|
|
98
|
+
.cpub-rmedit-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
|
|
99
|
+
.cpub-rmedit-add { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 4px; }
|
|
100
|
+
.cpub-rmedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
|
|
101
|
+
|
|
102
|
+
.cpub-rmedit-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
103
|
+
.cpub-rmedit-input { width: 100%; padding: 6px 8px; font-size: 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
|
|
104
|
+
.cpub-rmedit-input:focus { border-color: var(--accent); }
|
|
105
|
+
.cpub-rmedit-input::placeholder { color: var(--text-faint); }
|
|
106
|
+
.cpub-rmedit-heading { font-weight: 600; }
|
|
107
|
+
.cpub-rmedit-desc { resize: vertical; font-family: inherit; }
|
|
108
|
+
|
|
109
|
+
.cpub-rmedit-row { border: var(--border-width-default) solid var(--border2); border-left-width: 3px; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
|
110
|
+
.cpub-rmedit-default { border-left-color: var(--border2); }
|
|
111
|
+
.cpub-rmedit-accent { border-left-color: var(--accent); }
|
|
112
|
+
.cpub-rmedit-highlight { border-left-color: var(--yellow); }
|
|
113
|
+
.cpub-rmedit-rowtop { display: flex; gap: 6px; align-items: center; }
|
|
114
|
+
.cpub-rmedit-date { width: 110px; flex-shrink: 0; }
|
|
115
|
+
.cpub-rmedit-badge { flex: 1; min-width: 0; }
|
|
116
|
+
.cpub-rmedit-tone { width: 100px; flex-shrink: 0; }
|
|
117
|
+
.cpub-rmedit-moves { display: inline-flex; flex-shrink: 0; }
|
|
118
|
+
.cpub-rmedit-move { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 9px; padding: 0 6px; }
|
|
119
|
+
.cpub-rmedit-move:first-child { border-right: 0; }
|
|
120
|
+
.cpub-rmedit-move:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
121
|
+
.cpub-rmedit-move:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
122
|
+
.cpub-rmedit-remove { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; padding: 0 8px; flex-shrink: 0; }
|
|
123
|
+
.cpub-rmedit-remove:hover { border-color: var(--red-border); color: var(--red); }
|
|
124
|
+
|
|
125
|
+
.cpub-rmedit-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; border: var(--border-width-default) dashed var(--border2); }
|
|
126
|
+
.cpub-rmedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
|
127
|
+
</style>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Edit component for the `sponsors` block — a logo wall. House block-edit contract:
|
|
4
|
+
* `content` in, `update` out, immutable list ops. Each logo can be uploaded (via
|
|
5
|
+
* the contest editor's UPLOAD_HANDLER_KEY) or pasted as a URL, with an alt name,
|
|
6
|
+
* an optional outbound link, and an optional tier label. Provided via
|
|
7
|
+
* BLOCK_COMPONENTS_KEY.
|
|
8
|
+
*/
|
|
9
|
+
import { inject, ref } from 'vue';
|
|
10
|
+
import { UPLOAD_HANDLER_KEY } from '@commonpub/editor/vue';
|
|
11
|
+
import type { SponsorLogo, SponsorsContent } from '../../../types/contestBlocks';
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
14
|
+
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
15
|
+
|
|
16
|
+
const uploadHandler = inject(UPLOAD_HANDLER_KEY, undefined);
|
|
17
|
+
const uploadingIndex = ref<number | null>(null);
|
|
18
|
+
|
|
19
|
+
const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading : ''));
|
|
20
|
+
const logos = computed<SponsorLogo[]>(() => (Array.isArray(props.content.logos) ? (props.content.logos as SponsorLogo[]) : []));
|
|
21
|
+
|
|
22
|
+
function commit(next: Partial<SponsorsContent>): void {
|
|
23
|
+
emit('update', { heading: heading.value || undefined, logos: logos.value, ...next });
|
|
24
|
+
}
|
|
25
|
+
function addLogo(): void {
|
|
26
|
+
commit({ logos: [...logos.value, { src: '', alt: '' }] });
|
|
27
|
+
}
|
|
28
|
+
function setLogo(i: number, field: keyof SponsorLogo, value: string): void {
|
|
29
|
+
commit({ logos: logos.value.map((l, idx) => (idx === i ? { ...l, [field]: value } : l)) });
|
|
30
|
+
}
|
|
31
|
+
function removeLogo(i: number): void {
|
|
32
|
+
commit({ logos: logos.value.filter((_, idx) => idx !== i) });
|
|
33
|
+
}
|
|
34
|
+
async function onFile(i: number, event: Event): Promise<void> {
|
|
35
|
+
const input = event.target as HTMLInputElement;
|
|
36
|
+
const file = input.files?.[0];
|
|
37
|
+
input.value = '';
|
|
38
|
+
if (!file || !uploadHandler) return;
|
|
39
|
+
uploadingIndex.value = i;
|
|
40
|
+
try {
|
|
41
|
+
const res = await uploadHandler(file);
|
|
42
|
+
setLogo(i, 'src', res.url);
|
|
43
|
+
} finally {
|
|
44
|
+
uploadingIndex.value = null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div class="cpub-spedit">
|
|
51
|
+
<div class="cpub-spedit-header">
|
|
52
|
+
<div class="cpub-spedit-icon"><i class="fa-solid fa-handshake-angle"></i></div>
|
|
53
|
+
<span class="cpub-spedit-title">Sponsors</span>
|
|
54
|
+
<span class="cpub-spedit-count">{{ logos.length }} {{ logos.length === 1 ? 'logo' : 'logos' }}</span>
|
|
55
|
+
<button type="button" class="cpub-spedit-add" @click="addLogo"><i class="fa-solid fa-plus"></i> Add logo</button>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="cpub-spedit-body">
|
|
59
|
+
<input
|
|
60
|
+
class="cpub-spedit-input cpub-spedit-heading"
|
|
61
|
+
type="text"
|
|
62
|
+
:value="heading"
|
|
63
|
+
placeholder="Eyebrow heading (optional), e.g. Sponsors"
|
|
64
|
+
aria-label="Sponsors heading"
|
|
65
|
+
@input="commit({ heading: ($event.target as HTMLInputElement).value || undefined })"
|
|
66
|
+
/>
|
|
67
|
+
|
|
68
|
+
<div v-for="(l, i) in logos" :key="i" class="cpub-spedit-row">
|
|
69
|
+
<div class="cpub-spedit-thumb" :class="{ 'is-empty': !l.src }">
|
|
70
|
+
<img v-if="l.src" :src="l.src" :alt="l.alt || 'Sponsor logo'" />
|
|
71
|
+
<i v-else class="fa-regular fa-image"></i>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="cpub-spedit-fields">
|
|
74
|
+
<div class="cpub-spedit-srcrow">
|
|
75
|
+
<input class="cpub-spedit-input" type="url" :value="l.src" placeholder="Logo image URL (https://…)" :aria-label="`Logo ${i + 1} image URL`" @input="setLogo(i, 'src', ($event.target as HTMLInputElement).value)" />
|
|
76
|
+
<label v-if="uploadHandler" class="cpub-spedit-upload" :title="`Upload logo ${i + 1}`">
|
|
77
|
+
<i class="fa-solid" :class="uploadingIndex === i ? 'fa-circle-notch fa-spin' : 'fa-arrow-up-from-bracket'"></i>
|
|
78
|
+
<input type="file" accept="image/*" class="cpub-sr-only" :aria-label="`Upload logo ${i + 1}`" @change="onFile(i, $event)" />
|
|
79
|
+
</label>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="cpub-spedit-metarow">
|
|
82
|
+
<input class="cpub-spedit-input" type="text" :value="l.alt" placeholder="Name (alt text)" :aria-label="`Logo ${i + 1} name`" @input="setLogo(i, 'alt', ($event.target as HTMLInputElement).value)" />
|
|
83
|
+
<input class="cpub-spedit-input" type="text" :value="l.tier ?? ''" placeholder="Tier (optional)" :aria-label="`Logo ${i + 1} tier`" @input="setLogo(i, 'tier', ($event.target as HTMLInputElement).value)" />
|
|
84
|
+
</div>
|
|
85
|
+
<input class="cpub-spedit-input" type="url" :value="l.url ?? ''" placeholder="Link (https://…, optional)" :aria-label="`Logo ${i + 1} link`" @input="setLogo(i, 'url', ($event.target as HTMLInputElement).value)" />
|
|
86
|
+
</div>
|
|
87
|
+
<button type="button" class="cpub-spedit-remove" :aria-label="`Remove logo ${i + 1}`" @click="removeLogo(i)"><i class="fa-solid fa-xmark"></i></button>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div v-if="!logos.length" class="cpub-spedit-empty" @click="addLogo"><i class="fa-solid fa-plus"></i> Add the first sponsor logo</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<style scoped>
|
|
96
|
+
.cpub-spedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
|
|
97
|
+
.cpub-spedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
|
|
98
|
+
.cpub-spedit-icon { font-size: 12px; color: var(--accent); }
|
|
99
|
+
.cpub-spedit-title { font-size: 12px; font-weight: 600; }
|
|
100
|
+
.cpub-spedit-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
|
|
101
|
+
.cpub-spedit-add { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 4px; margin-left: 8px; }
|
|
102
|
+
.cpub-spedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
|
|
103
|
+
|
|
104
|
+
.cpub-spedit-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
105
|
+
.cpub-spedit-input { width: 100%; padding: 6px 8px; font-size: 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
|
|
106
|
+
.cpub-spedit-input:focus { border-color: var(--accent); }
|
|
107
|
+
.cpub-spedit-input::placeholder { color: var(--text-faint); }
|
|
108
|
+
.cpub-spedit-heading { font-weight: 600; }
|
|
109
|
+
|
|
110
|
+
.cpub-spedit-row { display: flex; gap: 8px; align-items: flex-start; border: var(--border-width-default) dashed var(--border2); padding: 8px; }
|
|
111
|
+
.cpub-spedit-thumb { width: 56px; height: 56px; flex-shrink: 0; border: var(--border-width-default) solid var(--border); background: var(--surface2); display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
|
112
|
+
.cpub-spedit-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
|
113
|
+
.cpub-spedit-thumb.is-empty { color: var(--text-faint); font-size: 16px; }
|
|
114
|
+
.cpub-spedit-fields { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
|
115
|
+
.cpub-spedit-srcrow { display: flex; gap: 6px; }
|
|
116
|
+
.cpub-spedit-srcrow .cpub-spedit-input { flex: 1; }
|
|
117
|
+
.cpub-spedit-metarow { display: flex; gap: 6px; }
|
|
118
|
+
.cpub-spedit-metarow .cpub-spedit-input { flex: 1; }
|
|
119
|
+
.cpub-spedit-upload { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 30px; border: var(--border-width-default) solid var(--border); background: var(--surface2); color: var(--text-dim); cursor: pointer; }
|
|
120
|
+
.cpub-spedit-upload:hover { border-color: var(--accent); color: var(--accent); }
|
|
121
|
+
.cpub-spedit-remove { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; padding: 0 8px; flex-shrink: 0; align-self: stretch; }
|
|
122
|
+
.cpub-spedit-remove:hover { border-color: var(--red-border); color: var(--red); }
|
|
123
|
+
|
|
124
|
+
.cpub-spedit-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; border: var(--border-width-default) dashed var(--border2); }
|
|
125
|
+
.cpub-spedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
|
126
|
+
.cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
|
|
127
|
+
</style>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Edit component for the `table` block — an editable grid (header row + body
|
|
4
|
+
* rows) with add/remove row + column. Cells are plain strings (rendered as text
|
|
5
|
+
* in the view, so no HTML injection). House block-edit contract: `content` in,
|
|
6
|
+
* `update` out. Provided via BLOCK_COMPONENTS_KEY.
|
|
7
|
+
*/
|
|
8
|
+
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
9
|
+
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
10
|
+
|
|
11
|
+
const caption = computed(() => (typeof props.content.caption === 'string' ? props.content.caption : ''));
|
|
12
|
+
const header = computed<string[]>(() => (Array.isArray(props.content.header) ? (props.content.header as string[]) : ['Column 1', 'Column 2']));
|
|
13
|
+
const rows = computed<string[][]>(() => (Array.isArray(props.content.rows) ? (props.content.rows as string[][]) : [['', '']]));
|
|
14
|
+
|
|
15
|
+
function commit(next: Partial<{ caption: string; header: string[]; rows: string[][] }>): void {
|
|
16
|
+
emit('update', { caption: caption.value || undefined, header: header.value, rows: rows.value, ...next });
|
|
17
|
+
}
|
|
18
|
+
function setHeader(c: number, v: string): void {
|
|
19
|
+
commit({ header: header.value.map((h, i) => (i === c ? v : h)) });
|
|
20
|
+
}
|
|
21
|
+
function setCell(r: number, c: number, v: string): void {
|
|
22
|
+
commit({ rows: rows.value.map((row, ri) => (ri === r ? row.map((cell, ci) => (ci === c ? v : cell)) : row)) });
|
|
23
|
+
}
|
|
24
|
+
function addColumn(): void {
|
|
25
|
+
commit({ header: [...header.value, `Column ${header.value.length + 1}`], rows: rows.value.map((row) => [...row, '']) });
|
|
26
|
+
}
|
|
27
|
+
function removeColumn(c: number): void {
|
|
28
|
+
if (header.value.length <= 1) return;
|
|
29
|
+
commit({ header: header.value.filter((_, i) => i !== c), rows: rows.value.map((row) => row.filter((_, i) => i !== c)) });
|
|
30
|
+
}
|
|
31
|
+
function addRow(): void {
|
|
32
|
+
commit({ rows: [...rows.value, header.value.map(() => '')] });
|
|
33
|
+
}
|
|
34
|
+
function removeRow(r: number): void {
|
|
35
|
+
commit({ rows: rows.value.filter((_, i) => i !== r) });
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<div class="cpub-tedit">
|
|
41
|
+
<div class="cpub-tedit-header">
|
|
42
|
+
<div class="cpub-tedit-icon"><i class="fa-solid fa-table"></i></div>
|
|
43
|
+
<span class="cpub-tedit-title">Table</span>
|
|
44
|
+
<button type="button" class="cpub-tedit-act" @click="addColumn"><i class="fa-solid fa-plus"></i> Column</button>
|
|
45
|
+
<button type="button" class="cpub-tedit-act" @click="addRow"><i class="fa-solid fa-plus"></i> Row</button>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="cpub-tedit-body">
|
|
49
|
+
<input class="cpub-tedit-input cpub-tedit-caption" type="text" :value="caption" placeholder="Caption (optional)" aria-label="Table caption" @input="commit({ caption: ($event.target as HTMLInputElement).value || undefined })" />
|
|
50
|
+
|
|
51
|
+
<div class="cpub-tedit-scroll">
|
|
52
|
+
<table class="cpub-tedit-grid">
|
|
53
|
+
<thead>
|
|
54
|
+
<tr>
|
|
55
|
+
<th v-for="(h, c) in header" :key="c" class="cpub-tedit-th">
|
|
56
|
+
<input class="cpub-tedit-input cpub-tedit-hcell" type="text" :value="h" :placeholder="`Column ${c + 1}`" :aria-label="`Header ${c + 1}`" @input="setHeader(c, ($event.target as HTMLInputElement).value)" />
|
|
57
|
+
<button type="button" class="cpub-tedit-del" :disabled="header.length <= 1" :aria-label="`Remove column ${c + 1}`" title="Remove column" @click="removeColumn(c)"><i class="fa-solid fa-xmark"></i></button>
|
|
58
|
+
</th>
|
|
59
|
+
<th class="cpub-tedit-rowspacer" aria-hidden="true"></th>
|
|
60
|
+
</tr>
|
|
61
|
+
</thead>
|
|
62
|
+
<tbody>
|
|
63
|
+
<tr v-for="(row, r) in rows" :key="r">
|
|
64
|
+
<td v-for="(_, c) in header" :key="c">
|
|
65
|
+
<input class="cpub-tedit-input" type="text" :value="row[c] ?? ''" :aria-label="`Row ${r + 1} column ${c + 1}`" @input="setCell(r, c, ($event.target as HTMLInputElement).value)" />
|
|
66
|
+
</td>
|
|
67
|
+
<td class="cpub-tedit-rowdel">
|
|
68
|
+
<button type="button" class="cpub-tedit-del" :aria-label="`Remove row ${r + 1}`" title="Remove row" @click="removeRow(r)"><i class="fa-solid fa-xmark"></i></button>
|
|
69
|
+
</td>
|
|
70
|
+
</tr>
|
|
71
|
+
</tbody>
|
|
72
|
+
</table>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<style scoped>
|
|
79
|
+
.cpub-tedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
|
|
80
|
+
.cpub-tedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
|
|
81
|
+
.cpub-tedit-icon { font-size: 12px; color: var(--accent); }
|
|
82
|
+
.cpub-tedit-title { font-size: 12px; font-weight: 600; margin-right: auto; }
|
|
83
|
+
.cpub-tedit-act { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
|
|
84
|
+
.cpub-tedit-act:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
|
|
85
|
+
.cpub-tedit-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
86
|
+
.cpub-tedit-input { width: 100%; padding: 6px 8px; font-size: 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
|
|
87
|
+
.cpub-tedit-input:focus { border-color: var(--accent); }
|
|
88
|
+
.cpub-tedit-input::placeholder { color: var(--text-faint); }
|
|
89
|
+
.cpub-tedit-caption { font-style: italic; }
|
|
90
|
+
.cpub-tedit-scroll { overflow-x: auto; }
|
|
91
|
+
.cpub-tedit-grid { border-collapse: collapse; }
|
|
92
|
+
.cpub-tedit-th { padding: 0 4px 6px 0; vertical-align: bottom; }
|
|
93
|
+
.cpub-tedit-hcell { font-weight: 700; min-width: 120px; }
|
|
94
|
+
.cpub-tedit-grid td { padding: 0 4px 4px 0; }
|
|
95
|
+
.cpub-tedit-grid td .cpub-tedit-input { min-width: 120px; }
|
|
96
|
+
.cpub-tedit-del { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 10px; padding: 4px 6px; margin-top: 3px; }
|
|
97
|
+
.cpub-tedit-del:hover:not(:disabled) { border-color: var(--red-border); color: var(--red); }
|
|
98
|
+
.cpub-tedit-del:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
99
|
+
.cpub-tedit-rowspacer { width: 28px; }
|
|
100
|
+
.cpub-tedit-rowdel { width: 28px; text-align: center; }
|
|
101
|
+
</style>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Edit component for the `tabs` block — a tabbed container whose panels each hold
|
|
4
|
+
* nested blocks (the buildStep pattern, generalized). Solves tabbed / multiple
|
|
5
|
+
* rule sets (e.g. Track A vs Track B), each panel a full rich body. House
|
|
6
|
+
* block-edit contract: `content` in, `update` out. Provided via
|
|
7
|
+
* BLOCK_COMPONENTS_KEY. The nested palette (`PANEL_GROUPS`) deliberately omits
|
|
8
|
+
* container blocks so you can't nest tabs-in-tabs.
|
|
9
|
+
*/
|
|
10
|
+
import type { BlockTuple } from '@commonpub/editor';
|
|
11
|
+
import type { BlockTypeGroup } from '@commonpub/editor/vue';
|
|
12
|
+
import ContestTabPanel from './ContestTabPanel.vue';
|
|
13
|
+
|
|
14
|
+
interface TabDef { label: string; blocks: BlockTuple[] }
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
17
|
+
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
18
|
+
|
|
19
|
+
// Blocks offered inside a panel — rich content, but NO container blocks (tabs,
|
|
20
|
+
// buildStep) so nesting can't recurse.
|
|
21
|
+
const PANEL_GROUPS: BlockTypeGroup[] = [
|
|
22
|
+
{ name: 'Basic', blocks: [
|
|
23
|
+
{ type: 'paragraph', label: 'Text', icon: 'fa-align-left', description: 'Body text' },
|
|
24
|
+
{ type: 'heading', label: 'Heading', icon: 'fa-heading', description: 'Section header' },
|
|
25
|
+
{ type: 'image', label: 'Image', icon: 'fa-image', description: 'Upload or embed' },
|
|
26
|
+
{ type: 'code_block', label: 'Code', icon: 'fa-code', description: 'Code block' },
|
|
27
|
+
] },
|
|
28
|
+
{ name: 'Rich', blocks: [
|
|
29
|
+
{ type: 'callout', label: 'Tip', icon: 'fa-lightbulb', description: 'Tip callout', attrs: { variant: 'tip' } },
|
|
30
|
+
{ type: 'callout', label: 'Warning', icon: 'fa-triangle-exclamation', description: 'Warning callout', attrs: { variant: 'warning' } },
|
|
31
|
+
{ type: 'blockquote', label: 'Quote', icon: 'fa-quote-left', description: 'Blockquote' },
|
|
32
|
+
{ type: 'horizontal_rule', label: 'Divider', icon: 'fa-minus', description: 'Visual separator' },
|
|
33
|
+
{ type: 'table', label: 'Table', icon: 'fa-table', description: 'Responsive data table' },
|
|
34
|
+
{ type: 'criteriaBar', label: 'Criteria Bar', icon: 'fa-chart-simple', description: 'Weighted criteria bar' },
|
|
35
|
+
{ type: 'markdown', label: 'Markdown', icon: 'fa-brands fa-markdown', description: 'Raw markdown block' },
|
|
36
|
+
{ type: 'html', label: 'HTML', icon: 'fa-code', description: 'Raw HTML (sanitized on render)' },
|
|
37
|
+
] },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const tabs = computed<TabDef[]>(() =>
|
|
41
|
+
Array.isArray(props.content.tabs)
|
|
42
|
+
? (props.content.tabs as TabDef[]).map((t) => ({ label: t?.label ?? '', blocks: Array.isArray(t?.blocks) ? t.blocks : [] }))
|
|
43
|
+
: [],
|
|
44
|
+
);
|
|
45
|
+
const active = ref(0);
|
|
46
|
+
watchEffect(() => { if (active.value >= tabs.value.length) active.value = Math.max(0, tabs.value.length - 1); });
|
|
47
|
+
|
|
48
|
+
const urlKey = computed(() => (typeof props.content.urlKey === 'string' ? props.content.urlKey : ''));
|
|
49
|
+
function commit(next: TabDef[]): void {
|
|
50
|
+
emit('update', { tabs: next, urlKey: urlKey.value || undefined });
|
|
51
|
+
}
|
|
52
|
+
function setUrlKey(v: string): void {
|
|
53
|
+
emit('update', { tabs: tabs.value, urlKey: v.trim() || undefined });
|
|
54
|
+
}
|
|
55
|
+
function setLabel(i: number, label: string): void {
|
|
56
|
+
commit(tabs.value.map((t, idx) => (idx === i ? { ...t, label } : t)));
|
|
57
|
+
}
|
|
58
|
+
function setBlocks(i: number, blocks: BlockTuple[]): void {
|
|
59
|
+
commit(tabs.value.map((t, idx) => (idx === i ? { ...t, blocks } : t)));
|
|
60
|
+
}
|
|
61
|
+
function addTab(): void {
|
|
62
|
+
commit([...tabs.value, { label: `Tab ${tabs.value.length + 1}`, blocks: [] }]);
|
|
63
|
+
void nextTick(() => { active.value = tabs.value.length - 1; });
|
|
64
|
+
}
|
|
65
|
+
function removeTab(i: number): void {
|
|
66
|
+
commit(tabs.value.filter((_, idx) => idx !== i));
|
|
67
|
+
}
|
|
68
|
+
function moveTab(i: number, dir: -1 | 1): void {
|
|
69
|
+
const j = i + dir;
|
|
70
|
+
if (j < 0 || j >= tabs.value.length) return;
|
|
71
|
+
const next = [...tabs.value];
|
|
72
|
+
[next[i], next[j]] = [next[j]!, next[i]!];
|
|
73
|
+
commit(next);
|
|
74
|
+
active.value = j;
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<div class="cpub-tabsedit">
|
|
80
|
+
<div class="cpub-tabsedit-header">
|
|
81
|
+
<div class="cpub-tabsedit-icon"><i class="fa-solid fa-folder-tree"></i></div>
|
|
82
|
+
<span class="cpub-tabsedit-title">Tabs</span>
|
|
83
|
+
<span class="cpub-tabsedit-count">{{ tabs.length }} {{ tabs.length === 1 ? 'tab' : 'tabs' }}</span>
|
|
84
|
+
<button type="button" class="cpub-tabsedit-add" @click="addTab"><i class="fa-solid fa-plus"></i> Add tab</button>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div v-if="tabs.length" class="cpub-tabsedit-urlkey">
|
|
88
|
+
<input
|
|
89
|
+
class="cpub-tabsedit-keyinput"
|
|
90
|
+
type="text"
|
|
91
|
+
:value="urlKey"
|
|
92
|
+
placeholder="Deep-link key (optional), e.g. track"
|
|
93
|
+
aria-label="Deep-link URL key"
|
|
94
|
+
@input="setUrlKey(($event.target as HTMLInputElement).value)"
|
|
95
|
+
/>
|
|
96
|
+
<span class="cpub-tabsedit-keyhint">Shareable: <code>?{{ urlKey || 'key' }}=<tab></code> opens that tab.</span>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div v-if="tabs.length" class="cpub-tabsedit-bar" role="group" aria-label="Select a tab to edit">
|
|
100
|
+
<div v-for="(t, i) in tabs" :key="i" class="cpub-tabsedit-tab" :class="{ 'cpub-tabsedit-tab-on': active === i }">
|
|
101
|
+
<button type="button" class="cpub-tabsedit-select" :aria-pressed="active === i" @click="active = i">
|
|
102
|
+
{{ t.label || `Tab ${i + 1}` }}
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div v-if="tabs.length" class="cpub-tabsedit-panel">
|
|
108
|
+
<div class="cpub-tabsedit-meta">
|
|
109
|
+
<input
|
|
110
|
+
class="cpub-tabsedit-label"
|
|
111
|
+
type="text"
|
|
112
|
+
:value="tabs[active]?.label"
|
|
113
|
+
:placeholder="`Tab ${active + 1} label`"
|
|
114
|
+
aria-label="Active tab label"
|
|
115
|
+
@input="setLabel(active, ($event.target as HTMLInputElement).value)"
|
|
116
|
+
/>
|
|
117
|
+
<div class="cpub-tabsedit-meta-actions">
|
|
118
|
+
<button type="button" class="cpub-tabsedit-mbtn" :disabled="active === 0" aria-label="Move tab left" title="Move left" @click="moveTab(active, -1)"><i class="fa-solid fa-arrow-left"></i></button>
|
|
119
|
+
<button type="button" class="cpub-tabsedit-mbtn" :disabled="active === tabs.length - 1" aria-label="Move tab right" title="Move right" @click="moveTab(active, 1)"><i class="fa-solid fa-arrow-right"></i></button>
|
|
120
|
+
<button type="button" class="cpub-tabsedit-mbtn cpub-tabsedit-mbtn--danger" aria-label="Remove this tab" title="Remove tab" @click="removeTab(active)"><i class="fa-solid fa-trash"></i></button>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<!-- One panel editor at a time, keyed by tab so each gets a clean editor;
|
|
124
|
+
parent content persists across switches. -->
|
|
125
|
+
<ContestTabPanel
|
|
126
|
+
:key="active"
|
|
127
|
+
:blocks="tabs[active]?.blocks ?? []"
|
|
128
|
+
:groups="PANEL_GROUPS"
|
|
129
|
+
@update:blocks="setBlocks(active, $event)"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div v-else class="cpub-tabsedit-empty" @click="addTab"><i class="fa-solid fa-plus"></i> Add the first tab (e.g. Track A, Track B)</div>
|
|
134
|
+
</div>
|
|
135
|
+
</template>
|
|
136
|
+
|
|
137
|
+
<style scoped>
|
|
138
|
+
.cpub-tabsedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
|
|
139
|
+
.cpub-tabsedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
|
|
140
|
+
.cpub-tabsedit-icon { font-size: 12px; color: var(--accent); }
|
|
141
|
+
.cpub-tabsedit-title { font-size: 12px; font-weight: 600; }
|
|
142
|
+
.cpub-tabsedit-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
|
|
143
|
+
.cpub-tabsedit-add { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 4px; margin-left: 8px; }
|
|
144
|
+
.cpub-tabsedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
|
|
145
|
+
|
|
146
|
+
.cpub-tabsedit-urlkey { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 14px 0; }
|
|
147
|
+
.cpub-tabsedit-keyinput { padding: 5px 8px; font-size: 11px; font-family: var(--font-mono); background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; min-width: 220px; }
|
|
148
|
+
.cpub-tabsedit-keyinput:focus { border-color: var(--accent); }
|
|
149
|
+
.cpub-tabsedit-keyhint { font-size: 10px; color: var(--text-faint); }
|
|
150
|
+
.cpub-tabsedit-keyhint code { font-family: var(--font-mono); color: var(--text-dim); }
|
|
151
|
+
|
|
152
|
+
.cpub-tabsedit-bar { display: flex; flex-wrap: wrap; gap: 4px; padding: 10px 14px 0; }
|
|
153
|
+
.cpub-tabsedit-tab { display: inline-flex; }
|
|
154
|
+
.cpub-tabsedit-select { font-size: 12px; font-weight: 600; padding: 6px 12px; background: transparent; border: var(--border-width-default) solid var(--border2); border-bottom: none; color: var(--text-dim); cursor: pointer; }
|
|
155
|
+
.cpub-tabsedit-tab-on .cpub-tabsedit-select { color: var(--accent); background: var(--accent-bg); border-color: var(--accent-border); }
|
|
156
|
+
|
|
157
|
+
.cpub-tabsedit-panel { padding: 12px 14px; border-top: var(--border-width-default) solid var(--border2); }
|
|
158
|
+
.cpub-tabsedit-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
159
|
+
.cpub-tabsedit-label { flex: 1; padding: 6px 8px; font-size: 12px; font-weight: 600; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
|
|
160
|
+
.cpub-tabsedit-label:focus { border-color: var(--accent); }
|
|
161
|
+
.cpub-tabsedit-meta-actions { display: inline-flex; gap: 3px; }
|
|
162
|
+
.cpub-tabsedit-mbtn { width: 26px; height: 26px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-faint); cursor: pointer; font-size: 10px; display: inline-flex; align-items: center; justify-content: center; }
|
|
163
|
+
.cpub-tabsedit-mbtn:hover:not(:disabled) { color: var(--text); }
|
|
164
|
+
.cpub-tabsedit-mbtn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
165
|
+
.cpub-tabsedit-mbtn--danger:hover:not(:disabled) { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
|
|
166
|
+
.cpub-tabsedit-empty { padding: 18px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; margin: 12px 14px; border: var(--border-width-default) dashed var(--border2); }
|
|
167
|
+
.cpub-tabsedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
|
168
|
+
</style>
|