@commonpub/layer 0.13.0 → 0.14.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.
@@ -5,10 +5,13 @@ const props = defineProps<{
5
5
  authorAvatarUrl?: string | null;
6
6
  replyCount: number;
7
7
  voteCount: number;
8
+ voteScore?: number;
8
9
  lastReplyAt?: Date;
9
10
  solved?: boolean;
10
11
  }>();
11
12
 
13
+ const displayScore = computed(() => props.voteScore ?? props.voteCount);
14
+
12
15
  const emit = defineEmits<{
13
16
  upvote: [];
14
17
  downvote: [];
@@ -33,7 +36,7 @@ const lastReplyFormatted = computed((): string | null => {
33
36
  >
34
37
  <i class="fa-solid fa-chevron-up"></i>
35
38
  </button>
36
- <span class="cpub-vote-count">{{ voteCount }}</span>
39
+ <span class="cpub-vote-count">{{ displayScore }}</span>
37
40
  <button
38
41
  class="cpub-vote-btn"
39
42
  aria-label="Downvote"
@@ -16,7 +16,7 @@ const { data, refresh } = await useFetch<{ options: PollOptionResult[]; userVote
16
16
 
17
17
  const totalVotes = computed(() => {
18
18
  if (!data.value) return 0;
19
- return data.value.options.reduce((sum, opt) => sum + opt.voteCount, 0);
19
+ return data.value.options.reduce((sum: number, opt: { voteCount: number }) => sum + opt.voteCount, 0);
20
20
  });
21
21
 
22
22
  const hasVoted = computed(() => !!data.value?.userVote);
@@ -29,6 +29,7 @@ const discussionPosts = computed(() => {
29
29
  :author="post.author.name"
30
30
  :reply-count="post.replyCount"
31
31
  :vote-count="post.likeCount"
32
+ :vote-score="post.voteScore"
32
33
  />
33
34
  </NuxtLink>
34
35
  <div v-else>
@@ -37,6 +38,7 @@ const discussionPosts = computed(() => {
37
38
  :author="post.author.name"
38
39
  :reply-count="post.replyCount"
39
40
  :vote-count="post.likeCount"
41
+ :vote-score="post.voteScore"
40
42
  />
41
43
  </div>
42
44
  </template>
@@ -30,7 +30,7 @@ function isVisible(item: NavItem): boolean {
30
30
  }
31
31
 
32
32
  function visibleChildren(item: NavItem): NavItem[] {
33
- return (item.children ?? []).filter(c => isVisible(c));
33
+ return (item.children ?? []).filter((c: NavItem) => isVisible(c));
34
34
  }
35
35
  </script>
36
36
 
@@ -31,7 +31,7 @@ function isChildVisible(child: NavItem): boolean {
31
31
  }
32
32
 
33
33
  const visibleChildren = computed(() =>
34
- (props.item.children ?? []).filter(c => isChildVisible(c)),
34
+ (props.item.children ?? []).filter((c: NavItem) => isChildVisible(c)),
35
35
  );
36
36
 
37
37
  function handleKeydown(e: KeyboardEvent): void {
@@ -9,7 +9,7 @@ const isExternal = computed(() => props.item.type === 'external' && props.item.h
9
9
  </script>
10
10
 
11
11
  <template>
12
- <span v-if="item.disabled" class="cpub-nav-link cpub-nav-panel-item--disabled">
12
+ <span v-if="item.disabled" class="cpub-nav-link cpub-nav-link--disabled">
13
13
  <i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
14
14
  </span>
15
15
  <a
@@ -34,7 +34,7 @@ const isExternal = computed(() => props.item.type === 'external' && props.item.h
34
34
  <style scoped>
35
35
  .cpub-nav-external-icon {
36
36
  font-size: 8px;
37
- opacity: 0.5;
37
+ color: var(--text-faint);
38
38
  margin-left: 2px;
39
39
  }
40
40
  </style>
@@ -20,6 +20,21 @@ export interface ClientAuthSession {
20
20
  expiresAt: string;
21
21
  }
22
22
 
23
+ interface AuthResponse {
24
+ user: ClientAuthUser | null;
25
+ session: ClientAuthSession | null;
26
+ }
27
+
28
+ /** Type-safe POST fetch that avoids Nuxt $fetch TS2589 deep instantiation */
29
+ async function authPost(url: string, body: Record<string, unknown>): Promise<AuthResponse | null> {
30
+ return ($fetch as (url: string, opts: Record<string, unknown>) => Promise<AuthResponse | null>)(url, {
31
+ method: 'POST',
32
+ body,
33
+ credentials: 'include',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ }
37
+
23
38
  export function useAuth() {
24
39
  const user = useState<ClientAuthUser | null>('auth-user', () => null);
25
40
  const session = useState<ClientAuthSession | null>('auth-session', () => null);
@@ -28,27 +43,19 @@ export function useAuth() {
28
43
  const isAdmin = computed(() => user.value?.role === 'admin');
29
44
 
30
45
  async function signIn(email: string, password: string): Promise<void> {
31
- const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>('/api/auth/sign-in/email', {
32
- method: 'POST',
33
- body: { email, password },
34
- credentials: 'include',
35
- });
46
+ const data = await authPost('/api/auth/sign-in/email', { email, password });
36
47
  user.value = data?.user ?? null;
37
48
  session.value = data?.session ?? null;
38
49
  }
39
50
 
40
51
  async function signUp(email: string, password: string, username: string): Promise<void> {
41
- const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>('/api/auth/sign-up/email', {
42
- method: 'POST',
43
- body: { email, password, username, name: username },
44
- credentials: 'include',
45
- });
52
+ const data = await authPost('/api/auth/sign-up/email', { email, password, username, name: username });
46
53
  user.value = data?.user ?? null;
47
54
  session.value = data?.session ?? null;
48
55
  }
49
56
 
50
57
  async function signOut(): Promise<void> {
51
- await $fetch('/api/auth/sign-out', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: {} });
58
+ await authPost('/api/auth/sign-out', {});
52
59
  user.value = null;
53
60
  session.value = null;
54
61
  await navigateTo('/');
@@ -61,14 +68,12 @@ export function useAuth() {
61
68
  async function refreshSession(): Promise<void> {
62
69
  if (import.meta.server) return;
63
70
  try {
64
- const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>(
65
- '/api/me',
66
- { credentials: 'include' },
71
+ const data = await ($fetch as (url: string, opts: Record<string, unknown>) => Promise<AuthResponse | null>)(
72
+ '/api/me', { credentials: 'include' },
67
73
  );
68
74
  user.value = data?.user ?? null;
69
75
  session.value = data?.session ?? null;
70
76
  } catch {
71
- // Session invalid or server unreachable — clear client state
72
77
  user.value = null;
73
78
  session.value = null;
74
79
  }
@@ -1,4 +1,6 @@
1
1
  // Feature flag composable — reactive access to enabled features
2
+ // Initializes from build-time runtime config, then hydrates from /api/features
3
+ // to pick up runtime DB overrides set via admin panel.
2
4
 
3
5
  export interface FeatureFlags {
4
6
  content: boolean;
@@ -16,24 +18,41 @@ export interface FeatureFlags {
16
18
  emailNotifications: boolean;
17
19
  }
18
20
 
21
+ let hydrated = false;
22
+
19
23
  export function useFeatures() {
20
24
  const config = useRuntimeConfig();
21
- const flags = config.public.features as unknown as FeatureFlags;
25
+ const buildFlags = config.public.features as unknown as FeatureFlags;
26
+
27
+ // Shared reactive state — initialized from build-time config
28
+ const flags = useState<FeatureFlags>('feature-flags', () => ({ ...buildFlags }));
29
+
30
+ // On client, fetch dynamic features once to pick up DB overrides
31
+ if (import.meta.client && !hydrated) {
32
+ hydrated = true;
33
+ ($fetch as Function)('/api/features')
34
+ .then((dynamic: FeatureFlags) => {
35
+ if (dynamic && typeof dynamic === 'object') {
36
+ flags.value = { ...flags.value, ...dynamic };
37
+ }
38
+ })
39
+ .catch(() => { /* use build-time defaults on failure */ });
40
+ }
22
41
 
23
42
  return {
24
43
  features: flags,
25
- content: computed(() => flags.content),
26
- social: computed(() => flags.social),
27
- hubs: computed(() => flags.hubs),
28
- docs: computed(() => flags.docs),
29
- video: computed(() => flags.video),
30
- contests: computed(() => flags.contests),
31
- events: computed(() => flags.events),
32
- learning: computed(() => flags.learning),
33
- explainers: computed(() => flags.explainers),
34
- editorial: computed(() => flags.editorial),
35
- federation: computed(() => flags.federation),
36
- admin: computed(() => flags.admin),
37
- emailNotifications: computed(() => flags.emailNotifications),
44
+ content: computed(() => flags.value.content),
45
+ social: computed(() => flags.value.social),
46
+ hubs: computed(() => flags.value.hubs),
47
+ docs: computed(() => flags.value.docs),
48
+ video: computed(() => flags.value.video),
49
+ contests: computed(() => flags.value.contests),
50
+ events: computed(() => flags.value.events),
51
+ learning: computed(() => flags.value.learning),
52
+ explainers: computed(() => flags.value.explainers),
53
+ editorial: computed(() => flags.value.editorial),
54
+ federation: computed(() => flags.value.federation),
55
+ admin: computed(() => flags.value.admin),
56
+ emailNotifications: computed(() => flags.value.emailNotifications),
38
57
  };
39
58
  }
@@ -21,7 +21,11 @@ const mobileMenuOpen = ref(false);
21
21
  const openDropdown = ref<string | null>(null);
22
22
 
23
23
  // Fetch configurable nav items (falls back to defaults on server)
24
- const { data: navItems } = await useFetch<NavItem[]>('/api/navigation/items');
24
+ // useAsyncData avoids Nuxt's typed route inference which triggers TS2589
25
+ const { data: navItems } = await useAsyncData('nav-items', () =>
26
+ ($fetch as Function)('/api/navigation/items') as Promise<NavItem[]>,
27
+ { default: () => [] as NavItem[] },
28
+ );
25
29
 
26
30
  function toggleDropdown(name: string): void {
27
31
  openDropdown.value = openDropdown.value === name ? null : name;
@@ -224,42 +228,44 @@ const userUsername = computed(() => user.value?.username ?? '');
224
228
  }
225
229
  .cpub-topbar-logo { display: flex; align-items: center; flex-shrink: 0; text-decoration: none; color: var(--text); }
226
230
 
227
- .cpub-topbar-nav { display: flex; align-items: center; gap: 2px; margin-left: 24px; }
228
- .cpub-nav-link { font-size: 12px; color: var(--text-dim); padding: 5px 12px; border: var(--border-width-default) solid transparent; background: none; text-decoration: none; transition: color 0.15s, background 0.15s; display: flex; align-items: center; gap: 6px; }
229
- .cpub-nav-link i { font-size: 10px; }
230
- .cpub-nav-link:hover { color: var(--text); background: var(--surface2); }
231
- .cpub-nav-link.router-link-active { color: var(--text); background: var(--surface2); border-color: var(--border); }
231
+ /* Nav styles use :deep() to reach into NavRenderer/NavDropdown/NavLink child components */
232
+ :deep(.cpub-topbar-nav) { display: flex; align-items: center; gap: 2px; margin-left: 24px; }
233
+ :deep(.cpub-nav-link) { font-size: 12px; color: var(--text-dim); padding: 5px 12px; border: var(--border-width-default) solid transparent; background: none; text-decoration: none; transition: color 0.15s, background 0.15s; display: flex; align-items: center; gap: 6px; }
234
+ :deep(.cpub-nav-link i) { font-size: 10px; }
235
+ :deep(.cpub-nav-link:hover) { color: var(--text); background: var(--surface2); }
236
+ :deep(.cpub-nav-link.router-link-active) { color: var(--text); background: var(--surface2); border-color: var(--border); }
237
+ :deep(.cpub-nav-link--disabled) { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
232
238
 
233
239
  /* Nav dropdowns */
234
- .cpub-nav-dropdown { position: relative; }
235
- .cpub-nav-trigger { cursor: pointer; }
236
- .cpub-nav-caret { font-size: 7px !important; margin-left: 2px; transition: transform 0.15s; }
237
- .cpub-nav-trigger--open .cpub-nav-caret { transform: rotate(180deg); }
238
- .cpub-nav-panel {
240
+ :deep(.cpub-nav-dropdown) { position: relative; }
241
+ :deep(.cpub-nav-trigger) { cursor: pointer; }
242
+ :deep(.cpub-nav-caret) { font-size: 7px !important; margin-left: 2px; transition: transform 0.15s; }
243
+ :deep(.cpub-nav-trigger--open .cpub-nav-caret) { transform: rotate(180deg); }
244
+ :deep(.cpub-nav-panel) {
239
245
  position: absolute; top: 100%; left: 0; min-width: 180px;
240
246
  background: var(--surface); border: var(--border-width-default) solid var(--border);
241
247
  box-shadow: var(--shadow-md); z-index: 200; display: flex; flex-direction: column; padding: 4px 0;
242
248
  margin-top: 4px;
243
249
  }
244
- .cpub-nav-panel-item {
250
+ :deep(.cpub-nav-panel-item) {
245
251
  display: flex; align-items: center; gap: 8px; padding: 8px 14px;
246
252
  font-size: 12px; color: var(--text-dim); text-decoration: none;
247
253
  transition: background 0.1s, color 0.1s; cursor: pointer;
248
254
  }
249
- .cpub-nav-panel-item:hover { background: var(--surface2); color: var(--text); }
250
- .cpub-nav-panel-item i { width: 14px; text-align: center; font-size: 11px; }
251
- .cpub-nav-panel-item--disabled {
255
+ :deep(.cpub-nav-panel-item:hover) { background: var(--surface2); color: var(--text); }
256
+ :deep(.cpub-nav-panel-item i) { width: 14px; text-align: center; font-size: 11px; }
257
+ :deep(.cpub-nav-panel-item--disabled) {
252
258
  opacity: 0.35; cursor: not-allowed; pointer-events: none;
253
259
  }
254
260
 
255
- /* Mobile nav sections */
256
- .cpub-mobile-section-label {
261
+ /* Mobile nav sections — :deep() for MobileNavRenderer child component */
262
+ :deep(.cpub-mobile-section-label) {
257
263
  font-family: var(--font-mono); font-size: 9px; font-weight: 700;
258
264
  text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-faint);
259
265
  padding: 10px 20px 2px; margin-top: 4px;
260
266
  }
261
- .cpub-mobile-link--indent { padding-left: 36px; }
262
- .cpub-mobile-link--disabled { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
267
+ :deep(.cpub-mobile-link--indent) { padding-left: 36px; }
268
+ :deep(.cpub-mobile-link--disabled) { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
263
269
 
264
270
  .cpub-topbar-spacer { flex: 1; }
265
271
  .cpub-topbar-actions { display: flex; align-items: center; gap: 6px; }
@@ -288,10 +294,10 @@ const userUsername = computed(() => user.value?.username ?? '');
288
294
 
289
295
  .cpub-mobile-toggle { display: none; width: 32px; height: 32px; background: none; border: var(--border-width-default) solid transparent; color: var(--text-dim); font-size: 16px; cursor: pointer; align-items: center; justify-content: center; }
290
296
  .cpub-mobile-menu { display: none; position: fixed; inset: 0; top: 48px; z-index: 99; background: var(--color-surface-overlay-light); }
291
- .cpub-mobile-nav { background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); padding: 8px 0; display: flex; flex-direction: column; box-shadow: var(--shadow-md); }
292
- .cpub-mobile-link { display: flex; align-items: center; gap: 10px; padding: 10px 20px; font-size: 13px; color: var(--text-dim); text-decoration: none; transition: background 0.1s; }
293
- .cpub-mobile-link:hover { background: var(--surface2); color: var(--text); }
294
- .cpub-mobile-link i { width: 16px; text-align: center; font-size: 12px; }
297
+ :deep(.cpub-mobile-nav) { background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); padding: 8px 0; display: flex; flex-direction: column; box-shadow: var(--shadow-md); }
298
+ :deep(.cpub-mobile-link) { display: flex; align-items: center; gap: 10px; padding: 10px 20px; font-size: 13px; color: var(--text-dim); text-decoration: none; transition: background 0.1s; }
299
+ :deep(.cpub-mobile-link:hover) { background: var(--surface2); color: var(--text); }
300
+ :deep(.cpub-mobile-link i) { width: 16px; text-align: center; font-size: 12px; }
295
301
  .cpub-mobile-divider { height: 2px; background: var(--border2); margin: 4px 16px; }
296
302
  .cpub-mobile-nav-extra { border-top: var(--border-width-default) solid var(--border2); }
297
303
 
@@ -1,7 +1,10 @@
1
1
  // Global client-side middleware that mirrors server/middleware/features.ts.
2
2
  // Prevents client-side navigation to feature-gated pages when the flag is disabled.
3
+ // Uses useState('feature-flags') which is hydrated by useFeatures() from /api/features.
3
4
 
4
- const ROUTE_FEATURE_MAP: Record<string, keyof import('../composables/useFeatures').FeatureFlags> = {
5
+ import type { FeatureFlags } from '../composables/useFeatures';
6
+
7
+ const ROUTE_FEATURE_MAP: Record<string, keyof FeatureFlags> = {
5
8
  '/learn': 'learning',
6
9
  '/docs': 'docs',
7
10
  '/videos': 'video',
@@ -14,9 +17,10 @@ const ROUTE_FEATURE_MAP: Record<string, keyof import('../composables/useFeatures
14
17
  export default defineNuxtRouteMiddleware((to) => {
15
18
  for (const [prefix, feature] of Object.entries(ROUTE_FEATURE_MAP)) {
16
19
  if (to.path === prefix || to.path.startsWith(prefix + '/')) {
17
- const config = useRuntimeConfig();
18
- const flags = config.public.features as Record<string, boolean>;
19
- if (!flags[feature]) {
20
+ // Prefer reactive state (hydrated from /api/features), fall back to build-time config
21
+ const featureState = useState<FeatureFlags | null>('feature-flags', () => null);
22
+ const flags = featureState.value ?? (useRuntimeConfig().public.features as Record<string, boolean>);
23
+ if (!(flags as Record<string, boolean>)[feature]) {
20
24
  throw createError({ statusCode: 404, statusMessage: 'Not Found' });
21
25
  }
22
26
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -30,7 +30,7 @@
30
30
  "@aws-sdk/client-s3": "^3.1010.0",
31
31
  "@commonpub/explainer": "^0.7.11",
32
32
  "@commonpub/schema": "^0.13.0",
33
- "@commonpub/server": "^2.39.0",
33
+ "@commonpub/server": "^2.41.0",
34
34
  "@tiptap/core": "^2.11.0",
35
35
  "@tiptap/extension-bold": "^2.11.0",
36
36
  "@tiptap/extension-bullet-list": "^2.11.0",
@@ -53,13 +53,13 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/docs": "0.6.2",
56
+ "@commonpub/auth": "0.5.1",
57
57
  "@commonpub/editor": "0.7.9",
58
+ "@commonpub/docs": "0.6.2",
58
59
  "@commonpub/learning": "0.5.0",
60
+ "@commonpub/protocol": "0.9.9",
59
61
  "@commonpub/config": "0.10.0",
60
- "@commonpub/auth": "0.5.1",
61
- "@commonpub/ui": "0.8.5",
62
- "@commonpub/protocol": "0.9.9"
62
+ "@commonpub/ui": "0.8.5"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -140,6 +140,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
140
140
  <ContestRules v-if="c?.rules" :rules="c.rules" />
141
141
  <ContestPrizes v-if="c?.prizes?.length" :prizes="c.prizes" />
142
142
  <ContestJudges v-if="c?.judges?.length" :judge-ids="c.judges" />
143
+ <ContestJudgeManager v-if="isOwner && c" :contest-slug="slug" :is-owner="isOwner" />
143
144
  <ContestEntries
144
145
  :entries="entries"
145
146
  :contest-status="c?.status"
@@ -15,7 +15,7 @@ const rsvpLoading = ref(false);
15
15
 
16
16
  const myRsvpStatus = computed((): AttendeeStatus | null => {
17
17
  if (!isAuthenticated.value || !attendees.value) return null;
18
- const found = attendees.value.items.find(a => a.userId === user.value?.id);
18
+ const found = attendees.value.items.find((a: AttendeeItem) => a.userId === user.value?.id);
19
19
  return found?.status ?? null;
20
20
  });
21
21
 
@@ -74,6 +74,7 @@ const postsVM = computed<HubPostViewModel[]>(() => {
74
74
  },
75
75
  createdAt: String(p.publishedAt ?? p.receivedAt),
76
76
  likeCount: (p.localLikeCount ?? 0) + (p.remoteLikeCount ?? 0),
77
+ voteScore: 0,
77
78
  replyCount: (p.localReplyCount ?? 0) + (p.remoteReplyCount ?? 0),
78
79
  isPinned: p.isPinned ?? false,
79
80
  isLocked: false,
@@ -72,6 +72,7 @@ const postsVM = computed<HubPostViewModel[]>(() => {
72
72
  },
73
73
  createdAt: p.createdAt,
74
74
  likeCount: p.likeCount ?? 0,
75
+ voteScore: p.voteScore ?? 0,
75
76
  replyCount: p.replyCount ?? 0,
76
77
  isPinned: p.isPinned ?? false,
77
78
  isLocked: p.isLocked ?? false,
@@ -9,7 +9,7 @@ export default defineEventHandler(async (event): Promise<{ success: boolean }> =
9
9
 
10
10
  const result = await judgeContestEntry(db, input.entryId, input.score, user.id, input.feedback);
11
11
  if (!result.judged) {
12
- throw createError({ statusCode: 403, message: result.error ?? 'Judging failed' });
12
+ throw createError({ statusCode: 403, statusMessage: result.error ?? 'Judging failed' });
13
13
  }
14
14
 
15
15
  return { success: true };
package/types/hub.ts CHANGED
@@ -23,6 +23,7 @@ export interface HubPostViewModel {
23
23
  author: HubPostAuthor
24
24
  createdAt: string
25
25
  likeCount: number
26
+ voteScore: number
26
27
  replyCount: number
27
28
  isPinned: boolean
28
29
  isLocked: boolean