@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.
@@ -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.33",
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/server": "2.19.0",
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: { limit: 3 },
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 { ContentSearchResult, ContentSearchOptions } from '@commonpub/server';
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: ContentSearchResult[]; total: number }> => {
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
- // Get Meilisearch client if configured
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: params.limit,
50
- offset: params.offset,
124
+ limit,
125
+ offset,
51
126
  };
52
127
 
53
- return searchContent(db, opts, meiliClient);
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
  });