@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.
- package/components/homepage/ContentGridSection.vue +6 -34
- package/composables/useContentFeed.ts +120 -0
- package/package.json +7 -7
- package/pages/feed.vue +5 -36
- package/pages/index.vue +6 -40
- package/server/api/content/feed.get.ts +22 -0
- package/server/api/content/index.get.ts +4 -25
- package/server/utils/contentQuery.ts +40 -0
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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="
|
|
91
|
-
<ContentCard v-for="item in
|
|
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="
|
|
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.
|
|
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/
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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="
|
|
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="
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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="
|
|
378
|
-
<ContentCard v-for="item in
|
|
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="
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
}
|