@commonpub/layer 0.41.0 → 0.43.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.
@@ -1,5 +1,4 @@
1
1
  <script setup lang="ts">
2
- import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/server';
3
2
  import type { HomepageSectionConfig } from '@commonpub/server';
4
3
 
5
4
  const props = defineProps<{
@@ -9,7 +8,6 @@ const props = defineProps<{
9
8
 
10
9
  const { user: authUser } = useAuth();
11
10
  const { enabledTypeMeta } = useContentTypes();
12
- const toast = useToast();
13
11
 
14
12
  const activeTab = ref(authUser.value ? 'foryou' : 'latest');
15
13
  const tabs = computed(() => [
@@ -32,35 +30,9 @@ const contentQuery = computed(() => ({
32
30
  limit: limit.value,
33
31
  }));
34
32
 
35
- const { data: feed, pending: feedPending } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
36
- query: contentQuery,
37
- watch: [contentQuery],
38
- });
39
-
40
- const loadingMore = ref(false);
41
- const allLoaded = ref(false);
42
-
43
- watch(activeTab, () => { allLoaded.value = false; });
44
-
45
- async function loadMore(): Promise<void> {
46
- loadingMore.value = true;
47
- try {
48
- const nextOffset = (feed.value?.items?.length ?? 0);
49
- const more = await $fetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
50
- query: { ...contentQuery.value, offset: nextOffset },
51
- });
52
- if (more.items?.length && feed.value?.items) {
53
- feed.value.items.push(...more.items);
54
- }
55
- if (!more.items?.length || more.items.length < limit.value) {
56
- allLoaded.value = true;
57
- }
58
- } catch {
59
- toast.error('Failed to load more');
60
- } finally {
61
- loadingMore.value = false;
62
- }
63
- }
33
+ // Keyset for recency tabs, offset for popular handled transparently. Replaces the
34
+ // hand-rolled load-more (was a 3rd copy of the offset-window logic behind the dup saga).
35
+ const { items: feedItems, pending: feedPending, loadMore, canLoadMore, loadingMore } = useContentFeed(contentQuery);
64
36
 
65
37
  const isAuthenticated = computed(() => !!authUser.value);
66
38
  const columns = computed(() => props.config.columns ?? 2);
@@ -87,8 +59,8 @@ const columns = computed(() => props.config.columns ?? 2);
87
59
  <div v-if="feedPending" class="cpub-loading-state">
88
60
  <i class="fa-solid fa-circle-notch fa-spin"></i> Loading content...
89
61
  </div>
90
- <div v-else-if="feed?.items?.length" class="cpub-content-grid" :style="{ '--grid-cols': columns }">
91
- <ContentCard v-for="item in feed.items" :key="item.id" :item="item" />
62
+ <div v-else-if="feedItems.length" class="cpub-content-grid" :style="{ '--grid-cols': columns }">
63
+ <ContentCard v-for="item in feedItems" :key="item.id" :item="item" />
92
64
  </div>
93
65
  <div v-else class="cpub-empty-state">
94
66
  <div class="cpub-empty-state-icon"><i :class="activeTab === 'following' ? 'fa-solid fa-user-group' : 'fa-solid fa-inbox'"></i></div>
@@ -107,7 +79,7 @@ const columns = computed(() => props.config.columns ?? 2);
107
79
  </template>
108
80
  </div>
109
81
 
110
- <div v-if="!allLoaded && feed?.items?.length" class="cpub-load-more-row">
82
+ <div v-if="canLoadMore" class="cpub-load-more-row">
111
83
  <button class="cpub-btn-load-more" :disabled="loadingMore" @click="loadMore">
112
84
  <i :class="loadingMore ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-rotate'"></i>
113
85
  {{ loadingMore ? 'Loading...' : 'Load more' }}
