@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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HomepageSectionConfig } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
defineProps<{ config: HomepageSectionConfig }>();
|
|
5
|
+
|
|
6
|
+
const { contests: contestsEnabled } = useFeatures();
|
|
7
|
+
const { data: contests } = await useFetch('/api/contests', { query: { limit: 3 }, lazy: true });
|
|
8
|
+
|
|
9
|
+
const activeContest = computed(() => {
|
|
10
|
+
const items = (contests.value as { items?: Array<Record<string, unknown>> })?.items;
|
|
11
|
+
return items?.find((c) => c.status === 'active') ?? null;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const heroDismissed = ref(false);
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<section v-if="!heroDismissed" class="cpub-hero-banner">
|
|
19
|
+
<div class="cpub-hero-grid-bg" />
|
|
20
|
+
<div class="cpub-hero-gradient" />
|
|
21
|
+
<button class="cpub-hero-dismiss" title="Dismiss" @click="heroDismissed = true">
|
|
22
|
+
<i class="fa-solid fa-xmark"></i>
|
|
23
|
+
</button>
|
|
24
|
+
<div class="cpub-hero-inner">
|
|
25
|
+
<div class="cpub-hero-content">
|
|
26
|
+
<template v-if="contestsEnabled && activeContest">
|
|
27
|
+
<div class="cpub-hero-eyebrow">
|
|
28
|
+
<span class="cpub-hero-badge cpub-hero-badge-live"><span class="cpub-live-dot" /> Live Contest</span>
|
|
29
|
+
<span class="cpub-hero-badge">{{ activeContest.entryCount ?? 0 }} entries</span>
|
|
30
|
+
</div>
|
|
31
|
+
<h1 class="cpub-hero-title">{{ activeContest.title }}</h1>
|
|
32
|
+
<p v-if="activeContest.description" class="cpub-hero-excerpt">{{ activeContest.description }}</p>
|
|
33
|
+
<div class="cpub-hero-actions">
|
|
34
|
+
<NuxtLink :to="`/contests/${activeContest.slug}`" class="cpub-btn cpub-btn-primary"><i class="fa-solid fa-trophy"></i> Enter Contest</NuxtLink>
|
|
35
|
+
<NuxtLink :to="`/contests/${activeContest.slug}`" class="cpub-btn"><i class="fa-solid fa-circle-info"></i> View Details</NuxtLink>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
<template v-else>
|
|
39
|
+
<div class="cpub-hero-eyebrow">
|
|
40
|
+
<span class="cpub-hero-badge cpub-hero-badge-live"><span class="cpub-live-dot" /> Open Source</span>
|
|
41
|
+
</div>
|
|
42
|
+
<h1 class="cpub-hero-title">Build. Document.<br><span>Share.</span></h1>
|
|
43
|
+
<p class="cpub-hero-excerpt">
|
|
44
|
+
CommonPub is an open platform for maker communities. Document your builds with rich editors, join hubs, learn with structured paths, and share with the world.
|
|
45
|
+
</p>
|
|
46
|
+
<div class="cpub-hero-actions">
|
|
47
|
+
<NuxtLink to="/create" class="cpub-btn cpub-btn-primary"><i class="fa-solid fa-plus"></i> Start Building</NuxtLink>
|
|
48
|
+
<NuxtLink to="/explore" class="cpub-btn"><i class="fa-solid fa-compass"></i> Explore</NuxtLink>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</section>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<style scoped>
|
|
57
|
+
.cpub-hero-banner { position: relative; background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); overflow: hidden; min-height: 200px; display: flex; align-items: stretch; }
|
|
58
|
+
.cpub-hero-grid-bg { position: absolute; inset: 0; background-image: linear-gradient(var(--border2) 1px, transparent 1px), linear-gradient(90deg, var(--border2) 1px, transparent 1px); background-size: 32px 32px; opacity: 0.25; }
|
|
59
|
+
.cpub-hero-gradient { position: absolute; inset: 0; background: var(--surface2); opacity: 0.5; }
|
|
60
|
+
.cpub-hero-dismiss { position: absolute; top: 12px; right: 16px; background: transparent; border: none; color: var(--text-faint); font-size: 12px; cursor: pointer; padding: 4px; z-index: 2; }
|
|
61
|
+
.cpub-hero-dismiss:hover { color: var(--text-dim); }
|
|
62
|
+
.cpub-hero-inner { position: relative; z-index: 1; max-width: 1280px; margin: 0 auto; padding: 36px 32px; width: 100%; display: flex; align-items: center; gap: 48px; }
|
|
63
|
+
.cpub-hero-content { flex: 1; }
|
|
64
|
+
.cpub-hero-eyebrow { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
|
65
|
+
.cpub-hero-badge { font-size: 9px; font-family: var(--font-mono); letter-spacing: 0.1em; text-transform: uppercase; padding: 3px 9px; background: var(--yellow-bg); border: var(--border-width-default) solid var(--yellow); color: var(--yellow); }
|
|
66
|
+
.cpub-hero-badge-live { background: var(--green-bg); border-color: var(--green); color: var(--green); display: flex; align-items: center; gap: 5px; }
|
|
67
|
+
.cpub-live-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--green); animation: cpub-pulse 2s ease-in-out infinite; }
|
|
68
|
+
@keyframes cpub-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
69
|
+
.cpub-hero-title { font-size: 22px; font-weight: 700; line-height: 1.25; margin-bottom: 10px; }
|
|
70
|
+
.cpub-hero-title span { color: var(--accent); }
|
|
71
|
+
.cpub-hero-excerpt { font-size: 13px; color: var(--text-dim); line-height: 1.65; margin-bottom: 20px; max-width: 560px; }
|
|
72
|
+
.cpub-hero-actions { display: flex; gap: 8px; }
|
|
73
|
+
</style>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HomepageSection } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
sections: HomepageSection[];
|
|
6
|
+
/** Which zone to render: 'main' for feed column, 'sidebar' for sidebar */
|
|
7
|
+
zone: 'main' | 'sidebar' | 'full-width';
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
const features = useFeatures();
|
|
11
|
+
|
|
12
|
+
function isFeatureEnabled(featureGate?: string): boolean {
|
|
13
|
+
if (!featureGate) return true;
|
|
14
|
+
return (features.features as unknown as Record<string, boolean>)?.[featureGate] ?? true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Section types that render in the full-width zone (above the 2-column layout) */
|
|
18
|
+
const FULL_WIDTH_TYPES = new Set(['hero']);
|
|
19
|
+
|
|
20
|
+
/** Section types that render in the sidebar */
|
|
21
|
+
const SIDEBAR_TYPES = new Set(['stats', 'contests', 'hubs']);
|
|
22
|
+
|
|
23
|
+
function sectionZone(section: HomepageSection): 'full-width' | 'main' | 'sidebar' {
|
|
24
|
+
if (FULL_WIDTH_TYPES.has(section.type)) return 'full-width';
|
|
25
|
+
if (SIDEBAR_TYPES.has(section.type)) return 'sidebar';
|
|
26
|
+
return 'main';
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<template v-for="section in sections" :key="section.id">
|
|
32
|
+
<template v-if="section.enabled && sectionZone(section) === zone && isFeatureEnabled(section.config.featureGate)">
|
|
33
|
+
<HomepageHeroSection
|
|
34
|
+
v-if="section.type === 'hero'"
|
|
35
|
+
:config="section.config"
|
|
36
|
+
/>
|
|
37
|
+
<HomepageEditorialSection
|
|
38
|
+
v-else-if="section.type === 'editorial'"
|
|
39
|
+
:config="section.config"
|
|
40
|
+
/>
|
|
41
|
+
<HomepageContentGridSection
|
|
42
|
+
v-else-if="section.type === 'content-grid'"
|
|
43
|
+
:config="section.config"
|
|
44
|
+
:title="section.title"
|
|
45
|
+
/>
|
|
46
|
+
<HomepageContestsSection
|
|
47
|
+
v-else-if="section.type === 'contests'"
|
|
48
|
+
:config="section.config"
|
|
49
|
+
/>
|
|
50
|
+
<HomepageHubsSection
|
|
51
|
+
v-else-if="section.type === 'hubs'"
|
|
52
|
+
:config="section.config"
|
|
53
|
+
/>
|
|
54
|
+
<HomepageStatsSection
|
|
55
|
+
v-else-if="section.type === 'stats'"
|
|
56
|
+
/>
|
|
57
|
+
<HomepageCustomHtmlSection
|
|
58
|
+
v-else-if="section.type === 'custom-html'"
|
|
59
|
+
:config="section.config"
|
|
60
|
+
:title="section.title"
|
|
61
|
+
/>
|
|
62
|
+
</template>
|
|
63
|
+
</template>
|
|
64
|
+
</template>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HomepageSectionConfig } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ config: HomepageSectionConfig }>();
|
|
5
|
+
|
|
6
|
+
const limit = computed(() => props.config.limit ?? 4);
|
|
7
|
+
const { data: communities, pending } = await useFetch('/api/hubs', { query: { limit }, lazy: true });
|
|
8
|
+
|
|
9
|
+
const { user } = useAuth();
|
|
10
|
+
const isAuthenticated = computed(() => !!user.value);
|
|
11
|
+
const joinedHubs = ref(new Set<string>());
|
|
12
|
+
const toast = useToast();
|
|
13
|
+
|
|
14
|
+
async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
15
|
+
if (!isAuthenticated.value) {
|
|
16
|
+
await navigateTo('/auth/login?redirect=/');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
await $fetch(`/api/hubs/${hubSlug}/join`, { method: 'POST' });
|
|
21
|
+
joinedHubs.value.add(hubSlug);
|
|
22
|
+
toast.success('Joined hub!');
|
|
23
|
+
} catch {
|
|
24
|
+
toast.error('Failed to join hub');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<div v-if="pending" class="cpub-sb-card">
|
|
31
|
+
<div class="cpub-sb-head">Trending Hubs</div>
|
|
32
|
+
<div class="cpub-loading-state"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
|
|
33
|
+
</div>
|
|
34
|
+
<div v-else-if="communities?.items?.length" class="cpub-sb-card">
|
|
35
|
+
<div class="cpub-sb-head">Trending Hubs <NuxtLink to="/hubs">Browse</NuxtLink></div>
|
|
36
|
+
<div v-for="hub in communities.items" :key="hub.id" class="cpub-hub-item">
|
|
37
|
+
<div class="cpub-hub-icon">
|
|
38
|
+
<img v-if="hub.iconUrl" :src="hub.iconUrl" :alt="hub.name" class="cpub-hub-icon-img" />
|
|
39
|
+
<i v-else class="fa-solid fa-users"></i>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="cpub-hub-info">
|
|
42
|
+
<NuxtLink :to="(hub as Record<string, unknown>).source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`" class="cpub-hub-name">{{ hub.name }}</NuxtLink>
|
|
43
|
+
<div class="cpub-hub-members">{{ hub.memberCount ?? 0 }} members</div>
|
|
44
|
+
</div>
|
|
45
|
+
<button v-if="joinedHubs.has(hub.slug)" class="cpub-btn-joined" disabled><i class="fa-solid fa-check"></i> Joined</button>
|
|
46
|
+
<button v-else class="cpub-btn-join" @click.prevent="handleHubJoin(hub.slug)">Join</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<style scoped>
|
|
52
|
+
.cpub-sb-card { background: var(--surface); border: var(--border-width-default) solid var(--border); padding: 16px; margin-bottom: 16px; }
|
|
53
|
+
.cpub-sb-head { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); padding-bottom: 10px; border-bottom: var(--border-width-default) solid var(--border2); margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
|
|
54
|
+
.cpub-sb-head a { color: var(--accent); text-decoration: none; font-size: 10px; }
|
|
55
|
+
.cpub-hub-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: var(--border-width-default) solid var(--border2); }
|
|
56
|
+
.cpub-hub-item:last-child { border-bottom: none; }
|
|
57
|
+
.cpub-hub-icon { width: 32px; height: 32px; background: var(--accent-bg); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 13px; color: var(--accent); flex-shrink: 0; overflow: hidden; }
|
|
58
|
+
.cpub-hub-icon-img { width: 100%; height: 100%; object-fit: cover; }
|
|
59
|
+
.cpub-hub-info { flex: 1; min-width: 0; }
|
|
60
|
+
.cpub-hub-name { font-size: 12px; font-weight: 600; color: var(--text); text-decoration: none; display: block; }
|
|
61
|
+
.cpub-hub-name:hover { color: var(--accent); }
|
|
62
|
+
.cpub-hub-members { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); }
|
|
63
|
+
.cpub-btn-join { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 10px; border: var(--border-width-default) solid var(--accent); color: var(--accent); background: none; cursor: pointer; }
|
|
64
|
+
.cpub-btn-join:hover { background: var(--accent-bg); }
|
|
65
|
+
.cpub-btn-joined { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 10px; border: var(--border-width-default) solid var(--green-border); color: var(--green); background: var(--green-bg); cursor: default; display: flex; align-items: center; gap: 3px; }
|
|
66
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { hubs: hubsEnabled } = useFeatures();
|
|
3
|
+
const { data: stats, pending } = await useFetch('/api/stats', { lazy: true });
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div class="cpub-sb-card">
|
|
8
|
+
<div class="cpub-sb-head">Platform Stats</div>
|
|
9
|
+
<div v-if="pending" class="cpub-loading-state"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
|
|
10
|
+
<div v-else class="cpub-stats-grid">
|
|
11
|
+
<div class="cpub-stat-block">
|
|
12
|
+
<span class="cpub-stat-num">{{ stats?.content?.byType?.project ?? 0 }}</span>
|
|
13
|
+
<span class="cpub-stat-lbl">Projects</span>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="cpub-stat-block">
|
|
16
|
+
<span class="cpub-stat-num">{{ (stats?.content?.byType?.blog ?? 0) + (stats?.content?.byType?.article ?? 0) }}</span>
|
|
17
|
+
<span class="cpub-stat-lbl">Posts</span>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="cpub-stat-block">
|
|
20
|
+
<span class="cpub-stat-num">{{ stats?.users?.total ?? 0 }}</span>
|
|
21
|
+
<span class="cpub-stat-lbl">Members</span>
|
|
22
|
+
</div>
|
|
23
|
+
<div v-if="hubsEnabled" class="cpub-stat-block">
|
|
24
|
+
<span class="cpub-stat-num">{{ stats?.hubs?.total ?? 0 }}</span>
|
|
25
|
+
<span class="cpub-stat-lbl">Hubs</span>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<style scoped>
|
|
32
|
+
.cpub-sb-card { background: var(--surface); border: var(--border-width-default) solid var(--border); padding: 16px; margin-bottom: 16px; }
|
|
33
|
+
.cpub-sb-head { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); padding-bottom: 10px; border-bottom: var(--border-width-default) solid var(--border2); margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
|
|
34
|
+
.cpub-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
35
|
+
.cpub-stat-block { text-align: center; padding: 8px 0; }
|
|
36
|
+
.cpub-stat-num { display: block; font-family: var(--font-mono); font-size: 18px; font-weight: 700; color: var(--text); }
|
|
37
|
+
.cpub-stat-lbl { display: block; font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); }
|
|
38
|
+
</style>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { NavItem } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
items: NavItem[];
|
|
6
|
+
}>();
|
|
7
|
+
|
|
8
|
+
const emit = defineEmits<{
|
|
9
|
+
close: [];
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
const { isAuthenticated, isAdmin } = useAuth();
|
|
13
|
+
const features = useFeatures();
|
|
14
|
+
|
|
15
|
+
const featureMap = computed(() => {
|
|
16
|
+
const map: Record<string, boolean> = {};
|
|
17
|
+
for (const [key, val] of Object.entries(features)) {
|
|
18
|
+
if (typeof val === 'object' && val !== null && 'value' in val) {
|
|
19
|
+
map[key] = (val as { value: boolean }).value;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return map;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function isVisible(item: NavItem): boolean {
|
|
26
|
+
if (item.featureGate && !featureMap.value[item.featureGate]) return false;
|
|
27
|
+
if (item.visibleTo === 'authenticated' && !isAuthenticated.value) return false;
|
|
28
|
+
if (item.visibleTo === 'admin' && !isAdmin.value) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function visibleChildren(item: NavItem): NavItem[] {
|
|
33
|
+
return (item.children ?? []).filter(c => isVisible(c));
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<nav class="cpub-mobile-nav" aria-label="Mobile navigation">
|
|
39
|
+
<template v-for="item in items" :key="item.id">
|
|
40
|
+
<!-- Dropdown → section label + indented children -->
|
|
41
|
+
<template v-if="item.type === 'dropdown' && isVisible(item) && visibleChildren(item).length > 0">
|
|
42
|
+
<div class="cpub-mobile-section-label">{{ item.label }}</div>
|
|
43
|
+
<template v-for="child in visibleChildren(item)" :key="child.id">
|
|
44
|
+
<span
|
|
45
|
+
v-if="child.disabled"
|
|
46
|
+
class="cpub-mobile-link cpub-mobile-link--indent cpub-mobile-link--disabled"
|
|
47
|
+
>
|
|
48
|
+
<i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
|
|
49
|
+
</span>
|
|
50
|
+
<a
|
|
51
|
+
v-else-if="child.type === 'external' && child.href"
|
|
52
|
+
:href="child.href"
|
|
53
|
+
target="_blank"
|
|
54
|
+
rel="noopener"
|
|
55
|
+
class="cpub-mobile-link cpub-mobile-link--indent"
|
|
56
|
+
@click="emit('close')"
|
|
57
|
+
>
|
|
58
|
+
<i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
|
|
59
|
+
</a>
|
|
60
|
+
<NuxtLink
|
|
61
|
+
v-else-if="child.route"
|
|
62
|
+
:to="child.route"
|
|
63
|
+
class="cpub-mobile-link cpub-mobile-link--indent"
|
|
64
|
+
@click="emit('close')"
|
|
65
|
+
>
|
|
66
|
+
<i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
|
|
67
|
+
</NuxtLink>
|
|
68
|
+
</template>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<!-- Regular link -->
|
|
72
|
+
<template v-else-if="isVisible(item) && item.type !== 'dropdown'">
|
|
73
|
+
<a
|
|
74
|
+
v-if="item.type === 'external' && item.href"
|
|
75
|
+
:href="item.href"
|
|
76
|
+
target="_blank"
|
|
77
|
+
rel="noopener"
|
|
78
|
+
class="cpub-mobile-link"
|
|
79
|
+
@click="emit('close')"
|
|
80
|
+
>
|
|
81
|
+
<i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
|
|
82
|
+
</a>
|
|
83
|
+
<NuxtLink
|
|
84
|
+
v-else-if="item.route"
|
|
85
|
+
:to="item.route"
|
|
86
|
+
class="cpub-mobile-link"
|
|
87
|
+
@click="emit('close')"
|
|
88
|
+
>
|
|
89
|
+
<i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
|
|
90
|
+
</NuxtLink>
|
|
91
|
+
</template>
|
|
92
|
+
</template>
|
|
93
|
+
</nav>
|
|
94
|
+
</template>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { NavItem } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
item: NavItem;
|
|
6
|
+
open: boolean;
|
|
7
|
+
}>();
|
|
8
|
+
|
|
9
|
+
const emit = defineEmits<{
|
|
10
|
+
toggle: [];
|
|
11
|
+
close: [];
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const { isAuthenticated, isAdmin } = useAuth();
|
|
15
|
+
const features = useFeatures();
|
|
16
|
+
const featureMap = computed(() => {
|
|
17
|
+
const map: Record<string, boolean> = {};
|
|
18
|
+
for (const [key, val] of Object.entries(features)) {
|
|
19
|
+
if (typeof val === 'object' && val !== null && 'value' in val) {
|
|
20
|
+
map[key] = (val as { value: boolean }).value;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return map;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function isChildVisible(child: NavItem): boolean {
|
|
27
|
+
if (child.featureGate && !featureMap.value[child.featureGate]) return false;
|
|
28
|
+
if (child.visibleTo === 'authenticated' && !isAuthenticated.value) return false;
|
|
29
|
+
if (child.visibleTo === 'admin' && !isAdmin.value) return false;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const visibleChildren = computed(() =>
|
|
34
|
+
(props.item.children ?? []).filter(c => isChildVisible(c)),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
function handleKeydown(e: KeyboardEvent): void {
|
|
38
|
+
if (e.key === 'Escape' && props.open) {
|
|
39
|
+
emit('close');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<div v-if="visibleChildren.length > 0" class="cpub-nav-dropdown" @keydown="handleKeydown">
|
|
46
|
+
<button
|
|
47
|
+
class="cpub-nav-link cpub-nav-trigger"
|
|
48
|
+
:class="{ 'cpub-nav-trigger--open': open }"
|
|
49
|
+
:aria-label="`${item.label} menu`"
|
|
50
|
+
aria-haspopup="true"
|
|
51
|
+
:aria-expanded="open"
|
|
52
|
+
@click.stop="emit('toggle')"
|
|
53
|
+
@keydown.enter.stop="emit('toggle')"
|
|
54
|
+
@keydown.space.prevent.stop="emit('toggle')"
|
|
55
|
+
>
|
|
56
|
+
<i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
|
|
57
|
+
<i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
58
|
+
</button>
|
|
59
|
+
<div v-if="open" class="cpub-nav-panel" role="menu">
|
|
60
|
+
<template v-for="child in visibleChildren" :key="child.id">
|
|
61
|
+
<span
|
|
62
|
+
v-if="child.disabled"
|
|
63
|
+
class="cpub-nav-panel-item cpub-nav-panel-item--disabled"
|
|
64
|
+
role="menuitem"
|
|
65
|
+
aria-disabled="true"
|
|
66
|
+
>
|
|
67
|
+
<i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
|
|
68
|
+
</span>
|
|
69
|
+
<a
|
|
70
|
+
v-else-if="child.type === 'external' && child.href"
|
|
71
|
+
:href="child.href"
|
|
72
|
+
target="_blank"
|
|
73
|
+
rel="noopener"
|
|
74
|
+
class="cpub-nav-panel-item"
|
|
75
|
+
role="menuitem"
|
|
76
|
+
@click="emit('close')"
|
|
77
|
+
>
|
|
78
|
+
<i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
|
|
79
|
+
<i class="fa-solid fa-arrow-up-right-from-square cpub-nav-external-icon"></i>
|
|
80
|
+
</a>
|
|
81
|
+
<NuxtLink
|
|
82
|
+
v-else-if="child.route"
|
|
83
|
+
:to="child.route"
|
|
84
|
+
class="cpub-nav-panel-item"
|
|
85
|
+
role="menuitem"
|
|
86
|
+
@click="emit('close')"
|
|
87
|
+
>
|
|
88
|
+
<i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
|
|
89
|
+
</NuxtLink>
|
|
90
|
+
</template>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<style scoped>
|
|
96
|
+
.cpub-nav-external-icon {
|
|
97
|
+
font-size: 8px;
|
|
98
|
+
color: var(--text-faint);
|
|
99
|
+
margin-left: 2px;
|
|
100
|
+
}
|
|
101
|
+
</style>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { NavItem } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
item: NavItem;
|
|
6
|
+
}>();
|
|
7
|
+
|
|
8
|
+
const isExternal = computed(() => props.item.type === 'external' && props.item.href);
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<span v-if="item.disabled" class="cpub-nav-link cpub-nav-panel-item--disabled">
|
|
13
|
+
<i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
|
|
14
|
+
</span>
|
|
15
|
+
<a
|
|
16
|
+
v-else-if="isExternal"
|
|
17
|
+
:href="item.href"
|
|
18
|
+
target="_blank"
|
|
19
|
+
rel="noopener"
|
|
20
|
+
class="cpub-nav-link"
|
|
21
|
+
>
|
|
22
|
+
<i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
|
|
23
|
+
<i class="fa-solid fa-arrow-up-right-from-square cpub-nav-external-icon"></i>
|
|
24
|
+
</a>
|
|
25
|
+
<NuxtLink
|
|
26
|
+
v-else-if="item.route"
|
|
27
|
+
:to="item.route"
|
|
28
|
+
class="cpub-nav-link"
|
|
29
|
+
>
|
|
30
|
+
<i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
|
|
31
|
+
</NuxtLink>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<style scoped>
|
|
35
|
+
.cpub-nav-external-icon {
|
|
36
|
+
font-size: 8px;
|
|
37
|
+
opacity: 0.5;
|
|
38
|
+
margin-left: 2px;
|
|
39
|
+
}
|
|
40
|
+
</style>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { NavItem } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
items: NavItem[];
|
|
6
|
+
openDropdown: string | null;
|
|
7
|
+
}>();
|
|
8
|
+
|
|
9
|
+
const emit = defineEmits<{
|
|
10
|
+
'toggle-dropdown': [name: string];
|
|
11
|
+
'close-dropdowns': [];
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const { isAuthenticated, isAdmin } = useAuth();
|
|
15
|
+
const features = useFeatures();
|
|
16
|
+
|
|
17
|
+
const featureMap = computed(() => {
|
|
18
|
+
const map: Record<string, boolean> = {};
|
|
19
|
+
for (const [key, val] of Object.entries(features)) {
|
|
20
|
+
if (typeof val === 'object' && val !== null && 'value' in val) {
|
|
21
|
+
map[key] = (val as { value: boolean }).value;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return map;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function isVisible(item: NavItem): boolean {
|
|
28
|
+
if (item.featureGate && !featureMap.value[item.featureGate]) return false;
|
|
29
|
+
if (item.visibleTo === 'authenticated' && !isAuthenticated.value) return false;
|
|
30
|
+
if (item.visibleTo === 'admin' && !isAdmin.value) return false;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<nav class="cpub-topbar-nav" aria-label="Main navigation">
|
|
37
|
+
<template v-for="item in items" :key="item.id">
|
|
38
|
+
<NavDropdown
|
|
39
|
+
v-if="item.type === 'dropdown' && isVisible(item)"
|
|
40
|
+
:item="item"
|
|
41
|
+
:open="openDropdown === item.id"
|
|
42
|
+
@toggle="emit('toggle-dropdown', item.id)"
|
|
43
|
+
@close="emit('close-dropdowns')"
|
|
44
|
+
/>
|
|
45
|
+
<NavLink
|
|
46
|
+
v-else-if="isVisible(item)"
|
|
47
|
+
:item="item"
|
|
48
|
+
/>
|
|
49
|
+
</template>
|
|
50
|
+
</nav>
|
|
51
|
+
</template>
|
|
@@ -7,6 +7,7 @@ export interface FeatureFlags {
|
|
|
7
7
|
docs: boolean;
|
|
8
8
|
video: boolean;
|
|
9
9
|
contests: boolean;
|
|
10
|
+
events: boolean;
|
|
10
11
|
learning: boolean;
|
|
11
12
|
explainers: boolean;
|
|
12
13
|
editorial: boolean;
|
|
@@ -27,6 +28,7 @@ export function useFeatures() {
|
|
|
27
28
|
docs: computed(() => flags.docs),
|
|
28
29
|
video: computed(() => flags.video),
|
|
29
30
|
contests: computed(() => flags.contests),
|
|
31
|
+
events: computed(() => flags.events),
|
|
30
32
|
learning: computed(() => flags.learning),
|
|
31
33
|
explainers: computed(() => flags.explainers),
|
|
32
34
|
editorial: computed(() => flags.editorial),
|
package/layouts/admin.vue
CHANGED
|
@@ -33,6 +33,9 @@ const sidebarOpen = ref(false);
|
|
|
33
33
|
<NuxtLink to="/admin/reports" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-flag"></i> Reports</NuxtLink>
|
|
34
34
|
<NuxtLink to="/admin/audit" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-clipboard-list"></i> Audit Log</NuxtLink>
|
|
35
35
|
<NuxtLink to="/admin/theme" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-palette"></i> Theme</NuxtLink>
|
|
36
|
+
<NuxtLink to="/admin/homepage" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-house"></i> Homepage</NuxtLink>
|
|
37
|
+
<NuxtLink to="/admin/navigation" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-bars"></i> Navigation</NuxtLink>
|
|
38
|
+
<NuxtLink to="/admin/features" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-toggle-on"></i> Features</NuxtLink>
|
|
36
39
|
<NuxtLink to="/admin/federation" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-globe"></i> Federation</NuxtLink>
|
|
37
40
|
<NuxtLink to="/admin/settings" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
|
|
38
41
|
</nav>
|