Sifa professional network frontend (Next.js, React, TailwindCSS)
sifa.id/
1'use client';
2
3import { useState } from 'react';
4import { useTranslations } from 'next-intl';
5import {
6 ChatCircle,
7 GitBranch,
8 CalendarBlank,
9 Camera,
10 Article,
11 Newspaper,
12 ChatsCircle,
13 LinkSimple,
14 Clipboard,
15 BookmarkSimple,
16 FileText,
17 Globe,
18 UsersThree,
19 Key,
20 Star,
21 Broadcast,
22 CircleDashed,
23} from '@phosphor-icons/react';
24import type { Icon } from '@phosphor-icons/react';
25
26import { getAppMeta, getAppStripeColor } from '@/lib/atproto-apps';
27import type { ActiveApp } from '@/lib/types';
28import { ActivityTooltip } from './activity-tooltip';
29
30const MOBILE_MAX = 3;
31
32const ICON_MAP: Record<string, Icon> = {
33 bluesky: ChatCircle,
34 tangled: GitBranch,
35 smokesignal: CalendarBlank,
36 flashes: Camera,
37 whitewind: Article,
38 frontpage: Newspaper,
39 picosky: ChatsCircle,
40 linkat: LinkSimple,
41 pastesphere: Clipboard,
42 kipclip: BookmarkSimple,
43 standard: FileText,
44 aetheros: Globe,
45 roomy: UsersThree,
46 keytrace: Key,
47 popfeed: Star,
48 streamplace: Broadcast,
49};
50
51interface ActivityIndicatorsProps {
52 apps: ActiveApp[];
53 maxVisible?: number;
54 activeFilter?: string | null;
55 onFilter?: (appId: string | null) => void;
56}
57
58export function ActivityIndicators({
59 apps,
60 maxVisible = 5,
61 activeFilter,
62 onFilter,
63}: ActivityIndicatorsProps) {
64 const [expanded, setExpanded] = useState(false);
65 const t = useTranslations('activityIndicators');
66
67 if (apps.length === 0) return null;
68
69 const sorted = [...apps].sort((a, b) => b.recentCount - a.recentCount);
70 const visible = sorted.slice(0, maxVisible);
71 const overflow = sorted.slice(maxVisible);
72 const overflowCount = overflow.length;
73
74 function handleClick(appId: string) {
75 if (!onFilter) return;
76 onFilter(activeFilter === appId ? null : appId);
77 }
78
79 function renderPill(app: ActiveApp, index: number) {
80 const meta = getAppMeta(app.id);
81 const IconComponent = ICON_MAP[app.id] ?? CircleDashed;
82 const label = t('activeOn', { app: meta.name });
83 const displayClass = !expanded && index >= MOBILE_MAX ? 'hidden sm:inline-flex' : 'inline-flex';
84 const stripe = getAppStripeColor(app.id);
85 const pillStyle = {
86 '--_accent': stripe,
87 backgroundColor: `color-mix(in oklch, ${stripe} 12%, transparent)`,
88 color: stripe,
89 borderColor: `color-mix(in oklch, ${stripe} 35%, transparent)`,
90 } as React.CSSProperties;
91 const pillClasses = `${displayClass} items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors`;
92
93 const pill = onFilter ? (
94 <button
95 type="button"
96 className={`${pillClasses} hover:opacity-80`}
97 style={pillStyle}
98 aria-label={label}
99 aria-pressed={activeFilter === app.id}
100 onClick={() => handleClick(app.id)}
101 >
102 <IconComponent size={14} weight="regular" aria-hidden="true" />
103 {meta.name}
104 </button>
105 ) : (
106 <span className={pillClasses} style={pillStyle} aria-label={label}>
107 <IconComponent size={14} weight="regular" aria-hidden="true" />
108 {meta.name}
109 </span>
110 );
111
112 return (
113 <ActivityTooltip
114 key={app.id}
115 appName={meta.name}
116 tooltipDescription={meta.tooltipDescription}
117 tooltipNetworkNote={meta.tooltipNetworkNote}
118 appUrl={meta.appUrl}
119 >
120 {pill}
121 </ActivityTooltip>
122 );
123 }
124
125 return (
126 <div role="group" aria-label={t('label')} className="flex flex-wrap items-center gap-1.5">
127 <span className="text-xs font-medium text-muted-foreground">{t('rowLabel')}</span>
128 {visible.map((app, index) => renderPill(app, index))}
129 {expanded && overflow.map((app, index) => renderPill(app, visible.length + index))}
130 {/* Mobile overflow: visible below sm, counts pills hidden by CSS */}
131 {!expanded && sorted.length > MOBILE_MAX && (
132 <button
133 type="button"
134 className="inline-flex items-center rounded-full border border-border bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:hidden"
135 onClick={() => setExpanded(true)}
136 >
137 {t('moreApps', { count: sorted.length - MOBILE_MAX })}
138 </button>
139 )}
140 {/* Desktop overflow: visible at sm+ */}
141 {!expanded && overflowCount > 0 && (
142 <button
143 type="button"
144 className="hidden items-center rounded-full border border-border bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:inline-flex"
145 aria-expanded={expanded}
146 onClick={() => setExpanded(true)}
147 >
148 {t('moreApps', { count: overflowCount })}
149 </button>
150 )}
151 </div>
152 );
153}