@commonpub/layer 0.72.2 → 0.73.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/SearchSidebar.vue +3 -2
- package/components/nav/NavRenderer.vue +87 -5
- package/layouts/default.vue +60 -10
- package/package.json +8 -8
- package/pages/search.vue +24 -13
- package/server/api/search/index.get.ts +44 -2
- package/server/middleware/theme.ts +8 -4
- package/server/utils/instanceTheme.ts +87 -14
- package/utils/navOverflow.ts +56 -0
|
@@ -18,8 +18,9 @@ const { hubs: hubsEnabled } = useFeatures();
|
|
|
18
18
|
|
|
19
19
|
<template>
|
|
20
20
|
<aside class="cpub-sidebar-col">
|
|
21
|
-
<!-- Trending Searches
|
|
22
|
-
|
|
21
|
+
<!-- Trending Searches — hidden entirely when there's no data (a header
|
|
22
|
+
over an empty list reads as broken on quiet/new instances). -->
|
|
23
|
+
<div v-if="trendingSearches?.length" class="cpub-sb-block">
|
|
23
24
|
<div class="cpub-sb-heading">Trending Searches</div>
|
|
24
25
|
<ul class="cpub-pop-search-list">
|
|
25
26
|
<li
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { NavItem } from '@commonpub/server';
|
|
3
|
+
import { computeVisibleCount, buildMoreItem } from '../../utils/navOverflow';
|
|
3
4
|
|
|
4
|
-
defineProps<{
|
|
5
|
+
const props = defineProps<{
|
|
5
6
|
items: NavItem[];
|
|
6
7
|
openDropdown: string | null;
|
|
7
8
|
}>();
|
|
@@ -30,22 +31,103 @@ function isVisible(item: NavItem): boolean {
|
|
|
30
31
|
if (item.visibleTo === 'admin' && !isAdmin.value) return false;
|
|
31
32
|
return true;
|
|
32
33
|
}
|
|
34
|
+
|
|
35
|
+
const shownItems = computed(() => props.items.filter(isVisible));
|
|
36
|
+
|
|
37
|
+
// --- Priority nav (overflow → "More" dropdown) --------------------------
|
|
38
|
+
// The bar used to push the search box and Log in/avatar off-screen whenever
|
|
39
|
+
// the links outgrew the viewport (any width between the 768px hamburger
|
|
40
|
+
// cutover and ~1100px, worse with extra links or wide-link themes). A hidden
|
|
41
|
+
// duplicate row renders EVERY item so their widths are measurable even when
|
|
42
|
+
// collapsed; computeVisibleCount decides the split. SSR renders everything
|
|
43
|
+
// (no measurement) and the client corrects after hydration.
|
|
44
|
+
const containerEl = ref<HTMLElement | null>(null);
|
|
45
|
+
const measureEl = ref<HTMLElement | null>(null);
|
|
46
|
+
const moreMeasureEl = ref<HTMLElement | null>(null);
|
|
47
|
+
const visibleCount = ref(Number.POSITIVE_INFINITY);
|
|
48
|
+
|
|
49
|
+
const displayItems = computed(() => shownItems.value.slice(0, visibleCount.value));
|
|
50
|
+
const moreItem = computed(() => buildMoreItem(shownItems.value.slice(displayItems.value.length)));
|
|
51
|
+
|
|
52
|
+
function measure(): void {
|
|
53
|
+
const container = containerEl.value;
|
|
54
|
+
const row = measureEl.value;
|
|
55
|
+
if (!container || !row) return;
|
|
56
|
+
const widths = Array.from(row.children).map((el) => (el as HTMLElement).offsetWidth);
|
|
57
|
+
const moreWidth = moreMeasureEl.value?.offsetWidth ?? 90;
|
|
58
|
+
visibleCount.value = computeVisibleCount(widths, container.clientWidth, moreWidth);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
62
|
+
onMounted(() => {
|
|
63
|
+
measure();
|
|
64
|
+
if (typeof ResizeObserver !== 'undefined' && containerEl.value) {
|
|
65
|
+
resizeObserver = new ResizeObserver(() => measure());
|
|
66
|
+
resizeObserver.observe(containerEl.value);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
onUnmounted(() => resizeObserver?.disconnect());
|
|
70
|
+
watch(shownItems, () => nextTick(measure));
|
|
33
71
|
</script>
|
|
34
72
|
|
|
35
73
|
<template>
|
|
36
|
-
<nav class="cpub-topbar-nav" aria-label="Main navigation">
|
|
37
|
-
<template v-for="item in
|
|
74
|
+
<nav ref="containerEl" class="cpub-topbar-nav" aria-label="Main navigation">
|
|
75
|
+
<template v-for="item in displayItems" :key="item.id">
|
|
38
76
|
<NavDropdown
|
|
39
|
-
v-if="item.type === 'dropdown'
|
|
77
|
+
v-if="item.type === 'dropdown'"
|
|
40
78
|
:item="item"
|
|
41
79
|
:open="openDropdown === item.id"
|
|
42
80
|
@toggle="emit('toggle-dropdown', item.id)"
|
|
43
81
|
@close="emit('close-dropdowns')"
|
|
44
82
|
/>
|
|
45
83
|
<NavLink
|
|
46
|
-
v-else
|
|
84
|
+
v-else
|
|
47
85
|
:item="item"
|
|
48
86
|
/>
|
|
49
87
|
</template>
|
|
88
|
+
|
|
89
|
+
<NavDropdown
|
|
90
|
+
v-if="moreItem"
|
|
91
|
+
:item="moreItem"
|
|
92
|
+
:open="openDropdown === '__more'"
|
|
93
|
+
@toggle="emit('toggle-dropdown', '__more')"
|
|
94
|
+
@close="emit('close-dropdowns')"
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
<!-- Hidden measurement row: every item at natural width + a More trigger
|
|
98
|
+
replica. visibility:hidden keeps it out of the a11y tree and tab
|
|
99
|
+
order; the layout owner styles .cpub-nav-measure to zero height. -->
|
|
100
|
+
<div ref="measureEl" class="cpub-nav-measure" aria-hidden="true">
|
|
101
|
+
<template v-for="item in shownItems" :key="`m-${item.id}`">
|
|
102
|
+
<span v-if="item.type === 'dropdown'" class="cpub-nav-link cpub-nav-trigger">
|
|
103
|
+
<i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
|
|
104
|
+
<i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
105
|
+
</span>
|
|
106
|
+
<span v-else class="cpub-nav-link">
|
|
107
|
+
<i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
|
|
108
|
+
</span>
|
|
109
|
+
</template>
|
|
110
|
+
</div>
|
|
111
|
+
<div ref="moreMeasureEl" class="cpub-nav-measure" aria-hidden="true">
|
|
112
|
+
<span class="cpub-nav-link cpub-nav-trigger">
|
|
113
|
+
More <i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
50
116
|
</nav>
|
|
51
117
|
</template>
|
|
118
|
+
|
|
119
|
+
<style scoped>
|
|
120
|
+
/* Measurement rows are layout-inert under ANY host layout (the base topbar
|
|
121
|
+
and forked layouts like deveco's both wrap this component) — carried here,
|
|
122
|
+
scoped, so a fork can't forget them and render the duplicates visibly. */
|
|
123
|
+
.cpub-nav-measure {
|
|
124
|
+
position: absolute;
|
|
125
|
+
visibility: hidden;
|
|
126
|
+
pointer-events: none;
|
|
127
|
+
height: 0;
|
|
128
|
+
overflow: hidden;
|
|
129
|
+
display: flex;
|
|
130
|
+
gap: 2px;
|
|
131
|
+
white-space: nowrap;
|
|
132
|
+
}
|
|
133
|
+
</style>
|
package/layouts/default.vue
CHANGED
|
@@ -26,6 +26,22 @@ const userMenuOpen = ref(false);
|
|
|
26
26
|
const mobileMenuOpen = ref(false);
|
|
27
27
|
const openDropdown = ref<string | null>(null);
|
|
28
28
|
|
|
29
|
+
// Inline topbar search (replaces the old link-styled box that just navigated
|
|
30
|
+
// to /search — users read it as a broken input). Submit goes to the search
|
|
31
|
+
// page with the query; Cmd+K focuses it when visible.
|
|
32
|
+
const searchQuery = ref('');
|
|
33
|
+
const searchInputRef = ref<HTMLInputElement | null>(null);
|
|
34
|
+
function handleSearchSubmit(): void {
|
|
35
|
+
const q = searchQuery.value.trim();
|
|
36
|
+
if (q) {
|
|
37
|
+
navigateTo(`/search?q=${encodeURIComponent(q)}`);
|
|
38
|
+
searchQuery.value = '';
|
|
39
|
+
searchInputRef.value?.blur();
|
|
40
|
+
} else {
|
|
41
|
+
navigateTo('/search');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
// Fetch configurable nav items (falls back to defaults on server)
|
|
30
46
|
// useAsyncData avoids Nuxt's typed route inference which triggers TS2589
|
|
31
47
|
const { data: navItems } = await useAsyncData('nav-items', () =>
|
|
@@ -41,11 +57,17 @@ function closeDropdowns(): void {
|
|
|
41
57
|
openDropdown.value = null;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
|
-
// Cmd+K / Ctrl+K → search
|
|
60
|
+
// Cmd+K / Ctrl+K → focus the inline search (or go to /search when it's
|
|
61
|
+
// hidden, e.g. on mobile where the bar only shows the magnifier link).
|
|
45
62
|
function handleGlobalKeydown(e: KeyboardEvent): void {
|
|
46
63
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
47
64
|
e.preventDefault();
|
|
48
|
-
|
|
65
|
+
const input = searchInputRef.value;
|
|
66
|
+
if (input && input.offsetParent !== null) {
|
|
67
|
+
input.focus();
|
|
68
|
+
} else {
|
|
69
|
+
navigateTo('/search');
|
|
70
|
+
}
|
|
49
71
|
}
|
|
50
72
|
}
|
|
51
73
|
|
|
@@ -114,11 +136,18 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
114
136
|
On mobile they live in the hamburger menu (search) and the
|
|
115
137
|
avatar dropdown (messages/notifications) so the bar can't
|
|
116
138
|
overflow and hide the hamburger toggle. -->
|
|
117
|
-
<
|
|
118
|
-
<i class="fa-solid fa-magnifying-glass"></i>
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
139
|
+
<form class="cpub-search-btn cpub-topbar-desktop-only" role="search" @submit.prevent="handleSearchSubmit">
|
|
140
|
+
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
|
141
|
+
<input
|
|
142
|
+
ref="searchInputRef"
|
|
143
|
+
v-model="searchQuery"
|
|
144
|
+
type="text"
|
|
145
|
+
class="cpub-search-input"
|
|
146
|
+
placeholder="Search..."
|
|
147
|
+
aria-label="Search"
|
|
148
|
+
/>
|
|
149
|
+
<span class="cpub-kbd">⌘K</span>
|
|
150
|
+
</form>
|
|
122
151
|
|
|
123
152
|
<template v-if="isAuthenticated">
|
|
124
153
|
<NuxtLink to="/messages" class="cpub-icon-btn cpub-topbar-desktop-only" title="Messages" aria-label="Messages">
|
|
@@ -289,7 +318,17 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
289
318
|
.cpub-topbar-logo { display: flex; align-items: center; flex-shrink: 0; text-decoration: none; color: var(--text); }
|
|
290
319
|
|
|
291
320
|
/* Nav styles use :deep() to reach into NavRenderer/NavDropdown/NavLink child components */
|
|
292
|
-
:
|
|
321
|
+
/* Containment: the nav takes the flexible middle (flex:1) and min-width:0 lets
|
|
322
|
+
it shrink below content width, so NavRenderer can measure its ACTUAL
|
|
323
|
+
allocated space and collapse links that don't fit into the "More" overflow
|
|
324
|
+
dropdown. Before this, a long link list (or a wide-link theme) pushed the
|
|
325
|
+
search box and Log in/avatar clean off the right edge between the 768px
|
|
326
|
+
hamburger cutover and ~1100px — on all three instances, with STOCK links. */
|
|
327
|
+
:deep(.cpub-topbar-nav) {
|
|
328
|
+
display: flex; align-items: center; gap: 2px; margin-left: 24px;
|
|
329
|
+
flex: 1 1 auto; min-width: 0;
|
|
330
|
+
}
|
|
331
|
+
:deep(.cpub-topbar-nav .cpub-nav-link) { white-space: nowrap; flex-shrink: 0; }
|
|
293
332
|
/* Nav-link shape + active state are token-driven (--cpub-nav-link-*) so a theme can
|
|
294
333
|
make pill-shaped/larger/accent-colored nav links (deveco) without forking. Defaults
|
|
295
334
|
= the current 12px square neutral link. */
|
|
@@ -349,9 +388,20 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
349
388
|
.cpub-topbar-spacer { flex: 1; }
|
|
350
389
|
.cpub-topbar-actions { display: flex; align-items: center; gap: 6px; }
|
|
351
390
|
|
|
352
|
-
.cpub-search-btn { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); font-size: 12px; min-width: 180px; text-decoration: none; transition: border-color 0.15s; }
|
|
391
|
+
.cpub-search-btn { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); font-size: 12px; min-width: 180px; text-decoration: none; transition: border-color 0.15s, box-shadow 0.15s; cursor: text; }
|
|
353
392
|
.cpub-search-btn:hover { border-color: var(--accent-border); color: var(--text); }
|
|
393
|
+
/* The form ring is the ONE focus indicator. */
|
|
394
|
+
.cpub-search-btn:focus-within { border-color: var(--accent); box-shadow: var(--accent-focus-ring); }
|
|
354
395
|
.cpub-search-btn i { font-size: 11px; }
|
|
396
|
+
.cpub-search-input {
|
|
397
|
+
flex: 1; min-width: 0; border: none; background: none; padding: 0;
|
|
398
|
+
font-size: 12px; font-family: inherit; color: var(--text);
|
|
399
|
+
}
|
|
400
|
+
.cpub-search-input::placeholder { color: var(--text-dim); }
|
|
401
|
+
/* Suppress BOTH outline and box-shadow on the input itself — themes (stoa)
|
|
402
|
+
put a box-shadow glow on every :focus-visible, which would draw a second
|
|
403
|
+
ring inside the form's ring (the deveco double-trace bug). */
|
|
404
|
+
.cpub-search-input:focus-visible { outline: none; box-shadow: none; }
|
|
355
405
|
.cpub-kbd { margin-left: auto; font-size: 10px; font-family: var(--font-mono); padding: 2px 6px; background: var(--surface3); border: var(--border-width-default) solid var(--border2); color: var(--text-faint); }
|
|
356
406
|
|
|
357
407
|
.cpub-icon-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: transparent; border: var(--border-width-default) solid transparent; color: var(--text-dim); font-size: 13px; position: relative; transition: all 0.15s; text-decoration: none; }
|
|
@@ -412,7 +462,7 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
412
462
|
the row can't overflow and clip the hamburger + avatar. */
|
|
413
463
|
.cpub-topbar-desktop-only { display: none !important; }
|
|
414
464
|
.cpub-dropdown-item--mobile { display: flex; }
|
|
415
|
-
.cpub-
|
|
465
|
+
.cpub-new-text { display: none; }
|
|
416
466
|
.cpub-mobile-toggle { display: flex; }
|
|
417
467
|
.cpub-mobile-menu { display: block; }
|
|
418
468
|
.cpub-footer-inner { grid-template-columns: 1fr 1fr; gap: 24px; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,17 +53,17 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
-
"@commonpub/docs": "0.6.3",
|
|
57
|
-
"@commonpub/editor": "0.7.11",
|
|
58
|
-
"@commonpub/config": "0.21.0",
|
|
59
|
-
"@commonpub/schema": "0.40.1",
|
|
60
56
|
"@commonpub/auth": "0.8.0",
|
|
57
|
+
"@commonpub/config": "0.22.1",
|
|
58
|
+
"@commonpub/editor": "0.7.11",
|
|
59
|
+
"@commonpub/learning": "0.5.2",
|
|
61
60
|
"@commonpub/protocol": "0.13.0",
|
|
61
|
+
"@commonpub/schema": "0.40.1",
|
|
62
62
|
"@commonpub/theme-studio": "0.6.1",
|
|
63
|
-
"@commonpub/learning": "0.5.2",
|
|
64
|
-
"@commonpub/ui": "0.13.1",
|
|
65
63
|
"@commonpub/server": "2.84.1",
|
|
66
|
-
"@commonpub/explainer": "0.7.15"
|
|
64
|
+
"@commonpub/explainer": "0.7.15",
|
|
65
|
+
"@commonpub/ui": "0.13.1",
|
|
66
|
+
"@commonpub/docs": "0.6.3"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@testing-library/jest-dom": "^6.9.1",
|
package/pages/search.vue
CHANGED
|
@@ -222,18 +222,22 @@ const { data: relatedCommunities } = await useFetch('/api/hubs', {
|
|
|
222
222
|
</span>
|
|
223
223
|
</div>
|
|
224
224
|
|
|
225
|
-
<!-- FILTER STRIP
|
|
225
|
+
<!-- FILTER STRIP — pills scroll in their own region; the sort/filter
|
|
226
|
+
cluster is pinned outside it so it can never be clipped mid-word
|
|
227
|
+
(the strip used to scroll as one unit, cutting the sort select). -->
|
|
226
228
|
<div class="cpub-filter-strip">
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
229
|
+
<div class="cpub-type-pills">
|
|
230
|
+
<button
|
|
231
|
+
v-for="pill in typePills"
|
|
232
|
+
:key="pill.value"
|
|
233
|
+
class="cpub-type-pill"
|
|
234
|
+
:class="{ active: activeType === pill.value }"
|
|
235
|
+
@click="activeType = pill.value"
|
|
236
|
+
>
|
|
237
|
+
<i v-if="pill.icon" :class="pill.icon" style="font-size: 10px"></i>
|
|
238
|
+
{{ pill.label }}
|
|
239
|
+
</button>
|
|
240
|
+
</div>
|
|
237
241
|
|
|
238
242
|
<div class="cpub-filter-right">
|
|
239
243
|
<SortSelect
|
|
@@ -565,11 +569,18 @@ const { data: relatedCommunities } = await useFetch('/api/hubs', {
|
|
|
565
569
|
border-top: var(--border-width-default) solid var(--border);
|
|
566
570
|
margin-top: 14px;
|
|
567
571
|
padding-top: 0;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/* Pills scroll; the sort/filter cluster never moves or clips. */
|
|
575
|
+
.cpub-type-pills {
|
|
576
|
+
display: flex;
|
|
577
|
+
align-items: center;
|
|
578
|
+
flex: 1;
|
|
579
|
+
min-width: 0;
|
|
568
580
|
overflow-x: auto;
|
|
569
581
|
scrollbar-width: none;
|
|
570
582
|
}
|
|
571
|
-
|
|
572
|
-
.cpub-filter-strip::-webkit-scrollbar { display: none; }
|
|
583
|
+
.cpub-type-pills::-webkit-scrollbar { display: none; }
|
|
573
584
|
|
|
574
585
|
.cpub-type-pill {
|
|
575
586
|
font-size: 12px;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { searchContent, listHubs, escapeLike } from '@commonpub/server';
|
|
2
|
-
import type { ContentSearchOptions, MeiliClient } from '@commonpub/server';
|
|
1
|
+
import { searchContent, listContent, listHubs, escapeLike } from '@commonpub/server';
|
|
2
|
+
import type { ContentSearchOptions, ContentFilters, MeiliClient } from '@commonpub/server';
|
|
3
3
|
import { users, follows, hubs } from '@commonpub/schema';
|
|
4
4
|
import { sql, desc, ilike, or, and, isNull, eq, inArray } from 'drizzle-orm';
|
|
5
5
|
import { z } from 'zod';
|
|
@@ -112,6 +112,48 @@ export default defineEventHandler(async (event): Promise<{ items: unknown[]; tot
|
|
|
112
112
|
}
|
|
113
113
|
} catch { /* Meilisearch not available */ }
|
|
114
114
|
|
|
115
|
+
// Mirror-aware path: on a federation-enabled instance the homepage feed is a
|
|
116
|
+
// MERGED local+federated stream (listContent, session 179). Search must see
|
|
117
|
+
// the same universe — on a mirror-heavy instance (commonpub.io) the local
|
|
118
|
+
// content table is nearly empty, so the old local-only search returned 0 for
|
|
119
|
+
// items the instance's own homepage shows. Delegate to listContent (same
|
|
120
|
+
// merge, same pagination invariants, items already in ContentCard's shape)
|
|
121
|
+
// whenever the request uses only filters listContent supports. Search-only
|
|
122
|
+
// filters (author, date range, multiple tags) keep the dedicated local
|
|
123
|
+
// path: federated rows aren't indexed and don't carry those fields.
|
|
124
|
+
// NOTE this branch deliberately OUTRANKS Meilisearch when federation is on:
|
|
125
|
+
// meili only ever indexes LOCAL content, so on a mirror-heavy instance a
|
|
126
|
+
// configured-but-mostly-empty index would shadow the merge and return 0
|
|
127
|
+
// (exactly what happened on commonpub.io, whose compose stack sets
|
|
128
|
+
// MEILI_URL — the first ship of this fix gated on `!meiliClient` and was
|
|
129
|
+
// inert there). `resolveContentQuery` pins status=published +
|
|
130
|
+
// visibility=public so this path can never widen what search exposes.
|
|
131
|
+
const tagList = params.tags?.split(',').map((t) => t.trim()).filter(Boolean) ?? [];
|
|
132
|
+
const usesSearchOnlyFilters = !!(params.author || params.dateFrom || params.dateTo || tagList.length > 1);
|
|
133
|
+
const CONTENT_TYPES = new Set(['project', 'article', 'blog', 'explainer']);
|
|
134
|
+
if (config.features.seamlessFederation && !usesSearchOnlyFilters) {
|
|
135
|
+
const raw: ContentFilters = {
|
|
136
|
+
search: q,
|
|
137
|
+
type: params.type && CONTENT_TYPES.has(params.type) ? (params.type as ContentFilters['type']) : undefined,
|
|
138
|
+
// difficulty + tag suppress the federated merge inside listContent
|
|
139
|
+
// (canMergeFederated) — federated rows lack those columns, so those
|
|
140
|
+
// queries are local-only by construction rather than leaking past the filter.
|
|
141
|
+
difficulty: params.difficulty as ContentFilters['difficulty'],
|
|
142
|
+
tag: tagList[0],
|
|
143
|
+
// Postgres has no relevance ranking (that's Meilisearch's job) — the old
|
|
144
|
+
// path also fell back to recency for 'relevance'.
|
|
145
|
+
sort: params.sort === 'popular' ? 'popular' : 'recent',
|
|
146
|
+
limit,
|
|
147
|
+
offset,
|
|
148
|
+
};
|
|
149
|
+
const { filters, options } = resolveContentQuery(event, raw);
|
|
150
|
+
const result = await listContent(db, filters, options);
|
|
151
|
+
return {
|
|
152
|
+
items: result.items.map((item) => ({ ...item, _resultType: 'content' })),
|
|
153
|
+
total: result.total,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
115
157
|
const opts: ContentSearchOptions = {
|
|
116
158
|
query: q,
|
|
117
159
|
type: params.type,
|
|
@@ -26,9 +26,13 @@ export default defineEventHandler(async (event) => {
|
|
|
26
26
|
const path = getRequestURL(event).pathname;
|
|
27
27
|
if (path.startsWith('/api') || path.startsWith('/_nuxt') || path.startsWith('/__nuxt')) return;
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
// Code-registered themes: full metadata (family/isDark/pairId), so the
|
|
30
|
+
// resolver can flip light/dark WITHIN a registered family; plus the thin
|
|
31
|
+
// app's config-pinned default theme.
|
|
32
|
+
const config = useConfig() as unknown as {
|
|
33
|
+
themes?: Array<{ id: string; family?: string; isDark?: boolean; pairId?: string }>;
|
|
34
|
+
defaultTheme?: string;
|
|
35
|
+
};
|
|
32
36
|
|
|
33
37
|
// Read user's light/dark preference from cookie
|
|
34
38
|
const schemeCookie = getCookie(event, 'cpub-color-scheme');
|
|
@@ -36,7 +40,7 @@ export default defineEventHandler(async (event) => {
|
|
|
36
40
|
? schemeCookie
|
|
37
41
|
: null;
|
|
38
42
|
|
|
39
|
-
const ctx = await resolveThemeContext(userScheme,
|
|
43
|
+
const ctx = await resolveThemeContext(userScheme, config.themes ?? [], config.defaultTheme);
|
|
40
44
|
|
|
41
45
|
event.context.instanceTheme = ctx.instanceTheme;
|
|
42
46
|
event.context.resolvedTheme = ctx.resolvedTheme;
|
|
@@ -32,7 +32,8 @@ export function sanitizeRenderTokens(tokens: Record<string, string>): Record<str
|
|
|
32
32
|
|
|
33
33
|
interface CachedThemeState {
|
|
34
34
|
/** The admin's chosen default theme (built-in id, custom data-attr, or registered id) */
|
|
35
|
-
|
|
35
|
+
/** Admin-picked default theme id, or null when never set in the DB. */
|
|
36
|
+
defaultTheme: string | null;
|
|
36
37
|
/** All DB-stored custom themes, keyed by their data-theme attribute (`cpub-custom-<slug>`) */
|
|
37
38
|
customByAttr: Map<string, CustomThemeRecord>;
|
|
38
39
|
/** Instance-wide token overrides applied on top of the active theme */
|
|
@@ -45,12 +46,14 @@ let cacheTime = 0;
|
|
|
45
46
|
async function loadThemeState(): Promise<CachedThemeState> {
|
|
46
47
|
const db = useDB();
|
|
47
48
|
|
|
48
|
-
// 1. Default theme ID.
|
|
49
|
-
//
|
|
50
|
-
// instance
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
49
|
+
// 1. Default theme ID from the DB. `null` when the admin never picked one —
|
|
50
|
+
// resolveThemeContext then falls back to the thin app's `config.defaultTheme`
|
|
51
|
+
// (a branded instance pins its identity in code) and finally to 'stoa' (the
|
|
52
|
+
// CommonPub default for fresh installs). Tracking absence here, instead of
|
|
53
|
+
// baking 'stoa' in, is what lets a config-pinned brand theme take effect —
|
|
54
|
+
// deveco rode the stoa fallback for months, which made its dark mode and
|
|
55
|
+
// theme identity wrong.
|
|
56
|
+
let defaultTheme: string | null = null;
|
|
54
57
|
try {
|
|
55
58
|
const [row] = await db
|
|
56
59
|
.select({ value: instanceSettings.value })
|
|
@@ -91,6 +94,14 @@ async function getState(): Promise<CachedThemeState> {
|
|
|
91
94
|
return cached;
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
/** The registry metadata the theme resolver needs (subset of RegisteredTheme). */
|
|
98
|
+
export interface RegisteredThemeMeta {
|
|
99
|
+
id: string;
|
|
100
|
+
family?: string;
|
|
101
|
+
isDark?: boolean;
|
|
102
|
+
pairId?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
/** Validate a theme ID against built-in, custom, and registered themes. */
|
|
95
106
|
function isKnownThemeId(id: string, state: CachedThemeState, registeredIds: Set<string>): boolean {
|
|
96
107
|
if (VALID_THEME_IDS.has(id)) return true;
|
|
@@ -99,9 +110,54 @@ function isKnownThemeId(id: string, state: CachedThemeState, registeredIds: Set<
|
|
|
99
110
|
return false;
|
|
100
111
|
}
|
|
101
112
|
|
|
113
|
+
/** A registered theme's dark-ness: explicit flag, else inferred from the
|
|
114
|
+
* `-dark` id suffix — naming a theme `foo-dark` should just work. */
|
|
115
|
+
function registeredIsDark(meta: RegisteredThemeMeta): boolean {
|
|
116
|
+
return meta.isDark ?? /(^|-)dark$/.test(meta.id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Pick a registered theme's variant for the user's light/dark preference.
|
|
121
|
+
* Pure (exported for tests). Sibling resolution order:
|
|
122
|
+
* 1. explicit `pairId`
|
|
123
|
+
* 2. same `family`, opposite dark-ness
|
|
124
|
+
* 3. NAME CONVENTION: `<id>` ↔ `<id>-dark` — registering two themes named
|
|
125
|
+
* like a pair auto-detects with no family/pairId declared at all.
|
|
126
|
+
* Falls back to the theme itself when no opposite-mode sibling exists.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveRegisteredVariant(
|
|
129
|
+
themeId: string,
|
|
130
|
+
userScheme: 'light' | 'dark' | null,
|
|
131
|
+
registered: RegisteredThemeMeta[],
|
|
132
|
+
): { resolved: string; isDark: boolean; pair: { lightAttr: string; darkAttr: string } | null } {
|
|
133
|
+
const meta = registered.find((t) => t.id === themeId);
|
|
134
|
+
if (!meta) return { resolved: themeId, isDark: false, pair: null };
|
|
135
|
+
const metaDark = registeredIsDark(meta);
|
|
136
|
+
|
|
137
|
+
const conventionId = metaDark ? meta.id.replace(/-dark$/, '') : `${meta.id}-dark`;
|
|
138
|
+
const sibling =
|
|
139
|
+
(meta.pairId ? registered.find((t) => t.id === meta.pairId) : undefined) ??
|
|
140
|
+
(meta.family
|
|
141
|
+
? registered.find((t) => t.id !== meta.id && t.family === meta.family && registeredIsDark(t) !== metaDark)
|
|
142
|
+
: undefined) ??
|
|
143
|
+
(conventionId !== meta.id
|
|
144
|
+
? registered.find((t) => t.id === conventionId && registeredIsDark(t) !== metaDark)
|
|
145
|
+
: undefined);
|
|
146
|
+
|
|
147
|
+
const light = metaDark ? sibling : meta;
|
|
148
|
+
const dark = metaDark ? meta : sibling;
|
|
149
|
+
const pair = light && dark ? { lightAttr: light.id, darkAttr: dark.id } : null;
|
|
150
|
+
|
|
151
|
+
let resolvedMeta = meta;
|
|
152
|
+
if (userScheme === 'dark' && dark) resolvedMeta = dark;
|
|
153
|
+
else if (userScheme === 'light' && light) resolvedMeta = light;
|
|
154
|
+
|
|
155
|
+
return { resolved: resolvedMeta.id, isDark: registeredIsDark(resolvedMeta), pair };
|
|
156
|
+
}
|
|
157
|
+
|
|
102
158
|
export async function getInstanceDefaultTheme(): Promise<string> {
|
|
103
159
|
const state = await getState();
|
|
104
|
-
return state.defaultTheme;
|
|
160
|
+
return state.defaultTheme ?? 'stoa';
|
|
105
161
|
}
|
|
106
162
|
|
|
107
163
|
/**
|
|
@@ -111,7 +167,8 @@ export async function getInstanceDefaultTheme(): Promise<string> {
|
|
|
111
167
|
*/
|
|
112
168
|
export async function resolveThemeContext(
|
|
113
169
|
userScheme: 'light' | 'dark' | null,
|
|
114
|
-
|
|
170
|
+
registered: RegisteredThemeMeta[],
|
|
171
|
+
configDefaultTheme?: string,
|
|
115
172
|
): Promise<{
|
|
116
173
|
/** Final data-theme value for <html> */
|
|
117
174
|
resolvedTheme: string;
|
|
@@ -134,9 +191,16 @@ export async function resolveThemeContext(
|
|
|
134
191
|
fontHref: string;
|
|
135
192
|
}> {
|
|
136
193
|
const state = await getState();
|
|
194
|
+
const registeredIds = new Set(registered.map((t) => t.id));
|
|
137
195
|
|
|
138
|
-
//
|
|
139
|
-
|
|
196
|
+
// Default resolution chain: explicit DB setting → the thin app's
|
|
197
|
+
// config.defaultTheme (brand identity pinned in code) → 'stoa' (CommonPub
|
|
198
|
+
// default). Each candidate must be a KNOWN id; an unknown choice falls
|
|
199
|
+
// through rather than rendering an unstyled attr.
|
|
200
|
+
const candidates = [state.defaultTheme, configDefaultTheme, 'stoa'];
|
|
201
|
+
const admin = candidates.find(
|
|
202
|
+
(c): c is string => !!c && isKnownThemeId(c, state, registeredIds),
|
|
203
|
+
) ?? 'base';
|
|
140
204
|
|
|
141
205
|
const activeCustom = state.customByAttr.get(admin);
|
|
142
206
|
let resolved = admin;
|
|
@@ -167,14 +231,23 @@ export async function resolveThemeContext(
|
|
|
167
231
|
// Load every variant's fonts so a client-side flip already has them.
|
|
168
232
|
const allFonts = [...new Set(members.flatMap((m) => m.rec.fonts ?? []))];
|
|
169
233
|
fontHref = allFonts.length ? googleHref(allFonts) : '';
|
|
170
|
-
} else {
|
|
171
|
-
// Built-in
|
|
172
|
-
if (userScheme !== null
|
|
234
|
+
} else if (VALID_THEME_IDS.has(admin)) {
|
|
235
|
+
// Built-in: flip via the family's CSS variants.
|
|
236
|
+
if (userScheme !== null) {
|
|
173
237
|
const family = THEME_TO_FAMILY[admin] ?? 'classic';
|
|
174
238
|
const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
|
|
175
239
|
resolved = userScheme === 'dark' ? variants.dark : variants.light;
|
|
176
240
|
}
|
|
177
241
|
isDark = IS_DARK[resolved] ?? false;
|
|
242
|
+
} else {
|
|
243
|
+
// Code-registered theme: flip within ITS registered family (pairId or
|
|
244
|
+
// family+isDark), and expose the pair so the client toggle can switch
|
|
245
|
+
// instantly — previously registered themes had NO light/dark support and
|
|
246
|
+
// the toggle silently did nothing (or fell back to the layer family).
|
|
247
|
+
const r = resolveRegisteredVariant(admin, userScheme, registered);
|
|
248
|
+
resolved = r.resolved;
|
|
249
|
+
isDark = r.isDark;
|
|
250
|
+
pair = r.pair;
|
|
178
251
|
}
|
|
179
252
|
|
|
180
253
|
return {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Priority-nav fit computation — how many top-level nav items fit in the
|
|
3
|
+
* bar's allocated width before the rest collapse into the "More" dropdown.
|
|
4
|
+
* Pure so it's unit-testable; NavRenderer feeds it measured pixel widths.
|
|
5
|
+
*/
|
|
6
|
+
import type { NavItem } from '@commonpub/server';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Greedy fit: if everything fits, show everything; otherwise reserve room
|
|
10
|
+
* for the More trigger and take items in order until the next one would
|
|
11
|
+
* overflow. Zero/unmeasured widths (SSR, jsdom) mean "show everything" —
|
|
12
|
+
* the client re-measures after hydration.
|
|
13
|
+
*/
|
|
14
|
+
export function computeVisibleCount(
|
|
15
|
+
itemWidths: number[],
|
|
16
|
+
containerWidth: number,
|
|
17
|
+
moreWidth: number,
|
|
18
|
+
gap = 2,
|
|
19
|
+
): number {
|
|
20
|
+
if (itemWidths.length === 0) return 0;
|
|
21
|
+
if (containerWidth <= 0 || itemWidths.every((w) => w <= 0)) return itemWidths.length;
|
|
22
|
+
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (let i = 0; i < itemWidths.length; i++) total += itemWidths[i]! + (i > 0 ? gap : 0);
|
|
25
|
+
if (total <= containerWidth) return itemWidths.length;
|
|
26
|
+
|
|
27
|
+
let used = moreWidth;
|
|
28
|
+
let count = 0;
|
|
29
|
+
for (const w of itemWidths) {
|
|
30
|
+
const next = used + gap + w;
|
|
31
|
+
if (next > containerWidth) break;
|
|
32
|
+
used = next;
|
|
33
|
+
count++;
|
|
34
|
+
}
|
|
35
|
+
return count;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Collapse the overflowed tail into a single synthetic dropdown item.
|
|
40
|
+
* Top-level links become children; an overflowed dropdown contributes its
|
|
41
|
+
* children directly (the panel re-checks feature/auth gates per child, so
|
|
42
|
+
* spreading is safe). Returns null when nothing overflows.
|
|
43
|
+
*/
|
|
44
|
+
export function buildMoreItem(overflow: NavItem[]): NavItem | null {
|
|
45
|
+
if (overflow.length === 0) return null;
|
|
46
|
+
const children: NavItem[] = [];
|
|
47
|
+
for (const item of overflow) {
|
|
48
|
+
if (item.type === 'dropdown') {
|
|
49
|
+
children.push(...(item.children ?? []));
|
|
50
|
+
} else {
|
|
51
|
+
children.push(item);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (children.length === 0) return null;
|
|
55
|
+
return { id: '__more', type: 'dropdown', label: 'More', icon: '', children } as NavItem;
|
|
56
|
+
}
|