|
| 1 | +'use client' |
| 2 | + |
| 3 | +import Link from 'next/link' |
| 4 | +import { useCallback, useEffect, useState } from 'react' |
| 5 | + |
| 6 | +type ActivityType = 'merge' | 'pr' | 'task' | 'test' | 'forum' | 'deploy' |
| 7 | +type ActivityItem = { id: string; type: ActivityType; title: string; description?: string; actor?: string; at: number } |
| 8 | +type ActivityRes = { items: ActivityItem[]; total: number; page: number; pages: number } |
| 9 | + |
| 10 | +const TYPE_ICON: Record<ActivityType, string> = { |
| 11 | + merge: '🔀', pr: '📬', task: '📋', test: '🧪', forum: '💬', deploy: '🚀', |
| 12 | +} |
| 13 | +const TYPE_COLOR: Record<ActivityType, string> = { |
| 14 | + merge: 'var(--color-green)', pr: 'var(--color-yellow)', task: 'var(--color-muted)', |
| 15 | + test: 'var(--color-green)', forum: 'var(--color-dim)', deploy: 'var(--color-orange)', |
| 16 | +} |
| 17 | +const TYPE_LABEL: Record<ActivityType, string> = { |
| 18 | + merge: 'merge', pr: 'PR', task: 'task', test: 'test', forum: 'forum', deploy: 'deploy', |
| 19 | +} |
| 20 | +const FILTERS: Array<{ key: 'all' | ActivityType; label: string }> = [ |
| 21 | + { key: 'all', label: 'tutto' }, { key: 'merge', label: 'merge' }, { key: 'pr', label: 'PR' }, |
| 22 | + { key: 'task', label: 'task' }, { key: 'test', label: 'test' }, { key: 'forum', label: 'forum' }, |
| 23 | +] |
| 24 | + |
| 25 | +function fmtTime(ms: number) { |
| 26 | + const diff = Date.now() - ms |
| 27 | + const m = Math.floor(diff / 60000) |
| 28 | + if (m < 1) return 'adesso' |
| 29 | + if (m < 60) return `${m}m fa` |
| 30 | + const h = Math.floor(m / 60) |
| 31 | + if (h < 24) return `${h}h fa` |
| 32 | + return new Date(ms).toLocaleDateString('it-IT', { day: '2-digit', month: 'short' }) |
| 33 | +} |
| 34 | + |
| 35 | +function TimelineDot({ type }: { type: ActivityType }) { |
| 36 | + return ( |
| 37 | + <div className="flex flex-col items-center flex-shrink-0" style={{ width: 32 }}> |
| 38 | + <div className="w-7 h-7 rounded-full flex items-center justify-center text-sm flex-shrink-0" |
| 39 | + style={{ background: `${TYPE_COLOR[type]}18`, border: `1px solid ${TYPE_COLOR[type]}44` }}> |
| 40 | + {TYPE_ICON[type]} |
| 41 | + </div> |
| 42 | + </div> |
| 43 | + ) |
| 44 | +} |
| 45 | + |
| 46 | +export default function ActivityPage() { |
| 47 | + const [data, setData] = useState<ActivityRes | null>(null) |
| 48 | + const [loading, setLoading] = useState(true) |
| 49 | + const [filter, setFilter] = useState<'all' | ActivityType>('all') |
| 50 | + const [page, setPage] = useState(1) |
| 51 | + |
| 52 | + const fetchData = useCallback(async () => { |
| 53 | + setLoading(true) |
| 54 | + const q = new URLSearchParams({ page: String(page), limit: '20', type: filter }) |
| 55 | + const res = await fetch(`/api/activity?${q}`).catch(() => null) |
| 56 | + if (res?.ok) setData(await res.json()) |
| 57 | + setLoading(false) |
| 58 | + }, [page, filter]) |
| 59 | + |
| 60 | + useEffect(() => { fetchData() }, [fetchData]) |
| 61 | + |
| 62 | + const onFilter = (f: typeof filter) => { setFilter(f); setPage(1) } |
| 63 | + |
| 64 | + return ( |
| 65 | + <div style={{ animation: 'fade-in 0.35s ease both' }}> |
| 66 | + <div className="mb-8 pb-6 border-b border-[var(--color-border)]"> |
| 67 | + <div className="flex items-center gap-2 mb-1"> |
| 68 | + <Link href="/dashboard" className="text-[10px] text-[var(--color-dim)] hover:text-[var(--color-muted)] no-underline transition-colors">Dashboard</Link> |
| 69 | + <span className="text-[var(--color-border)]">/</span> |
| 70 | + <span className="text-[10px] text-[var(--color-muted)]">Attività</span> |
| 71 | + </div> |
| 72 | + <div className="mt-3"> |
| 73 | + <h1 className="text-2xl font-bold tracking-tight text-[var(--color-white)]">Attività team</h1> |
| 74 | + <p className="text-[var(--color-muted)] text-[11px] mt-1">{data ? `${data.total} eventi totali` : '…'}</p> |
| 75 | + </div> |
| 76 | + <div className="flex gap-1.5 flex-wrap mt-4"> |
| 77 | + {FILTERS.map(f => ( |
| 78 | + <button key={f.key} onClick={() => onFilter(f.key)} |
| 79 | + className="px-3 py-1.5 rounded text-[10px] font-semibold cursor-pointer transition-all" |
| 80 | + style={{ border: `1px solid ${filter === f.key ? 'var(--color-green)' : 'var(--color-border)'}`, color: filter === f.key ? 'var(--color-green)' : 'var(--color-dim)', background: filter === f.key ? 'rgba(0,232,122,0.08)' : 'transparent' }}> |
| 81 | + {f.label} |
| 82 | + </button> |
| 83 | + ))} |
| 84 | + </div> |
| 85 | + </div> |
| 86 | + |
| 87 | + {loading && <div className="flex justify-center py-16"><span className="text-[var(--color-dim)] text-[12px]">Caricamento…</span></div>} |
| 88 | + |
| 89 | + {!loading && data && data.items.length === 0 && ( |
| 90 | + <div className="flex flex-col items-center justify-center py-16 gap-3"> |
| 91 | + <span className="text-4xl">📭</span> |
| 92 | + <p className="text-[12px] font-semibold text-[var(--color-muted)]">Nessuna attività</p> |
| 93 | + <p className="text-[10px] text-[var(--color-dim)]">L'attività del team apparirà qui.</p> |
| 94 | + </div> |
| 95 | + )} |
| 96 | + |
| 97 | + {!loading && data && data.items.length > 0 && ( |
| 98 | + <div className="flex flex-col gap-0"> |
| 99 | + {data.items.map((item, i) => ( |
| 100 | + <div key={item.id} className="flex gap-3 group"> |
| 101 | + <div className="flex flex-col items-center"> |
| 102 | + <TimelineDot type={item.type} /> |
| 103 | + {i < data.items.length - 1 && <div className="w-px flex-1 mt-1" style={{ background: 'var(--color-border)', minHeight: 20 }} />} |
| 104 | + </div> |
| 105 | + <div className="flex-1 pb-5 pt-0.5"> |
| 106 | + <div className="flex items-start justify-between gap-3 flex-wrap"> |
| 107 | + <div className="flex-1 min-w-0"> |
| 108 | + <div className="flex items-center gap-2 mb-0.5 flex-wrap"> |
| 109 | + <span className="badge text-[9px]" style={{ color: TYPE_COLOR[item.type], background: `${TYPE_COLOR[item.type]}12`, border: `1px solid ${TYPE_COLOR[item.type]}33` }}> |
| 110 | + {TYPE_LABEL[item.type]} |
| 111 | + </span> |
| 112 | + {item.actor && <span className="text-[9px] text-[var(--color-dim)] font-mono">{item.actor}</span>} |
| 113 | + </div> |
| 114 | + <p className="text-[12px] text-[var(--color-bright)] leading-snug">{item.title}</p> |
| 115 | + {item.description && <p className="text-[10px] text-[var(--color-dim)] mt-0.5 truncate">{item.description}</p>} |
| 116 | + </div> |
| 117 | + <span className="text-[9px] text-[var(--color-dim)] flex-shrink-0 mt-0.5">{fmtTime(item.at)}</span> |
| 118 | + </div> |
| 119 | + </div> |
| 120 | + </div> |
| 121 | + ))} |
| 122 | + </div> |
| 123 | + )} |
| 124 | + |
| 125 | + {data && data.pages > 1 && ( |
| 126 | + <div className="flex items-center justify-between mt-6 pt-4 border-t border-[var(--color-border)]"> |
| 127 | + <button disabled={page <= 1} onClick={() => setPage(p => p - 1)} |
| 128 | + className="px-4 py-2 rounded text-[10px] font-semibold transition-colors" |
| 129 | + style={{ border: '1px solid var(--color-border)', color: page <= 1 ? 'var(--color-border)' : 'var(--color-muted)', cursor: page <= 1 ? 'default' : 'pointer', background: 'transparent' }}> |
| 130 | + ← precedente |
| 131 | + </button> |
| 132 | + <span className="text-[10px] text-[var(--color-dim)]">pag. {page} / {data.pages}</span> |
| 133 | + <button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)} |
| 134 | + className="px-4 py-2 rounded text-[10px] font-semibold transition-colors" |
| 135 | + style={{ border: '1px solid var(--color-border)', color: page >= data.pages ? 'var(--color-border)' : 'var(--color-muted)', cursor: page >= data.pages ? 'default' : 'pointer', background: 'transparent' }}> |
| 136 | + successiva → |
| 137 | + </button> |
| 138 | + </div> |
| 139 | + )} |
| 140 | + </div> |
| 141 | + ) |
| 142 | +} |
0 commit comments