@@ -0,0 +1,120 @@
1
+ import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/server';
2
+
3
+ /**
4
+ * Unified content-feed loader with transparent keyset/offset pagination.
5
+ *
6
+ * Why this exists: the homepage, the homepage section renderer, and the deveco custom
7
+ * homepage each hand-rolled the SAME load-more block (fetch next page, push, set
8
+ * allLoaded) — three copies that drifted and each re-implemented the offset window
9
+ * behind the load-more dup saga. This centralises it.
10
+ *
11
+ * Pagination strategy is chosen by sort, not by the caller:
12
+ * - RECENCY feeds (sort 'recent' / 'following' / undefined) use the scalable keyset
13
+ * endpoint `GET /api/content/feed` (O(limit)/page, no offset window, no COUNT) and
14
+ * page by the opaque `nextCursor`. This is what structurally fixes load-more dups.
15
+ * - NON-recency feeds (sort 'popular'/'featured'/'editorial') stay on the offset
16
+ * `GET /api/content` — keyset needs a stable total order, and viewCount/featured
17
+ * mutate, so a viewCount cursor would drift mid-scroll. Offset-popular is already
18
+ * dup-stable since the `id` tiebreaker (server 2.68), so this is not a regression.
19
+ *
20
+ * The caller passes a reactive query (the same shape it already builds). When the query
21
+ * identity changes (tab switch), the feed re-fetches from the first page and pagination
22
+ * state resets. Uniform surface regardless of strategy: { items, pending, loadMore,
23
+ * canLoadMore, loadingMore }.
24
+ */
25
+
26
+ type FeedQuery = Record<string, unknown> & { sort?: string; limit?: number };
27
+ type Item = Serialized<ContentListItem>;
28
+
29
+ interface KeysetResponse { items: Item[]; nextCursor: string | null }
30
+
31
+ export function useContentFeed(query: Ref<FeedQuery> | ComputedRef<FeedQuery>) {
32
+ const toast = useToast();
33
+
34
+ // Recency is the keyset-eligible order (sort 'recent', or unset → server default
35
+ // recency). 'popular'/'featured'/'editorial' use mutable keys → stay on offset.
36
+ const isKeyset = computed(() => {
37
+ const s = query.value.sort;
38
+ return s === undefined || s === 'recent';
39
+ });
40
+ const limit = computed(() => Number(query.value.limit ?? 12));
41
+
42
+ // Keyset cursor for the current query identity (reset on query change below).
43
+ const cursor = ref<string | null>(null);
44
+ const loadingMore = ref(false);
45
+ const exhausted = ref(false);
46
+
47
+ // Initial page — SSR-friendly via useFetch. Both endpoints accept the same query;
48
+ // the keyset one returns { items, nextCursor }, the offset one { items, total }.
49
+ const endpoint = computed(() => (isKeyset.value ? '/api/content/feed' : '/api/content'));
50
+ const { data, pending } = useFetch<KeysetResponse | PaginatedResponse<Item>>(endpoint, {
51
+ query,
52
+ watch: [query],
53
+ });
54
+
55
+ // Local accumulator: the first page from useFetch, plus any load-more pages. Kept
56
+ // separate from `data` so we never mutate the useFetch payload (which it re-creates
57
+ // on refetch) — and so a tab switch cleanly replaces the list.
58
+ const extra = ref<Item[]>([]);
59
+ const items = computed<Item[]>(() => [...(data.value?.items ?? []), ...extra.value]);
60
+
61
+ // Reset pagination whenever the underlying query (tab/filter) changes.
62
+ watch(
63
+ query,
64
+ () => {
65
+ extra.value = [];
66
+ cursor.value = null;
67
+ exhausted.value = false;
68
+ },
69
+ { deep: true },
70
+ );
71
+
72
+ // Seed the keyset cursor from the first page once it arrives.
73
+ watch(
74
+ data,
75
+ (d) => {
76
+ if (isKeyset.value) {
77
+ cursor.value = (d as KeysetResponse | null)?.nextCursor ?? null;
78
+ if (cursor.value === null) exhausted.value = true;
79
+ }
80
+ },
81
+ { immediate: true },
82
+ );
83
+
84
+ const canLoadMore = computed(() => {
85
+ if (!items.value.length) return false;
86
+ if (exhausted.value) return false;
87
+ if (isKeyset.value) return cursor.value !== null;
88
+ // Offset: stop once a page comes back short.
89
+ return true;
90
+ });
91
+
92
+ async function loadMore(): Promise<void> {
93
+ if (loadingMore.value || exhausted.value) return;
94
+ loadingMore.value = true;
95
+ try {
96
+ if (isKeyset.value) {
97
+ if (!cursor.value) { exhausted.value = true; return; }
98
+ const res = await $fetch<KeysetResponse>('/api/content/feed', {
99
+ query: { ...query.value, cursor: cursor.value },
100
+ });
101
+ if (res.items?.length) extra.value.push(...res.items);
102
+ cursor.value = res.nextCursor;
103
+ if (!res.nextCursor) exhausted.value = true;
104
+ } else {
105
+ const offset = items.value.length;
106
+ const res = await $fetch<PaginatedResponse<Item>>('/api/content', {
107
+ query: { ...query.value, offset },
108
+ });
109
+ if (res.items?.length) extra.value.push(...res.items);
110
+ if (!res.items?.length || res.items.length < limit.value) exhausted.value = true;
111
+ }
112
+ } catch {
113
+ toast.error('Failed to load more');
114
+ } finally {
115
+ loadingMore.value = false;
116
+ }
117
+ }
118
+
119
+ return { items, pending, loadMore, canLoadMore, loadingMore };
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
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",
58
56
  "@commonpub/docs": "0.6.3",
57
+ "@commonpub/learning": "0.5.2",
58
+ "@commonpub/auth": "0.7.0",
59
59
  "@commonpub/explainer": "0.7.15",
60
+ "@commonpub/schema": "0.25.0",
61
+ "@commonpub/server": "2.71.0",
62
+ "@commonpub/protocol": "0.12.0",
60
63
  "@commonpub/editor": "0.7.11",
61
- "@commonpub/learning": "0.5.2",
62
- "@commonpub/schema": "0.24.0",
63
64
  "@commonpub/ui": "0.9.1",
64
- "@commonpub/protocol": "0.12.0",
65
- "@commonpub/server": "2.69.0"
65
+ "@commonpub/config": "0.16.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
package/pages/feed.vue CHANGED
@@ -1,6 +1,4 @@
1
1
  <script setup lang="ts">
2
- import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/server';
3
-
4
2
  useSeoMeta({
5
3
  title: `Feed — ${useSiteName()}`,
6
4
  description: 'Recent published content from the community.',
@@ -9,8 +7,6 @@ useSeoMeta({
9
7
  const { isAuthenticated } = useAuth();
10
8
  const { enabledTypeMeta } = useContentTypes();
11
9
  const activeFilter = ref('all');
12
- const loadingMore = ref(false);
13
- const allLoaded = ref(false);
14
10
 
15
11
  const contentQuery = computed(() => ({
16
12
  status: 'published',
@@ -19,36 +15,9 @@ const contentQuery = computed(() => ({
19
15
  limit: 12,
20
16
  }));
21
17
 
22
- const { data, status } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
23
- query: contentQuery,
24
- watch: [contentQuery],
25
- });
26
-
27
- const items = computed(() => data.value?.items ?? []);
28
- const total = computed(() => data.value?.total ?? 0);
29
-
30
- async function loadMore(): Promise<void> {
31
- if (!data.value?.items) return;
32
- loadingMore.value = true;
33
- try {
34
- const nextOffset = data.value.items.length;
35
- const more = await $fetch<{ items: Array<Record<string, unknown>> }>('/api/content', {
36
- query: { ...contentQuery.value, offset: nextOffset },
37
- });
38
- if (more?.items?.length) {
39
- data.value.items.push(...(more.items as typeof data.value.items));
40
- }
41
- if (!more?.items?.length || more.items.length < 12) {
42
- allLoaded.value = true;
43
- }
44
- } catch {
45
- allLoaded.value = true;
46
- } finally {
47
- loadingMore.value = false;
48
- }
49
- }
50
-
51
- watch(activeFilter, () => { allLoaded.value = false; });
18
+ // Pure recency feed keyset pagination (scalable, dup-free). useContentFeed handles
19
+ // cursor threading + reset-on-filter-change.
20
+ const { items, pending, loadMore, canLoadMore, loadingMore } = useContentFeed(contentQuery);
52
21
 
53
22
  const filters = computed(() => [
54
23
  { value: 'all', label: 'All', icon: 'fa-solid fa-layer-group' },
@@ -83,7 +52,7 @@ const filters = computed(() => [
83
52
  <ContentCard v-for="item in items" :key="item.id" :item="item" />
84
53
  </div>
85
54
 
86
- <div v-else-if="status === 'pending'" class="feed-loading">Loading...</div>
55
+ <div v-else-if="pending" class="feed-loading">Loading...</div>
87
56
 
88
57
  <div v-else class="feed-empty">
89
58
  <div class="feed-empty-icon"><i class="fa-solid fa-rss"></i></div>
@@ -92,7 +61,7 @@ const filters = computed(() => [
92
61
  </div>
93
62
 
94
63
  <!-- Load More -->
95
- <div v-if="!allLoaded && items.length >= 12" class="feed-more">
64
+ <div v-if="canLoadMore" class="feed-more">
96
65
  <button class="cpub-btn" @click="loadMore" :disabled="loadingMore">
97
66
  {{ loadingMore ? 'Loading...' : 'Load More' }}
98
67
  </button>
package/pages/index.vue CHANGED
@@ -48,10 +48,9 @@ const contentQuery = computed(() => ({
48
48
  limit: 12,
49
49
  }));
50
50
 
51
- const { data: feed, pending: feedPending } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
52
- query: contentQuery,
53
- watch: [contentQuery],
54
- });
51
+ // Keyset for recency tabs (latest/following/per-type), offset for the popular "For You"
52
+ // tab — chosen transparently by useContentFeed. Replaces the hand-rolled offset loadMore.
53
+ const { items: feedItems, pending: feedPending, loadMore, canLoadMore, loadingMore } = useContentFeed(contentQuery);
55
54
 
56
55
  // Editorial picks — staff-curated content for the homepage (only when editorial feature is enabled)
57
56
  const { data: editorialPicks } = editorialEnabled.value
@@ -106,39 +105,6 @@ const activeContest = computed<ContestListItem | null>(() => {
106
105
  const isAuthenticated = computed(() => !!user.value);
107
106
  const toast = useToast();
108
107
 
109
- // Load more state
110
- const feedOffset = ref(0);
111
- const loadingMore = ref(false);
112
- const allLoaded = ref(false);
113
-
114
- async function loadMore(): Promise<void> {
115
- loadingMore.value = true;
116
- try {
117
- const nextOffset = (feed.value?.items?.length ?? 0);
118
- const more = await $fetch<{ items: Array<Record<string, unknown>> }>('/api/content', {
119
- query: {
120
- ...contentQuery.value,
121
- offset: nextOffset,
122
- },
123
- });
124
- if (more.items?.length) {
125
- if (feed.value?.items) {
126
- feed.value.items.push(...(more.items as typeof feed.value.items));
127
- }
128
- }
129
- if (!more.items?.length || more.items.length < 12) {
130
- allLoaded.value = true;
131
- }
132
- } catch {
133
- toast.error('Failed to load more');
134
- } finally {
135
- loadingMore.value = false;
136
- }
137
- }
138
-
139
- // Reset load more when tab changes
140
- watch(activeTab, () => { allLoaded.value = false; });
141
-
142
108
  async function handleHubJoin(hubSlug: string): Promise<void> {
143
109
  if (!isAuthenticated.value) {
144
110
  await navigateTo(`/auth/login?redirect=/`);
@@ -374,8 +340,8 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
374
340
  <div v-if="feedPending" class="cpub-loading-state">
375
341
  <i class="fa-solid fa-circle-notch fa-spin"></i> Loading content...
376
342
  </div>
377
- <div v-else-if="feed?.items?.length" class="cpub-content-grid">
378
- <ContentCard v-for="item in feed.items" :key="item.id" :item="item" />
343
+ <div v-else-if="feedItems.length" class="cpub-content-grid">
344
+ <ContentCard v-for="item in feedItems" :key="item.id" :item="item" />
379
345
  </div>
380
346
  <div v-else class="cpub-empty-state">
381
347
  <div class="cpub-empty-state-icon"><i :class="activeTab === 'following' ? 'fa-solid fa-user-group' : 'fa-solid fa-inbox'"></i></div>
@@ -395,7 +361,7 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
395
361
  </template>
396
362
  </div>
397
363
 
398
- <div v-if="!allLoaded && feed?.items?.length" class="cpub-load-more-row">
364
+ <div v-if="canLoadMore" class="cpub-load-more-row">
399
365
  <button class="cpub-btn-load-more" :disabled="loadingMore" @click="loadMore">
400
366
  <i :class="loadingMore ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-rotate'"></i>
401
367
  {{ loadingMore ? 'Loading...' : 'Load more' }}
@@ -0,0 +1,22 @@
1
+ import { listContentKeyset } from '@commonpub/server';
2
+ import type { ContentListItem } from '@commonpub/server';
3
+ import { contentFiltersSchema } from '@commonpub/schema';
4
+
5
+ /**
6
+ * Keyset (cursor) pagination for the chronological content feed — the scalable
7
+ * infinite-scroll endpoint (O(limit) per page, no COUNT, no offset window).
8
+ *
9
+ * Separate from the offset `GET /api/content` (which stays for numbered/admin listing
10
+ * and for `total`); both share `resolveContentQuery` so the auth/status/visibility/
11
+ * federation gate can't drift between them. Order is fixed to recency — popular/
12
+ * featured/editorial sorts and `offset` are not meaningful here and are ignored.
13
+ *
14
+ * Request: GET /api/content/feed?type=blog&limit=20[&cursor=<opaque>]
15
+ * Response: { items, nextCursor } (nextCursor null when the feed is exhausted)
16
+ */
17
+ export default defineEventHandler(async (event): Promise<{ items: ContentListItem[]; nextCursor: string | null }> => {
18
+ const db = useDB();
19
+ const rawFilters = parseQueryParams(event, contentFiltersSchema);
20
+ const { filters, options } = resolveContentQuery(event, rawFilters);
21
+ return listContentKeyset(db, filters, options);
22
+ });
@@ -2,31 +2,10 @@ import { listContent } from '@commonpub/server';
2
2
  import type { PaginatedResponse, ContentListItem } from '@commonpub/server';
3
3
  import { contentFiltersSchema } from '@commonpub/schema';
4
4
 
5
- // Statuses a non-owner may request. Any other value (draft, scheduled, deleted,
6
- // etc.) is coerced to 'published' — the old behavior passed the filter through
7
- // verbatim, so /api/content?status=draft leaked every user's drafts.
8
- const PUBLIC_STATUSES = new Set(['published', 'archived']);
9
-
10
5
  export default defineEventHandler(async (event): Promise<PaginatedResponse<ContentListItem>> => {
11
6
  const db = useDB();
12
- const user = getOptionalUser(event);
13
- const filters = parseQueryParams(event, contentFiltersSchema);
14
-
15
- const isOwnContent = filters.authorId && user?.id === filters.authorId;
16
-
17
- const config = useConfig();
18
-
19
- const resolvedStatus = isOwnContent
20
- ? filters.status
21
- : (filters.status && PUBLIC_STATUSES.has(filters.status) ? filters.status : 'published');
22
-
23
- return listContent(db, {
24
- ...filters,
25
- status: resolvedStatus,
26
- // Only show public content unless viewing own content
27
- visibility: isOwnContent ? filters.visibility : 'public',
28
- }, {
29
- includeFederated: config.features.seamlessFederation,
30
- allowedContentTypes: config.instance.contentTypes,
31
- });
7
+ const rawFilters = parseQueryParams(event, contentFiltersSchema);
8
+ // Shared auth/status/visibility/federation gate (also used by the keyset feed endpoint).
9
+ const { filters, options } = resolveContentQuery(event, rawFilters);
10
+ return listContent(db, filters, options);
32
11
  });
@@ -0,0 +1,40 @@
1
+ import type { H3Event } from 'h3';
2
+ import type { ContentFilters } from '@commonpub/schema';
3
+
4
+ // Statuses a non-owner may request. Any other value (draft, scheduled, deleted, etc.)
5
+ // is coerced to 'published' — passing the filter through verbatim would leak drafts
6
+ // (/api/content?status=draft once leaked every user's drafts).
7
+ const PUBLIC_STATUSES = new Set(['published', 'archived']);
8
+
9
+ /**
10
+ * Resolve a raw ContentFilters query into the SAFE filters + server options shared by
11
+ * BOTH content list endpoints (offset `index.get.ts` and keyset `feed.get.ts`). Centralises
12
+ * the auth/status/visibility gate + the federation/content-type options so the two endpoints
13
+ * can't drift on security: a non-owner only ever sees published+public content.
14
+ */
15
+ export function resolveContentQuery(
16
+ event: H3Event,
17
+ filters: ContentFilters,
18
+ ): { filters: ContentFilters; options: { includeFederated: boolean; allowedContentTypes?: string[] } } {
19
+ const user = getOptionalUser(event);
20
+ const config = useConfig();
21
+
22
+ const isOwnContent = !!filters.authorId && user?.id === filters.authorId;
23
+
24
+ const resolvedStatus = isOwnContent
25
+ ? filters.status
26
+ : (filters.status && PUBLIC_STATUSES.has(filters.status) ? filters.status : 'published');
27
+
28
+ return {
29
+ filters: {
30
+ ...filters,
31
+ status: resolvedStatus,
32
+ // Only show public content unless viewing own content.
33
+ visibility: isOwnContent ? filters.visibility : 'public',
34
+ },
35
+ options: {
36
+ includeFederated: config.features.seamlessFederation,
37
+ allowedContentTypes: config.instance.contentTypes,
38
+ },
39
+ };
40
+ }