Sifa professional network frontend (Next.js, React, TailwindCSS)
sifa.id/
1'use client';
2
3import { lazy, Suspense, useEffect, useState } from 'react';
4import Link from 'next/link';
5import { useTranslations } from 'next-intl';
6import { ArrowRight } from '@phosphor-icons/react';
7import { fetchActivityTeaser } from '@/lib/api';
8import type { ActivityItem } from '@/lib/api';
9import { ActivityIndicators } from './activity-indicators';
10import { getCardComponent } from './activity-cards/card-registry';
11import { GenericActivityCard } from './activity-cards/generic-activity-card';
12import { CardErrorBoundary } from './activity-cards/card-error-boundary';
13import type { ActiveApp } from '@/lib/types';
14
15const ActivityHeatmap = lazy(() =>
16 import('./activity-heatmap/activity-heatmap').then((m) => ({ default: m.ActivityHeatmap })),
17);
18
19interface ActivityOverviewProps {
20 handle: string;
21 activeApps?: ActiveApp[];
22}
23
24export function ActivityOverview({ handle, activeApps = [] }: ActivityOverviewProps) {
25 const t = useTranslations('activityOverview');
26 const [items, setItems] = useState<ActivityItem[] | null>(null);
27 const [loaded, setLoaded] = useState(false);
28
29 useEffect(() => {
30 let cancelled = false;
31 fetchActivityTeaser(handle).then((data) => {
32 if (cancelled) return;
33 setItems(data?.items ?? null);
34 setLoaded(true);
35 });
36 return () => {
37 cancelled = true;
38 };
39 }, [handle]);
40
41 const validItems = (items ?? []).filter((item) => {
42 if (item.record == null) return false;
43 // Hide image-only Bluesky posts — they render as empty rows in compact mode
44 if (
45 item.collection === 'app.bsky.feed.post' &&
46 !(item.record as { text?: string }).text?.trim()
47 ) {
48 return false;
49 }
50 return true;
51 });
52 const teaserItems = validItems.slice(0, 5);
53
54 return (
55 <section className="mt-8" aria-label={t('title')} data-testid="activity-overview">
56 <div className="flex items-center justify-between">
57 <h2 className="text-xl font-semibold">{t('title')}</h2>
58 </div>
59 {activeApps.length > 0 && (
60 <div className="mt-3">
61 <ActivityIndicators apps={activeApps} maxVisible={5} />
62 </div>
63 )}
64 <div className="mt-4">
65 <Suspense fallback={<div className="h-[100px] w-full animate-pulse rounded-lg bg-muted" />}>
66 <ActivityHeatmap handle={handle} days={365} variant="compact" />
67 </Suspense>
68 </div>
69 {teaserItems.length > 0 && (
70 <div className="mt-4 space-y-2">
71 {teaserItems.map((item) => {
72 const SpecificCard = getCardComponent(item.collection);
73 const CardComponent = SpecificCard ?? GenericActivityCard;
74 const did = item.uri.split('/')[2] ?? '';
75 return (
76 <CardErrorBoundary key={item.uri}>
77 <CardComponent
78 uri={item.uri}
79 collection={item.collection}
80 rkey={item.rkey}
81 record={item.record}
82 authorDid={did}
83 authorHandle={handle}
84 showAuthor={false}
85 compact={true}
86 />
87 </CardErrorBoundary>
88 );
89 })}
90 </div>
91 )}
92 <Link
93 href={`/p/${handle}/activity`}
94 className="mt-4 flex w-full items-center justify-center gap-2 rounded-lg bg-muted py-3 text-sm font-medium text-foreground transition-colors hover:bg-muted/80"
95 data-testid="activity-view-all"
96 >
97 {t('viewAll')}
98 <ArrowRight className="h-4 w-4" weight="bold" aria-hidden="true" />
99 </Link>
100 </section>
101 );
102}