@commonpub/layer 0.82.0 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/AppToast.vue +1 -1
- package/components/ContentAvatar.vue +98 -0
- package/components/CpubCriteriaBar.vue +88 -0
- package/components/CpubDateTimeField.vue +73 -0
- package/components/CpubMarkdown.vue +3 -1
- package/components/FormatToggle.vue +2 -2
- package/components/ImageUpload.vue +5 -8
- package/components/MirrorDetailModal.vue +3 -1
- package/components/MirrorRequestApproveModal.vue +3 -1
- package/components/ProductEditModal.vue +184 -0
- package/components/RemoteFollowDialog.vue +2 -2
- package/components/SearchSidebar.vue +14 -21
- package/components/ShareToHubModal.vue +3 -1
- package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
- package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
- package/components/blocks/BlockCompareColumnsView.vue +92 -0
- package/components/blocks/BlockContentRenderer.vue +17 -0
- package/components/blocks/BlockCriteriaBarView.vue +25 -0
- package/components/blocks/BlockGalleryView.vue +5 -0
- package/components/blocks/BlockHtmlView.vue +26 -0
- package/components/blocks/BlockImageView.vue +4 -0
- package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
- package/components/blocks/BlockRoadmapView.vue +84 -0
- package/components/blocks/BlockSponsorsView.vue +89 -0
- package/components/blocks/BlockTableView.vue +49 -0
- package/components/blocks/BlockTabsView.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +155 -0
- package/components/contest/ContestCriteriaEditor.vue +79 -0
- package/components/contest/ContestEditor.vue +948 -0
- package/components/contest/ContestEntries.vue +1 -1
- package/components/contest/ContestEntryPrivateData.vue +126 -0
- package/components/contest/ContestHero.vue +114 -186
- package/components/contest/ContestJudgeManager.vue +6 -4
- package/components/contest/ContestJudgingCriteria.vue +5 -21
- package/components/contest/ContestPrizes.vue +8 -1
- package/components/contest/ContestProposalForm.vue +88 -0
- package/components/contest/ContestRules.vue +8 -1
- package/components/contest/ContestSidebar.vue +8 -2
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +3 -2
- package/components/contest/ContestSubmissionField.vue +141 -0
- package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
- package/components/contest/blocks/ContestTabPanel.vue +27 -0
- package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
- package/components/contest/blocks/HtmlBlock.vue +61 -0
- package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
- package/components/contest/blocks/RoadmapBlock.vue +127 -0
- package/components/contest/blocks/SponsorsBlock.vue +127 -0
- package/components/contest/blocks/TableBlock.vue +101 -0
- package/components/contest/blocks/TabsBlock.vue +168 -0
- package/components/editors/ArticleEditor.vue +9 -16
- package/components/editors/ExplainerEditor.vue +8 -5
- package/components/editors/ProjectEditor.vue +13 -10
- package/components/homepage/CustomHtmlSection.vue +11 -2
- package/components/hub/HubProducts.vue +4 -2
- package/components/nav/NavDropdown.vue +1 -5
- package/components/nav/NavLink.vue +2 -0
- package/components/views/ArticleView.vue +3 -56
- package/components/views/ExplainerView.vue +4 -0
- package/components/views/ProjectView.vue +83 -245
- package/composables/useContestEditor.ts +388 -0
- package/composables/useDocsPageTree.ts +154 -0
- package/composables/useDocsSiteSettings.ts +107 -0
- package/composables/useEditorAutosave.ts +131 -0
- package/composables/useEngagement.ts +13 -6
- package/composables/useFeatures.ts +9 -1
- package/composables/useFileUpload.ts +60 -0
- package/composables/useProfileContent.ts +84 -0
- package/composables/useSanitize.ts +38 -4
- package/composables/useScrollSpy.ts +87 -0
- package/layouts/admin.vue +41 -19
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +9 -9
- package/pages/[type]/index.vue +6 -1
- package/pages/admin/api-keys.vue +13 -3
- package/pages/admin/features.vue +2 -0
- package/pages/admin/federation.vue +1 -1
- package/pages/admin/layouts/[id].vue +30 -2
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +1 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -769
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +93 -7
- package/pages/contests/[slug]/judge.vue +49 -26
- package/pages/contests/create.vue +5 -466
- package/pages/contests/index.vue +7 -2
- package/pages/cookies.vue +1 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
- package/pages/docs/[siteSlug]/edit.vue +93 -231
- package/pages/events/[slug]/edit.vue +20 -20
- package/pages/events/create.vue +18 -18
- package/pages/events/index.vue +7 -2
- package/pages/hubs/[slug]/index.vue +34 -9
- package/pages/hubs/[slug]/invites.vue +312 -0
- package/pages/hubs/[slug]/members.vue +128 -0
- package/pages/hubs/[slug]/posts/[postId].vue +2 -2
- package/pages/hubs/index.vue +6 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
- package/pages/learn/index.vue +8 -1
- package/pages/messages/index.vue +1 -1
- package/pages/mirror/[id].vue +1 -1
- package/pages/products/[slug].vue +55 -2
- package/pages/products/index.vue +6 -1
- package/pages/settings/account.vue +8 -8
- package/pages/settings/profile.vue +23 -14
- package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
- package/pages/u/[username]/followers.vue +11 -3
- package/pages/u/[username]/following.vue +10 -8
- package/pages/u/[username]/index.vue +73 -7
- package/pages/videos/index.vue +13 -10
- package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
- package/server/api/admin/api-keys/[id].delete.ts +2 -2
- package/server/api/admin/api-keys/index.get.ts +1 -0
- package/server/api/admin/api-keys/index.post.ts +1 -0
- package/server/api/admin/federation/refederate.post.ts +18 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
- package/server/api/admin/layouts/[id].delete.ts +1 -4
- package/server/api/admin/layouts/[id].get.ts +1 -4
- package/server/api/admin/layouts/[id].put.ts +1 -4
- package/server/api/auth/federated/login.post.ts +12 -5
- package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
- package/server/api/content/[id]/build.get.ts +11 -0
- package/server/api/content/[id]/report.post.ts +2 -0
- package/server/api/content/[id]/versions.get.ts +15 -0
- package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
- package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
- package/server/api/contests/[slug]/export.get.ts +43 -0
- package/server/api/contests/[slug]/judge.post.ts +8 -2
- package/server/api/contests/[slug]/proposal.post.ts +36 -0
- package/server/api/contests/[slug]/user-search.get.ts +30 -0
- package/server/api/contests/index.post.ts +1 -1
- package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
- package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
- package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
- package/server/api/docs/[siteSlug]/search.get.ts +7 -1
- package/server/api/events/[slug]/attendees.get.ts +10 -0
- package/server/api/events/[slug].get.ts +9 -0
- package/server/api/events/index.get.ts +8 -1
- package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
- package/server/api/federation/content/[id]/build.get.ts +10 -0
- package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
- package/server/api/hubs/[slug]/invites.get.ts +5 -3
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
- package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
- package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
- package/server/api/hubs/[slug]/requests.get.ts +20 -0
- package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
- package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
- package/server/api/products/[id].delete.ts +22 -2
- package/server/api/registry/ping.post.ts +17 -3
- package/server/api/search/index.get.ts +5 -3
- package/server/api/social/bookmark.get.ts +1 -0
- package/server/api/social/bookmark.post.ts +1 -0
- package/server/api/social/bookmarks.get.ts +1 -0
- package/server/api/social/comments/[id].delete.ts +1 -0
- package/server/api/social/comments.get.ts +1 -0
- package/server/api/social/comments.post.ts +1 -0
- package/server/api/social/like.get.ts +1 -0
- package/server/api/social/like.post.ts +1 -0
- package/server/api/users/[username]/content.get.ts +15 -3
- package/server/api/users/[username]/follow.delete.ts +1 -0
- package/server/api/users/[username]/follow.post.ts +1 -0
- package/server/api/users/[username]/followers.get.ts +2 -1
- package/server/api/users/[username]/following.get.ts +2 -1
- package/server/middleware/content-ap.ts +8 -3
- package/server/middleware/csrf.ts +93 -0
- package/server/plugins/federation-hub-sync.ts +48 -17
- package/server/plugins/notification-email.ts +22 -3
- package/server/routes/hubs/[slug]/inbox.ts +13 -1
- package/server/routes/inbox.ts +14 -1
- package/server/routes/users/[username]/inbox.ts +13 -1
- package/server/utils/inbox.ts +7 -2
- package/server/utils/validate.ts +22 -0
- package/theme/base.css +5 -0
- package/theme/prose.css +20 -0
- package/theme/stoa-dark.css +4 -0
- package/types/contestBlocks.ts +122 -0
- package/utils/contestBlocks.ts +107 -0
- package/utils/contestBody.ts +25 -0
- package/utils/contestStages.ts +62 -0
- package/utils/contestSubmission.ts +97 -0
- package/utils/datetime.ts +45 -0
- package/utils/projectBlocks.ts +162 -0
- package/components/editors/BlogEditor.vue +0 -648
|
@@ -4,6 +4,8 @@ defineProps<{
|
|
|
4
4
|
prizes: Prize[];
|
|
5
5
|
/** Optional intro shown above the prize cards (section-level, not per-prize). */
|
|
6
6
|
description?: string | null;
|
|
7
|
+
/** Block-editor intro (BlockTuple[]); rendered instead of `description` when present. */
|
|
8
|
+
blocks?: unknown[] | null;
|
|
7
9
|
format?: 'markdown' | 'html' | null;
|
|
8
10
|
}>();
|
|
9
11
|
|
|
@@ -38,7 +40,12 @@ function prizeIcon(prize: Prize): string {
|
|
|
38
40
|
<div class="cpub-sec-head">
|
|
39
41
|
<h2><i class="fa fa-trophy" style="color: var(--yellow);"></i> Prizes</h2>
|
|
40
42
|
</div>
|
|
41
|
-
<
|
|
43
|
+
<BlocksBlockContentRenderer
|
|
44
|
+
v-if="blocks?.length"
|
|
45
|
+
:blocks="(blocks as [string, Record<string, unknown>][])"
|
|
46
|
+
class="cpub-prose cpub-md cpub-prizes-intro"
|
|
47
|
+
/>
|
|
48
|
+
<CpubMarkdown v-else-if="description" :source="description" :format="format" class="cpub-prizes-intro" />
|
|
42
49
|
<div v-if="prizes.length" class="cpub-prize-grid">
|
|
43
50
|
<div
|
|
44
51
|
v-for="(prize, i) in prizes"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ContestStage } from '@commonpub/schema';
|
|
3
|
+
import { blockingFields, buildSubmissionPayload } from '../../utils/contestSubmission';
|
|
4
|
+
|
|
5
|
+
// Form-first proposal entry (Phase 4). For a current, proposal-mode submission
|
|
6
|
+
// stage: the entrant fills the stage form and the server creates a DRAFT
|
|
7
|
+
// placeholder project linked as their entry, then routes them into the editor.
|
|
8
|
+
// Gated by features.contestProposals at the route; shown by the parent page only
|
|
9
|
+
// when the current stage is proposal-mode.
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
contestSlug: string;
|
|
13
|
+
/** The current proposal-mode submission stage (carries the template). */
|
|
14
|
+
stage: ContestStage;
|
|
15
|
+
}>();
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{ (e: 'submitted', projectSlug: string, contentType: string): void }>();
|
|
18
|
+
|
|
19
|
+
const toast = useToast();
|
|
20
|
+
const { extract: extractError } = useApiError();
|
|
21
|
+
|
|
22
|
+
const template = computed(() => props.stage.submissionTemplate ?? []);
|
|
23
|
+
const values = ref<Record<string, string>>({});
|
|
24
|
+
watch(template, (t) => {
|
|
25
|
+
const next: Record<string, string> = {};
|
|
26
|
+
for (const f of t) next[f.key] = '';
|
|
27
|
+
values.value = next;
|
|
28
|
+
}, { immediate: true });
|
|
29
|
+
|
|
30
|
+
const missingRequired = computed(() => blockingFields(template.value, values.value));
|
|
31
|
+
const submitting = ref(false);
|
|
32
|
+
|
|
33
|
+
async function submit(): Promise<void> {
|
|
34
|
+
if (submitting.value) return;
|
|
35
|
+
if (missingRequired.value.length) {
|
|
36
|
+
toast.error(`Please complete: ${missingRequired.value.join(', ')}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
submitting.value = true;
|
|
40
|
+
try {
|
|
41
|
+
const fields = buildSubmissionPayload(template.value, values.value);
|
|
42
|
+
const res = await $fetch<{ entryId: string; projectSlug: string; contentType: string }>(
|
|
43
|
+
`/api/contests/${props.contestSlug}/proposal`,
|
|
44
|
+
{ method: 'POST', body: { stageId: props.stage.id, fields } },
|
|
45
|
+
);
|
|
46
|
+
toast.success('Proposal submitted. Continue building your project for the next round.');
|
|
47
|
+
emit('submitted', res.projectSlug, res.contentType);
|
|
48
|
+
} catch (err: unknown) {
|
|
49
|
+
toast.error(extractError(err));
|
|
50
|
+
} finally {
|
|
51
|
+
submitting.value = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<section v-if="template.length" class="cpub-proposal" :aria-label="`${stage.name} proposal form`">
|
|
58
|
+
<div class="cpub-proposal-head">
|
|
59
|
+
<h3 class="cpub-proposal-title"><i class="fa-solid fa-clipboard-list"></i> {{ stage.name }}: submit a proposal</h3>
|
|
60
|
+
</div>
|
|
61
|
+
<p v-if="stage.description" class="cpub-proposal-desc">{{ stage.description }}</p>
|
|
62
|
+
<p class="cpub-proposal-desc">Submitting creates a draft project you can develop for later rounds. You can edit it any time.</p>
|
|
63
|
+
|
|
64
|
+
<ContestSubmissionField
|
|
65
|
+
v-for="f in template"
|
|
66
|
+
:key="f.key"
|
|
67
|
+
:field="f"
|
|
68
|
+
v-model="values[f.key]"
|
|
69
|
+
id-prefix="cpub-proposal"
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
<div class="cpub-proposal-actions">
|
|
73
|
+
<button type="button" class="cpub-btn cpub-btn-primary" :disabled="submitting" @click="submit">
|
|
74
|
+
<i class="fa-solid fa-paper-plane"></i>
|
|
75
|
+
{{ submitting ? 'Submitting...' : 'Submit proposal' }}
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
</section>
|
|
79
|
+
</template>
|
|
80
|
+
|
|
81
|
+
<style scoped>
|
|
82
|
+
.cpub-proposal { border: var(--border-width-default) solid var(--accent-border); background: var(--accent-bg); padding: 16px 20px; margin-bottom: 18px; }
|
|
83
|
+
.cpub-proposal-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
|
|
84
|
+
.cpub-proposal-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin: 0; }
|
|
85
|
+
.cpub-proposal-title i { color: var(--accent); }
|
|
86
|
+
.cpub-proposal-desc { font-size: 12px; color: var(--text-dim); margin: 0 0 12px; line-height: 1.6; }
|
|
87
|
+
.cpub-proposal-actions { display: flex; align-items: center; gap: 10px; }
|
|
88
|
+
</style>
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
defineProps<{
|
|
10
10
|
rules: string;
|
|
11
|
+
/** Block-editor body (BlockTuple[]); rendered instead of `rules` when present. */
|
|
12
|
+
blocks?: unknown[] | null;
|
|
11
13
|
format?: 'markdown' | 'html' | null;
|
|
12
14
|
}>();
|
|
13
15
|
</script>
|
|
@@ -18,7 +20,12 @@ defineProps<{
|
|
|
18
20
|
<h2><i class="fa fa-file-lines" style="color: var(--purple);"></i> Rules</h2>
|
|
19
21
|
</div>
|
|
20
22
|
<div class="cpub-rules-card">
|
|
21
|
-
<
|
|
23
|
+
<BlocksBlockContentRenderer
|
|
24
|
+
v-if="blocks?.length"
|
|
25
|
+
:blocks="(blocks as [string, Record<string, unknown>][])"
|
|
26
|
+
class="cpub-prose cpub-md"
|
|
27
|
+
/>
|
|
28
|
+
<CpubMarkdown v-else :source="rules" :format="format" />
|
|
22
29
|
</div>
|
|
23
30
|
</div>
|
|
24
31
|
</template>
|
|
@@ -17,9 +17,15 @@ const emit = defineEmits<{
|
|
|
17
17
|
type StepState = 'done' | 'current' | 'upcoming';
|
|
18
18
|
interface TimelineStep { label: string; date: string | null; state: StepState; icon: string }
|
|
19
19
|
|
|
20
|
+
// toLocaleDateString is timezone-dependent, so it would mismatch between the
|
|
21
|
+
// server's TZ and the viewer's on hydration. Gate it on a client `mounted` flag so
|
|
22
|
+
// the timeline dates only render (in the viewer's local TZ) after mount.
|
|
23
|
+
const mounted = ref(false);
|
|
24
|
+
onMounted(() => { mounted.value = true; });
|
|
25
|
+
|
|
20
26
|
function fmt(d: string | null | undefined): string | null {
|
|
21
|
-
if (!d) return null;
|
|
22
|
-
return
|
|
27
|
+
if (!d || !mounted.value) return null;
|
|
28
|
+
return formatLocalDate(d);
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
// Phase B1 — the timeline renders the contest's stages (its explicit `stages`, or
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { ContestStage, ContestStageSubmission } from '@commonpub/schema';
|
|
3
|
+
import { blockingFields, buildSubmissionPayload } from '../../utils/contestSubmission';
|
|
3
4
|
|
|
4
5
|
// Per-stage artifact form: an entrant with an entry fills the CURRENT
|
|
5
6
|
// submission stage's template fields (a proposal, a prototype's links, ...).
|
|
@@ -51,9 +52,7 @@ watch([existing, template], () => {
|
|
|
51
52
|
const dirty = computed(() =>
|
|
52
53
|
template.value.some((f) => (values.value[f.key] ?? '') !== (existing.value?.fields[f.key] ?? '')),
|
|
53
54
|
);
|
|
54
|
-
const missingRequired = computed(() =>
|
|
55
|
-
template.value.filter((f) => f.required && !(values.value[f.key] ?? '').trim()).map((f) => f.label),
|
|
56
|
-
);
|
|
55
|
+
const missingRequired = computed(() => blockingFields(template.value, values.value));
|
|
57
56
|
|
|
58
57
|
const saving = ref(false);
|
|
59
58
|
async function save(): Promise<void> {
|
|
@@ -64,11 +63,7 @@ async function save(): Promise<void> {
|
|
|
64
63
|
}
|
|
65
64
|
saving.value = true;
|
|
66
65
|
try {
|
|
67
|
-
const fields
|
|
68
|
-
for (const f of template.value) {
|
|
69
|
-
const v = (values.value[f.key] ?? '').trim();
|
|
70
|
-
if (v) fields[f.key] = v;
|
|
71
|
-
}
|
|
66
|
+
const fields = buildSubmissionPayload(template.value, values.value);
|
|
72
67
|
await $fetch(`/api/contests/${props.contestSlug}/entries/${selectedEntryId.value}/submission`, {
|
|
73
68
|
method: 'PUT',
|
|
74
69
|
body: { stageId: props.stage.id, fields },
|
|
@@ -107,34 +102,13 @@ function submittedAtLabel(iso: string): string {
|
|
|
107
102
|
</select>
|
|
108
103
|
</div>
|
|
109
104
|
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
:id="`cpub-stagesub-${f.key}`"
|
|
118
|
-
v-model="values[f.key]"
|
|
119
|
-
class="cpub-stagesub-input cpub-stagesub-textarea"
|
|
120
|
-
rows="4"
|
|
121
|
-
maxlength="4000"
|
|
122
|
-
:required="f.required"
|
|
123
|
-
:aria-describedby="f.help ? `cpub-stagesub-${f.key}-help` : undefined"
|
|
124
|
-
></textarea>
|
|
125
|
-
<input
|
|
126
|
-
v-else
|
|
127
|
-
:id="`cpub-stagesub-${f.key}`"
|
|
128
|
-
v-model="values[f.key]"
|
|
129
|
-
:type="f.type === 'url' ? 'url' : 'text'"
|
|
130
|
-
class="cpub-stagesub-input"
|
|
131
|
-
maxlength="4000"
|
|
132
|
-
:placeholder="f.type === 'url' ? 'https://' : undefined"
|
|
133
|
-
:required="f.required"
|
|
134
|
-
:aria-describedby="f.help ? `cpub-stagesub-${f.key}-help` : undefined"
|
|
135
|
-
/>
|
|
136
|
-
<p v-if="f.help" :id="`cpub-stagesub-${f.key}-help`" class="cpub-stagesub-help">{{ f.help }}</p>
|
|
137
|
-
</div>
|
|
105
|
+
<ContestSubmissionField
|
|
106
|
+
v-for="f in template"
|
|
107
|
+
:key="f.key"
|
|
108
|
+
:field="f"
|
|
109
|
+
v-model="values[f.key]"
|
|
110
|
+
id-prefix="cpub-stagesub"
|
|
111
|
+
/>
|
|
138
112
|
|
|
139
113
|
<div class="cpub-stagesub-actions">
|
|
140
114
|
<button
|
|
@@ -20,17 +20,7 @@ const props = defineProps<{
|
|
|
20
20
|
|
|
21
21
|
const KINDS: ContestStage['kind'][] = ['submission', 'review', 'interim', 'results', 'event', 'custom'];
|
|
22
22
|
|
|
23
|
-
// datetime-local <-> ISO
|
|
24
|
-
function toLocal(iso?: string): string {
|
|
25
|
-
if (!iso) return '';
|
|
26
|
-
try { return new Date(iso).toISOString().slice(0, 16); } catch { return ''; }
|
|
27
|
-
}
|
|
28
|
-
function toIso(local: string): string | undefined {
|
|
29
|
-
if (!local) return undefined;
|
|
30
|
-
const d = new Date(local);
|
|
31
|
-
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
32
|
-
}
|
|
33
|
-
|
|
23
|
+
// datetime-local <-> ISO via the shared, offset-correct helpers (utils/datetime).
|
|
34
24
|
function commit(next: ContestStage[]): void {
|
|
35
25
|
stages.value = next;
|
|
36
26
|
}
|
|
@@ -40,29 +30,8 @@ function setField(i: number, patch: Partial<ContestStage>): void {
|
|
|
40
30
|
commit(next);
|
|
41
31
|
}
|
|
42
32
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
setField(i, { [field]: v } as Partial<ContestStage>);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Per-round rubric (review stages). Immutable updates via setField.
|
|
49
|
-
type StageCriterion = { label: string; weight?: number; description?: string };
|
|
50
|
-
function addCriterion(i: number): void {
|
|
51
|
-
const cur = (stages.value[i]?.criteria ?? []) as StageCriterion[];
|
|
52
|
-
setField(i, { criteria: [...cur, { label: '' }] });
|
|
53
|
-
}
|
|
54
|
-
function setCriterion(i: number, ci: number, patch: Partial<StageCriterion>): void {
|
|
55
|
-
const cur = (stages.value[i]?.criteria ?? []).map((c, idx) => (idx === ci ? { ...c, ...patch } : c));
|
|
56
|
-
setField(i, { criteria: cur });
|
|
57
|
-
}
|
|
58
|
-
function removeCriterion(i: number, ci: number): void {
|
|
59
|
-
const cur = (stages.value[i]?.criteria ?? []).filter((_, idx) => idx !== ci);
|
|
60
|
-
setField(i, { criteria: cur.length ? cur : undefined });
|
|
61
|
-
}
|
|
62
|
-
function critWeightInput(i: number, ci: number, e: Event): void {
|
|
63
|
-
const v = (e.target as HTMLInputElement).value;
|
|
64
|
-
setCriterion(i, ci, { weight: v === '' ? undefined : Math.max(0, Math.min(100, Math.round(Number(v)))) });
|
|
65
|
-
}
|
|
33
|
+
// Per-round rubric (review stages) is edited by the shared ContestCriteriaEditor
|
|
34
|
+
// (same component as the contest-level Judging tab — one rubric editor, no dup).
|
|
66
35
|
function advanceCountInput(i: number, e: Event): void {
|
|
67
36
|
const v = (e.target as HTMLInputElement).value;
|
|
68
37
|
setField(i, { advanceCount: v === '' ? undefined : Math.max(1, Math.round(Number(v))) });
|
|
@@ -73,7 +42,15 @@ function advanceCountInput(i: number, e: Event): void {
|
|
|
73
42
|
// functions in utils/contestStages.ts (unit-tested). Flag-gated (rule #2).
|
|
74
43
|
const { features } = useFeatures();
|
|
75
44
|
const templatesEnabled = computed(() => features.value.contestStageSubmissions !== false);
|
|
76
|
-
|
|
45
|
+
// Phase 4: the PII flag offers the agreement/address types + the per-field PII
|
|
46
|
+
// toggle; the proposals flag offers per-stage submission mode (attach vs proposal).
|
|
47
|
+
const piiEnabled = computed(() => features.value.contestPii === true);
|
|
48
|
+
const proposalsEnabled = computed(() => features.value.contestProposals === true);
|
|
49
|
+
const FIELD_TYPES = computed<ContestSubmissionTemplateField['type'][]>(() => {
|
|
50
|
+
const base: ContestSubmissionTemplateField['type'][] = ['text', 'textarea', 'url', 'email', 'number', 'select', 'checkbox', 'date'];
|
|
51
|
+
if (piiEnabled.value) base.push('agreement', 'address');
|
|
52
|
+
return base;
|
|
53
|
+
});
|
|
77
54
|
|
|
78
55
|
function addTemplateField(i: number): void {
|
|
79
56
|
commit(withTemplateFieldAdded(stages.value, i));
|
|
@@ -81,12 +58,25 @@ function addTemplateField(i: number): void {
|
|
|
81
58
|
function setTemplateField(i: number, fi: number, patch: Partial<ContestSubmissionTemplateField>): void {
|
|
82
59
|
commit(withTemplateFieldSet(stages.value, i, fi, patch));
|
|
83
60
|
}
|
|
61
|
+
function changeTemplateFieldType(i: number, fi: number, type: ContestSubmissionTemplateField['type']): void {
|
|
62
|
+
commit(withTemplateFieldTypeChanged(stages.value, i, fi, type));
|
|
63
|
+
}
|
|
84
64
|
function templateFieldLabelInput(i: number, fi: number, e: Event): void {
|
|
85
65
|
commit(withTemplateFieldLabelChanged(stages.value, i, fi, (e.target as HTMLInputElement).value));
|
|
86
66
|
}
|
|
87
67
|
function removeTemplateField(i: number, fi: number): void {
|
|
88
68
|
commit(withTemplateFieldRemoved(stages.value, i, fi));
|
|
89
69
|
}
|
|
70
|
+
// Select-option ops (pure helpers in utils/contestStages.ts).
|
|
71
|
+
function addOption(i: number, fi: number): void {
|
|
72
|
+
commit(withTemplateOptionAdded(stages.value, i, fi));
|
|
73
|
+
}
|
|
74
|
+
function setOption(i: number, fi: number, oi: number, patch: Partial<{ value: string; label: string }>): void {
|
|
75
|
+
commit(withTemplateOptionSet(stages.value, i, fi, oi, patch));
|
|
76
|
+
}
|
|
77
|
+
function removeOption(i: number, fi: number, oi: number): void {
|
|
78
|
+
commit(withTemplateOptionRemoved(stages.value, i, fi, oi));
|
|
79
|
+
}
|
|
90
80
|
|
|
91
81
|
// Array operations live as pure functions in utils/contestStages.ts (unit-tested).
|
|
92
82
|
function addStage(): void {
|
|
@@ -169,8 +159,9 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
169
159
|
|
|
170
160
|
<div class="cpub-form-row">
|
|
171
161
|
<div class="cpub-form-field" style="flex: 2;">
|
|
172
|
-
<label class="cpub-form-label">Stage name</label>
|
|
162
|
+
<label :for="`stage-name-${i}`" class="cpub-form-label">Stage name</label>
|
|
173
163
|
<input
|
|
164
|
+
:id="`stage-name-${i}`"
|
|
174
165
|
:value="stage.name"
|
|
175
166
|
type="text"
|
|
176
167
|
class="cpub-form-input"
|
|
@@ -179,8 +170,9 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
179
170
|
/>
|
|
180
171
|
</div>
|
|
181
172
|
<div class="cpub-form-field" style="flex: 1;">
|
|
182
|
-
<label class="cpub-form-label">Type</label>
|
|
173
|
+
<label :for="`stage-type-${i}`" class="cpub-form-label">Type</label>
|
|
183
174
|
<select
|
|
175
|
+
:id="`stage-type-${i}`"
|
|
184
176
|
:value="stage.kind"
|
|
185
177
|
class="cpub-form-input"
|
|
186
178
|
@change="setField(i, { kind: ($event.target as HTMLSelectElement).value as ContestStage['kind'] })"
|
|
@@ -193,19 +185,24 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
193
185
|
<p class="cpub-stage-kind-help"><i class="fa-solid fa-circle-info"></i> {{ STAGE_KIND_HELP[stage.kind] }}</p>
|
|
194
186
|
|
|
195
187
|
<div class="cpub-form-row">
|
|
196
|
-
<
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
188
|
+
<CpubDateTimeField
|
|
189
|
+
label="Starts"
|
|
190
|
+
:model-value="stage.startsAt"
|
|
191
|
+
:max="stage.endsAt"
|
|
192
|
+
@update:model-value="setField(i, { startsAt: $event })"
|
|
193
|
+
/>
|
|
194
|
+
<CpubDateTimeField
|
|
195
|
+
label="Ends (countdown target)"
|
|
196
|
+
:model-value="stage.endsAt"
|
|
197
|
+
:min="stage.startsAt"
|
|
198
|
+
@update:model-value="setField(i, { endsAt: $event })"
|
|
199
|
+
/>
|
|
204
200
|
</div>
|
|
205
201
|
|
|
206
202
|
<div class="cpub-form-field">
|
|
207
|
-
<label class="cpub-form-label">Description (optional)</label>
|
|
203
|
+
<label :for="`stage-desc-${i}`" class="cpub-form-label">Description (optional)</label>
|
|
208
204
|
<input
|
|
205
|
+
:id="`stage-desc-${i}`"
|
|
209
206
|
:value="stage.description ?? ''"
|
|
210
207
|
type="text"
|
|
211
208
|
class="cpub-form-input"
|
|
@@ -217,19 +214,32 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
217
214
|
<!-- Per-round config (review stages): how many advance + the rubric -->
|
|
218
215
|
<div v-if="stage.kind === 'review'" class="cpub-stage-criteria">
|
|
219
216
|
<div class="cpub-form-field" style="margin-bottom: 10px;">
|
|
220
|
-
<label class="cpub-form-label">Advance the top N to the next stage</label>
|
|
221
|
-
<input :value="stage.advanceCount ?? ''" type="number" min="1" class="cpub-form-input cpub-stage-advn" placeholder="e.g. 50, leave blank to decide at advance time" @input="advanceCountInput(i, $event)" />
|
|
222
|
-
</div>
|
|
223
|
-
<div class="cpub-stage-criteria-head">
|
|
224
|
-
<span class="cpub-form-label" style="margin: 0;">Judging criteria, this round</span>
|
|
225
|
-
<button type="button" class="cpub-btn cpub-btn-sm" @click="addCriterion(i)"><i class="fa-solid fa-plus"></i> Add</button>
|
|
217
|
+
<label :for="`stage-advance-${i}`" class="cpub-form-label">Advance the top N to the next stage</label>
|
|
218
|
+
<input :id="`stage-advance-${i}`" :value="stage.advanceCount ?? ''" type="number" min="1" class="cpub-form-input cpub-stage-advn" placeholder="e.g. 50, leave blank to decide at advance time" @input="advanceCountInput(i, $event)" />
|
|
226
219
|
</div>
|
|
227
220
|
<p class="cpub-form-hint" style="margin: 4px 0;">Optional, leave empty to use the contest’s default criteria. Set per-round criteria for multi-round contests (e.g. judge proposals on Feasibility, prototypes on Deployment readiness).</p>
|
|
228
|
-
<
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
221
|
+
<ContestCriteriaEditor
|
|
222
|
+
:model-value="(stage.criteria ?? [])"
|
|
223
|
+
label="Judging criteria, this round"
|
|
224
|
+
:show-total="false"
|
|
225
|
+
@update:model-value="setField(i, { criteria: ($event as ContestStage['criteria']) })"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<!-- Submission mode (Phase 4): attach an existing project, or collect a
|
|
230
|
+
form-first proposal that seeds a draft placeholder project. -->
|
|
231
|
+
<div v-if="stage.kind === 'submission' && proposalsEnabled" class="cpub-form-field">
|
|
232
|
+
<label :for="`stage-mode-${i}`" class="cpub-form-label">How entrants submit</label>
|
|
233
|
+
<select
|
|
234
|
+
:id="`stage-mode-${i}`"
|
|
235
|
+
:value="stage.submissionMode ?? 'attach'"
|
|
236
|
+
class="cpub-form-input"
|
|
237
|
+
@change="setField(i, { submissionMode: (($event.target as HTMLSelectElement).value as 'attach' | 'proposal') })"
|
|
238
|
+
>
|
|
239
|
+
<option value="attach">Attach an existing published project</option>
|
|
240
|
+
<option value="proposal">Proposal form (creates a draft project)</option>
|
|
241
|
+
</select>
|
|
242
|
+
<p class="cpub-form-hint" style="margin: 4px 0;">Proposal mode lets entrants apply with just this form. The server creates a draft project they develop for later rounds.</p>
|
|
233
243
|
</div>
|
|
234
244
|
|
|
235
245
|
<!-- Per-stage submission template (submission stages): the artifact
|
|
@@ -254,7 +264,7 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
254
264
|
:value="tf.type"
|
|
255
265
|
class="cpub-form-input cpub-stage-tfield-type"
|
|
256
266
|
:aria-label="`Field ${fi + 1} type`"
|
|
257
|
-
@change="
|
|
267
|
+
@change="changeTemplateFieldType(i, fi, ($event.target as HTMLSelectElement).value as ContestSubmissionTemplateField['type'])"
|
|
258
268
|
>
|
|
259
269
|
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ TEMPLATE_FIELD_TYPE_LABEL[t] }}</option>
|
|
260
270
|
</select>
|
|
@@ -277,17 +287,82 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
277
287
|
:aria-label="`Field ${fi + 1} hint`"
|
|
278
288
|
@input="setTemplateField(i, fi, { help: ($event.target as HTMLInputElement).value || undefined })"
|
|
279
289
|
/>
|
|
290
|
+
|
|
291
|
+
<!-- select: the allowed options -->
|
|
292
|
+
<div v-if="tf.type === 'select'" class="cpub-stage-tfield-extra">
|
|
293
|
+
<span class="cpub-form-hint" style="margin: 0;">Choices</span>
|
|
294
|
+
<div v-for="(opt, oi) in (tf.options ?? [])" :key="oi" class="cpub-stage-opt-row">
|
|
295
|
+
<input
|
|
296
|
+
:value="opt.label"
|
|
297
|
+
type="text"
|
|
298
|
+
class="cpub-form-input"
|
|
299
|
+
placeholder="Label (shown to entrants)"
|
|
300
|
+
:aria-label="`Field ${fi + 1} option ${oi + 1} label`"
|
|
301
|
+
@input="setOption(i, fi, oi, { label: ($event.target as HTMLInputElement).value })"
|
|
302
|
+
/>
|
|
303
|
+
<input
|
|
304
|
+
:value="opt.value"
|
|
305
|
+
type="text"
|
|
306
|
+
class="cpub-form-input"
|
|
307
|
+
placeholder="Value (stored)"
|
|
308
|
+
:aria-label="`Field ${fi + 1} option ${oi + 1} value`"
|
|
309
|
+
@input="setOption(i, fi, oi, { value: ($event.target as HTMLInputElement).value })"
|
|
310
|
+
/>
|
|
311
|
+
<button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove option" @click="removeOption(i, fi, oi)"><i class="fa-solid fa-xmark"></i></button>
|
|
312
|
+
</div>
|
|
313
|
+
<button type="button" class="cpub-btn cpub-btn-sm" @click="addOption(i, fi)"><i class="fa-solid fa-plus"></i> Add choice</button>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<!-- agreement: terms the entrant must accept -->
|
|
317
|
+
<div v-if="tf.type === 'agreement'" class="cpub-stage-tfield-extra">
|
|
318
|
+
<textarea
|
|
319
|
+
:value="tf.terms ?? ''"
|
|
320
|
+
class="cpub-form-input cpub-form-textarea"
|
|
321
|
+
rows="3"
|
|
322
|
+
placeholder="Terms the entrant must accept (e.g. shipping the hardware to winners)"
|
|
323
|
+
:aria-label="`Field ${fi + 1} agreement terms`"
|
|
324
|
+
@input="setTemplateField(i, fi, { terms: ($event.target as HTMLTextAreaElement).value || undefined })"
|
|
325
|
+
></textarea>
|
|
326
|
+
<label class="cpub-stage-tfield-req">
|
|
327
|
+
<input
|
|
328
|
+
type="checkbox"
|
|
329
|
+
:checked="tf.mustAccept !== false"
|
|
330
|
+
:aria-label="`Field ${fi + 1} must accept`"
|
|
331
|
+
@change="setTemplateField(i, fi, { mustAccept: ($event.target as HTMLInputElement).checked })"
|
|
332
|
+
/>
|
|
333
|
+
<span>Must accept to submit</span>
|
|
334
|
+
</label>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<!-- address: structured + always personal data -->
|
|
338
|
+
<p v-if="tf.type === 'address'" class="cpub-form-hint" style="margin: 4px 0;">
|
|
339
|
+
Collected as a structured mailing address and stored as personal data. Visible only to staff with PII access and the entrant.
|
|
340
|
+
</p>
|
|
341
|
+
|
|
342
|
+
<!-- PII toggle (non-address, non-agreement scalar fields) -->
|
|
343
|
+
<label
|
|
344
|
+
v-if="piiEnabled && tf.type !== 'address' && tf.type !== 'agreement'"
|
|
345
|
+
class="cpub-stage-tfield-req cpub-stage-tfield-pii"
|
|
346
|
+
>
|
|
347
|
+
<input
|
|
348
|
+
type="checkbox"
|
|
349
|
+
:checked="tf.pii === true"
|
|
350
|
+
:aria-label="`Field ${fi + 1} is personal data`"
|
|
351
|
+
@change="setTemplateField(i, fi, { pii: ($event.target as HTMLInputElement).checked || undefined })"
|
|
352
|
+
/>
|
|
353
|
+
<span>Personal data (store privately, hide from the public listing)</span>
|
|
354
|
+
</label>
|
|
280
355
|
</div>
|
|
281
356
|
</div>
|
|
282
357
|
|
|
283
358
|
<div v-if="stage.kind === 'event'" class="cpub-form-row">
|
|
284
359
|
<div class="cpub-form-field">
|
|
285
|
-
<label class="cpub-form-label">Location</label>
|
|
286
|
-
<input :value="stage.location ?? ''" type="text" class="cpub-form-input" placeholder="e.g. Washington, D.C." @input="setField(i, { location: ($event.target as HTMLInputElement).value || undefined })" />
|
|
360
|
+
<label :for="`stage-location-${i}`" class="cpub-form-label">Location</label>
|
|
361
|
+
<input :id="`stage-location-${i}`" :value="stage.location ?? ''" type="text" class="cpub-form-input" placeholder="e.g. Washington, D.C." @input="setField(i, { location: ($event.target as HTMLInputElement).value || undefined })" />
|
|
287
362
|
</div>
|
|
288
363
|
<div class="cpub-form-field">
|
|
289
|
-
<label class="cpub-form-label">Link</label>
|
|
290
|
-
<input :value="stage.url ?? ''" type="url" class="cpub-form-input" placeholder="https://…" @input="setField(i, { url: ($event.target as HTMLInputElement).value || undefined })" />
|
|
364
|
+
<label :for="`stage-url-${i}`" class="cpub-form-label">Link</label>
|
|
365
|
+
<input :id="`stage-url-${i}`" :value="stage.url ?? ''" type="url" class="cpub-form-input" placeholder="https://…" @input="setField(i, { url: ($event.target as HTMLInputElement).value || undefined })" />
|
|
291
366
|
</div>
|
|
292
367
|
</div>
|
|
293
368
|
</li>
|
|
@@ -331,9 +406,6 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
331
406
|
.cpub-stage-kind-help i { color: var(--accent); margin-top: 2px; flex-shrink: 0; }
|
|
332
407
|
.cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
|
|
333
408
|
.cpub-stage-criteria-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
334
|
-
.cpub-stage-crit-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
|
|
335
|
-
.cpub-stage-crit-row .cpub-form-input { margin: 0; }
|
|
336
|
-
.cpub-stage-crit-pts { max-width: 70px; flex-shrink: 0; }
|
|
337
409
|
.cpub-stage-advn { max-width: 320px; }
|
|
338
410
|
.cpub-stage-tfield { margin-top: 8px; padding-top: 8px; border-top: var(--border-width-default) dashed var(--border2); }
|
|
339
411
|
.cpub-stage-tfield:first-of-type { border-top: 0; padding-top: 0; }
|
|
@@ -343,4 +415,8 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
343
415
|
.cpub-stage-tfield-req { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); cursor: pointer; flex-shrink: 0; }
|
|
344
416
|
.cpub-stage-tfield-req input { width: 13px; height: 13px; }
|
|
345
417
|
.cpub-stage-tfield-help { margin-top: 6px !important; font-size: var(--text-xs) !important; }
|
|
418
|
+
.cpub-stage-tfield-extra { margin-top: 6px; padding: 8px; border: var(--border-width-default) dashed var(--border2); background: var(--surface2); display: flex; flex-direction: column; gap: 6px; }
|
|
419
|
+
.cpub-stage-opt-row { display: flex; align-items: center; gap: 6px; }
|
|
420
|
+
.cpub-stage-opt-row .cpub-form-input { flex: 1; min-width: 100px; margin: 0; }
|
|
421
|
+
.cpub-stage-tfield-pii { margin-top: 6px; }
|
|
346
422
|
</style>
|
|
@@ -25,9 +25,10 @@ function handleSearch(): void {
|
|
|
25
25
|
searchTimeout = setTimeout(async () => {
|
|
26
26
|
searching.value = true;
|
|
27
27
|
try {
|
|
28
|
-
|
|
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 }>;
|
|
29
30
|
const existing = new Set((stakeholders.value ?? []).map((s) => s.userId));
|
|
30
|
-
searchResults.value = data.
|
|
31
|
+
searchResults.value = data.filter((u) => !existing.has(u.id));
|
|
31
32
|
} catch {
|
|
32
33
|
searchResults.value = [];
|
|
33
34
|
} finally {
|