@commonpub/layer 0.3.33 → 0.3.34
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 +1 -1
- package/package.json +6 -6
- package/pages/search.vue +175 -1
- package/server/api/search/index.get.ts +89 -10
|
@@ -75,7 +75,7 @@ const { hubs: hubsEnabled } = useFeatures();
|
|
|
75
75
|
<i class="fa-solid fa-users"></i>
|
|
76
76
|
</div>
|
|
77
77
|
<div class="cpub-related-hub-info">
|
|
78
|
-
<NuxtLink :to="`/hubs/${hub.slug}`" class="cpub-related-hub-name">{{ hub.name }}</NuxtLink>
|
|
78
|
+
<NuxtLink :to="hub.source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`" class="cpub-related-hub-name">{{ hub.name }}</NuxtLink>
|
|
79
79
|
<div class="cpub-related-hub-members">{{ hub.memberCount ?? 0 }} members</div>
|
|
80
80
|
</div>
|
|
81
81
|
<button class="cpub-btn-join-sm">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.34",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -44,13 +44,13 @@
|
|
|
44
44
|
"vue": "^3.4.0",
|
|
45
45
|
"vue-router": "^4.3.0",
|
|
46
46
|
"zod": "^4.3.6",
|
|
47
|
-
"@commonpub/docs": "0.5.2",
|
|
48
|
-
"@commonpub/learning": "0.5.0",
|
|
49
|
-
"@commonpub/config": "0.7.1",
|
|
50
47
|
"@commonpub/auth": "0.5.0",
|
|
51
|
-
"@commonpub/
|
|
52
|
-
"@commonpub/protocol": "0.9.5",
|
|
48
|
+
"@commonpub/config": "0.7.1",
|
|
53
49
|
"@commonpub/editor": "0.5.0",
|
|
50
|
+
"@commonpub/learning": "0.5.0",
|
|
51
|
+
"@commonpub/docs": "0.5.2",
|
|
52
|
+
"@commonpub/protocol": "0.9.5",
|
|
53
|
+
"@commonpub/server": "2.19.0",
|
|
54
54
|
"@commonpub/schema": "0.8.13",
|
|
55
55
|
"@commonpub/ui": "0.7.1"
|
|
56
56
|
},
|
package/pages/search.vue
CHANGED
|
@@ -181,10 +181,15 @@ function setCategory(label: string): void {
|
|
|
181
181
|
page.value = 1;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
const relatedHubsQuery = computed(() => ({
|
|
185
|
+
search: query.value || undefined,
|
|
186
|
+
limit: 3,
|
|
187
|
+
}));
|
|
184
188
|
const { data: relatedCommunities } = await useFetch('/api/hubs', {
|
|
185
|
-
query:
|
|
189
|
+
query: relatedHubsQuery,
|
|
186
190
|
default: () => ({ items: [] }),
|
|
187
191
|
immediate: hubsEnabled.value,
|
|
192
|
+
watch: [relatedHubsQuery],
|
|
188
193
|
});
|
|
189
194
|
</script>
|
|
190
195
|
|
|
@@ -317,7 +322,56 @@ const { data: relatedCommunities } = await useFetch('/api/hubs', {
|
|
|
317
322
|
</template>
|
|
318
323
|
|
|
319
324
|
<template v-else-if="activeType !== 'fediverse' && results?.items?.length">
|
|
325
|
+
<!-- COMMUNITY RESULTS -->
|
|
326
|
+
<div v-if="activeType === 'community'" class="cpub-results-grid">
|
|
327
|
+
<NuxtLink
|
|
328
|
+
v-for="hub in results.items"
|
|
329
|
+
:key="hub.id"
|
|
330
|
+
:to="hub.source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`"
|
|
331
|
+
class="cpub-search-hub-card"
|
|
332
|
+
>
|
|
333
|
+
<div class="cpub-search-hub-icon">
|
|
334
|
+
<img v-if="hub.iconUrl" :src="hub.iconUrl" :alt="hub.name" class="cpub-search-hub-img" />
|
|
335
|
+
<i v-else class="fa-solid fa-users"></i>
|
|
336
|
+
</div>
|
|
337
|
+
<div class="cpub-search-hub-body">
|
|
338
|
+
<h3 class="cpub-search-hub-name">{{ hub.name }}</h3>
|
|
339
|
+
<p v-if="hub.description" class="cpub-search-hub-desc">{{ hub.description }}</p>
|
|
340
|
+
<div class="cpub-search-hub-meta">
|
|
341
|
+
<span><i class="fa-solid fa-users"></i> {{ hub.memberCount ?? 0 }} members</span>
|
|
342
|
+
<span><i class="fa-solid fa-message"></i> {{ hub.postCount ?? 0 }} posts</span>
|
|
343
|
+
<span v-if="hub.source === 'federated'" class="cpub-search-hub-fed"><i class="fa-solid fa-globe"></i> Federated</span>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</NuxtLink>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<!-- PEOPLE RESULTS -->
|
|
350
|
+
<div v-else-if="activeType === 'people'" class="cpub-results-grid">
|
|
351
|
+
<NuxtLink
|
|
352
|
+
v-for="user in results.items"
|
|
353
|
+
:key="user.id"
|
|
354
|
+
:to="`/u/${user.username}`"
|
|
355
|
+
class="cpub-search-person-card"
|
|
356
|
+
>
|
|
357
|
+
<div class="cpub-search-person-avatar">
|
|
358
|
+
<img v-if="user.avatarUrl" :src="user.avatarUrl" :alt="user.displayName || user.username" />
|
|
359
|
+
<span v-else>{{ (user.displayName || user.username || '?').charAt(0).toUpperCase() }}</span>
|
|
360
|
+
</div>
|
|
361
|
+
<div class="cpub-search-person-body">
|
|
362
|
+
<h3 class="cpub-search-person-name">{{ user.displayName || user.username }}</h3>
|
|
363
|
+
<div class="cpub-search-person-handle">@{{ user.username }}</div>
|
|
364
|
+
<p v-if="user.headline" class="cpub-search-person-headline">{{ user.headline }}</p>
|
|
365
|
+
<div class="cpub-search-person-meta">
|
|
366
|
+
<span><i class="fa-solid fa-user-group"></i> {{ user.followerCount ?? 0 }} followers</span>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
</NuxtLink>
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<!-- CONTENT RESULTS (default) -->
|
|
320
373
|
<div
|
|
374
|
+
v-else
|
|
321
375
|
class="cpub-results-grid"
|
|
322
376
|
:class="{ 'list-view': viewMode === 'list' }"
|
|
323
377
|
>
|
|
@@ -696,4 +750,124 @@ const { data: relatedCommunities } = await useFetch('/api/hubs', {
|
|
|
696
750
|
grid-template-columns: 1fr;
|
|
697
751
|
}
|
|
698
752
|
}
|
|
753
|
+
|
|
754
|
+
/* ── COMMUNITY RESULT CARDS ── */
|
|
755
|
+
.cpub-search-hub-card {
|
|
756
|
+
display: flex;
|
|
757
|
+
gap: var(--space-3);
|
|
758
|
+
padding: var(--space-4);
|
|
759
|
+
border: var(--border-width-default) solid var(--border);
|
|
760
|
+
background: var(--surface);
|
|
761
|
+
text-decoration: none;
|
|
762
|
+
color: inherit;
|
|
763
|
+
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
|
764
|
+
}
|
|
765
|
+
.cpub-search-hub-card:hover {
|
|
766
|
+
transform: translate(-2px, -2px);
|
|
767
|
+
box-shadow: var(--shadow-md);
|
|
768
|
+
}
|
|
769
|
+
.cpub-search-hub-icon {
|
|
770
|
+
width: 48px;
|
|
771
|
+
height: 48px;
|
|
772
|
+
flex-shrink: 0;
|
|
773
|
+
display: flex;
|
|
774
|
+
align-items: center;
|
|
775
|
+
justify-content: center;
|
|
776
|
+
border: var(--border-width-default) solid var(--border);
|
|
777
|
+
background: var(--surface-2);
|
|
778
|
+
color: var(--text-dim);
|
|
779
|
+
overflow: hidden;
|
|
780
|
+
}
|
|
781
|
+
.cpub-search-hub-img {
|
|
782
|
+
width: 100%;
|
|
783
|
+
height: 100%;
|
|
784
|
+
object-fit: cover;
|
|
785
|
+
}
|
|
786
|
+
.cpub-search-hub-body {
|
|
787
|
+
flex: 1;
|
|
788
|
+
min-width: 0;
|
|
789
|
+
}
|
|
790
|
+
.cpub-search-hub-name {
|
|
791
|
+
font-size: var(--text-md);
|
|
792
|
+
font-weight: var(--font-weight-semibold);
|
|
793
|
+
margin-bottom: var(--space-1);
|
|
794
|
+
}
|
|
795
|
+
.cpub-search-hub-desc {
|
|
796
|
+
font-size: var(--text-sm);
|
|
797
|
+
color: var(--text-dim);
|
|
798
|
+
margin-bottom: var(--space-2);
|
|
799
|
+
display: -webkit-box;
|
|
800
|
+
-webkit-line-clamp: 2;
|
|
801
|
+
-webkit-box-orient: vertical;
|
|
802
|
+
overflow: hidden;
|
|
803
|
+
}
|
|
804
|
+
.cpub-search-hub-meta {
|
|
805
|
+
display: flex;
|
|
806
|
+
gap: var(--space-3);
|
|
807
|
+
font-size: var(--text-xs);
|
|
808
|
+
color: var(--text-faint);
|
|
809
|
+
font-family: var(--font-mono);
|
|
810
|
+
}
|
|
811
|
+
.cpub-search-hub-fed {
|
|
812
|
+
color: var(--accent);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/* ── PEOPLE RESULT CARDS ── */
|
|
816
|
+
.cpub-search-person-card {
|
|
817
|
+
display: flex;
|
|
818
|
+
gap: var(--space-3);
|
|
819
|
+
padding: var(--space-4);
|
|
820
|
+
border: var(--border-width-default) solid var(--border);
|
|
821
|
+
background: var(--surface);
|
|
822
|
+
text-decoration: none;
|
|
823
|
+
color: inherit;
|
|
824
|
+
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
|
825
|
+
}
|
|
826
|
+
.cpub-search-person-card:hover {
|
|
827
|
+
transform: translate(-2px, -2px);
|
|
828
|
+
box-shadow: var(--shadow-md);
|
|
829
|
+
}
|
|
830
|
+
.cpub-search-person-avatar {
|
|
831
|
+
width: 48px;
|
|
832
|
+
height: 48px;
|
|
833
|
+
flex-shrink: 0;
|
|
834
|
+
display: flex;
|
|
835
|
+
align-items: center;
|
|
836
|
+
justify-content: center;
|
|
837
|
+
border: var(--border-width-default) solid var(--border);
|
|
838
|
+
background: var(--surface-2);
|
|
839
|
+
color: var(--text-dim);
|
|
840
|
+
font-weight: var(--font-weight-bold);
|
|
841
|
+
font-size: var(--text-lg);
|
|
842
|
+
overflow: hidden;
|
|
843
|
+
}
|
|
844
|
+
.cpub-search-person-avatar img {
|
|
845
|
+
width: 100%;
|
|
846
|
+
height: 100%;
|
|
847
|
+
object-fit: cover;
|
|
848
|
+
}
|
|
849
|
+
.cpub-search-person-body {
|
|
850
|
+
flex: 1;
|
|
851
|
+
min-width: 0;
|
|
852
|
+
}
|
|
853
|
+
.cpub-search-person-name {
|
|
854
|
+
font-size: var(--text-md);
|
|
855
|
+
font-weight: var(--font-weight-semibold);
|
|
856
|
+
}
|
|
857
|
+
.cpub-search-person-handle {
|
|
858
|
+
font-size: var(--text-sm);
|
|
859
|
+
font-family: var(--font-mono);
|
|
860
|
+
color: var(--text-faint);
|
|
861
|
+
margin-bottom: var(--space-1);
|
|
862
|
+
}
|
|
863
|
+
.cpub-search-person-headline {
|
|
864
|
+
font-size: var(--text-sm);
|
|
865
|
+
color: var(--text-dim);
|
|
866
|
+
margin-bottom: var(--space-2);
|
|
867
|
+
}
|
|
868
|
+
.cpub-search-person-meta {
|
|
869
|
+
font-size: var(--text-xs);
|
|
870
|
+
color: var(--text-faint);
|
|
871
|
+
font-family: var(--font-mono);
|
|
872
|
+
}
|
|
699
873
|
</style>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { searchContent } from '@commonpub/server';
|
|
2
|
-
import type {
|
|
1
|
+
import { searchContent, listHubs, escapeLike } from '@commonpub/server';
|
|
2
|
+
import type { ContentSearchOptions } from '@commonpub/server';
|
|
3
|
+
import { users, follows, hubs } from '@commonpub/schema';
|
|
4
|
+
import { sql, desc, ilike, or, and, isNull, eq } from 'drizzle-orm';
|
|
3
5
|
import { z } from 'zod';
|
|
4
6
|
|
|
5
7
|
const searchQuerySchema = z.object({
|
|
@@ -15,8 +17,9 @@ const searchQuerySchema = z.object({
|
|
|
15
17
|
offset: z.coerce.number().int().min(0).optional(),
|
|
16
18
|
});
|
|
17
19
|
|
|
18
|
-
export default defineEventHandler(async (event): Promise<{ items:
|
|
20
|
+
export default defineEventHandler(async (event): Promise<{ items: unknown[]; total: number }> => {
|
|
19
21
|
const db = useDB();
|
|
22
|
+
const config = useConfig();
|
|
20
23
|
const params = parseQueryParams(event, searchQuerySchema);
|
|
21
24
|
const q = params.q?.trim();
|
|
22
25
|
|
|
@@ -24,7 +27,81 @@ export default defineEventHandler(async (event): Promise<{ items: ContentSearchR
|
|
|
24
27
|
return { items: [], total: 0 };
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
const limit = Math.min(params.limit ?? 24, 100);
|
|
31
|
+
const offset = params.offset ?? 0;
|
|
32
|
+
|
|
33
|
+
// --- Community search ---
|
|
34
|
+
if (params.type === 'community') {
|
|
35
|
+
const includeFederated = !!config.features.seamlessFederation;
|
|
36
|
+
const result = await listHubs(db, { search: q, limit, offset }, { includeFederated });
|
|
37
|
+
return {
|
|
38
|
+
items: result.items.map((hub) => ({
|
|
39
|
+
_resultType: 'community',
|
|
40
|
+
id: hub.id,
|
|
41
|
+
name: hub.name,
|
|
42
|
+
slug: hub.slug,
|
|
43
|
+
description: hub.description,
|
|
44
|
+
iconUrl: hub.iconUrl,
|
|
45
|
+
bannerUrl: hub.bannerUrl,
|
|
46
|
+
memberCount: hub.memberCount,
|
|
47
|
+
postCount: hub.postCount,
|
|
48
|
+
source: (hub as Record<string, unknown>).source ?? 'local',
|
|
49
|
+
})),
|
|
50
|
+
total: result.total,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- People search ---
|
|
55
|
+
if (params.type === 'people') {
|
|
56
|
+
const term = `%${escapeLike(q)}%`;
|
|
57
|
+
const where = and(
|
|
58
|
+
isNull(users.deletedAt),
|
|
59
|
+
or(ilike(users.username, term), ilike(users.displayName, term)),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const [rows, countResult] = await Promise.all([
|
|
63
|
+
db.select({
|
|
64
|
+
id: users.id,
|
|
65
|
+
username: users.username,
|
|
66
|
+
displayName: users.displayName,
|
|
67
|
+
headline: users.headline,
|
|
68
|
+
avatarUrl: users.avatarUrl,
|
|
69
|
+
})
|
|
70
|
+
.from(users)
|
|
71
|
+
.where(where)
|
|
72
|
+
.orderBy(desc(users.createdAt))
|
|
73
|
+
.limit(limit)
|
|
74
|
+
.offset(offset),
|
|
75
|
+
db.select({ count: sql<number>`count(*)::int` }).from(users).where(where),
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// Follower counts
|
|
79
|
+
const userIds = rows.map((r) => r.id);
|
|
80
|
+
const followerCounts: Record<string, number> = {};
|
|
81
|
+
if (userIds.length > 0) {
|
|
82
|
+
const counts = await db
|
|
83
|
+
.select({ followingId: follows.followingId, count: sql<number>`count(*)::int` })
|
|
84
|
+
.from(follows)
|
|
85
|
+
.where(sql`${follows.followingId} = ANY(ARRAY[${sql.join(userIds.map((id) => sql`${id}::uuid`), sql`, `)}])`)
|
|
86
|
+
.groupBy(follows.followingId);
|
|
87
|
+
for (const c of counts) followerCounts[c.followingId] = c.count;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
items: rows.map((r) => ({
|
|
92
|
+
_resultType: 'person',
|
|
93
|
+
id: r.id,
|
|
94
|
+
username: r.username,
|
|
95
|
+
displayName: r.displayName,
|
|
96
|
+
headline: r.headline,
|
|
97
|
+
avatarUrl: r.avatarUrl,
|
|
98
|
+
followerCount: followerCounts[r.id] ?? 0,
|
|
99
|
+
})),
|
|
100
|
+
total: countResult[0]?.count ?? rows.length,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Content search (default) ---
|
|
28
105
|
let meiliClient = null;
|
|
29
106
|
try {
|
|
30
107
|
const meiliUrl = process.env.MEILI_URL;
|
|
@@ -33,9 +110,7 @@ export default defineEventHandler(async (event): Promise<{ items: ContentSearchR
|
|
|
33
110
|
const { MeiliSearch } = await import('meilisearch');
|
|
34
111
|
meiliClient = new MeiliSearch({ host: meiliUrl, apiKey: meiliKey });
|
|
35
112
|
}
|
|
36
|
-
} catch {
|
|
37
|
-
// Meilisearch not available — will use Postgres fallback
|
|
38
|
-
}
|
|
113
|
+
} catch { /* Meilisearch not available */ }
|
|
39
114
|
|
|
40
115
|
const opts: ContentSearchOptions = {
|
|
41
116
|
query: q,
|
|
@@ -46,9 +121,13 @@ export default defineEventHandler(async (event): Promise<{ items: ContentSearchR
|
|
|
46
121
|
dateFrom: params.dateFrom,
|
|
47
122
|
dateTo: params.dateTo,
|
|
48
123
|
sort: (params.sort as ContentSearchOptions['sort']) ?? 'relevance',
|
|
49
|
-
limit
|
|
50
|
-
offset
|
|
124
|
+
limit,
|
|
125
|
+
offset,
|
|
51
126
|
};
|
|
52
127
|
|
|
53
|
-
|
|
128
|
+
const result = await searchContent(db, opts, meiliClient);
|
|
129
|
+
return {
|
|
130
|
+
items: result.items.map((item) => ({ ...item, _resultType: 'content' })),
|
|
131
|
+
total: result.total,
|
|
132
|
+
};
|
|
54
133
|
});
|