← back to Hub
components/ActivityTab.tsx
214 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { SkeletonList } from './Skeleton';
import {
Activity,
FileText,
Handshake,
Award,
Megaphone,
Mail,
Zap,
Users,
Clock,
RefreshCw,
Filter,
} from 'lucide-react';
import SortDropdown from './shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
interface ActivityItem {
id: string;
app: string;
type: string;
title: string;
description: string;
created_at: string;
}
const TYPE_ICONS: Record<string, React.ElementType> = {
draft: FileText,
collaboration: Handshake,
grant: Award,
proposal: FileText,
petition: Megaphone,
campaign: Mail,
match: Zap,
contact: Users,
};
const SORT_OPTIONS = [
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'app', label: 'App A-Z' },
{ value: 'type', label: 'Type A-Z' },
];
const SORT_CONFIGS: Record<string, SortConfig> = {
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
app: { key: 'app', direction: 'asc', type: 'string' },
type: { key: 'type', direction: 'asc', type: 'string' },
};
const APP_COLORS: Record<string, string> = {
sdcc: '#3b82f6',
grant: '#059669',
patty: '#7c3aed',
freddy: '#d97706',
};
function formatTime(ts: string) {
const d = new Date(ts);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +
' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'America/Los_Angeles' });
}
export default function ActivityTab() {
const [items, setItems] = useState<ActivityItem[]>([]);
const [filter, setFilter] = useState<string>('all');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [sortBy, setSortBy] = useState('date_desc');
const fetchActivity = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
try {
const res = await fetch('/api/activity', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setItems(data.items || []);
}
} catch (err) {
console.error('Activity fetch error:', err);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => { fetchActivity(); }, [fetchActivity]);
// Auto-refresh every 30 seconds
useEffect(() => {
const interval = setInterval(() => fetchActivity(true), 30000);
return () => clearInterval(interval);
}, [fetchActivity]);
const filtered = filter === 'all' ? items : items.filter(i => i.app === filter);
const sortedFiltered = useClientSort(filtered, sortBy, SORT_CONFIGS);
if (loading) {
return <div className="p-5"><SkeletonList count={4} /></div>;
}
return (
<div className="p-5 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Activity size={20} style={{ color: 'var(--color-accent)' }} />
<div>
<h2 className="text-xl font-bold" style={{ color: 'var(--color-text)' }}>Activity Feed</h2>
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>Cross-app activity (auto-refreshes every 30s)</p>
</div>
</div>
<button
onClick={() => fetchActivity(true)}
disabled={refreshing}
className="btn btn-secondary btn-sm"
>
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{/* Filters */}
<div className="flex items-center gap-2">
<Filter size={14} style={{ color: 'var(--color-text-muted)' }} />
{['all', 'sdcc', 'grant', 'patty', 'freddy'].map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className="btn btn-sm"
style={{
backgroundColor: filter === f ? (f === 'all' ? 'var(--color-primary)' : `${APP_COLORS[f]}`) : 'var(--color-surface-el)',
color: filter === f ? '#fff' : 'var(--color-text-secondary)',
borderColor: filter === f ? 'transparent' : 'var(--color-border)',
}}
>
{f === 'all' ? 'All' : f.toUpperCase()}
</button>
))}
<div style={{ marginLeft: 'auto' }}>
<SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
</div>
</div>
{/* Activity List */}
<div className="space-y-2">
{sortedFiltered.map((item) => {
const TypeIcon = TYPE_ICONS[item.type] || Activity;
const appColor = APP_COLORS[item.app] || 'var(--color-accent)';
return (
<div
key={item.id}
className="card flex items-start gap-3"
>
<div
className="flex items-center justify-center shrink-0 mt-0.5"
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: `${appColor}18`,
}}
>
<TypeIcon size={16} style={{ color: appColor }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span
className="badge"
style={{
backgroundColor: `${appColor}18`,
color: appColor,
border: `1px solid ${appColor}40`,
fontSize: 10,
padding: '1px 6px',
}}
>
{item.app.toUpperCase()}
</span>
<span className="badge" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border)', fontSize: 10, padding: '1px 6px' }}>
{item.type}
</span>
</div>
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
{item.title}
</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
{item.description}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Clock size={11} style={{ color: 'var(--color-text-muted)' }} />
<span className="text-xs whitespace-nowrap" style={{ color: 'var(--color-text-muted)' }}>
{formatTime(item.created_at)}
</span>
</div>
</div>
);
})}
{sortedFiltered.length === 0 && (
<div className="text-center py-8">
<Activity size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 8px' }} />
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>No activity found</p>
</div>
)}
</div>
</div>
);
}