import React, { useEffect, useRef, useState, useCallback } from 'react'; import api from '../api'; import { getSocket } from '../socket'; interface ActivityEntry { id: string; board_id: string; user_id: string | null; actor_name: string | null; actor_email: string | null; action: string; target_type: string | null; target_id: string | null; target_label: string | null; metadata: any; created_at: string; } interface Props { boardId: string; onClose: () => void; } const PAGE = 50; const ACTION_META: Record = { 'image.added': { label: 'Image', verb: 'added', tone: 'add', icon: '🖼' }, 'video.added': { label: 'Video', verb: 'added', tone: 'add', icon: '🎞' }, 'pdf.added': { label: 'PDF', verb: 'added', tone: 'add', icon: '📄' }, 'board.created': { label: 'Board', verb: 'created', tone: 'add', icon: '✦' }, 'board.renamed': { label: 'Board', verb: 'renamed', tone: 'edit', icon: '✎' }, 'board.deleted': { label: 'Board', verb: 'deleted', tone: 'remove', icon: '✕' }, 'thread.created': { label: 'Comment', verb: 'started', tone: 'comment', icon: '💬' }, 'thread.resolved': { label: 'Thread', verb: 'resolved', tone: 'edit', icon: '✓' }, 'thread.reopened': { label: 'Thread', verb: 'reopened', tone: 'edit', icon: '↺' }, 'comment.added': { label: 'Reply', verb: 'posted', tone: 'comment', icon: '↪' }, }; const TONE_COLOR: Record = { add: '#5fc97e', remove: '#ff8a8a', edit: '#7ba9ff', comment: '#e0b75b', }; function timeAgo(iso: string): string { // SQLite returns "2026-04-28 15:50:02" — append Z so JS parses as UTC const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z'); const diff = (Date.now() - d.getTime()) / 1000; if (diff < 5) return 'just now'; if (diff < 60) return `${Math.floor(diff)}s ago`; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`; return d.toLocaleDateString(); } function dayBucket(iso: string): string { const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z'); const now = new Date(); const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); const days = Math.floor((startOfDay(now) - startOfDay(d)) / 86400000); if (days <= 0) return 'Today'; if (days === 1) return 'Yesterday'; if (days < 7) return `${days} days ago`; return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } export default function ActivityPanel({ boardId, onClose }: Props) { const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(false); const [error, setError] = useState(''); const [tick, setTick] = useState(0); // Force re-render every 30s for relative timestamps const listRef = useRef(null); const load = useCallback(async (before: string | null = null) => { if (before) setLoadingMore(true); else setLoading(true); setError(''); try { const params = new URLSearchParams({ limit: String(PAGE) }); if (before) params.append('before', before); const res = await api.get(`/api/boards/${boardId}/activity?${params.toString()}`); const newEntries: ActivityEntry[] = res.data?.activity || []; setEntries((prev) => before ? [...prev, ...newEntries] : newEntries); setHasMore(!!res.data?.hasMore); } catch (e: any) { setError(e?.response?.data?.error || 'Failed to load activity'); } finally { setLoading(false); setLoadingMore(false); } }, [boardId]); useEffect(() => { load(null); }, [load]); // Live updates via Socket.IO useEffect(() => { const socket = getSocket(); if (!socket) return; const onNew = (entry: ActivityEntry) => { if (entry.board_id !== boardId) return; setEntries((prev) => { if (prev.some((e) => e.id === entry.id)) return prev; return [entry, ...prev]; }); }; socket.on('activity:new', onNew); return () => { socket.off('activity:new', onNew); }; }, [boardId]); // Tick every 30s so relative timestamps stay fresh useEffect(() => { const t = setInterval(() => setTick((n) => n + 1), 30000); return () => clearInterval(t); }, []); // Group by day bucket const groups: { label: string; items: ActivityEntry[] }[] = []; for (const e of entries) { const bucket = dayBucket(e.created_at); const last = groups[groups.length - 1]; if (last && last.label === bucket) last.items.push(e); else groups.push({ label: bucket, items: [e] }); } const oldestTs = entries.length ? entries[entries.length - 1].created_at : null; // Quiet "tick" warning void tick; return (
{/* Header */}
~
Activity
Per-board log · live
{/* List */}
{loading ? (
Loading activity…
) : error ? (
{error}
) : entries.length === 0 ? (
No activity yet.
Uploads, board renames, threads and comments will show up here.
) : groups.map((g) => (
{g.label}
{g.items.map((e) => ( ))}
))} {hasMore && !loading && (
)}
); } function Row({ entry }: { entry: ActivityEntry }) { const meta = ACTION_META[entry.action] || { label: entry.action, verb: '', tone: 'edit' as const, icon: '•' }; const accent = TONE_COLOR[meta.tone]; return (
{meta.icon}
{entry.actor_name || 'Someone'} {meta.verb} {meta.label.toLowerCase()} {entry.target_label && ( <> · {entry.target_label} )}
{timeAgo(entry.created_at)}
); }