@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { ContestStakeholderItem } from '@commonpub/server';
|
|
3
|
+
import type { StakeholderRole } from '@commonpub/schema';
|
|
3
4
|
|
|
4
5
|
const props = defineProps<{ contestSlug: string }>();
|
|
5
6
|
|
|
@@ -12,17 +13,22 @@ const searchQuery = ref('');
|
|
|
12
13
|
const searchResults = ref<Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>>([]);
|
|
13
14
|
const searching = ref(false);
|
|
14
15
|
const adding = ref(false);
|
|
16
|
+
// Role applied to the next person added.
|
|
17
|
+
const addRole = ref<StakeholderRole>('reviewer');
|
|
15
18
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
16
19
|
|
|
20
|
+
const roleLabel = (r: StakeholderRole): string => (r === 'editor' ? 'Editor' : 'Reviewer');
|
|
21
|
+
|
|
17
22
|
function handleSearch(): void {
|
|
18
23
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
19
24
|
if (!searchQuery.value || searchQuery.value.length < 2) { searchResults.value = []; return; }
|
|
20
25
|
searchTimeout = setTimeout(async () => {
|
|
21
26
|
searching.value = true;
|
|
22
27
|
try {
|
|
23
|
-
|
|
28
|
+
// Contest-scoped, non-admin user search (public fields only).
|
|
29
|
+
const data = await ($fetch as Function)(`/api/contests/${props.contestSlug}/user-search`, { query: { q: searchQuery.value, limit: 8 } }) as Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>;
|
|
24
30
|
const existing = new Set((stakeholders.value ?? []).map((s) => s.userId));
|
|
25
|
-
searchResults.value = data.
|
|
31
|
+
searchResults.value = data.filter((u) => !existing.has(u.id));
|
|
26
32
|
} catch {
|
|
27
33
|
searchResults.value = [];
|
|
28
34
|
} finally {
|
|
@@ -31,36 +37,46 @@ function handleSearch(): void {
|
|
|
31
37
|
}, 300);
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
async function
|
|
40
|
+
async function grant(userId: string, role: StakeholderRole): Promise<void> {
|
|
35
41
|
adding.value = true;
|
|
36
42
|
try {
|
|
37
|
-
await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders`, { method: 'POST', body: { userId } });
|
|
38
|
-
toast.success(
|
|
43
|
+
await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders`, { method: 'POST', body: { userId, role } });
|
|
44
|
+
toast.success(`${roleLabel(role)} added`);
|
|
39
45
|
searchQuery.value = '';
|
|
40
46
|
searchResults.value = [];
|
|
41
47
|
await refresh();
|
|
42
48
|
} catch {
|
|
43
|
-
toast.error(
|
|
49
|
+
toast.error(`Failed to add ${roleLabel(role).toLowerCase()}`);
|
|
44
50
|
} finally {
|
|
45
51
|
adding.value = false;
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
async function changeRole(userId: string, role: StakeholderRole): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders`, { method: 'POST', body: { userId, role } });
|
|
58
|
+
toast.success(`Role changed to ${roleLabel(role)}`);
|
|
59
|
+
await refresh();
|
|
60
|
+
} catch {
|
|
61
|
+
toast.error('Failed to change role');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
async function removeStakeholder(userId: string): Promise<void> {
|
|
50
|
-
if (!confirm('Remove this
|
|
66
|
+
if (!confirm('Remove this person’s access to the contest?')) return;
|
|
51
67
|
try {
|
|
52
68
|
await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders/${userId}`, { method: 'DELETE' });
|
|
53
|
-
toast.success('
|
|
69
|
+
toast.success('Access removed');
|
|
54
70
|
await refresh();
|
|
55
71
|
} catch {
|
|
56
|
-
toast.error('Failed to remove
|
|
72
|
+
toast.error('Failed to remove access');
|
|
57
73
|
}
|
|
58
74
|
}
|
|
59
75
|
</script>
|
|
60
76
|
|
|
61
77
|
<template>
|
|
62
78
|
<div class="cpub-sh">
|
|
63
|
-
<p class="cpub-sh-hint">Reviewers can view this contest (even while private/unpublished) but can't edit
|
|
79
|
+
<p class="cpub-sh-hint">Reviewers can view this contest (even while private/unpublished) but can't edit or judge. Editors can fully edit this contest. Neither gets any access to the rest of the site.</p>
|
|
64
80
|
<div v-if="stakeholders?.length" class="cpub-sh-list">
|
|
65
81
|
<div v-for="s in stakeholders" :key="s.id" class="cpub-sh-row">
|
|
66
82
|
<NuxtLink :to="`/u/${s.userUsername}`" class="cpub-sh-link">
|
|
@@ -70,23 +86,38 @@ async function removeStakeholder(userId: string): Promise<void> {
|
|
|
70
86
|
</span>
|
|
71
87
|
<span class="cpub-sh-name">{{ s.userName }}</span>
|
|
72
88
|
</NuxtLink>
|
|
89
|
+
<select
|
|
90
|
+
class="cpub-sh-role"
|
|
91
|
+
:value="s.role"
|
|
92
|
+
:aria-label="`Role for ${s.userName}`"
|
|
93
|
+
@change="changeRole(s.userId, ($event.target as HTMLSelectElement).value as StakeholderRole)"
|
|
94
|
+
>
|
|
95
|
+
<option value="reviewer">Reviewer</option>
|
|
96
|
+
<option value="editor">Editor</option>
|
|
97
|
+
</select>
|
|
73
98
|
<button class="cpub-sh-remove" :aria-label="`Remove ${s.userName}`" @click="removeStakeholder(s.userId)">
|
|
74
99
|
<i class="fa-solid fa-xmark"></i>
|
|
75
100
|
</button>
|
|
76
101
|
</div>
|
|
77
102
|
</div>
|
|
78
|
-
<p v-else class="cpub-sh-empty">No
|
|
103
|
+
<p v-else class="cpub-sh-empty">No collaborators yet.</p>
|
|
79
104
|
|
|
80
105
|
<div class="cpub-sh-search">
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
<div class="cpub-sh-search-row">
|
|
107
|
+
<input
|
|
108
|
+
v-model="searchQuery"
|
|
109
|
+
class="cpub-sh-input"
|
|
110
|
+
placeholder="Search users by name or username..."
|
|
111
|
+
aria-label="Search users to add as a collaborator"
|
|
112
|
+
@input="handleSearch"
|
|
113
|
+
/>
|
|
114
|
+
<select v-model="addRole" class="cpub-sh-role" aria-label="Role to grant">
|
|
115
|
+
<option value="reviewer">Reviewer</option>
|
|
116
|
+
<option value="editor">Editor</option>
|
|
117
|
+
</select>
|
|
118
|
+
</div>
|
|
88
119
|
<div v-if="searchResults.length" class="cpub-sh-dropdown">
|
|
89
|
-
<button v-for="u in searchResults" :key="u.id" class="cpub-sh-option" :disabled="adding" @click="
|
|
120
|
+
<button v-for="u in searchResults" :key="u.id" class="cpub-sh-option" :disabled="adding" @click="grant(u.id, addRole)">
|
|
90
121
|
<span class="cpub-sh-av cpub-sh-av-sm">
|
|
91
122
|
<img v-if="u.avatarUrl" :src="u.avatarUrl" :alt="u.displayName || u.username" />
|
|
92
123
|
<span v-else>{{ (u.displayName || u.username).charAt(0) }}</span>
|
|
@@ -110,11 +141,14 @@ async function removeStakeholder(userId: string): Promise<void> {
|
|
|
110
141
|
.cpub-sh-av img { width: 100%; height: 100%; object-fit: cover; }
|
|
111
142
|
.cpub-sh-av-sm { width: 20px; height: 20px; font-size: 8px; }
|
|
112
143
|
.cpub-sh-name { font-size: 12px; font-weight: 600; }
|
|
144
|
+
.cpub-sh-role { font-size: 11px; padding: 4px 6px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; flex-shrink: 0; }
|
|
145
|
+
.cpub-sh-role:focus { border-color: var(--accent); }
|
|
113
146
|
.cpub-sh-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 6px; min-height: 28px; }
|
|
114
147
|
.cpub-sh-remove:hover { color: var(--red); }
|
|
115
148
|
.cpub-sh-empty { font-size: 12px; color: var(--text-faint); font-style: italic; margin-bottom: 12px; }
|
|
116
149
|
.cpub-sh-search { position: relative; }
|
|
117
|
-
.cpub-sh-
|
|
150
|
+
.cpub-sh-search-row { display: flex; gap: 8px; }
|
|
151
|
+
.cpub-sh-input { font-size: 12px; padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; flex: 1; min-width: 0; }
|
|
118
152
|
.cpub-sh-input:focus { border-color: var(--accent); }
|
|
119
153
|
.cpub-sh-dropdown { position: absolute; top: 100%; left: 0; right: 0; z-index: 10; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); margin-top: 2px; max-height: 200px; overflow-y: auto; }
|
|
120
154
|
.cpub-sh-option { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: none; border: none; width: 100%; text-align: left; cursor: pointer; }
|
|
@@ -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>
|