@commonpub/layer 0.82.0 → 0.83.1
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,141 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ContestSubmissionTemplateField } from '@commonpub/schema';
|
|
3
|
+
import { ADDRESS_SUBFIELDS, parseAddress, serializeAddress, isChecked } from '../../utils/contestSubmission';
|
|
4
|
+
|
|
5
|
+
// One entrant-facing control for a submission-template field. Renders the right
|
|
6
|
+
// input for the field type (text/textarea/url/email/number/select/checkbox/date/
|
|
7
|
+
// agreement/address). The model is always a string (the wire shape): checkbox/
|
|
8
|
+
// agreement use 'true'/''; address is JSON-encoded. Reused by the per-stage
|
|
9
|
+
// artifact form AND the proposal form, so both surfaces behave identically.
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(defineProps<{
|
|
12
|
+
field: ContestSubmissionTemplateField;
|
|
13
|
+
/** Unique id prefix so multiple forms on a page don't collide. */
|
|
14
|
+
idPrefix?: string;
|
|
15
|
+
}>(), { idPrefix: 'cpub-subfield' });
|
|
16
|
+
|
|
17
|
+
const model = defineModel<string>({ default: '' });
|
|
18
|
+
|
|
19
|
+
const fieldId = computed(() => `${props.idPrefix}-${props.field.key}`);
|
|
20
|
+
const helpId = computed(() => (props.field.help ? `${fieldId.value}-help` : undefined));
|
|
21
|
+
|
|
22
|
+
// Address: a parsed view-model re-serialized back into the string model on edit.
|
|
23
|
+
const address = computed(() => parseAddress(model.value));
|
|
24
|
+
function setAddressPart(key: string, value: string): void {
|
|
25
|
+
model.value = serializeAddress({ ...address.value, [key]: value });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const checked = computed(() => isChecked(model.value));
|
|
29
|
+
function setChecked(on: boolean): void {
|
|
30
|
+
model.value = on ? 'true' : '';
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<div class="cpub-subfield">
|
|
36
|
+
<!-- Agreement: terms to read + an explicit accept checkbox. -->
|
|
37
|
+
<template v-if="field.type === 'agreement'">
|
|
38
|
+
<span :id="fieldId" class="cpub-subfield-label">
|
|
39
|
+
{{ field.label }} <span v-if="field.required || field.mustAccept !== false" class="cpub-subfield-req" aria-hidden="true">*</span>
|
|
40
|
+
</span>
|
|
41
|
+
<div v-if="field.terms" class="cpub-subfield-terms">{{ field.terms }}</div>
|
|
42
|
+
<label class="cpub-subfield-check">
|
|
43
|
+
<input
|
|
44
|
+
type="checkbox"
|
|
45
|
+
:checked="checked"
|
|
46
|
+
:aria-describedby="helpId"
|
|
47
|
+
@change="setChecked(($event.target as HTMLInputElement).checked)"
|
|
48
|
+
/>
|
|
49
|
+
<span>I accept{{ field.required || field.mustAccept !== false ? ' (required)' : '' }}</span>
|
|
50
|
+
</label>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<!-- Checkbox: a single boolean consent / opt-in. -->
|
|
54
|
+
<template v-else-if="field.type === 'checkbox'">
|
|
55
|
+
<label class="cpub-subfield-check">
|
|
56
|
+
<input
|
|
57
|
+
:id="fieldId"
|
|
58
|
+
type="checkbox"
|
|
59
|
+
:checked="checked"
|
|
60
|
+
:aria-describedby="helpId"
|
|
61
|
+
@change="setChecked(($event.target as HTMLInputElement).checked)"
|
|
62
|
+
/>
|
|
63
|
+
<span>{{ field.label }} <span v-if="field.required" class="cpub-subfield-req" aria-hidden="true">*</span></span>
|
|
64
|
+
</label>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<!-- Address: structured subfields, JSON-encoded into the model. -->
|
|
68
|
+
<template v-else-if="field.type === 'address'">
|
|
69
|
+
<span :id="fieldId" class="cpub-subfield-label">
|
|
70
|
+
{{ field.label }} <span v-if="field.required" class="cpub-subfield-req" aria-hidden="true">*</span>
|
|
71
|
+
</span>
|
|
72
|
+
<div class="cpub-subfield-address" role="group" :aria-labelledby="fieldId">
|
|
73
|
+
<input
|
|
74
|
+
v-for="sub in ADDRESS_SUBFIELDS"
|
|
75
|
+
:key="sub.key"
|
|
76
|
+
:value="address[sub.key] ?? ''"
|
|
77
|
+
type="text"
|
|
78
|
+
class="cpub-subfield-input"
|
|
79
|
+
:placeholder="sub.label"
|
|
80
|
+
:aria-label="`${field.label}: ${sub.label}`"
|
|
81
|
+
@input="setAddressPart(sub.key, ($event.target as HTMLInputElement).value)"
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
</template>
|
|
85
|
+
|
|
86
|
+
<!-- Everything else: a labelled single control. -->
|
|
87
|
+
<template v-else>
|
|
88
|
+
<label class="cpub-subfield-label" :for="fieldId">
|
|
89
|
+
{{ field.label }} <span v-if="field.required" class="cpub-subfield-req" aria-hidden="true">*</span>
|
|
90
|
+
<span v-if="field.required" class="cpub-sr-only">(required)</span>
|
|
91
|
+
</label>
|
|
92
|
+
<textarea
|
|
93
|
+
v-if="field.type === 'textarea'"
|
|
94
|
+
:id="fieldId"
|
|
95
|
+
v-model="model"
|
|
96
|
+
class="cpub-subfield-input cpub-subfield-textarea"
|
|
97
|
+
rows="4"
|
|
98
|
+
maxlength="4000"
|
|
99
|
+
:aria-describedby="helpId"
|
|
100
|
+
></textarea>
|
|
101
|
+
<select
|
|
102
|
+
v-else-if="field.type === 'select'"
|
|
103
|
+
:id="fieldId"
|
|
104
|
+
v-model="model"
|
|
105
|
+
class="cpub-subfield-input"
|
|
106
|
+
:aria-describedby="helpId"
|
|
107
|
+
>
|
|
108
|
+
<option value="" disabled>Choose…</option>
|
|
109
|
+
<option v-for="o in (field.options ?? [])" :key="o.value" :value="o.value">{{ o.label }}</option>
|
|
110
|
+
</select>
|
|
111
|
+
<input
|
|
112
|
+
v-else
|
|
113
|
+
:id="fieldId"
|
|
114
|
+
v-model="model"
|
|
115
|
+
:type="field.type === 'url' ? 'url' : field.type === 'email' ? 'email' : field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'"
|
|
116
|
+
class="cpub-subfield-input"
|
|
117
|
+
maxlength="4000"
|
|
118
|
+
:placeholder="field.type === 'url' ? 'https://' : undefined"
|
|
119
|
+
:aria-describedby="helpId"
|
|
120
|
+
/>
|
|
121
|
+
</template>
|
|
122
|
+
|
|
123
|
+
<p v-if="field.help" :id="helpId" class="cpub-subfield-help">{{ field.help }}</p>
|
|
124
|
+
</div>
|
|
125
|
+
</template>
|
|
126
|
+
|
|
127
|
+
<style scoped>
|
|
128
|
+
.cpub-subfield { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
|
|
129
|
+
.cpub-subfield-label { font-size: 11px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-dim); }
|
|
130
|
+
.cpub-subfield-req { color: var(--red); }
|
|
131
|
+
.cpub-subfield-input { width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans); }
|
|
132
|
+
.cpub-subfield-input:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
|
|
133
|
+
.cpub-subfield-textarea { resize: vertical; }
|
|
134
|
+
.cpub-subfield-help { font-size: 11px; color: var(--text-faint); margin: 0; }
|
|
135
|
+
.cpub-subfield-terms { max-height: 160px; overflow-y: auto; white-space: pre-wrap; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border2); background: var(--surface2); color: var(--text-dim); font-size: var(--text-sm); line-height: 1.6; }
|
|
136
|
+
.cpub-subfield-check { display: flex; align-items: flex-start; gap: 8px; font-size: var(--text-sm); color: var(--text); cursor: pointer; }
|
|
137
|
+
.cpub-subfield-check input { margin-top: 3px; width: 15px; height: 15px; flex-shrink: 0; }
|
|
138
|
+
.cpub-subfield-address { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
|
139
|
+
.cpub-subfield-address .cpub-subfield-input:first-child, .cpub-subfield-address .cpub-subfield-input:nth-child(2) { grid-column: 1 / -1; }
|
|
140
|
+
.cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
141
|
+
</style>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Edit component for the `compareColumns` block — side-by-side guidance columns
|
|
4
|
+
* (the "Encouraged / Out of scope" pattern). House block-edit contract: `content`
|
|
5
|
+
* in, `update` out, immutable list ops. Each column has a tone (color), a title,
|
|
6
|
+
* and a list of items; the block also carries an optional eyebrow, heading, and
|
|
7
|
+
* footer note. Provided via BLOCK_COMPONENTS_KEY.
|
|
8
|
+
*/
|
|
9
|
+
import type { CompareColumn, CompareColumnsContent, CompareTone } from '../../../types/contestBlocks';
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
12
|
+
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
13
|
+
|
|
14
|
+
const TONES: { value: CompareTone; label: string }[] = [
|
|
15
|
+
{ value: 'positive', label: 'Positive (green)' },
|
|
16
|
+
{ value: 'negative', label: 'Negative (red)' },
|
|
17
|
+
{ value: 'neutral', label: 'Neutral (accent)' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const eyebrow = computed(() => (typeof props.content.eyebrow === 'string' ? props.content.eyebrow : ''));
|
|
21
|
+
const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading : ''));
|
|
22
|
+
const note = computed(() => (typeof props.content.note === 'string' ? props.content.note : ''));
|
|
23
|
+
const columns = computed<CompareColumn[]>(() => (Array.isArray(props.content.columns) ? (props.content.columns as CompareColumn[]) : []));
|
|
24
|
+
|
|
25
|
+
function commit(next: Partial<CompareColumnsContent>): void {
|
|
26
|
+
emit('update', {
|
|
27
|
+
eyebrow: eyebrow.value || undefined,
|
|
28
|
+
heading: heading.value || undefined,
|
|
29
|
+
note: note.value || undefined,
|
|
30
|
+
columns: columns.value,
|
|
31
|
+
...next,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function patchColumn(ci: number, patch: Partial<CompareColumn>): void {
|
|
35
|
+
commit({ columns: columns.value.map((c, idx) => (idx === ci ? { ...c, ...patch } : c)) });
|
|
36
|
+
}
|
|
37
|
+
function addColumn(): void {
|
|
38
|
+
commit({ columns: [...columns.value, { tone: 'neutral', title: '', items: [''] }] });
|
|
39
|
+
}
|
|
40
|
+
function removeColumn(ci: number): void {
|
|
41
|
+
commit({ columns: columns.value.filter((_, idx) => idx !== ci) });
|
|
42
|
+
}
|
|
43
|
+
function setItem(ci: number, ii: number, value: string): void {
|
|
44
|
+
patchColumn(ci, { items: (columns.value[ci]?.items ?? []).map((it, idx) => (idx === ii ? value : it)) });
|
|
45
|
+
}
|
|
46
|
+
function addItem(ci: number): void {
|
|
47
|
+
patchColumn(ci, { items: [...(columns.value[ci]?.items ?? []), ''] });
|
|
48
|
+
}
|
|
49
|
+
function removeItem(ci: number, ii: number): void {
|
|
50
|
+
patchColumn(ci, { items: (columns.value[ci]?.items ?? []).filter((_, idx) => idx !== ii) });
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<div class="cpub-cmpedit">
|
|
56
|
+
<div class="cpub-cmpedit-header">
|
|
57
|
+
<div class="cpub-cmpedit-icon"><i class="fa-solid fa-table-columns"></i></div>
|
|
58
|
+
<span class="cpub-cmpedit-title">Compare Columns</span>
|
|
59
|
+
<span class="cpub-cmpedit-count">{{ columns.length }} {{ columns.length === 1 ? 'column' : 'columns' }}</span>
|
|
60
|
+
<button type="button" class="cpub-cmpedit-add" @click="addColumn"><i class="fa-solid fa-plus"></i> Add column</button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="cpub-cmpedit-body">
|
|
64
|
+
<input class="cpub-cmpedit-input" type="text" :value="eyebrow" placeholder="Eyebrow label (optional), e.g. What is in scope" aria-label="Eyebrow label" @input="commit({ eyebrow: ($event.target as HTMLInputElement).value || undefined })" />
|
|
65
|
+
<input class="cpub-cmpedit-input cpub-cmpedit-heading" type="text" :value="heading" placeholder="Heading (optional), e.g. Build to help, not to harm" aria-label="Heading" @input="commit({ heading: ($event.target as HTMLInputElement).value || undefined })" />
|
|
66
|
+
|
|
67
|
+
<div class="cpub-cmpedit-cols">
|
|
68
|
+
<div v-for="(c, ci) in columns" :key="ci" class="cpub-cmpedit-col" :class="`cpub-cmpedit-${c.tone}`">
|
|
69
|
+
<div class="cpub-cmpedit-col-head">
|
|
70
|
+
<input class="cpub-cmpedit-input cpub-cmpedit-coltitle" type="text" :value="c.title" placeholder="Column title" :aria-label="`Column ${ci + 1} title`" @input="patchColumn(ci, { title: ($event.target as HTMLInputElement).value })" />
|
|
71
|
+
<select class="cpub-cmpedit-input cpub-cmpedit-tone" :value="c.tone" :aria-label="`Column ${ci + 1} tone`" @change="patchColumn(ci, { tone: ($event.target as HTMLSelectElement).value as CompareTone })">
|
|
72
|
+
<option v-for="t in TONES" :key="t.value" :value="t.value">{{ t.label }}</option>
|
|
73
|
+
</select>
|
|
74
|
+
<button type="button" class="cpub-cmpedit-remove" :aria-label="`Remove column ${ci + 1}`" @click="removeColumn(ci)"><i class="fa-solid fa-xmark"></i></button>
|
|
75
|
+
</div>
|
|
76
|
+
<div v-for="(item, ii) in c.items" :key="ii" class="cpub-cmpedit-itemrow">
|
|
77
|
+
<input class="cpub-cmpedit-input" type="text" :value="item" placeholder="Item" :aria-label="`Column ${ci + 1} item ${ii + 1}`" @input="setItem(ci, ii, ($event.target as HTMLInputElement).value)" />
|
|
78
|
+
<button type="button" class="cpub-cmpedit-itemremove" :aria-label="`Remove item ${ii + 1} from column ${ci + 1}`" @click="removeItem(ci, ii)"><i class="fa-solid fa-xmark"></i></button>
|
|
79
|
+
</div>
|
|
80
|
+
<button type="button" class="cpub-cmpedit-itemadd" @click="addItem(ci)"><i class="fa-solid fa-plus"></i> Add item</button>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div v-if="!columns.length" class="cpub-cmpedit-empty" @click="addColumn"><i class="fa-solid fa-plus"></i> Add the first column</div>
|
|
85
|
+
|
|
86
|
+
<input class="cpub-cmpedit-input" type="text" :value="note" placeholder="Footer note (optional)" aria-label="Footer note" @input="commit({ note: ($event.target as HTMLInputElement).value || undefined })" />
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
90
|
+
|
|
91
|
+
<style scoped>
|
|
92
|
+
.cpub-cmpedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
|
|
93
|
+
.cpub-cmpedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
|
|
94
|
+
.cpub-cmpedit-icon { font-size: 12px; color: var(--accent); }
|
|
95
|
+
.cpub-cmpedit-title { font-size: 12px; font-weight: 600; }
|
|
96
|
+
.cpub-cmpedit-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
|
|
97
|
+
.cpub-cmpedit-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; }
|
|
98
|
+
.cpub-cmpedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
|
|
99
|
+
|
|
100
|
+
.cpub-cmpedit-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
101
|
+
.cpub-cmpedit-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; }
|
|
102
|
+
.cpub-cmpedit-input:focus { border-color: var(--accent); }
|
|
103
|
+
.cpub-cmpedit-input::placeholder { color: var(--text-faint); }
|
|
104
|
+
.cpub-cmpedit-heading { font-weight: 600; }
|
|
105
|
+
|
|
106
|
+
.cpub-cmpedit-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
107
|
+
.cpub-cmpedit-col { border: var(--border-width-default) solid var(--border2); border-left-width: 3px; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
|
108
|
+
.cpub-cmpedit-positive { border-left-color: var(--green); }
|
|
109
|
+
.cpub-cmpedit-negative { border-left-color: var(--red); }
|
|
110
|
+
.cpub-cmpedit-neutral { border-left-color: var(--accent); }
|
|
111
|
+
.cpub-cmpedit-col-head { display: flex; gap: 6px; }
|
|
112
|
+
.cpub-cmpedit-coltitle { flex: 1; font-weight: 600; }
|
|
113
|
+
.cpub-cmpedit-tone { width: 130px; flex-shrink: 0; }
|
|
114
|
+
.cpub-cmpedit-itemrow { display: flex; gap: 6px; }
|
|
115
|
+
.cpub-cmpedit-itemrow .cpub-cmpedit-input { flex: 1; }
|
|
116
|
+
.cpub-cmpedit-remove,
|
|
117
|
+
.cpub-cmpedit-itemremove { 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; }
|
|
118
|
+
.cpub-cmpedit-remove:hover,
|
|
119
|
+
.cpub-cmpedit-itemremove:hover { border-color: var(--red-border); color: var(--red); }
|
|
120
|
+
.cpub-cmpedit-itemadd { align-self: flex-start; 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; }
|
|
121
|
+
.cpub-cmpedit-itemadd:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
|
|
122
|
+
|
|
123
|
+
.cpub-cmpedit-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; border: var(--border-width-default) dashed var(--border2); }
|
|
124
|
+
.cpub-cmpedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
|
125
|
+
|
|
126
|
+
@media (max-width: 640px) { .cpub-cmpedit-cols { grid-template-columns: 1fr; } }
|
|
127
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One editable tab panel for the `tabs` block: a nested BlockCanvas over its own
|
|
4
|
+
* useBlockEditor, seeded from the panel's BlockTuple[]. Emits the panel's blocks
|
|
5
|
+
* up on every change (watching a getter of .value so structural inserts are caught
|
|
6
|
+
* — a bare readonly-ref watch misses push/splice). Mounted one-at-a-time by the
|
|
7
|
+
* parent (keyed by tab) so each tab gets a clean editor; the parent's content is
|
|
8
|
+
* the source of truth across tab switches. Inherits BLOCK_COMPONENTS_KEY +
|
|
9
|
+
* UPLOAD_HANDLER_KEY from the contest editor, so table/criteriaBar/html/image all
|
|
10
|
+
* edit here too. The `groups` passed in exclude container blocks (no tabs-in-tabs).
|
|
11
|
+
*/
|
|
12
|
+
import { BlockCanvas, useBlockEditor, type BlockTypeGroup } from '@commonpub/editor/vue';
|
|
13
|
+
import type { BlockTuple } from '@commonpub/editor';
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
blocks: BlockTuple[];
|
|
17
|
+
groups: BlockTypeGroup[];
|
|
18
|
+
}>();
|
|
19
|
+
const emit = defineEmits<{ 'update:blocks': [blocks: BlockTuple[]] }>();
|
|
20
|
+
|
|
21
|
+
const editor = useBlockEditor(Array.isArray(props.blocks) ? props.blocks : []);
|
|
22
|
+
watch(() => editor.blocks.value, () => emit('update:blocks', editor.toBlockTuples()), { deep: true });
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<BlockCanvas :block-editor="editor" :block-types="groups" />
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Edit component for the `criteriaBar` block — judging criteria as one stacked
|
|
4
|
+
* weighted bar. Rows of {label, weight, color}, an optional heading, a legend
|
|
5
|
+
* toggle, and a live preview of the bar. House block-edit contract: `content`
|
|
6
|
+
* in, `update` out. Provided via BLOCK_COMPONENTS_KEY.
|
|
7
|
+
*/
|
|
8
|
+
import { inject } from 'vue';
|
|
9
|
+
import { CRITERIA_BAR_PALETTE, CONTEST_RUBRIC_KEY, criteriaBar, type CriteriaBarItem } from '../../../utils/contestBlocks';
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
12
|
+
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
13
|
+
|
|
14
|
+
// The contest editor provides its live judging rubric; offer a one-click fill.
|
|
15
|
+
const rubric = inject(CONTEST_RUBRIC_KEY, null);
|
|
16
|
+
const canUseRubric = computed(() => !!rubric?.value?.some((c) => (c.label ?? '').trim()));
|
|
17
|
+
|
|
18
|
+
const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading : ''));
|
|
19
|
+
const items = computed<CriteriaBarItem[]>(() => (Array.isArray(props.content.items) ? (props.content.items as CriteriaBarItem[]) : []));
|
|
20
|
+
const showLegend = computed(() => props.content.showLegend !== false);
|
|
21
|
+
const preview = computed(() => criteriaBar(items.value));
|
|
22
|
+
|
|
23
|
+
function commit(next: Partial<{ heading: string; items: CriteriaBarItem[]; showLegend: boolean }>): void {
|
|
24
|
+
emit('update', { heading: heading.value || undefined, items: items.value, showLegend: showLegend.value, ...next });
|
|
25
|
+
}
|
|
26
|
+
function addItem(): void {
|
|
27
|
+
commit({ items: [...items.value, { label: '', weight: 10, color: CRITERIA_BAR_PALETTE[items.value.length % CRITERIA_BAR_PALETTE.length]! }] });
|
|
28
|
+
}
|
|
29
|
+
function setItem(i: number, field: keyof CriteriaBarItem, value: string | number): void {
|
|
30
|
+
commit({ items: items.value.map((it, idx) => (idx === i ? { ...it, [field]: value } : it)) });
|
|
31
|
+
}
|
|
32
|
+
function removeItem(i: number): void {
|
|
33
|
+
commit({ items: items.value.filter((_, idx) => idx !== i) });
|
|
34
|
+
}
|
|
35
|
+
function useRubric(): void {
|
|
36
|
+
const src = (rubric?.value ?? []).filter((c) => (c.label ?? '').trim());
|
|
37
|
+
commit({ items: src.map((c, i) => ({ label: c.label.trim(), weight: Number(c.weight) || 0, color: CRITERIA_BAR_PALETTE[i % CRITERIA_BAR_PALETTE.length]! })) });
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<div class="cpub-cbedit">
|
|
43
|
+
<div class="cpub-cbedit-header">
|
|
44
|
+
<div class="cpub-cbedit-icon"><i class="fa-solid fa-chart-simple"></i></div>
|
|
45
|
+
<span class="cpub-cbedit-title">Criteria Bar</span>
|
|
46
|
+
<span class="cpub-cbedit-total">{{ preview.total }} total</span>
|
|
47
|
+
<button v-if="canUseRubric" type="button" class="cpub-cbedit-add" title="Fill from this contest's judging rubric" @click="useRubric"><i class="fa-solid fa-wand-magic-sparkles"></i> Use rubric</button>
|
|
48
|
+
<button type="button" class="cpub-cbedit-add" @click="addItem"><i class="fa-solid fa-plus"></i> Add criterion</button>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div class="cpub-cbedit-body">
|
|
52
|
+
<input
|
|
53
|
+
class="cpub-cbedit-input cpub-cbedit-heading"
|
|
54
|
+
type="text"
|
|
55
|
+
:value="heading"
|
|
56
|
+
placeholder="Heading (optional), e.g. Final evaluation"
|
|
57
|
+
aria-label="Criteria bar heading"
|
|
58
|
+
@input="commit({ heading: ($event.target as HTMLInputElement).value || undefined })"
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
<!-- Live preview (the real shared bar — WYSIWYG) -->
|
|
62
|
+
<div v-if="preview.rows.length" class="cpub-cbedit-preview">
|
|
63
|
+
<CpubCriteriaBar :items="items" :show-legend="showLegend" />
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div v-for="(it, i) in items" :key="i" class="cpub-cbedit-row">
|
|
67
|
+
<input class="cpub-cbedit-input cpub-cbedit-label" type="text" :value="it.label" placeholder="Criterion (e.g. Innovation)" :aria-label="`Criterion ${i + 1} label`" @input="setItem(i, 'label', ($event.target as HTMLInputElement).value)" />
|
|
68
|
+
<input class="cpub-cbedit-input cpub-cbedit-weight" type="number" min="0" :value="it.weight" :aria-label="`Criterion ${i + 1} weight`" @input="setItem(i, 'weight', Number(($event.target as HTMLInputElement).value))" />
|
|
69
|
+
<div class="cpub-cbedit-colors" role="group" :aria-label="`Criterion ${i + 1} color`">
|
|
70
|
+
<button
|
|
71
|
+
v-for="c in CRITERIA_BAR_PALETTE"
|
|
72
|
+
:key="c"
|
|
73
|
+
type="button"
|
|
74
|
+
class="cpub-cbedit-swatch"
|
|
75
|
+
:class="{ 'cpub-cbedit-swatch-on': (it.color || '') === c }"
|
|
76
|
+
:style="{ background: `var(--${c})` }"
|
|
77
|
+
:title="c"
|
|
78
|
+
:aria-label="`Use ${c}`"
|
|
79
|
+
:aria-pressed="(it.color || '') === c"
|
|
80
|
+
@click="setItem(i, 'color', c)"
|
|
81
|
+
></button>
|
|
82
|
+
</div>
|
|
83
|
+
<button type="button" class="cpub-cbedit-remove" :aria-label="`Remove criterion ${i + 1}`" @click="removeItem(i)"><i class="fa-solid fa-xmark"></i></button>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div v-if="!items.length" class="cpub-cbedit-empty" @click="addItem"><i class="fa-solid fa-plus"></i> Add the first criterion</div>
|
|
87
|
+
|
|
88
|
+
<label class="cpub-cbedit-check"><input type="checkbox" :checked="showLegend" @change="commit({ showLegend: ($event.target as HTMLInputElement).checked })" /> <span>Show color legend</span></label>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</template>
|
|
92
|
+
|
|
93
|
+
<style scoped>
|
|
94
|
+
.cpub-cbedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
|
|
95
|
+
.cpub-cbedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
|
|
96
|
+
.cpub-cbedit-icon { font-size: 12px; color: var(--accent); }
|
|
97
|
+
.cpub-cbedit-title { font-size: 12px; font-weight: 600; }
|
|
98
|
+
.cpub-cbedit-total { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
|
|
99
|
+
.cpub-cbedit-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; }
|
|
100
|
+
.cpub-cbedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
|
|
101
|
+
.cpub-cbedit-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
102
|
+
.cpub-cbedit-input { padding: 6px 8px; font-size: 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
|
|
103
|
+
.cpub-cbedit-input:focus { border-color: var(--accent); }
|
|
104
|
+
.cpub-cbedit-input::placeholder { color: var(--text-faint); }
|
|
105
|
+
.cpub-cbedit-heading { width: 100%; font-weight: 600; }
|
|
106
|
+
.cpub-cbedit-preview { padding: 6px 0 2px; }
|
|
107
|
+
.cpub-cbedit-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
108
|
+
.cpub-cbedit-label { flex: 1; min-width: 140px; }
|
|
109
|
+
.cpub-cbedit-weight { width: 70px; }
|
|
110
|
+
.cpub-cbedit-colors { display: inline-flex; gap: 3px; }
|
|
111
|
+
.cpub-cbedit-swatch { width: 18px; height: 18px; border: var(--border-width-default) solid var(--border2); cursor: pointer; padding: 0; }
|
|
112
|
+
.cpub-cbedit-swatch-on { outline: 2px solid var(--text); outline-offset: 1px; }
|
|
113
|
+
.cpub-cbedit-remove { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; padding: 4px 8px; }
|
|
114
|
+
.cpub-cbedit-remove:hover { border-color: var(--red-border); color: var(--red); }
|
|
115
|
+
.cpub-cbedit-empty { padding: 16px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; border: var(--border-width-default) dashed var(--border2); }
|
|
116
|
+
.cpub-cbedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
|
117
|
+
.cpub-cbedit-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
|
118
|
+
</style>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Edit component for the `html` block — a raw HTML snippet usable in any contest
|
|
4
|
+
* body. A monospace textarea plus a live sanitized preview (the same rich-HTML
|
|
5
|
+
* sanitizer + color neutralization the view uses), so the author sees exactly what
|
|
6
|
+
* will render and that scripts/event handlers are stripped. House block-edit
|
|
7
|
+
* contract: `content` in, `update` out. Provided via BLOCK_COMPONENTS_KEY.
|
|
8
|
+
*/
|
|
9
|
+
import { sanitizeRichHtml } from '../../../composables/useSanitize';
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
12
|
+
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
13
|
+
|
|
14
|
+
const html = computed(() => (typeof props.content.html === 'string' ? props.content.html : ''));
|
|
15
|
+
const safePreview = computed(() => sanitizeRichHtml(html.value, { neutralizeColors: true }));
|
|
16
|
+
|
|
17
|
+
function setHtml(v: string): void {
|
|
18
|
+
emit('update', { html: v });
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div class="cpub-htmledit">
|
|
24
|
+
<div class="cpub-htmledit-header">
|
|
25
|
+
<div class="cpub-htmledit-icon"><i class="fa-solid fa-code"></i></div>
|
|
26
|
+
<span class="cpub-htmledit-title">HTML</span>
|
|
27
|
+
<span class="cpub-htmledit-note">Scripts & event handlers are stripped on render.</span>
|
|
28
|
+
</div>
|
|
29
|
+
<textarea
|
|
30
|
+
class="cpub-htmledit-input"
|
|
31
|
+
:value="html"
|
|
32
|
+
rows="6"
|
|
33
|
+
spellcheck="false"
|
|
34
|
+
placeholder="<p>Paste or write raw HTML…</p>"
|
|
35
|
+
aria-label="Raw HTML"
|
|
36
|
+
@input="setHtml(($event.target as HTMLTextAreaElement).value)"
|
|
37
|
+
/>
|
|
38
|
+
<div v-if="safePreview" class="cpub-htmledit-preview">
|
|
39
|
+
<span class="cpub-htmledit-preview-label">Preview</span>
|
|
40
|
+
<!-- eslint-disable-next-line vue/no-v-html — sanitizeRichHtml is the XSS barrier -->
|
|
41
|
+
<div class="cpub-md-html" v-html="safePreview" />
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<style scoped>
|
|
47
|
+
.cpub-htmledit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
|
|
48
|
+
.cpub-htmledit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
|
|
49
|
+
.cpub-htmledit-icon { font-size: 12px; color: var(--accent); }
|
|
50
|
+
.cpub-htmledit-title { font-size: 12px; font-weight: 600; }
|
|
51
|
+
.cpub-htmledit-note { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
|
|
52
|
+
.cpub-htmledit-input {
|
|
53
|
+
width: 100%; padding: 10px 12px; font-family: var(--font-mono); font-size: 12px; line-height: 1.6;
|
|
54
|
+
background: var(--surface); border: none; color: var(--text); outline: none; resize: vertical;
|
|
55
|
+
white-space: pre; tab-size: 2;
|
|
56
|
+
}
|
|
57
|
+
.cpub-htmledit-input:focus { background: var(--surface2); }
|
|
58
|
+
.cpub-htmledit-input::placeholder { color: var(--text-faint); }
|
|
59
|
+
.cpub-htmledit-preview { border-top: var(--border-width-default) solid var(--border2); padding: 12px 14px; }
|
|
60
|
+
.cpub-htmledit-preview-label { display: block; font-family: var(--font-mono); font-size: 9px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-faint); margin-bottom: 8px; }
|
|
61
|
+
</style>
|
|
@@ -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>
|