@commonpub/layer 0.10.1 → 0.13.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/EventCard.vue +121 -0
- package/components/PollDisplay.vue +108 -0
- package/components/PostVoteButtons.vue +108 -0
- package/components/contest/ContestJudgeManager.vue +110 -0
- package/components/homepage/ContentGridSection.vue +133 -0
- package/components/homepage/ContestsSection.vue +39 -0
- package/components/homepage/CustomHtmlSection.vue +20 -0
- package/components/homepage/EditorialSection.vue +32 -0
- package/components/homepage/HeroSection.vue +73 -0
- package/components/homepage/HomepageSectionRenderer.vue +64 -0
- package/components/homepage/HubsSection.vue +66 -0
- package/components/homepage/StatsSection.vue +38 -0
- package/components/nav/MobileNavRenderer.vue +94 -0
- package/components/nav/NavDropdown.vue +101 -0
- package/components/nav/NavLink.vue +40 -0
- package/components/nav/NavRenderer.vue +51 -0
- package/composables/useFeatures.ts +2 -0
- package/layouts/admin.vue +3 -0
- package/layouts/default.vue +22 -86
- package/middleware/feature-gate.global.ts +1 -0
- package/package.json +6 -6
- package/pages/admin/features.vue +338 -0
- package/pages/admin/homepage.vue +292 -0
- package/pages/admin/navigation.vue +350 -0
- package/pages/events/[slug]/edit.vue +182 -0
- package/pages/events/[slug]/index.vue +249 -0
- package/pages/events/create.vue +140 -0
- package/pages/events/index.vue +47 -0
- package/pages/index.vue +34 -1
- package/server/api/admin/features/index.get.ts +32 -0
- package/server/api/admin/features/index.put.ts +56 -0
- package/server/api/admin/homepage/sections.get.ts +11 -0
- package/server/api/admin/homepage/sections.put.ts +52 -0
- package/server/api/admin/navigation/items.get.ts +11 -0
- package/server/api/admin/navigation/items.put.ts +51 -0
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +20 -0
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +20 -0
- package/server/api/contests/[slug]/judge.post.ts +5 -7
- package/server/api/contests/[slug]/judges/[userId].delete.ts +26 -0
- package/server/api/contests/[slug]/judges/accept.post.ts +21 -0
- package/server/api/contests/[slug]/judges/index.get.ts +17 -0
- package/server/api/contests/[slug]/judges/index.post.ts +36 -0
- package/server/api/events/[slug]/attendees.get.ts +23 -0
- package/server/api/events/[slug]/rsvp.delete.ts +23 -0
- package/server/api/events/[slug]/rsvp.post.ts +23 -0
- package/server/api/events/[slug].delete.ts +22 -0
- package/server/api/events/[slug].get.ts +17 -0
- package/server/api/events/[slug].put.ts +38 -0
- package/server/api/events/index.get.ts +21 -0
- package/server/api/events/index.post.ts +40 -0
- package/server/api/features.get.ts +9 -0
- package/server/api/homepage/sections.get.ts +10 -0
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +18 -0
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +27 -0
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +21 -0
- package/server/api/navigation/items.get.ts +10 -0
- package/server/middleware/features.ts +1 -0
package/pages/index.vue
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/server';
|
|
2
|
+
import type { Serialized, ContentListItem, PaginatedResponse, HomepageSection } from '@commonpub/server';
|
|
3
3
|
|
|
4
4
|
useSeoMeta({
|
|
5
5
|
title: `${useSiteName()} — Open Maker Platform`,
|
|
6
6
|
description: 'Build, document, and share your projects with a community of makers.',
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
+
// Fetch configurable homepage sections (from DB or defaults)
|
|
10
|
+
const { data: homepageSections } = await useFetch<HomepageSection[]>('/api/homepage/sections');
|
|
11
|
+
const hasCustomSections = computed(() => !!homepageSections.value?.length);
|
|
12
|
+
const sortedSections = computed(() =>
|
|
13
|
+
[...(homepageSections.value ?? [])].sort((a, b) => a.order - b.order),
|
|
14
|
+
);
|
|
15
|
+
|
|
9
16
|
const { user: authUser } = useAuth();
|
|
10
17
|
const { hubs: hubsEnabled, contests: contestsEnabled, learning: learningEnabled, video: videoEnabled, docs: docsEnabled, editorial: editorialEnabled } = useFeatures();
|
|
11
18
|
const { enabledTypeMeta } = useContentTypes();
|
|
@@ -117,6 +124,30 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
117
124
|
|
|
118
125
|
<template>
|
|
119
126
|
<div>
|
|
127
|
+
<!-- ═══ CONFIGURABLE HOMEPAGE (section renderer) ═══ -->
|
|
128
|
+
<template v-if="hasCustomSections">
|
|
129
|
+
<!-- Full-width sections (hero) -->
|
|
130
|
+
<HomepageSectionRenderer :sections="sortedSections" zone="full-width" />
|
|
131
|
+
|
|
132
|
+
<!-- 2-column layout: main + sidebar -->
|
|
133
|
+
<div class="cpub-main-layout">
|
|
134
|
+
<main class="cpub-feed-col">
|
|
135
|
+
<HomepageSectionRenderer :sections="sortedSections" zone="main" />
|
|
136
|
+
</main>
|
|
137
|
+
<aside class="cpub-sidebar">
|
|
138
|
+
<HomepageSectionRenderer :sections="sortedSections" zone="sidebar" />
|
|
139
|
+
<!-- Powered badge -->
|
|
140
|
+
<div class="cpub-powered-badge">
|
|
141
|
+
<span class="cpub-powered-text">Powered by</span>
|
|
142
|
+
<span class="cpub-powered-logo">[<span>C</span>] CommonPub</span>
|
|
143
|
+
</div>
|
|
144
|
+
</aside>
|
|
145
|
+
</div>
|
|
146
|
+
</template>
|
|
147
|
+
|
|
148
|
+
<!-- ═══ LEGACY HARDCODED HOMEPAGE (fallback) ═══ -->
|
|
149
|
+
<template v-else>
|
|
150
|
+
|
|
120
151
|
<!-- ═══ HERO BANNER ═══ -->
|
|
121
152
|
<section v-if="!heroDismissed" class="cpub-hero-banner">
|
|
122
153
|
<div class="cpub-hero-grid-bg" />
|
|
@@ -383,6 +414,8 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
383
414
|
</div>
|
|
384
415
|
</aside>
|
|
385
416
|
</div>
|
|
417
|
+
|
|
418
|
+
</template><!-- end legacy fallback -->
|
|
386
419
|
</div>
|
|
387
420
|
</template>
|
|
388
421
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getInstanceSetting } from '@commonpub/server';
|
|
2
|
+
import type { FeatureFlags } from '@commonpub/config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/admin/features
|
|
6
|
+
* Returns current feature flags with metadata about defaults vs overrides.
|
|
7
|
+
*/
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
requireAdmin(event);
|
|
10
|
+
|
|
11
|
+
const db = useDB();
|
|
12
|
+
const config = useConfig();
|
|
13
|
+
|
|
14
|
+
// Get DB overrides (may be null if never set)
|
|
15
|
+
const raw = await getInstanceSetting(db, 'features.overrides');
|
|
16
|
+
const overrides: Partial<FeatureFlags> = (raw && typeof raw === 'object' && !Array.isArray(raw))
|
|
17
|
+
? raw as Partial<FeatureFlags>
|
|
18
|
+
: {};
|
|
19
|
+
|
|
20
|
+
// Build response with default + effective values for each flag
|
|
21
|
+
const flags = config.features as unknown as Record<string, boolean>;
|
|
22
|
+
const result: Record<string, { enabled: boolean; isOverridden: boolean }> = {};
|
|
23
|
+
|
|
24
|
+
for (const [key, value] of Object.entries(flags)) {
|
|
25
|
+
result[key] = {
|
|
26
|
+
enabled: value,
|
|
27
|
+
isOverridden: key in overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { flags: result, overrides };
|
|
32
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { setInstanceSetting, getInstanceSetting } from '@commonpub/server';
|
|
2
|
+
import type { FeatureFlags } from '@commonpub/config';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const updateFeaturesSchema = z.object({
|
|
6
|
+
overrides: z.record(z.string(), z.boolean()).refine(
|
|
7
|
+
(obj) => Object.keys(obj).length <= 20,
|
|
8
|
+
'Too many overrides',
|
|
9
|
+
),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* PUT /api/admin/features
|
|
14
|
+
* Set feature flag overrides. Pass { overrides: { flagName: true/false } }.
|
|
15
|
+
* To remove an override, omit the key from overrides.
|
|
16
|
+
*/
|
|
17
|
+
export default defineEventHandler(async (event) => {
|
|
18
|
+
const user = requireAdmin(event);
|
|
19
|
+
|
|
20
|
+
const body = await parseBody(event, updateFeaturesSchema);
|
|
21
|
+
const db = useDB();
|
|
22
|
+
|
|
23
|
+
// Validate that all keys are known feature flags
|
|
24
|
+
const config = useConfig();
|
|
25
|
+
const knownFlags = Object.keys(config.features);
|
|
26
|
+
for (const key of Object.keys(body.overrides)) {
|
|
27
|
+
if (!knownFlags.includes(key)) {
|
|
28
|
+
throw createError({ statusCode: 400, statusMessage: `Unknown feature flag: ${key}` });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Merge with existing overrides (so partial updates work)
|
|
33
|
+
const raw = await getInstanceSetting(db, 'features.overrides');
|
|
34
|
+
const existing: Partial<FeatureFlags> = (raw && typeof raw === 'object' && !Array.isArray(raw))
|
|
35
|
+
? raw as Partial<FeatureFlags>
|
|
36
|
+
: {};
|
|
37
|
+
|
|
38
|
+
const merged = { ...existing, ...body.overrides };
|
|
39
|
+
|
|
40
|
+
// Remove overrides that match the base config default (no point overriding to same value)
|
|
41
|
+
const base = config.features as unknown as Record<string, boolean>;
|
|
42
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
43
|
+
if (base[key] === value) {
|
|
44
|
+
delete (merged as Record<string, unknown>)[key];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await setInstanceSetting(db, 'features.overrides', merged, user.id, getRequestIP(event) ?? undefined);
|
|
49
|
+
|
|
50
|
+
// Invalidate config cache so the change takes effect immediately
|
|
51
|
+
if (typeof invalidateConfigCache === 'function') {
|
|
52
|
+
invalidateConfigCache();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { overrides: merged, message: 'Feature flags updated' };
|
|
56
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { getHomepageSections } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/admin/homepage/sections
|
|
5
|
+
* Returns homepage sections for admin editing.
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireAdmin(event);
|
|
9
|
+
const db = useDB();
|
|
10
|
+
return getHomepageSections(db);
|
|
11
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { setHomepageSections } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const sectionConfigSchema = z.object({
|
|
5
|
+
contentType: z.string().max(64).optional(),
|
|
6
|
+
sort: z.enum(['popular', 'recent', 'featured', 'editorial']).optional(),
|
|
7
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
8
|
+
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(),
|
|
9
|
+
showEditorial: z.boolean().optional(),
|
|
10
|
+
categorySlug: z.string().max(64).optional(),
|
|
11
|
+
featureGate: z.string().max(64).optional(),
|
|
12
|
+
variant: z.string().max(64).optional(),
|
|
13
|
+
customTitle: z.string().max(255).optional(),
|
|
14
|
+
customSubtitle: z.string().max(500).optional(),
|
|
15
|
+
html: z.string().max(10000).optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const sectionSchema = z.object({
|
|
19
|
+
id: z.string().min(1).max(64),
|
|
20
|
+
type: z.enum(['hero', 'editorial', 'content-grid', 'content-carousel', 'contests', 'hubs', 'learning', 'stats', 'custom-html']),
|
|
21
|
+
title: z.string().max(255).optional(),
|
|
22
|
+
enabled: z.boolean(),
|
|
23
|
+
order: z.number().int().min(0),
|
|
24
|
+
config: sectionConfigSchema,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const updateSectionsSchema = z.object({
|
|
28
|
+
sections: z.array(sectionSchema).min(1).max(20),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* PUT /api/admin/homepage/sections
|
|
33
|
+
* Save homepage section configuration.
|
|
34
|
+
*/
|
|
35
|
+
export default defineEventHandler(async (event) => {
|
|
36
|
+
const user = requireAdmin(event);
|
|
37
|
+
const db = useDB();
|
|
38
|
+
const body = await parseBody(event, updateSectionsSchema);
|
|
39
|
+
|
|
40
|
+
// Validate unique IDs
|
|
41
|
+
const ids = new Set<string>();
|
|
42
|
+
for (const section of body.sections) {
|
|
43
|
+
if (ids.has(section.id)) {
|
|
44
|
+
throw createError({ statusCode: 400, statusMessage: `Duplicate section ID: ${section.id}` });
|
|
45
|
+
}
|
|
46
|
+
ids.add(section.id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await setHomepageSections(db, body.sections, user.id, getRequestIP(event) ?? undefined);
|
|
50
|
+
|
|
51
|
+
return { sections: body.sections, message: 'Homepage updated' };
|
|
52
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { getNavItems } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/admin/navigation/items
|
|
5
|
+
* Returns navigation items for admin editing.
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireAdmin(event);
|
|
9
|
+
const db = useDB();
|
|
10
|
+
return getNavItems(db);
|
|
11
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { NavItem } from '@commonpub/server';
|
|
2
|
+
import { setNavItems } from '@commonpub/server';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const navItemSchema: z.ZodType<NavItem> = z.lazy(() =>
|
|
6
|
+
z.object({
|
|
7
|
+
id: z.string().min(1).max(64),
|
|
8
|
+
type: z.enum(['link', 'dropdown', 'external']),
|
|
9
|
+
label: z.string().min(1).max(128),
|
|
10
|
+
icon: z.string().max(128).optional(),
|
|
11
|
+
route: z.string().max(255).optional(),
|
|
12
|
+
href: z.string().url().max(1024).optional(),
|
|
13
|
+
featureGate: z.string().max(64).optional(),
|
|
14
|
+
children: z.array(navItemSchema).max(20).optional(),
|
|
15
|
+
visibleTo: z.enum(['all', 'authenticated', 'admin']).optional(),
|
|
16
|
+
disabled: z.boolean().optional(),
|
|
17
|
+
}),
|
|
18
|
+
) as z.ZodType<NavItem>;
|
|
19
|
+
|
|
20
|
+
const updateNavSchema = z.object({
|
|
21
|
+
items: z.array(navItemSchema).min(1).max(30),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* PUT /api/admin/navigation/items
|
|
26
|
+
* Save navigation item configuration.
|
|
27
|
+
*/
|
|
28
|
+
export default defineEventHandler(async (event) => {
|
|
29
|
+
const user = requireAdmin(event);
|
|
30
|
+
const db = useDB();
|
|
31
|
+
const body = await parseBody(event, updateNavSchema);
|
|
32
|
+
|
|
33
|
+
// Validate unique IDs (flatten to check children too)
|
|
34
|
+
const ids = new Set<string>();
|
|
35
|
+
function collectIds(items: NavItem[]): void {
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
if (ids.has(item.id)) {
|
|
38
|
+
throw createError({ statusCode: 400, statusMessage: `Duplicate nav item ID: ${item.id}` });
|
|
39
|
+
}
|
|
40
|
+
ids.add(item.id);
|
|
41
|
+
if (item.children) {
|
|
42
|
+
collectIds(item.children);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
collectIds(body.items);
|
|
47
|
+
|
|
48
|
+
await setNavItems(db, body.items, user.id, getRequestIP(event) ?? undefined);
|
|
49
|
+
|
|
50
|
+
return { items: body.items, message: 'Navigation updated' };
|
|
51
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { removeContestEntryVote } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DELETE /api/contests/:slug/entries/:entryId/vote
|
|
5
|
+
* Remove community vote from a contest entry.
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireFeature('contests');
|
|
9
|
+
const user = requireAuth(event);
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const entryId = getRouterParam(event, 'entryId');
|
|
12
|
+
if (!entryId) throw createError({ statusCode: 400, statusMessage: 'Missing entryId' });
|
|
13
|
+
|
|
14
|
+
const removed = await removeContestEntryVote(db, entryId, user.id);
|
|
15
|
+
if (!removed) {
|
|
16
|
+
throw createError({ statusCode: 400, statusMessage: 'No vote found' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return { removed: true };
|
|
20
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { voteOnContestEntry } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* POST /api/contests/:slug/entries/:entryId/vote
|
|
5
|
+
* Vote on a contest entry (community voting).
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireFeature('contests');
|
|
9
|
+
const user = requireAuth(event);
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const entryId = getRouterParam(event, 'entryId');
|
|
12
|
+
if (!entryId) throw createError({ statusCode: 400, statusMessage: 'Missing entryId' });
|
|
13
|
+
|
|
14
|
+
const result = await voteOnContestEntry(db, entryId, user.id);
|
|
15
|
+
if (!result.voted) {
|
|
16
|
+
throw createError({ statusCode: 400, statusMessage: result.error ?? 'Vote failed' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return { voted: true };
|
|
20
|
+
});
|
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
import { judgeContestEntry
|
|
1
|
+
import { judgeContestEntry } from '@commonpub/server';
|
|
2
2
|
import { judgeEntrySchema } from '@commonpub/schema';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
|
|
5
5
|
requireFeature('contests');
|
|
6
6
|
const user = requireAuth(event);
|
|
7
7
|
const db = useDB();
|
|
8
|
-
const { slug } = parseParams(event, { slug: 'string' });
|
|
9
8
|
const input = await parseBody(event, judgeEntrySchema);
|
|
10
9
|
|
|
11
|
-
const
|
|
12
|
-
if (!
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
const result = await judgeContestEntry(db, input.entryId, input.score, user.id, input.feedback);
|
|
11
|
+
if (!result.judged) {
|
|
12
|
+
throw createError({ statusCode: 403, message: result.error ?? 'Judging failed' });
|
|
13
|
+
}
|
|
15
14
|
|
|
16
|
-
await judgeContestEntry(db, input.entryId, input.score, user.id, input.feedback);
|
|
17
15
|
return { success: true };
|
|
18
16
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getContestBySlug, removeContestJudge } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DELETE /api/contests/:slug/judges/:userId
|
|
5
|
+
* Remove a judge from a contest (contest owner or admin only).
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireFeature('contests');
|
|
9
|
+
const user = requireAuth(event);
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const slug = getRouterParam(event, 'slug');
|
|
12
|
+
const targetUserId = getRouterParam(event, 'userId');
|
|
13
|
+
if (!slug || !targetUserId) throw createError({ statusCode: 400, statusMessage: 'Missing parameters' });
|
|
14
|
+
|
|
15
|
+
const contest = await getContestBySlug(db, slug);
|
|
16
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
17
|
+
|
|
18
|
+
if (contest.createdById !== user.id && user.role !== 'admin') {
|
|
19
|
+
throw createError({ statusCode: 403, statusMessage: 'Only contest owner or admin can manage judges' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const removed = await removeContestJudge(db, contest.id, targetUserId);
|
|
23
|
+
if (!removed) throw createError({ statusCode: 404, statusMessage: 'Judge not found' });
|
|
24
|
+
|
|
25
|
+
return { removed: true };
|
|
26
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getContestBySlug, acceptJudgeInvite } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* POST /api/contests/:slug/judges/accept
|
|
5
|
+
* Accept a judge invitation (authenticated user).
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireFeature('contests');
|
|
9
|
+
const user = requireAuth(event);
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const slug = getRouterParam(event, 'slug');
|
|
12
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
13
|
+
|
|
14
|
+
const contest = await getContestBySlug(db, slug);
|
|
15
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
16
|
+
|
|
17
|
+
const accepted = await acceptJudgeInvite(db, contest.id, user.id);
|
|
18
|
+
if (!accepted) throw createError({ statusCode: 400, statusMessage: 'No pending invitation found' });
|
|
19
|
+
|
|
20
|
+
return { accepted: true };
|
|
21
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getContestBySlug, listContestJudges } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/contests/:slug/judges
|
|
5
|
+
* List judges for a contest.
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireFeature('contests');
|
|
9
|
+
const db = useDB();
|
|
10
|
+
const slug = getRouterParam(event, 'slug');
|
|
11
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
12
|
+
|
|
13
|
+
const contest = await getContestBySlug(db, slug);
|
|
14
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
15
|
+
|
|
16
|
+
return listContestJudges(db, contest.id);
|
|
17
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getContestBySlug, addContestJudge } from '@commonpub/server';
|
|
2
|
+
import type { JudgeRole } from '@commonpub/server';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const addJudgeSchema = z.object({
|
|
6
|
+
userId: z.string().uuid(),
|
|
7
|
+
role: z.enum(['lead', 'judge', 'guest']).optional(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* POST /api/contests/:slug/judges
|
|
12
|
+
* Add a judge to a contest (contest owner or admin only).
|
|
13
|
+
*/
|
|
14
|
+
export default defineEventHandler(async (event) => {
|
|
15
|
+
requireFeature('contests');
|
|
16
|
+
const user = requireAuth(event);
|
|
17
|
+
const db = useDB();
|
|
18
|
+
const slug = getRouterParam(event, 'slug');
|
|
19
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
20
|
+
|
|
21
|
+
const contest = await getContestBySlug(db, slug);
|
|
22
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
23
|
+
|
|
24
|
+
if (contest.createdById !== user.id && user.role !== 'admin') {
|
|
25
|
+
throw createError({ statusCode: 403, statusMessage: 'Only contest owner or admin can manage judges' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const body = await parseBody(event, addJudgeSchema);
|
|
29
|
+
const result = await addContestJudge(db, contest.id, body.userId, (body.role ?? 'judge') as JudgeRole);
|
|
30
|
+
|
|
31
|
+
if (!result.added) {
|
|
32
|
+
throw createError({ statusCode: 400, statusMessage: result.error ?? 'Failed to add judge' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { added: true };
|
|
36
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getEventBySlug, listEventAttendees } from '@commonpub/server';
|
|
2
|
+
import type { AttendeeStatus } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/events/:slug/attendees
|
|
6
|
+
* List attendees for an event (public).
|
|
7
|
+
*/
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
requireFeature('events');
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const slug = getRouterParam(event, 'slug');
|
|
12
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
13
|
+
|
|
14
|
+
const existing = await getEventBySlug(db, slug);
|
|
15
|
+
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
16
|
+
|
|
17
|
+
const query = getQuery(event);
|
|
18
|
+
return listEventAttendees(db, existing.id, {
|
|
19
|
+
status: (query.status as AttendeeStatus) || undefined,
|
|
20
|
+
limit: query.limit ? Number(query.limit) : undefined,
|
|
21
|
+
offset: query.offset ? Number(query.offset) : undefined,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getEventBySlug, cancelRsvp } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DELETE /api/events/:slug/rsvp
|
|
5
|
+
* Cancel RSVP for an event (authenticated).
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireFeature('events');
|
|
9
|
+
const user = requireAuth(event);
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const slug = getRouterParam(event, 'slug');
|
|
12
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
13
|
+
|
|
14
|
+
const existing = await getEventBySlug(db, slug);
|
|
15
|
+
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
16
|
+
|
|
17
|
+
const cancelled = await cancelRsvp(db, existing.id, user.id);
|
|
18
|
+
if (!cancelled) {
|
|
19
|
+
throw createError({ statusCode: 400, statusMessage: 'No RSVP found' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return { cancelled: true };
|
|
23
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getEventBySlug, rsvpEvent } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* POST /api/events/:slug/rsvp
|
|
5
|
+
* RSVP to an event (authenticated).
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireFeature('events');
|
|
9
|
+
const user = requireAuth(event);
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const slug = getRouterParam(event, 'slug');
|
|
12
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
13
|
+
|
|
14
|
+
const existing = await getEventBySlug(db, slug);
|
|
15
|
+
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
16
|
+
|
|
17
|
+
const result = await rsvpEvent(db, existing.id, user.id);
|
|
18
|
+
if (!result.success) {
|
|
19
|
+
throw createError({ statusCode: 400, statusMessage: result.error ?? 'RSVP failed' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return { status: result.status };
|
|
23
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { deleteEvent, getEventBySlug } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DELETE /api/events/:slug
|
|
5
|
+
* Delete an event (owner or admin).
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireFeature('events');
|
|
9
|
+
const user = requireAuth(event);
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const slug = getRouterParam(event, 'slug');
|
|
12
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
13
|
+
|
|
14
|
+
const existing = await getEventBySlug(db, slug);
|
|
15
|
+
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
16
|
+
|
|
17
|
+
const isAdmin = user.role === 'admin';
|
|
18
|
+
const deleted = await deleteEvent(db, existing.id, user.id, isAdmin);
|
|
19
|
+
if (!deleted) throw createError({ statusCode: 403, statusMessage: 'Unauthorized' });
|
|
20
|
+
|
|
21
|
+
return { deleted: true };
|
|
22
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getEventBySlug } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/events/:slug
|
|
5
|
+
* Get event details by slug (public).
|
|
6
|
+
*/
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireFeature('events');
|
|
9
|
+
const db = useDB();
|
|
10
|
+
const slug = getRouterParam(event, 'slug');
|
|
11
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
12
|
+
|
|
13
|
+
const result = await getEventBySlug(db, slug);
|
|
14
|
+
if (!result) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
15
|
+
|
|
16
|
+
return result;
|
|
17
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { updateEvent } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const updateEventSchema = z.object({
|
|
5
|
+
title: z.string().min(1).max(255).optional(),
|
|
6
|
+
description: z.string().max(10000).optional(),
|
|
7
|
+
coverImage: z.string().max(500).optional(),
|
|
8
|
+
eventType: z.enum(['in-person', 'online', 'hybrid']).optional(),
|
|
9
|
+
status: z.enum(['draft', 'published', 'active', 'completed', 'cancelled']).optional(),
|
|
10
|
+
startDate: z.string().datetime().optional(),
|
|
11
|
+
endDate: z.string().datetime().optional(),
|
|
12
|
+
timezone: z.string().max(64).optional(),
|
|
13
|
+
location: z.string().max(500).optional(),
|
|
14
|
+
locationUrl: z.string().url().max(500).optional(),
|
|
15
|
+
onlineUrl: z.string().url().max(500).optional(),
|
|
16
|
+
capacity: z.number().int().min(1).max(100000).optional(),
|
|
17
|
+
isFeatured: z.boolean().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* PUT /api/events/:slug
|
|
22
|
+
* Update an event (owner or admin).
|
|
23
|
+
*/
|
|
24
|
+
export default defineEventHandler(async (event) => {
|
|
25
|
+
requireFeature('events');
|
|
26
|
+
const user = requireAuth(event);
|
|
27
|
+
const db = useDB();
|
|
28
|
+
const slug = getRouterParam(event, 'slug');
|
|
29
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
30
|
+
|
|
31
|
+
const body = await parseBody(event, updateEventSchema);
|
|
32
|
+
const isAdmin = user.role === 'admin';
|
|
33
|
+
|
|
34
|
+
const result = await updateEvent(db, slug, user.id, body, isAdmin);
|
|
35
|
+
if (!result) throw createError({ statusCode: 404, statusMessage: 'Event not found or unauthorized' });
|
|
36
|
+
|
|
37
|
+
return result;
|
|
38
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { listEvents } from '@commonpub/server';
|
|
2
|
+
import type { EventStatus } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/events
|
|
6
|
+
* List published/active events (public).
|
|
7
|
+
*/
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
requireFeature('events');
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const query = getQuery(event);
|
|
12
|
+
|
|
13
|
+
return listEvents(db, {
|
|
14
|
+
status: (query.status as EventStatus) || undefined,
|
|
15
|
+
hubId: (query.hubId as string) || undefined,
|
|
16
|
+
upcoming: query.upcoming === 'true',
|
|
17
|
+
featured: query.featured === 'true',
|
|
18
|
+
limit: query.limit ? Number(query.limit) : undefined,
|
|
19
|
+
offset: query.offset ? Number(query.offset) : undefined,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createEvent } from '@commonpub/server';
|
|
2
|
+
import { generateSlug } from '@commonpub/server';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const createEventSchema = z.object({
|
|
6
|
+
title: z.string().min(1).max(255),
|
|
7
|
+
description: z.string().max(10000).optional(),
|
|
8
|
+
coverImage: z.string().max(500).optional(),
|
|
9
|
+
eventType: z.enum(['in-person', 'online', 'hybrid']).optional(),
|
|
10
|
+
startDate: z.string().datetime(),
|
|
11
|
+
endDate: z.string().datetime(),
|
|
12
|
+
timezone: z.string().max(64).optional(),
|
|
13
|
+
location: z.string().max(500).optional(),
|
|
14
|
+
locationUrl: z.string().url().max(500).optional(),
|
|
15
|
+
onlineUrl: z.string().url().max(500).optional(),
|
|
16
|
+
capacity: z.number().int().min(1).max(100000).optional(),
|
|
17
|
+
hubId: z.string().uuid().optional(),
|
|
18
|
+
}).refine(d => new Date(d.endDate) > new Date(d.startDate), {
|
|
19
|
+
message: 'End date must be after start date',
|
|
20
|
+
path: ['endDate'],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* POST /api/events
|
|
25
|
+
* Create a new event (authenticated).
|
|
26
|
+
*/
|
|
27
|
+
export default defineEventHandler(async (event) => {
|
|
28
|
+
requireFeature('events');
|
|
29
|
+
const user = requireAuth(event);
|
|
30
|
+
const db = useDB();
|
|
31
|
+
const body = await parseBody(event, createEventSchema);
|
|
32
|
+
|
|
33
|
+
const slug = generateSlug(body.title);
|
|
34
|
+
|
|
35
|
+
return createEvent(db, {
|
|
36
|
+
...body,
|
|
37
|
+
slug,
|
|
38
|
+
createdBy: user.id,
|
|
39
|
+
});
|
|
40
|
+
});
|