Sifa professional network frontend (Next.js, React, TailwindCSS) sifa.id/
at main 153 lines 4.7 kB view raw
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}