'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { createClient } from '@/lib/supabase/client' import RevertConfirmDialog from './RevertConfirmDialog' const PAGE_SIZE = 20 export type AuditEntry = { id: string project_id: string user_id: string action_type: string entity_id: string previous_state: Record | null new_state: Record | null created_at: string user_display_name?: string } type ActivityHistorySidebarProps = { projectId: string onClose: () => void onSelectEntity: (entityId: string, actionType: string) => void onRevert: (entry: AuditEntry) => void } const ACTION_LABELS: Record = { node_add: 'Added node', node_update: 'Updated node', node_delete: 'Deleted node', edge_add: 'Added edge', edge_update: 'Updated edge', edge_delete: 'Deleted edge', } const ACTION_ICONS: Record = { node_add: '+', node_update: '~', node_delete: '-', edge_add: '+', edge_update: '~', edge_delete: '-', } const ACTION_COLORS: Record = { node_add: 'text-green-500', node_update: 'text-blue-500', node_delete: 'text-red-500', edge_add: 'text-green-500', edge_update: 'text-blue-500', edge_delete: 'text-red-500', } // Same hash logic as PresenceAvatars and FlowchartEditor const PRESENCE_COLORS = [ '#EF4444', '#F97316', '#F59E0B', '#10B981', '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', '#6366F1', '#F43F5E', '#84CC16', '#06B6D4', ] function getUserColor(userId: string): string { let hash = 0 for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0 } return PRESENCE_COLORS[Math.abs(hash) % PRESENCE_COLORS.length] } function getEntityDescription(entry: AuditEntry): string { const state = entry.new_state || entry.previous_state if (!state) return entry.entity_id.slice(0, 8) if (entry.action_type.startsWith('node_')) { const type = (state as Record).type as string | undefined const data = (state as Record).data as Record | undefined if (type === 'dialogue' && data) { const speaker = (data.speaker as string) || (data.characterId as string) || '' return speaker ? `Dialogue (${speaker})` : 'Dialogue node' } if (type === 'choice' && data) { const question = (data.question as string) || '' return question ? `Choice: "${question.slice(0, 20)}${question.length > 20 ? '…' : ''}"` : 'Choice node' } if (type === 'variable' && data) { const name = (data.variableName as string) || '' return name ? `Variable: ${name}` : 'Variable node' } return `${type || 'Unknown'} node` } // Edge entries return 'Connection' } function formatTime(dateStr: string): string { const date = new Date(dateStr) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } function getTimePeriod(dateStr: string): 'Today' | 'Yesterday' | 'Earlier' { const date = new Date(dateStr) const now = new Date() const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const yesterday = new Date(today) yesterday.setDate(yesterday.getDate() - 1) if (date >= today) return 'Today' if (date >= yesterday) return 'Yesterday' return 'Earlier' } type GroupedEntries = { period: 'Today' | 'Yesterday' | 'Earlier' entries: AuditEntry[] } function groupByPeriod(entries: AuditEntry[]): GroupedEntries[] { const groups: Record = {} const order: ('Today' | 'Yesterday' | 'Earlier')[] = ['Today', 'Yesterday', 'Earlier'] for (const entry of entries) { const period = getTimePeriod(entry.created_at) if (!groups[period]) groups[period] = [] groups[period].push(entry) } return order .filter((p) => groups[p] && groups[p].length > 0) .map((p) => ({ period: p, entries: groups[p] })) } export default function ActivityHistorySidebar({ projectId, onClose, onSelectEntity, onRevert, }: ActivityHistorySidebarProps) { const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const [hasMore, setHasMore] = useState(true) const [error, setError] = useState(null) const [revertEntry, setRevertEntry] = useState(null) const mountedRef = useRef(true) const fetchEntriesRaw = useCallback(async (offset: number): Promise<{ entries: AuditEntry[]; hasMore: boolean; error?: string }> => { const supabase = createClient() const { data, error: fetchError } = await supabase .from('audit_trail') .select('id, project_id, user_id, action_type, entity_id, previous_state, new_state, created_at') .eq('project_id', projectId) .order('created_at', { ascending: false }) .range(offset, offset + PAGE_SIZE - 1) if (fetchError) { return { entries: [], hasMore: false, error: fetchError.message } } const moreAvailable = !!data && data.length >= PAGE_SIZE // Fetch user display names for the entries if (data && data.length > 0) { const userIds = [...new Set(data.map((e) => e.user_id))] const { data: profiles } = await supabase .from('profiles') .select('id, display_name') .in('id', userIds) const nameMap = new Map() if (profiles) { for (const p of profiles) { nameMap.set(p.id, p.display_name || 'Unknown') } } return { entries: data.map((e) => ({ ...e, user_display_name: nameMap.get(e.user_id) || 'Unknown', })), hasMore: moreAvailable, } } return { entries: data || [], hasMore: moreAvailable } }, [projectId]) useEffect(() => { mountedRef.current = true const controller = new AbortController() fetchEntriesRaw(0).then((result) => { if (!controller.signal.aborted && mountedRef.current) { if (result.error) { setError(result.error) } else { setEntries(result.entries) setHasMore(result.hasMore) } setLoading(false) } }) return () => { controller.abort() mountedRef.current = false } }, [fetchEntriesRaw]) const handleLoadMore = async () => { setLoadingMore(true) const result = await fetchEntriesRaw(entries.length) if (result.error) { setError(result.error) } else { setEntries((prev) => [...prev, ...result.entries]) setHasMore(result.hasMore) } setLoadingMore(false) } const grouped = groupByPeriod(entries) return (

Activity History

{loading && (
)} {error && (
Failed to load history: {error}
)} {!loading && !error && entries.length === 0 && (
No activity recorded yet
)} {!loading && grouped.map((group) => (
{group.period}
{group.entries.map((entry) => { const userColor = getUserColor(entry.user_id) const isDeleted = entry.action_type.endsWith('_delete') return (
{ACTION_ICONS[entry.action_type] || '?'}
{getEntityDescription(entry)}
{entry.user_display_name} · {formatTime(entry.created_at)}
) })}
))} {!loading && hasMore && entries.length > 0 && (
)}
{revertEntry && ( { onRevert(revertEntry) setRevertEntry(null) }} onCancel={() => setRevertEntry(null)} /> )}
) }