@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
|
@@ -4,6 +4,12 @@ import type { HubViewModel, HubPostViewModel, HubMemberViewModel, HubTabDef } fr
|
|
|
4
4
|
|
|
5
5
|
const route = useRoute();
|
|
6
6
|
const slug = computed(() => route.params.slug as string);
|
|
7
|
+
// Invite-link redemption: `/hubs/<slug>?invite=<token>` lets a recipient join an
|
|
8
|
+
// invite-only / approval hub. The token rides along to the join endpoint below.
|
|
9
|
+
const inviteToken = computed(() => {
|
|
10
|
+
const q = route.query.invite;
|
|
11
|
+
return typeof q === 'string' ? q : '';
|
|
12
|
+
});
|
|
7
13
|
|
|
8
14
|
function remoteDomain(uri: string | undefined): string | null {
|
|
9
15
|
if (!uri) return null;
|
|
@@ -228,14 +234,14 @@ function openImagePicker(): void {
|
|
|
228
234
|
imageInput.value?.click();
|
|
229
235
|
}
|
|
230
236
|
|
|
237
|
+
const { uploadFile } = useFileUpload();
|
|
238
|
+
|
|
231
239
|
async function handleImageUpload(event: Event): Promise<void> {
|
|
232
240
|
const input = event.target as HTMLInputElement;
|
|
233
241
|
const file = input.files?.[0];
|
|
234
242
|
if (!file) return;
|
|
235
|
-
const formData = new FormData();
|
|
236
|
-
formData.append('file', file);
|
|
237
243
|
try {
|
|
238
|
-
const result = await
|
|
244
|
+
const result = await uploadFile(file);
|
|
239
245
|
newPostContent.value += (newPostContent.value ? ' ' : '') + result.url;
|
|
240
246
|
toast.success('Image uploaded');
|
|
241
247
|
} catch {
|
|
@@ -253,13 +259,27 @@ function handleLinkInsert(): void {
|
|
|
253
259
|
|
|
254
260
|
async function handleJoin(): Promise<void> {
|
|
255
261
|
if (!isAuthenticated.value) {
|
|
256
|
-
|
|
262
|
+
// Preserve the invite token across login so the redemption survives the round-trip.
|
|
263
|
+
const target = `/hubs/${slug.value}${inviteToken.value ? `?invite=${inviteToken.value}` : ''}`;
|
|
264
|
+
await navigateTo(`/auth/login?redirect=${encodeURIComponent(target)}`);
|
|
257
265
|
return;
|
|
258
266
|
}
|
|
259
267
|
try {
|
|
260
|
-
await $fetch(`/api/hubs/${slug.value}/join`, {
|
|
261
|
-
|
|
262
|
-
|
|
268
|
+
const result = await $fetch<{ joined: boolean; pending?: boolean; error?: string }>(`/api/hubs/${slug.value}/join`, {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
body: inviteToken.value ? { inviteToken: inviteToken.value } : undefined,
|
|
271
|
+
});
|
|
272
|
+
if (result.joined) {
|
|
273
|
+
toast.success('Joined hub!');
|
|
274
|
+
await refreshHub();
|
|
275
|
+
} else if (result.pending) {
|
|
276
|
+
// Approval-gated hub: a pending request was created, awaiting admin review.
|
|
277
|
+
toast.success('Join request submitted');
|
|
278
|
+
await refreshHub();
|
|
279
|
+
} else {
|
|
280
|
+
// joinHub resolves 200 with { joined: false } for policy failures (e.g. invite required).
|
|
281
|
+
toast.error(result.error || 'Could not join this hub');
|
|
282
|
+
}
|
|
263
283
|
} catch {
|
|
264
284
|
toast.error('Failed to join hub');
|
|
265
285
|
}
|
|
@@ -292,13 +312,18 @@ async function onRefreshGallery(): Promise<void> {
|
|
|
292
312
|
<template #hero>
|
|
293
313
|
<HubHero :hub="hubVM" :gallery-total="gallery?.total">
|
|
294
314
|
<template #actions>
|
|
295
|
-
<button v-if="isAuthenticated && !hub?.currentUserRole" class="cpub-btn cpub-btn-primary" @click="handleJoin">
|
|
296
|
-
<i class="fa-solid fa-plus"></i> Join Hub
|
|
315
|
+
<button v-if="(isAuthenticated || inviteToken) && !hub?.currentUserRole && !hub?.joinRequestPending" class="cpub-btn cpub-btn-primary" @click="handleJoin">
|
|
316
|
+
<i class="fa-solid fa-plus"></i> {{ inviteToken ? 'Accept invite' : (hub?.joinPolicy === 'approval' ? 'Request to join' : 'Join Hub') }}
|
|
297
317
|
</button>
|
|
318
|
+
<span v-else-if="hub?.joinRequestPending" class="cpub-member-badge">
|
|
319
|
+
<i class="fa-solid fa-hourglass-half"></i> Request pending
|
|
320
|
+
</span>
|
|
298
321
|
<span v-else-if="hub?.currentUserRole" class="cpub-member-badge">
|
|
299
322
|
<i class="fa-solid fa-check"></i> Joined
|
|
300
323
|
</span>
|
|
301
324
|
<button class="cpub-btn cpub-btn-sm" aria-label="Share hub" @click="handleShare"><i class="fa-solid fa-share-nodes"></i></button>
|
|
325
|
+
<NuxtLink v-if="['owner', 'admin'].includes(hub?.currentUserRole ?? '')" :to="`/hubs/${slug}/members`" class="cpub-btn cpub-btn-sm" aria-label="Manage members"><i class="fa-solid fa-users-gear"></i> Members</NuxtLink>
|
|
326
|
+
<NuxtLink v-if="['owner', 'admin'].includes(hub?.currentUserRole ?? '')" :to="`/hubs/${slug}/invites`" class="cpub-btn cpub-btn-sm" aria-label="Manage invites"><i class="fa-solid fa-user-plus"></i> Invites</NuxtLink>
|
|
302
327
|
<NuxtLink v-if="hub?.currentUserRole === 'owner'" :to="`/hubs/${slug}/settings`" class="cpub-btn cpub-btn-sm" aria-label="Hub settings"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
|
|
303
328
|
</template>
|
|
304
329
|
<template #badges>
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({ middleware: 'auth' });
|
|
3
|
+
|
|
4
|
+
import type { Serialized, HubDetail, HubInviteItem } from '@commonpub/server';
|
|
5
|
+
|
|
6
|
+
const route = useRoute();
|
|
7
|
+
const slug = computed(() => route.params.slug as string);
|
|
8
|
+
const toast = useToast();
|
|
9
|
+
const { extract: extractError } = useApiError();
|
|
10
|
+
|
|
11
|
+
const { data: hub } = useLazyFetch<Serialized<HubDetail>>(() => `/api/hubs/${slug.value}`);
|
|
12
|
+
const { data: invitesData, refresh, error: invitesError } = useLazyFetch<Serialized<HubInviteItem>[]>(
|
|
13
|
+
() => `/api/hubs/${slug.value}/invites`,
|
|
14
|
+
{ default: () => [] },
|
|
15
|
+
);
|
|
16
|
+
const invites = computed(() => invitesData.value ?? []);
|
|
17
|
+
|
|
18
|
+
const currentUserRole = computed(() => hub.value?.currentUserRole ?? null);
|
|
19
|
+
// admin+ only — mirrors the server's manageMembers permission (createInvite/revokeInvite).
|
|
20
|
+
const canManage = computed(() =>
|
|
21
|
+
['owner', 'admin'].includes(currentUserRole.value ?? ''),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
useSeoMeta({ title: () => `Invites, ${hub.value?.name ?? 'Hub'}, ${useSiteName()}` });
|
|
25
|
+
|
|
26
|
+
// --- Create form ---
|
|
27
|
+
const expiryOptions = [
|
|
28
|
+
{ label: 'Never expires', days: 0 },
|
|
29
|
+
{ label: 'Expires in 1 day', days: 1 },
|
|
30
|
+
{ label: 'Expires in 7 days', days: 7 },
|
|
31
|
+
{ label: 'Expires in 30 days', days: 30 },
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
const form = reactive({ maxUses: '', expiryDays: 0 });
|
|
35
|
+
const creating = ref(false);
|
|
36
|
+
|
|
37
|
+
async function createInvite(): Promise<void> {
|
|
38
|
+
creating.value = true;
|
|
39
|
+
try {
|
|
40
|
+
const body: { maxUses?: number; expiresAt?: string } = {};
|
|
41
|
+
const max = Number(form.maxUses);
|
|
42
|
+
if (form.maxUses && Number.isFinite(max) && max > 0) body.maxUses = Math.trunc(max);
|
|
43
|
+
if (form.expiryDays > 0) {
|
|
44
|
+
body.expiresAt = new Date(Date.now() + form.expiryDays * 86_400_000).toISOString();
|
|
45
|
+
}
|
|
46
|
+
await $fetch(`/api/hubs/${slug.value}/invites`, { method: 'POST', body });
|
|
47
|
+
toast.success('Invite created');
|
|
48
|
+
form.maxUses = '';
|
|
49
|
+
form.expiryDays = 0;
|
|
50
|
+
await refresh();
|
|
51
|
+
} catch (err: unknown) {
|
|
52
|
+
toast.error(extractError(err));
|
|
53
|
+
} finally {
|
|
54
|
+
creating.value = false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function inviteLink(token: string): string {
|
|
59
|
+
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
60
|
+
return `${origin}/hubs/${slug.value}?invite=${token}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function copyLink(token: string): Promise<void> {
|
|
64
|
+
try {
|
|
65
|
+
await navigator.clipboard.writeText(inviteLink(token));
|
|
66
|
+
toast.success('Invite link copied');
|
|
67
|
+
} catch {
|
|
68
|
+
toast.error('Could not copy link');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function revoke(id: string): Promise<void> {
|
|
73
|
+
if (!confirm('Revoke this invite? The link will stop working.')) return;
|
|
74
|
+
try {
|
|
75
|
+
await $fetch(`/api/hubs/${slug.value}/invites/${id}`, { method: 'DELETE' });
|
|
76
|
+
toast.success('Invite revoked');
|
|
77
|
+
await refresh();
|
|
78
|
+
} catch (err: unknown) {
|
|
79
|
+
toast.error(extractError(err));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function usesLabel(invite: Serialized<HubInviteItem>): string {
|
|
84
|
+
return invite.maxUses == null
|
|
85
|
+
? `${invite.useCount} uses`
|
|
86
|
+
: `${invite.useCount} / ${invite.maxUses} uses`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isExhausted(invite: Serialized<HubInviteItem>): boolean {
|
|
90
|
+
const expired = invite.expiresAt != null && new Date(invite.expiresAt).getTime() <= Date.now();
|
|
91
|
+
const maxedOut = invite.maxUses != null && invite.useCount >= invite.maxUses;
|
|
92
|
+
return expired || maxedOut;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fmtDate(d: string | null): string {
|
|
96
|
+
if (!d) return '';
|
|
97
|
+
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
98
|
+
}
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<template>
|
|
102
|
+
<div class="cpub-invites-page">
|
|
103
|
+
<div class="cpub-invites-header">
|
|
104
|
+
<NuxtLink :to="`/hubs/${slug}`" class="cpub-back-link">
|
|
105
|
+
<i class="fa-solid fa-arrow-left"></i> {{ hub?.name ?? 'Hub' }}
|
|
106
|
+
</NuxtLink>
|
|
107
|
+
<h1 class="cpub-invites-title">Invites</h1>
|
|
108
|
+
<p class="cpub-invites-subtitle">Share an invite link so people can join this hub.</p>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div v-if="!hub" class="cpub-loading">Loading...</div>
|
|
112
|
+
|
|
113
|
+
<div v-else-if="invitesError || !canManage" class="cpub-empty-state" style="padding: 48px 24px">
|
|
114
|
+
<p class="cpub-empty-state-title">You do not have permission to manage invites.</p>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<template v-else>
|
|
118
|
+
<form class="cpub-invites-create" @submit.prevent="createInvite">
|
|
119
|
+
<div class="cpub-invites-create-fields">
|
|
120
|
+
<div class="cpub-field">
|
|
121
|
+
<label for="invite-max-uses" class="cpub-field-label">Max uses (optional)</label>
|
|
122
|
+
<input
|
|
123
|
+
id="invite-max-uses"
|
|
124
|
+
v-model="form.maxUses"
|
|
125
|
+
type="number"
|
|
126
|
+
min="1"
|
|
127
|
+
inputmode="numeric"
|
|
128
|
+
class="cpub-field-input"
|
|
129
|
+
placeholder="Unlimited"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="cpub-field">
|
|
133
|
+
<label for="invite-expiry" class="cpub-field-label">Expiry</label>
|
|
134
|
+
<select id="invite-expiry" v-model.number="form.expiryDays" class="cpub-field-input">
|
|
135
|
+
<option v-for="opt in expiryOptions" :key="opt.days" :value="opt.days">{{ opt.label }}</option>
|
|
136
|
+
</select>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="creating">
|
|
140
|
+
<i class="fa-solid fa-plus"></i> {{ creating ? 'Creating...' : 'Create invite' }}
|
|
141
|
+
</button>
|
|
142
|
+
</form>
|
|
143
|
+
|
|
144
|
+
<ul v-if="invites.length" class="cpub-invites-list">
|
|
145
|
+
<li v-for="invite in invites" :key="invite.id" class="cpub-invite-card">
|
|
146
|
+
<div class="cpub-invite-info">
|
|
147
|
+
<code class="cpub-invite-token">{{ invite.token.slice(0, 12) }}...</code>
|
|
148
|
+
<div class="cpub-invite-meta">
|
|
149
|
+
<span :class="{ 'cpub-invite-exhausted': isExhausted(invite) }">{{ usesLabel(invite) }}</span>
|
|
150
|
+
<span v-if="invite.expiresAt">Expires {{ fmtDate(invite.expiresAt) }}</span>
|
|
151
|
+
<span v-else>No expiry</span>
|
|
152
|
+
<span>Created {{ fmtDate(invite.createdAt) }}</span>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="cpub-invite-actions">
|
|
156
|
+
<button class="cpub-btn cpub-btn-sm" type="button" @click="copyLink(invite.token)">
|
|
157
|
+
<i class="fa-solid fa-link"></i> Copy link
|
|
158
|
+
</button>
|
|
159
|
+
<button class="cpub-btn cpub-btn-sm cpub-invite-revoke" type="button" aria-label="Revoke invite" @click="revoke(invite.id)">
|
|
160
|
+
<i class="fa-solid fa-trash"></i>
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
</li>
|
|
164
|
+
</ul>
|
|
165
|
+
|
|
166
|
+
<div v-else class="cpub-empty-state" style="padding: 48px 24px">
|
|
167
|
+
<p class="cpub-empty-state-title">No invites yet.</p>
|
|
168
|
+
<p class="cpub-empty-state-text">Create one above to share a join link.</p>
|
|
169
|
+
</div>
|
|
170
|
+
</template>
|
|
171
|
+
</div>
|
|
172
|
+
</template>
|
|
173
|
+
|
|
174
|
+
<style scoped>
|
|
175
|
+
.cpub-invites-page {
|
|
176
|
+
max-width: 640px;
|
|
177
|
+
margin: 0 auto;
|
|
178
|
+
padding: 32px;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.cpub-invites-header {
|
|
182
|
+
margin-bottom: 24px;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* cpub-back-link → global components.css */
|
|
186
|
+
|
|
187
|
+
.cpub-invites-title {
|
|
188
|
+
font-size: 22px;
|
|
189
|
+
font-weight: 700;
|
|
190
|
+
margin-bottom: 4px;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.cpub-invites-subtitle {
|
|
194
|
+
font-size: 13px;
|
|
195
|
+
color: var(--text-dim);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.cpub-invites-create {
|
|
199
|
+
display: flex;
|
|
200
|
+
align-items: flex-end;
|
|
201
|
+
gap: 12px;
|
|
202
|
+
flex-wrap: wrap;
|
|
203
|
+
padding: 16px;
|
|
204
|
+
border: var(--border-width-default) solid var(--border);
|
|
205
|
+
background: var(--surface);
|
|
206
|
+
margin-bottom: 24px;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.cpub-invites-create-fields {
|
|
210
|
+
display: grid;
|
|
211
|
+
grid-template-columns: 1fr 1fr;
|
|
212
|
+
gap: 12px;
|
|
213
|
+
flex: 1;
|
|
214
|
+
min-width: 240px;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.cpub-field {
|
|
218
|
+
display: flex;
|
|
219
|
+
flex-direction: column;
|
|
220
|
+
gap: 6px;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.cpub-field-label {
|
|
224
|
+
font-size: 11px;
|
|
225
|
+
font-weight: 600;
|
|
226
|
+
font-family: var(--font-mono);
|
|
227
|
+
text-transform: uppercase;
|
|
228
|
+
letter-spacing: 0.06em;
|
|
229
|
+
color: var(--text-dim);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.cpub-field-input {
|
|
233
|
+
padding: 8px 12px;
|
|
234
|
+
border: var(--border-width-default) solid var(--border);
|
|
235
|
+
background: var(--surface);
|
|
236
|
+
color: var(--text);
|
|
237
|
+
font-size: 13px;
|
|
238
|
+
font-family: var(--font-sans);
|
|
239
|
+
outline: none;
|
|
240
|
+
transition: border-color 0.15s;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.cpub-field-input:focus {
|
|
244
|
+
border-color: var(--accent);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
select.cpub-field-input {
|
|
248
|
+
cursor: pointer;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.cpub-invites-list {
|
|
252
|
+
list-style: none;
|
|
253
|
+
margin: 0;
|
|
254
|
+
padding: 0;
|
|
255
|
+
border: var(--border-width-default) solid var(--border);
|
|
256
|
+
background: var(--surface);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.cpub-invite-card {
|
|
260
|
+
display: flex;
|
|
261
|
+
align-items: center;
|
|
262
|
+
gap: 12px;
|
|
263
|
+
padding: 12px 16px;
|
|
264
|
+
border-bottom: var(--border-width-default) solid var(--border2);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.cpub-invite-card:last-child {
|
|
268
|
+
border-bottom: none;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.cpub-invite-info {
|
|
272
|
+
flex: 1;
|
|
273
|
+
min-width: 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.cpub-invite-token {
|
|
277
|
+
font-family: var(--font-mono);
|
|
278
|
+
font-size: 13px;
|
|
279
|
+
color: var(--text);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.cpub-invite-meta {
|
|
283
|
+
display: flex;
|
|
284
|
+
flex-wrap: wrap;
|
|
285
|
+
gap: 12px;
|
|
286
|
+
margin-top: 4px;
|
|
287
|
+
font-size: 11px;
|
|
288
|
+
font-family: var(--font-mono);
|
|
289
|
+
color: var(--text-faint);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.cpub-invite-exhausted {
|
|
293
|
+
color: var(--red);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.cpub-invite-actions {
|
|
297
|
+
display: flex;
|
|
298
|
+
gap: 6px;
|
|
299
|
+
flex-shrink: 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.cpub-invite-revoke:hover {
|
|
303
|
+
color: var(--red);
|
|
304
|
+
border-color: var(--red);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
@media (max-width: 640px) {
|
|
308
|
+
.cpub-invites-page { padding: 16px; }
|
|
309
|
+
.cpub-invites-create-fields { grid-template-columns: 1fr; }
|
|
310
|
+
.cpub-invite-card { flex-wrap: wrap; }
|
|
311
|
+
}
|
|
312
|
+
</style>
|
|
@@ -11,6 +11,46 @@ const { user } = useAuth();
|
|
|
11
11
|
const currentUserRole = computed(() => hub.value?.currentUserRole ?? null);
|
|
12
12
|
const canManage = computed(() => currentUserRole.value === 'owner' || currentUserRole.value === 'admin');
|
|
13
13
|
|
|
14
|
+
interface BanItem {
|
|
15
|
+
id: string;
|
|
16
|
+
reason: string | null;
|
|
17
|
+
expiresAt: string | null;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
user: { id: string; username: string; displayName: string | null; avatarUrl: string | null };
|
|
20
|
+
bannedBy: { username: string; displayName: string | null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The bans endpoint is manager-only (403 otherwise), so fetch it lazily once
|
|
24
|
+
// the viewer is confirmed to manage this hub.
|
|
25
|
+
const { data: bansData, refresh: refreshBans } = useLazyFetch<BanItem[]>(() => `/api/hubs/${slug.value}/bans`, { immediate: false });
|
|
26
|
+
const bans = computed(() => bansData.value ?? []);
|
|
27
|
+
watch(canManage, (v) => { if (v) refreshBans(); }, { immediate: true });
|
|
28
|
+
|
|
29
|
+
// Pending join requests (approval-gated hubs). Manager-only, fetched lazily.
|
|
30
|
+
const { data: requestsData, refresh: refreshRequests } = useLazyFetch<{ items: any[]; total: number }>(() => `/api/hubs/${slug.value}/requests`, { immediate: false });
|
|
31
|
+
const requests = computed(() => requestsData.value?.items ?? []);
|
|
32
|
+
watch(canManage, (v) => { if (v) refreshRequests(); }, { immediate: true });
|
|
33
|
+
|
|
34
|
+
async function approveRequest(userId: string): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
await $fetch(`/api/hubs/${slug.value}/requests/${userId}/approve`, { method: 'POST' });
|
|
37
|
+
toast.success('Request approved');
|
|
38
|
+
await Promise.all([refreshRequests(), refresh()]);
|
|
39
|
+
} catch {
|
|
40
|
+
toast.error('Failed to approve request');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function denyRequest(userId: string): Promise<void> {
|
|
45
|
+
try {
|
|
46
|
+
await $fetch(`/api/hubs/${slug.value}/requests/${userId}/deny`, { method: 'POST' });
|
|
47
|
+
toast.success('Request declined');
|
|
48
|
+
await refreshRequests();
|
|
49
|
+
} catch {
|
|
50
|
+
toast.error('Failed to decline request');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
14
54
|
useSeoMeta({ title: () => `Members, ${hub.value?.name ?? 'Hub'}, ${useSiteName()}` });
|
|
15
55
|
|
|
16
56
|
const roles = ['member', 'moderator', 'admin'] as const;
|
|
@@ -45,6 +85,34 @@ async function kickMember(userId: string, username: string): Promise<void> {
|
|
|
45
85
|
toast.error('Failed to remove member');
|
|
46
86
|
}
|
|
47
87
|
}
|
|
88
|
+
|
|
89
|
+
async function banMember(userId: string, username: string): Promise<void> {
|
|
90
|
+
// One native dialog doubles as the confirm: OK (even empty) bans with that
|
|
91
|
+
// optional reason; Cancel aborts.
|
|
92
|
+
const reason = prompt(`Ban @${username} from this hub? They will be removed and blocked from rejoining. Enter an optional reason, or Cancel to abort.`);
|
|
93
|
+
if (reason === null) return;
|
|
94
|
+
try {
|
|
95
|
+
await $fetch(`/api/hubs/${slug.value}/bans`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
body: { userId, reason: reason.trim() || undefined },
|
|
98
|
+
});
|
|
99
|
+
toast.success('Member banned');
|
|
100
|
+
await Promise.all([refresh(), refreshBans()]);
|
|
101
|
+
} catch {
|
|
102
|
+
toast.error('Failed to ban member');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function unbanMember(userId: string, username: string): Promise<void> {
|
|
107
|
+
if (!confirm(`Lift the ban on @${username}? They will be able to rejoin.`)) return;
|
|
108
|
+
try {
|
|
109
|
+
await $fetch(`/api/hubs/${slug.value}/bans/${userId}`, { method: 'DELETE' });
|
|
110
|
+
toast.success('Ban lifted');
|
|
111
|
+
await refreshBans();
|
|
112
|
+
} catch {
|
|
113
|
+
toast.error('Failed to lift ban');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
48
116
|
</script>
|
|
49
117
|
|
|
50
118
|
<template>
|
|
@@ -55,6 +123,28 @@ async function kickMember(userId: string, username: string): Promise<void> {
|
|
|
55
123
|
<p class="members-count" v-if="membersData?.total">{{ membersData.total }} members</p>
|
|
56
124
|
</div>
|
|
57
125
|
|
|
126
|
+
<!-- Pending join requests (managers only) -->
|
|
127
|
+
<section v-if="canManage && requests.length" class="requests-section">
|
|
128
|
+
<h2 class="requests-title">Join requests</h2>
|
|
129
|
+
<div class="members-list">
|
|
130
|
+
<div class="member-card" v-for="r in requests" :key="r.userId">
|
|
131
|
+
<NuxtLink :to="`/u/${r.user.username}`" class="member-avatar">
|
|
132
|
+
<img v-if="r.user.avatarUrl" :src="r.user.avatarUrl" :alt="r.user.displayName || r.user.username" class="member-avatar-img" />
|
|
133
|
+
<span v-else>{{ (r.user.displayName || r.user.username).charAt(0).toUpperCase() }}</span>
|
|
134
|
+
</NuxtLink>
|
|
135
|
+
<div class="member-info">
|
|
136
|
+
<NuxtLink :to="`/u/${r.user.username}`" class="member-name">{{ r.user.displayName || r.user.username }}</NuxtLink>
|
|
137
|
+
<span class="member-handle">@{{ r.user.username }}</span>
|
|
138
|
+
</div>
|
|
139
|
+
<time class="member-joined">{{ new Date(r.joinedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}</time>
|
|
140
|
+
<div class="member-actions">
|
|
141
|
+
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" @click="approveRequest(r.userId)">Approve</button>
|
|
142
|
+
<button class="cpub-btn cpub-btn-sm member-deny-btn" @click="denyRequest(r.userId)">Deny</button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</section>
|
|
147
|
+
|
|
58
148
|
<div class="members-list" v-if="members?.length">
|
|
59
149
|
<div class="member-card" v-for="m in members" :key="m.userId">
|
|
60
150
|
<NuxtLink :to="`/u/${m.user.username}`" class="member-avatar">
|
|
@@ -84,12 +174,37 @@ async function kickMember(userId: string, username: string): Promise<void> {
|
|
|
84
174
|
<button class="member-kick-btn" title="Remove member" @click="kickMember(m.userId, m.user.username)">
|
|
85
175
|
<i class="fa-solid fa-user-xmark"></i>
|
|
86
176
|
</button>
|
|
177
|
+
<button class="member-ban-btn" title="Ban member" aria-label="Ban member" @click="banMember(m.userId, m.user.username)">
|
|
178
|
+
<i class="fa-solid fa-ban"></i>
|
|
179
|
+
</button>
|
|
87
180
|
</div>
|
|
88
181
|
</div>
|
|
89
182
|
</div>
|
|
90
183
|
<div v-else class="members-empty">
|
|
91
184
|
<p>No members yet.</p>
|
|
92
185
|
</div>
|
|
186
|
+
|
|
187
|
+
<!-- Banned users (managers only) -->
|
|
188
|
+
<section v-if="canManage && bans.length" class="bans-section">
|
|
189
|
+
<h2 class="bans-title">Banned</h2>
|
|
190
|
+
<div class="members-list">
|
|
191
|
+
<div class="member-card" v-for="b in bans" :key="b.id">
|
|
192
|
+
<NuxtLink :to="`/u/${b.user.username}`" class="member-avatar">
|
|
193
|
+
<img v-if="b.user.avatarUrl" :src="b.user.avatarUrl" :alt="b.user.displayName || b.user.username" class="member-avatar-img" />
|
|
194
|
+
<span v-else>{{ (b.user.displayName || b.user.username).charAt(0).toUpperCase() }}</span>
|
|
195
|
+
</NuxtLink>
|
|
196
|
+
<div class="member-info">
|
|
197
|
+
<NuxtLink :to="`/u/${b.user.username}`" class="member-name">{{ b.user.displayName || b.user.username }}</NuxtLink>
|
|
198
|
+
<span class="member-handle">@{{ b.user.username }}</span>
|
|
199
|
+
<span v-if="b.reason" class="ban-reason">{{ b.reason }}</span>
|
|
200
|
+
</div>
|
|
201
|
+
<span class="ban-meta">{{ b.expiresAt ? 'Temporary' : 'Permanent' }}</span>
|
|
202
|
+
<button class="member-unban-btn" title="Lift ban" @click="unbanMember(b.user.id, b.user.username)">
|
|
203
|
+
<i class="fa-solid fa-rotate-left"></i> Unban
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</section>
|
|
93
208
|
</div>
|
|
94
209
|
</template>
|
|
95
210
|
|
|
@@ -122,9 +237,22 @@ async function kickMember(userId: string, username: string): Promise<void> {
|
|
|
122
237
|
.member-role-select:focus { border-color: var(--accent); outline: none; }
|
|
123
238
|
.member-kick-btn { background: none; border: var(--border-width-default) solid var(--border2); color: var(--text-faint); cursor: pointer; font-size: 10px; padding: 3px 6px; }
|
|
124
239
|
.member-kick-btn:hover { color: var(--red); border-color: var(--red); }
|
|
240
|
+
.member-ban-btn { background: none; border: var(--border-width-default) solid var(--border2); color: var(--text-faint); cursor: pointer; font-size: 10px; padding: 3px 6px; }
|
|
241
|
+
.member-ban-btn:hover { color: var(--red); border-color: var(--red); }
|
|
125
242
|
|
|
126
243
|
.members-empty { text-align: center; padding: 48px 0; color: var(--text-faint); }
|
|
127
244
|
|
|
245
|
+
.requests-section { margin-bottom: 28px; }
|
|
246
|
+
.requests-title { font-size: 13px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 10px; }
|
|
247
|
+
.member-deny-btn:hover { color: var(--red); border-color: var(--red); }
|
|
248
|
+
|
|
249
|
+
.bans-section { margin-top: 28px; }
|
|
250
|
+
.bans-title { font-size: 13px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 10px; }
|
|
251
|
+
.ban-reason { display: block; font-size: 11px; color: var(--text-faint); margin-top: 2px; }
|
|
252
|
+
.ban-meta { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); flex-shrink: 0; }
|
|
253
|
+
.member-unban-btn { background: none; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; font-size: 10px; font-family: var(--font-mono); padding: 3px 8px; display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
|
254
|
+
.member-unban-btn:hover { color: var(--accent); border-color: var(--accent); }
|
|
255
|
+
|
|
128
256
|
@media (max-width: 768px) {
|
|
129
257
|
.members-page { padding: 16px; }
|
|
130
258
|
.member-card { flex-wrap: wrap; gap: 8px; padding: 10px 12px; }
|
|
@@ -379,11 +379,11 @@ useSeoMeta({
|
|
|
379
379
|
display: flex; gap: 6px;
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
-
.cpub-btn-primary { background: var(--accent); color: var(--accent
|
|
382
|
+
.cpub-btn-primary { background: var(--accent); color: var(--color-on-accent); border-color: var(--accent); }
|
|
383
383
|
.cpub-btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
|
384
384
|
.cpub-btn-primary:disabled { opacity: 0.5; cursor: default; }
|
|
385
385
|
.cpub-btn-danger { color: var(--red); border-color: var(--red); }
|
|
386
|
-
.cpub-btn-danger:hover { background: var(--red); color: var(--accent
|
|
386
|
+
.cpub-btn-danger:hover { background: var(--red); color: var(--color-on-accent); }
|
|
387
387
|
|
|
388
388
|
/* Reply form */
|
|
389
389
|
.cpub-reply-form { margin-bottom: 16px; }
|
package/pages/hubs/index.vue
CHANGED
|
@@ -4,7 +4,7 @@ useSeoMeta({
|
|
|
4
4
|
description: 'Browse and join maker communities.',
|
|
5
5
|
});
|
|
6
6
|
|
|
7
|
-
const { data, pending } = await useFetch('/api/hubs');
|
|
7
|
+
const { data, pending, error, refresh } = await useFetch('/api/hubs');
|
|
8
8
|
const { isAuthenticated } = useAuth();
|
|
9
9
|
|
|
10
10
|
const hubs = computed(() => data.value?.items ?? []);
|
|
@@ -32,6 +32,11 @@ function hubLink(hub: Record<string, unknown>): string {
|
|
|
32
32
|
</div>
|
|
33
33
|
|
|
34
34
|
<div v-if="pending" class="cpub-empty-state"><p><i class="fa-solid fa-circle-notch fa-spin"></i> Loading hubs...</p></div>
|
|
35
|
+
<div v-else-if="error" class="cpub-fetch-error">
|
|
36
|
+
<div class="cpub-fetch-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
|
37
|
+
<div class="cpub-fetch-error-msg">Failed to load hubs.</div>
|
|
38
|
+
<button class="cpub-btn cpub-btn-sm" @click="refresh()">Retry</button>
|
|
39
|
+
</div>
|
|
35
40
|
<div v-else-if="hubs.length" class="cpub-hubs-grid">
|
|
36
41
|
<NuxtLink
|
|
37
42
|
v-for="hub in hubs"
|
|
@@ -34,13 +34,22 @@ useSeoMeta({
|
|
|
34
34
|
const { isAuthenticated, user } = useAuth();
|
|
35
35
|
const toast = useToast();
|
|
36
36
|
const completing = ref(false);
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
// Completion is seeded from the server (the path fetch carries per-lesson
|
|
39
|
+
// isCompleted for the viewer) and flipped locally once the viewer marks it,
|
|
40
|
+
// so a reload no longer shows "Mark as Complete" on an already-done lesson.
|
|
41
|
+
const serverCompleted = computed(() => {
|
|
42
|
+
const lessons = path.value?.modules?.flatMap((m) => m.lessons ?? []) ?? [];
|
|
43
|
+
return lessons.find((l) => l.slug === lessonSlug.value)?.isCompleted ?? false;
|
|
44
|
+
});
|
|
45
|
+
const justCompleted = ref(false);
|
|
46
|
+
const completed = computed(() => justCompleted.value || serverCompleted.value);
|
|
38
47
|
|
|
39
48
|
async function markComplete(): Promise<void> {
|
|
40
49
|
completing.value = true;
|
|
41
50
|
try {
|
|
42
51
|
await $fetch(`/api/learn/${slug.value}/${lessonSlug.value}/complete`, { method: 'POST' });
|
|
43
|
-
|
|
52
|
+
justCompleted.value = true;
|
|
44
53
|
toast.success('Lesson completed!');
|
|
45
54
|
} catch {
|
|
46
55
|
toast.error('Failed to mark as complete');
|
|
@@ -164,7 +173,7 @@ async function submitQuiz(): Promise<void> {
|
|
|
164
173
|
if (res.quiz) {
|
|
165
174
|
quizGrade.value = res.quiz;
|
|
166
175
|
if (res.quiz.passed) {
|
|
167
|
-
|
|
176
|
+
justCompleted.value = true;
|
|
168
177
|
toast.success(`Passed, ${res.quiz.score}%`);
|
|
169
178
|
} else {
|
|
170
179
|
toast.error(`Scored ${res.quiz.score}%, below passing. Try again.`);
|
package/pages/learn/index.vue
CHANGED
|
@@ -3,7 +3,7 @@ useSeoMeta({ title: `Learn, ${useSiteName()}` });
|
|
|
3
3
|
|
|
4
4
|
const { isAuthenticated, user } = useAuth();
|
|
5
5
|
|
|
6
|
-
const { data: pathsData, pending: loadingPaths } = useFetch('/api/learn');
|
|
6
|
+
const { data: pathsData, pending: loadingPaths, error: pathsError, refresh: refreshPaths } = useFetch('/api/learn');
|
|
7
7
|
|
|
8
8
|
// Fetch author's own paths (including drafts) when authenticated
|
|
9
9
|
const myPathsQuery = computed(() => user.value?.id ? { authorId: user.value.id } : {});
|
|
@@ -141,6 +141,13 @@ const activeDifficultyFilter = ref('');
|
|
|
141
141
|
<i class="fa-solid fa-circle-notch fa-spin"></i> Loading paths...
|
|
142
142
|
</div>
|
|
143
143
|
|
|
144
|
+
<!-- Fetch error -->
|
|
145
|
+
<div v-else-if="pathsError" class="cpub-fetch-error">
|
|
146
|
+
<div class="cpub-fetch-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
|
147
|
+
<div class="cpub-fetch-error-msg">Failed to load learning paths.</div>
|
|
148
|
+
<button class="cpub-btn cpub-btn-sm" @click="refreshPaths()">Retry</button>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
144
151
|
<!-- Real data -->
|
|
145
152
|
<template v-else-if="paths.length">
|
|
146
153
|
<NuxtLink v-for="p in paths" :key="p.id" :to="`/learn/${p.slug}`" class="cpub-path-card" style="text-decoration: none; color: inherit;">
|
package/pages/messages/index.vue
CHANGED
|
@@ -128,7 +128,7 @@ async function startConversation(): Promise<void> {
|
|
|
128
128
|
<div class="cpub-new-msg-field">
|
|
129
129
|
<label class="cpub-new-msg-label">Recipients</label>
|
|
130
130
|
<div v-if="newRecipients.length" class="cpub-new-msg-chips">
|
|
131
|
-
<span v-for="(r, idx) in newRecipients" :key="
|
|
131
|
+
<span v-for="(r, idx) in newRecipients" :key="r" class="cpub-new-msg-chip">
|
|
132
132
|
{{ r }}
|
|
133
133
|
<button class="cpub-new-msg-chip-remove" @click="removeRecipient(idx)" :aria-label="`Remove ${r}`">×</button>
|
|
134
134
|
</span>
|