@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
package/utils/contestStages.ts
CHANGED
|
@@ -154,11 +154,73 @@ export function withTemplateFieldRemoved(stages: ContestStage[], i: number, fi:
|
|
|
154
154
|
return withTemplate(stages, i, cur);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
type FieldType = ContestSubmissionTemplateField['type'];
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Change a template field's type AND normalize the type-specific ancillary props
|
|
161
|
+
* so the stored field stays coherent (Phase 4): `address` forces `pii`; leaving
|
|
162
|
+
* `select` drops `options`; leaving `agreement` drops `terms`/`termsFormat`/
|
|
163
|
+
* `mustAccept`; entering `select` seeds one blank option; entering `agreement`
|
|
164
|
+
* defaults `mustAccept` true.
|
|
165
|
+
*/
|
|
166
|
+
export function withTemplateFieldTypeChanged(
|
|
167
|
+
stages: ContestStage[],
|
|
168
|
+
i: number,
|
|
169
|
+
fi: number,
|
|
170
|
+
type: FieldType,
|
|
171
|
+
): ContestStage[] {
|
|
172
|
+
const field = stages[i]?.submissionTemplate?.[fi];
|
|
173
|
+
if (!field) return stages;
|
|
174
|
+
const patch: Partial<ContestSubmissionTemplateField> = { type };
|
|
175
|
+
patch.options = type === 'select' ? (field.options?.length ? field.options : [{ value: '', label: '' }]) : undefined;
|
|
176
|
+
if (type === 'agreement') {
|
|
177
|
+
patch.mustAccept = field.mustAccept ?? true;
|
|
178
|
+
} else {
|
|
179
|
+
patch.terms = undefined;
|
|
180
|
+
patch.termsFormat = undefined;
|
|
181
|
+
patch.mustAccept = undefined;
|
|
182
|
+
}
|
|
183
|
+
if (type === 'address') patch.pii = true;
|
|
184
|
+
return withTemplateFieldSet(stages, i, fi, patch);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function withTemplateOptionAdded(stages: ContestStage[], i: number, fi: number): ContestStage[] {
|
|
188
|
+
const field = stages[i]?.submissionTemplate?.[fi];
|
|
189
|
+
if (!field) return stages;
|
|
190
|
+
return withTemplateFieldSet(stages, i, fi, { options: [...(field.options ?? []), { value: '', label: '' }] });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function withTemplateOptionSet(
|
|
194
|
+
stages: ContestStage[],
|
|
195
|
+
i: number,
|
|
196
|
+
fi: number,
|
|
197
|
+
oi: number,
|
|
198
|
+
patch: Partial<{ value: string; label: string }>,
|
|
199
|
+
): ContestStage[] {
|
|
200
|
+
const field = stages[i]?.submissionTemplate?.[fi];
|
|
201
|
+
if (!field) return stages;
|
|
202
|
+
const options = (field.options ?? []).map((o, idx) => (idx === oi ? { ...o, ...patch } : o));
|
|
203
|
+
return withTemplateFieldSet(stages, i, fi, { options });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function withTemplateOptionRemoved(stages: ContestStage[], i: number, fi: number, oi: number): ContestStage[] {
|
|
207
|
+
const field = stages[i]?.submissionTemplate?.[fi];
|
|
208
|
+
if (!field) return stages;
|
|
209
|
+
return withTemplateFieldSet(stages, i, fi, { options: (field.options ?? []).filter((_, idx) => idx !== oi) });
|
|
210
|
+
}
|
|
211
|
+
|
|
157
212
|
/** Human label for each template field type (for the editor dropdown). */
|
|
158
213
|
export const TEMPLATE_FIELD_TYPE_LABEL: Record<ContestSubmissionTemplateField['type'], string> = {
|
|
159
214
|
text: 'Short text',
|
|
160
215
|
textarea: 'Long text',
|
|
161
216
|
url: 'Link (URL)',
|
|
217
|
+
email: 'Email address',
|
|
218
|
+
number: 'Number',
|
|
219
|
+
select: 'Dropdown (select)',
|
|
220
|
+
checkbox: 'Checkbox',
|
|
221
|
+
date: 'Date',
|
|
222
|
+
agreement: 'Agreement (terms to accept)',
|
|
223
|
+
address: 'Mailing address',
|
|
162
224
|
};
|
|
163
225
|
|
|
164
226
|
/** FontAwesome icon (no `fa-solid` prefix) for each stage kind. */
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ContestSubmissionTemplateField } from '@commonpub/schema';
|
|
2
|
+
|
|
3
|
+
// Client-side helpers for the entrant submission form (the per-stage artifact
|
|
4
|
+
// form + the proposal form share these). The SERVER (validateSubmissionFields)
|
|
5
|
+
// is the authoritative validator; these only drive UX (required gating, the
|
|
6
|
+
// payload shape) so the two surfaces behave identically.
|
|
7
|
+
|
|
8
|
+
/** Markers a checkbox/agreement value counts as accepted/checked. */
|
|
9
|
+
const TRUTHY = new Set(['true', 'on', '1', 'yes', 'accepted', 'checked']);
|
|
10
|
+
|
|
11
|
+
export function isChecked(value: string | undefined): boolean {
|
|
12
|
+
return TRUTHY.has((value ?? '').trim().toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Structured mailing-address subfields (stored JSON-encoded in the value). */
|
|
16
|
+
export const ADDRESS_SUBFIELDS = [
|
|
17
|
+
{ key: 'line1', label: 'Address line 1' },
|
|
18
|
+
{ key: 'line2', label: 'Address line 2' },
|
|
19
|
+
{ key: 'city', label: 'City' },
|
|
20
|
+
{ key: 'region', label: 'State / region' },
|
|
21
|
+
{ key: 'postal', label: 'Postal code' },
|
|
22
|
+
{ key: 'country', label: 'Country' },
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export type AddressValue = Partial<Record<(typeof ADDRESS_SUBFIELDS)[number]['key'], string>>;
|
|
26
|
+
|
|
27
|
+
export function parseAddress(value: string | undefined): AddressValue {
|
|
28
|
+
if (!value) return {};
|
|
29
|
+
try {
|
|
30
|
+
const o = JSON.parse(value);
|
|
31
|
+
return o && typeof o === 'object' && !Array.isArray(o) ? (o as AddressValue) : {};
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Serialize an address to a compact JSON string, or '' when entirely empty. */
|
|
38
|
+
export function serializeAddress(addr: AddressValue): string {
|
|
39
|
+
const cleaned: AddressValue = {};
|
|
40
|
+
for (const { key } of ADDRESS_SUBFIELDS) {
|
|
41
|
+
const v = (addr[key] ?? '').trim();
|
|
42
|
+
if (v) cleaned[key] = v;
|
|
43
|
+
}
|
|
44
|
+
return Object.keys(cleaned).length ? JSON.stringify(cleaned) : '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when a field's value is "filled" for required purposes (type-aware). */
|
|
48
|
+
export function isFieldFilled(field: ContestSubmissionTemplateField, value: string | undefined): boolean {
|
|
49
|
+
const v = (value ?? '').trim();
|
|
50
|
+
if (field.type === 'checkbox' || field.type === 'agreement') return isChecked(v);
|
|
51
|
+
if (field.type === 'address') return Object.keys(parseAddress(v)).length > 0;
|
|
52
|
+
return v.length > 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The labels of fields that block submission: required-but-empty fields, plus
|
|
57
|
+
* any must-accept agreement that isn't accepted. Mirrors the server's gate so
|
|
58
|
+
* the entrant sees the problem before the round-trip.
|
|
59
|
+
*/
|
|
60
|
+
export function blockingFields(
|
|
61
|
+
template: ContestSubmissionTemplateField[],
|
|
62
|
+
values: Record<string, string>,
|
|
63
|
+
): string[] {
|
|
64
|
+
const out: string[] = [];
|
|
65
|
+
for (const f of template) {
|
|
66
|
+
const filled = isFieldFilled(f, values[f.key]);
|
|
67
|
+
if (f.type === 'agreement') {
|
|
68
|
+
if ((f.required || f.mustAccept !== false) && !filled) out.push(f.label);
|
|
69
|
+
} else if (f.required && !filled) {
|
|
70
|
+
out.push(f.label);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the submit payload: trimmed values, omitting blanks. Checkbox/agreement
|
|
78
|
+
* normalize to 'true'/'false'; address passes its JSON through; everything else
|
|
79
|
+
* is the trimmed string. Empty optional fields are dropped.
|
|
80
|
+
*/
|
|
81
|
+
export function buildSubmissionPayload(
|
|
82
|
+
template: ContestSubmissionTemplateField[],
|
|
83
|
+
values: Record<string, string>,
|
|
84
|
+
): Record<string, string> {
|
|
85
|
+
const out: Record<string, string> = {};
|
|
86
|
+
for (const f of template) {
|
|
87
|
+
const raw = (values[f.key] ?? '').trim();
|
|
88
|
+
if (f.type === 'checkbox' || f.type === 'agreement') {
|
|
89
|
+
// Only send a positive marker (the server treats absent as not-accepted).
|
|
90
|
+
if (isChecked(raw)) out[f.key] = 'true';
|
|
91
|
+
else if (f.type === 'checkbox' && raw) out[f.key] = 'false';
|
|
92
|
+
} else if (raw) {
|
|
93
|
+
out[f.key] = raw;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-time conversion for `<input type="datetime-local">`.
|
|
3
|
+
*
|
|
4
|
+
* A datetime-local control speaks the operator's LOCAL wall-clock with no zone.
|
|
5
|
+
* The common idiom `new Date(iso).toISOString().slice(0, 16)` is WRONG: toISOString
|
|
6
|
+
* is UTC, so the value shown is shifted by the local offset and the time the
|
|
7
|
+
* operator picks isn't the time that gets stored. These helpers build the input
|
|
8
|
+
* value from the date's LOCAL components and parse it back as local, so the
|
|
9
|
+
* round-trip is offset-correct in every timezone.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const pad = (n: number): string => String(n).padStart(2, '0');
|
|
13
|
+
|
|
14
|
+
/** An ISO instant rendered as `YYYY-MM-DDTHH:mm` in the viewer's local zone (the input value). */
|
|
15
|
+
export function toLocalInput(iso?: string | null): string {
|
|
16
|
+
if (!iso) return '';
|
|
17
|
+
const d = new Date(iso);
|
|
18
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
19
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A `YYYY-MM-DDTHH:mm` local wall-clock value parsed to an ISO instant. Empty/invalid -> undefined. */
|
|
23
|
+
export function fromLocalInput(local?: string | null): string | undefined {
|
|
24
|
+
if (!local) return undefined;
|
|
25
|
+
// A datetime-local string carries no offset, so the runtime parses it as LOCAL.
|
|
26
|
+
const d = new Date(local);
|
|
27
|
+
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An ISO instant as a short human date in the VIEWER's local zone, e.g.
|
|
32
|
+
* "Aug 1, 2026" (or "Aug 1" with `{ year: false }`). One formatter for every
|
|
33
|
+
* contest date surface (hero, sidebar timeline, entry rows).
|
|
34
|
+
*
|
|
35
|
+
* It is timezone-dependent (the local calendar day differs by zone), so any caller
|
|
36
|
+
* that renders server-side MUST gate it behind a client `mounted` flag — otherwise
|
|
37
|
+
* the server's TZ and the viewer's disagree on hydration and Vue won't rectify it.
|
|
38
|
+
* Empty / invalid input -> ''.
|
|
39
|
+
*/
|
|
40
|
+
export function formatLocalDate(iso?: string | null, opts?: { year?: boolean }): string {
|
|
41
|
+
if (!iso) return '';
|
|
42
|
+
const d = new Date(iso);
|
|
43
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
44
|
+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(opts?.year === false ? {} : { year: 'numeric' }) });
|
|
45
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projectBlocks — pure parsers that turn a project's BlockTuple content into
|
|
3
|
+
* the structured shapes the ProjectView tabs render (BOM parts, build steps,
|
|
4
|
+
* code snippets, download files, table-of-contents headings).
|
|
5
|
+
*
|
|
6
|
+
* Extracted verbatim from ProjectView.vue's inline computeds (session 206) so
|
|
7
|
+
* the parsing is unit-tested independently of the component. Each function
|
|
8
|
+
* accepts the raw `content` value (which may be undefined, a legacy markdown
|
|
9
|
+
* string, or a BlockTuple[]) and returns [] for anything that is not an array
|
|
10
|
+
* of blocks — matching the component's original guards exactly.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface PartItem {
|
|
14
|
+
name: string;
|
|
15
|
+
quantity: number;
|
|
16
|
+
productId?: string;
|
|
17
|
+
notes?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BuildStep {
|
|
21
|
+
number: number;
|
|
22
|
+
title: string;
|
|
23
|
+
children: Array<[string, Record<string, unknown>]>;
|
|
24
|
+
time?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CodeSnippet {
|
|
28
|
+
language: string;
|
|
29
|
+
filename: string;
|
|
30
|
+
code: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FileItem {
|
|
34
|
+
name: string;
|
|
35
|
+
url: string;
|
|
36
|
+
size?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TocEntry {
|
|
40
|
+
id: string;
|
|
41
|
+
text: string;
|
|
42
|
+
level: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Block = [string, Record<string, unknown>];
|
|
46
|
+
|
|
47
|
+
/** Narrow raw content to a block array, or [] when it is not one. */
|
|
48
|
+
function asBlocks(blocks: unknown): Block[] {
|
|
49
|
+
return Array.isArray(blocks) ? (blocks as Block[]) : [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Slugify heading text into a stable anchor id (lowercase, dash-separated). */
|
|
53
|
+
export function headingSlug(text: string): string {
|
|
54
|
+
return text
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
57
|
+
.replace(/(^-|-$)/g, '');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Flatten every `partsList` block into a BOM parts list. */
|
|
61
|
+
export function extractParts(blocks: unknown): PartItem[] {
|
|
62
|
+
const items: PartItem[] = [];
|
|
63
|
+
for (const [type, data] of asBlocks(blocks)) {
|
|
64
|
+
if (type === 'partsList' && Array.isArray(data.parts)) {
|
|
65
|
+
for (const part of data.parts as Array<Record<string, unknown>>) {
|
|
66
|
+
items.push({
|
|
67
|
+
name: (part.name as string) || 'Unknown',
|
|
68
|
+
quantity: (part.qty as number) ?? (part.quantity as number) ?? 1,
|
|
69
|
+
productId: part.productId as string | undefined,
|
|
70
|
+
notes: (part.notes as string) || '',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return items;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Collect `buildStep` blocks, migrating the legacy instructions+image shape into children. */
|
|
79
|
+
export function extractBuildSteps(blocks: unknown): BuildStep[] {
|
|
80
|
+
const steps: BuildStep[] = [];
|
|
81
|
+
let stepNum = 0;
|
|
82
|
+
for (const [type, data] of asBlocks(blocks)) {
|
|
83
|
+
if (type !== 'buildStep') continue;
|
|
84
|
+
stepNum++;
|
|
85
|
+
// Migrate old format (instructions + image) to children
|
|
86
|
+
let children: Array<[string, Record<string, unknown>]> = [];
|
|
87
|
+
if (data.children && Array.isArray(data.children) && data.children.length > 0) {
|
|
88
|
+
children = data.children as Array<[string, Record<string, unknown>]>;
|
|
89
|
+
} else {
|
|
90
|
+
const instructions = data.instructions as string | undefined;
|
|
91
|
+
if (instructions && instructions.trim()) {
|
|
92
|
+
const html = instructions.startsWith('<') ? instructions : `<p>${instructions}</p>`;
|
|
93
|
+
children.push(['paragraph', { html }]);
|
|
94
|
+
}
|
|
95
|
+
const image = data.image as string | undefined;
|
|
96
|
+
if (image && image.trim()) {
|
|
97
|
+
children.push(['image', { src: image, alt: `Step ${stepNum}`, caption: '' }]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
steps.push({
|
|
101
|
+
number: (data.stepNumber as number) || stepNum,
|
|
102
|
+
title: (data.title as string) || `Step ${stepNum}`,
|
|
103
|
+
children,
|
|
104
|
+
time: data.time as string | undefined,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return steps;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Collect `code_block`/`codeBlock` snippets for the Code tab. */
|
|
111
|
+
export function extractCodeBlocks(blocks: unknown): CodeSnippet[] {
|
|
112
|
+
const snippets: CodeSnippet[] = [];
|
|
113
|
+
for (const [type, data] of asBlocks(blocks)) {
|
|
114
|
+
if (type === 'code_block' || type === 'codeBlock') {
|
|
115
|
+
snippets.push({
|
|
116
|
+
language: (data.language as string) || '',
|
|
117
|
+
filename: (data.filename as string) || '',
|
|
118
|
+
code: (data.code as string) || '',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return snippets;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Flatten every `downloads` block into a flat file list for the Files tab. */
|
|
126
|
+
export function extractDownloadFiles(blocks: unknown): FileItem[] {
|
|
127
|
+
const files: FileItem[] = [];
|
|
128
|
+
for (const [type, data] of asBlocks(blocks)) {
|
|
129
|
+
if (type === 'downloads' && Array.isArray(data.files)) {
|
|
130
|
+
for (const file of data.files as Array<Record<string, unknown>>) {
|
|
131
|
+
files.push({
|
|
132
|
+
name: (file.name as string) || 'Unknown',
|
|
133
|
+
url: (file.url as string) || '',
|
|
134
|
+
size: (file.size as string) || '',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return files;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Build a table of contents from `heading` blocks (HTML stripped, slugified ids). */
|
|
143
|
+
export function extractTocEntries(blocks: unknown): TocEntry[] {
|
|
144
|
+
const entries: TocEntry[] = [];
|
|
145
|
+
for (const [type, data] of asBlocks(blocks)) {
|
|
146
|
+
if (type === 'heading' && data.text) {
|
|
147
|
+
const raw = String(data.text);
|
|
148
|
+
const label = raw.replace(/<[^>]+>/g, '');
|
|
149
|
+
if (label.trim()) {
|
|
150
|
+
entries.push({
|
|
151
|
+
// Slug the RAW text: BlockHeadingView.vue (and ArticleView.vue) render
|
|
152
|
+
// the anchor id by slugging the unstripped text, so the TOC id must match
|
|
153
|
+
// or getElementById()/scroll-spy can't find the heading.
|
|
154
|
+
id: headingSlug(raw),
|
|
155
|
+
text: label.trim(),
|
|
156
|
+
level: (data.level as number) ?? 2,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return entries;
|
|
162
|
+
}
|