@commonpub/layer 0.43.0 → 0.43.2
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/composables/useContentFeed.ts +32 -15
- package/layouts/default.vue +51 -16
- package/package.json +7 -7
- package/theme/base.css +40 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Serialized, ContentListItem
|
|
1
|
+
import type { Serialized, ContentListItem } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Unified content-feed loader with transparent keyset/offset pagination.
|
|
@@ -24,9 +24,15 @@ import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
type FeedQuery = Record<string, unknown> & { sort?: string; limit?: number };
|
|
27
|
-
type
|
|
27
|
+
/** Public item type callers consume (for ContentCard). */
|
|
28
|
+
export type FeedItem = Serialized<ContentListItem>;
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
// Wire response shape for BOTH endpoints (keyset → nextCursor, offset → total). The
|
|
31
|
+
// items are typed `Record<string, unknown>[]`, NOT `Serialized<ContentListItem>[]`, on
|
|
32
|
+
// purpose: feeding the deeply-recursive Serialized<…> mapped type through useFetch's own
|
|
33
|
+
// generic wrapper tripped TS2589 "excessively deep" under the consumer apps' stricter
|
|
34
|
+
// typecheck (deveco). We cast to FeedItem[] once, at the `items` boundary callers read.
|
|
35
|
+
interface FeedResponse { items: Array<Record<string, unknown>>; nextCursor?: string | null; total?: number }
|
|
30
36
|
|
|
31
37
|
export function useContentFeed(query: Ref<FeedQuery> | ComputedRef<FeedQuery>) {
|
|
32
38
|
const toast = useToast();
|
|
@@ -46,17 +52,28 @@ export function useContentFeed(query: Ref<FeedQuery> | ComputedRef<FeedQuery>) {
|
|
|
46
52
|
|
|
47
53
|
// Initial page — SSR-friendly via useFetch. Both endpoints accept the same query;
|
|
48
54
|
// the keyset one returns { items, nextCursor }, the offset one { items, total }.
|
|
55
|
+
//
|
|
56
|
+
// No explicit useFetch<…> generic: parameterising it makes TS instantiate useFetch's
|
|
57
|
+
// own deep transform/pick generic machinery, which trips TS2589 "excessively deep"
|
|
58
|
+
// under the consumer apps' stricter typecheck (deveco already @ts-ignores the same on
|
|
59
|
+
// its own useFetch calls). We let it infer and read `data` through a typed `page`
|
|
60
|
+
// computed cast to the shallow FeedResponse instead.
|
|
49
61
|
const endpoint = computed(() => (isKeyset.value ? '/api/content/feed' : '/api/content'));
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
// @ts-ignore TS2589: a computed endpoint that is a UNION of two typed routes makes
|
|
63
|
+
// Nuxt's useFetch resolve typed route data for both, blowing the instantiation depth
|
|
64
|
+
// under the consumer apps' stricter typecheck (deveco @ts-ignores the same on its own
|
|
65
|
+
// useFetch calls). Runtime is unaffected; `data` is read through the typed `page` below.
|
|
66
|
+
const { data, pending } = useFetch(endpoint, { query, watch: [query] });
|
|
67
|
+
const page = computed<FeedResponse | null>(() => (data.value as FeedResponse | null) ?? null);
|
|
54
68
|
|
|
55
69
|
// Local accumulator: the first page from useFetch, plus any load-more pages. Kept
|
|
56
70
|
// separate from `data` so we never mutate the useFetch payload (which it re-creates
|
|
57
71
|
// on refetch) — and so a tab switch cleanly replaces the list.
|
|
58
|
-
const extra = ref<
|
|
59
|
-
|
|
72
|
+
const extra = ref<Array<Record<string, unknown>>>([]);
|
|
73
|
+
// Single cast from the shallow wire type to the public FeedItem[] callers consume.
|
|
74
|
+
const items = computed<FeedItem[]>(
|
|
75
|
+
() => [...(page.value?.items ?? []), ...extra.value] as FeedItem[],
|
|
76
|
+
);
|
|
60
77
|
|
|
61
78
|
// Reset pagination whenever the underlying query (tab/filter) changes.
|
|
62
79
|
watch(
|
|
@@ -71,10 +88,10 @@ export function useContentFeed(query: Ref<FeedQuery> | ComputedRef<FeedQuery>) {
|
|
|
71
88
|
|
|
72
89
|
// Seed the keyset cursor from the first page once it arrives.
|
|
73
90
|
watch(
|
|
74
|
-
|
|
75
|
-
(
|
|
91
|
+
page,
|
|
92
|
+
(p) => {
|
|
76
93
|
if (isKeyset.value) {
|
|
77
|
-
cursor.value =
|
|
94
|
+
cursor.value = p?.nextCursor ?? null;
|
|
78
95
|
if (cursor.value === null) exhausted.value = true;
|
|
79
96
|
}
|
|
80
97
|
},
|
|
@@ -95,15 +112,15 @@ export function useContentFeed(query: Ref<FeedQuery> | ComputedRef<FeedQuery>) {
|
|
|
95
112
|
try {
|
|
96
113
|
if (isKeyset.value) {
|
|
97
114
|
if (!cursor.value) { exhausted.value = true; return; }
|
|
98
|
-
const res = await $fetch<
|
|
115
|
+
const res = await $fetch<FeedResponse>('/api/content/feed', {
|
|
99
116
|
query: { ...query.value, cursor: cursor.value },
|
|
100
117
|
});
|
|
101
118
|
if (res.items?.length) extra.value.push(...res.items);
|
|
102
|
-
cursor.value = res.nextCursor;
|
|
119
|
+
cursor.value = res.nextCursor ?? null;
|
|
103
120
|
if (!res.nextCursor) exhausted.value = true;
|
|
104
121
|
} else {
|
|
105
122
|
const offset = items.value.length;
|
|
106
|
-
const res = await $fetch<
|
|
123
|
+
const res = await $fetch<FeedResponse>('/api/content', {
|
|
107
124
|
query: { ...query.value, offset },
|
|
108
125
|
});
|
|
109
126
|
if (res.items?.length) extra.value.push(...res.items);
|
package/layouts/default.vue
CHANGED
|
@@ -266,20 +266,50 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
266
266
|
|
|
267
267
|
.cpub-layout { min-height: 100vh; display: flex; flex-direction: column; }
|
|
268
268
|
|
|
269
|
-
/* ═══ TOPBAR ═══
|
|
269
|
+
/* ═══ TOPBAR ═══
|
|
270
|
+
Structure is token-driven (--cpub-topbar-*) so a theme can change the bar's SHAPE
|
|
271
|
+
— height, radius, shadow, position, padding — not just its colors, without forking
|
|
272
|
+
this layout. Every default reproduces the current flat 48px bar exactly.
|
|
273
|
+
(Centering the bar's CONTENT at a max width while keeping a full-bleed background
|
|
274
|
+
needs an inner wrapper element, which the base markup doesn't have — that one
|
|
275
|
+
aspect stays a structural choice, not a token.) */
|
|
270
276
|
.cpub-topbar {
|
|
271
|
-
position: fixed; top: 0; left: 0; right: 0;
|
|
272
|
-
|
|
273
|
-
|
|
277
|
+
position: var(--cpub-topbar-position, fixed); top: 0; left: 0; right: 0;
|
|
278
|
+
height: var(--cpub-topbar-height, 48px);
|
|
279
|
+
background: var(--cpub-topbar-bg, var(--surface));
|
|
280
|
+
border-bottom: var(--cpub-topbar-border, var(--border-width-default) solid var(--border));
|
|
281
|
+
border-bottom-left-radius: var(--cpub-topbar-radius, 0);
|
|
282
|
+
border-bottom-right-radius: var(--cpub-topbar-radius, 0);
|
|
283
|
+
box-shadow: var(--cpub-topbar-shadow, none);
|
|
284
|
+
backdrop-filter: var(--cpub-topbar-blur, none);
|
|
285
|
+
display: flex; align-items: center;
|
|
286
|
+
padding: 0 var(--cpub-topbar-padding-x, 20px); gap: 0; z-index: 100;
|
|
274
287
|
}
|
|
275
288
|
.cpub-topbar-logo { display: flex; align-items: center; flex-shrink: 0; text-decoration: none; color: var(--text); }
|
|
276
289
|
|
|
277
290
|
/* Nav styles use :deep() to reach into NavRenderer/NavDropdown/NavLink child components */
|
|
278
291
|
:deep(.cpub-topbar-nav) { display: flex; align-items: center; gap: 2px; margin-left: 24px; }
|
|
279
|
-
|
|
292
|
+
/* Nav-link shape + active state are token-driven (--cpub-nav-link-*) so a theme can
|
|
293
|
+
make pill-shaped/larger/accent-colored nav links (deveco) without forking. Defaults
|
|
294
|
+
= the current 12px square neutral link. */
|
|
295
|
+
:deep(.cpub-nav-link) {
|
|
296
|
+
font-size: var(--cpub-nav-link-size, 12px);
|
|
297
|
+
font-weight: var(--cpub-nav-link-weight, 400);
|
|
298
|
+
color: var(--cpub-nav-link-color, var(--text-dim));
|
|
299
|
+
padding: var(--cpub-nav-link-padding, 5px 12px);
|
|
300
|
+
border: var(--border-width-default) solid transparent;
|
|
301
|
+
border-radius: var(--cpub-nav-link-radius, var(--radius));
|
|
302
|
+
background: none; text-decoration: none;
|
|
303
|
+
transition: color 0.15s, background 0.15s; display: flex; align-items: center; gap: 6px;
|
|
304
|
+
}
|
|
280
305
|
:deep(.cpub-nav-link i) { font-size: 10px; }
|
|
281
306
|
:deep(.cpub-nav-link:hover) { color: var(--text); background: var(--surface2); }
|
|
282
|
-
:deep(.cpub-nav-link.router-link-active) {
|
|
307
|
+
:deep(.cpub-nav-link.router-link-active) {
|
|
308
|
+
color: var(--cpub-nav-link-active-color, var(--text));
|
|
309
|
+
background: var(--cpub-nav-link-active-bg, var(--surface2));
|
|
310
|
+
border-color: var(--cpub-nav-link-active-border, var(--border));
|
|
311
|
+
font-weight: var(--cpub-nav-link-active-weight, 400);
|
|
312
|
+
}
|
|
283
313
|
:deep(.cpub-nav-link--disabled) { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
|
|
284
314
|
|
|
285
315
|
/* Nav dropdowns */
|
|
@@ -343,7 +373,7 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
343
373
|
.cpub-dropdown-item--mobile { display: none; }
|
|
344
374
|
|
|
345
375
|
.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; }
|
|
346
|
-
.cpub-mobile-menu { display: none; position: fixed; inset: 0; top: 48px; z-index: 99; background: var(--color-surface-overlay-light); }
|
|
376
|
+
.cpub-mobile-menu { display: none; position: fixed; inset: 0; top: var(--cpub-topbar-height, 48px); z-index: 99; background: var(--color-surface-overlay-light); }
|
|
347
377
|
: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); }
|
|
348
378
|
: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; }
|
|
349
379
|
:deep(.cpub-mobile-link:hover) { background: var(--surface2); color: var(--text); }
|
|
@@ -351,22 +381,27 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
351
381
|
.cpub-mobile-divider { height: 2px; background: var(--border2); margin: 4px 16px; }
|
|
352
382
|
.cpub-mobile-nav-extra { border-top: var(--border-width-default) solid var(--border2); }
|
|
353
383
|
|
|
354
|
-
|
|
384
|
+
/* Offsets the fixed top bar — MUST track --cpub-topbar-height (only when the bar is
|
|
385
|
+
actually fixed; a sticky bar reserves its own space, so a theme that switches to
|
|
386
|
+
sticky should zero this via --cpub-content-top-offset). */
|
|
387
|
+
#main-content { margin-top: var(--cpub-content-top-offset, var(--cpub-topbar-height, 48px)); flex: 1; }
|
|
355
388
|
|
|
356
389
|
/* ═══ FOOTER ═══ */
|
|
357
|
-
|
|
390
|
+
/* Footer bg + text are token-driven (--cpub-footer-*) so a theme can ship a dark/branded
|
|
391
|
+
footer (deveco green) without forking. Defaults = the current neutral surface footer. */
|
|
392
|
+
.cpub-footer { background: var(--cpub-footer-bg, var(--surface)); border-top: var(--border-width-default) solid var(--cpub-footer-border, var(--border)); margin-top: auto; }
|
|
358
393
|
.cpub-footer-inner { max-width: 1200px; margin: 0 auto; padding: 40px 32px 32px; display: grid; grid-template-columns: 1.5fr repeat(3, 1fr); gap: 32px; }
|
|
359
394
|
.cpub-footer-brand { display: flex; flex-direction: column; gap: 8px; }
|
|
360
|
-
.cpub-footer-logo { font-family: var(--font-mono); font-size: 14px; font-weight: 700; color: var(--text); }
|
|
361
|
-
.cpub-footer-tagline { font-size: 12px; color: var(--text-dim); }
|
|
395
|
+
.cpub-footer-logo { font-family: var(--font-mono); font-size: 14px; font-weight: 700; color: var(--cpub-footer-heading, var(--text)); }
|
|
396
|
+
.cpub-footer-tagline { font-size: 12px; color: var(--cpub-footer-text, var(--text-dim)); }
|
|
362
397
|
.cpub-footer-social { display: flex; gap: 8px; margin-top: 8px; }
|
|
363
|
-
.cpub-footer-social-link { width: 28px; height: 28px; background: var(--surface2); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 12px; text-decoration: none; transition: all 0.12s; }
|
|
398
|
+
.cpub-footer-social-link { width: 28px; height: 28px; background: var(--surface2); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; color: var(--cpub-footer-text, var(--text-dim)); font-size: 12px; text-decoration: none; transition: all 0.12s; }
|
|
364
399
|
.cpub-footer-social-link:hover { background: var(--accent); color: var(--color-text-inverse); border-color: var(--accent); }
|
|
365
400
|
.cpub-footer-col { display: flex; flex-direction: column; gap: 6px; }
|
|
366
|
-
.cpub-footer-col-title { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-faint); margin-bottom: 4px; }
|
|
367
|
-
.cpub-footer-link { font-size: 12px; color: var(--text-dim); text-decoration: none; transition: color 0.12s; }
|
|
368
|
-
.cpub-footer-link:hover { color: var(--text); }
|
|
369
|
-
.cpub-footer-bottom { max-width: 1200px; margin: 0 auto; padding: 16px 32px; border-top: var(--border-width-default) solid var(--border); font-size: 10px; font-family: var(--font-mono); color: var(--text-faint); }
|
|
401
|
+
.cpub-footer-col-title { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--cpub-footer-muted, var(--text-faint)); margin-bottom: 4px; }
|
|
402
|
+
.cpub-footer-link { font-size: 12px; color: var(--cpub-footer-text, var(--text-dim)); text-decoration: none; transition: color 0.12s; }
|
|
403
|
+
.cpub-footer-link:hover { color: var(--cpub-footer-link-hover, var(--text)); }
|
|
404
|
+
.cpub-footer-bottom { max-width: 1200px; margin: 0 auto; padding: 16px 32px; border-top: var(--border-width-default) solid var(--cpub-footer-border, var(--border)); font-size: 10px; font-family: var(--font-mono); color: var(--cpub-footer-muted, var(--text-faint)); }
|
|
370
405
|
|
|
371
406
|
@media (max-width: 768px) {
|
|
372
407
|
:deep(.cpub-topbar-nav) { display: none; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.43.
|
|
3
|
+
"version": "0.43.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,16 +53,16 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
+
"@commonpub/auth": "0.7.0",
|
|
57
|
+
"@commonpub/config": "0.16.0",
|
|
56
58
|
"@commonpub/docs": "0.6.3",
|
|
57
59
|
"@commonpub/learning": "0.5.2",
|
|
58
|
-
"@commonpub/
|
|
59
|
-
"@commonpub/explainer": "0.7.15",
|
|
60
|
+
"@commonpub/editor": "0.7.11",
|
|
60
61
|
"@commonpub/schema": "0.25.0",
|
|
61
|
-
"@commonpub/
|
|
62
|
+
"@commonpub/explainer": "0.7.15",
|
|
63
|
+
"@commonpub/ui": "0.9.2",
|
|
62
64
|
"@commonpub/protocol": "0.12.0",
|
|
63
|
-
"@commonpub/
|
|
64
|
-
"@commonpub/ui": "0.9.1",
|
|
65
|
-
"@commonpub/config": "0.16.0"
|
|
65
|
+
"@commonpub/server": "2.71.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|
package/theme/base.css
CHANGED
|
@@ -231,6 +231,46 @@
|
|
|
231
231
|
--cpub-card-min: 260px; /* min column width before wrapping (auto-fill) */
|
|
232
232
|
--cpub-card-gap: 20px; /* gutter between cards */
|
|
233
233
|
|
|
234
|
+
/* === CHROME (topbar / nav / footer / banner) ===
|
|
235
|
+
The global layout chrome is fully token-driven so an instance theme can change
|
|
236
|
+
its SHAPE — not just colors — without forking the layout (the deveco use case).
|
|
237
|
+
Every default below EXACTLY reproduces the current base look, so existing
|
|
238
|
+
instances + built-in themes are unaffected unless they opt in by overriding a
|
|
239
|
+
token. A theme that wants e.g. deveco's elevated rounded bar sets these in its
|
|
240
|
+
own [data-theme="…"] / :root block. See layers/base/layouts/default.vue. */
|
|
241
|
+
|
|
242
|
+
/* Top bar */
|
|
243
|
+
--cpub-topbar-height: 48px; /* MUST match the content top-offset + mobile-menu top */
|
|
244
|
+
--cpub-topbar-bg: var(--surface);
|
|
245
|
+
--cpub-topbar-border: var(--border-width-default) solid var(--border); /* shorthand: full bottom border */
|
|
246
|
+
--cpub-topbar-radius: 0; /* deveco: 12px rounded bottom */
|
|
247
|
+
--cpub-topbar-shadow: none; /* deveco: 0 2px 8px rgba(0,0,0,.08) */
|
|
248
|
+
--cpub-topbar-position: fixed; /* deveco: sticky */
|
|
249
|
+
--cpub-topbar-padding-x: 20px;
|
|
250
|
+
--cpub-topbar-blur: none; /* deveco: blur(8px) backdrop */
|
|
251
|
+
/* Top offset reserved for the fixed bar. A theme that makes the bar `sticky`
|
|
252
|
+
(which reserves its own space) should set this to 0. Default = the bar height. */
|
|
253
|
+
--cpub-content-top-offset: var(--cpub-topbar-height, 48px);
|
|
254
|
+
|
|
255
|
+
/* Nav links (rendered by NavRenderer → .cpub-nav-link) */
|
|
256
|
+
--cpub-nav-link-size: 12px; /* deveco: 14px */
|
|
257
|
+
--cpub-nav-link-weight: 400; /* deveco: 500 */
|
|
258
|
+
--cpub-nav-link-padding: 5px 12px; /* deveco: 8px 14px */
|
|
259
|
+
--cpub-nav-link-radius: var(--radius); /* deveco: 6px (pill) */
|
|
260
|
+
--cpub-nav-link-color: var(--text-dim);
|
|
261
|
+
--cpub-nav-link-active-color: var(--text); /* deveco: var(--deveco-dark-green) */
|
|
262
|
+
--cpub-nav-link-active-bg: var(--surface2); /* deveco: var(--accent-bg) */
|
|
263
|
+
--cpub-nav-link-active-weight: 400; /* deveco: 600 */
|
|
264
|
+
--cpub-nav-link-active-border: var(--border); /* color of the active link border */
|
|
265
|
+
|
|
266
|
+
/* Footer */
|
|
267
|
+
--cpub-footer-bg: var(--surface); /* deveco: var(--deveco-dark-green) */
|
|
268
|
+
--cpub-footer-text: var(--text-dim); /* body/link text; deveco: rgba(255,255,255,.7) */
|
|
269
|
+
--cpub-footer-muted: var(--text-faint); /* col titles / bottom bar; deveco: rgba(255,255,255,.4) */
|
|
270
|
+
--cpub-footer-border: var(--border); /* deveco: rgba(255,255,255,.1) */
|
|
271
|
+
--cpub-footer-link-hover: var(--text); /* link hover color; deveco: var(--accent) */
|
|
272
|
+
--cpub-footer-heading: var(--text); /* brand logo text; deveco uses its dark-bg logo */
|
|
273
|
+
|
|
234
274
|
/* === FOCUS === */
|
|
235
275
|
--focus-ring: var(--shadow-accent);
|
|
236
276
|
}
|