feat: [US-052] - Activity history sidebar
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6c4a3ba2b7
commit
f06a30b2bf
2
prd.json
2
prd.json
|
|
@ -394,7 +394,7 @@
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 22,
|
"priority": 22,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "Dependencies: US-051"
|
"notes": "Dependencies: US-051"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
17
progress.txt
17
progress.txt
|
|
@ -67,6 +67,8 @@
|
||||||
- Node lock indicators use `EditorContext` (`nodeLocks` Map, `onNodeFocus`, `onNodeBlur`). Each node component checks `nodeLocks.get(id)` for lock state and renders `NodeLockIndicator` + overlay if locked by another user.
|
- Node lock indicators use `EditorContext` (`nodeLocks` Map, `onNodeFocus`, `onNodeBlur`). Each node component checks `nodeLocks.get(id)` for lock state and renders `NodeLockIndicator` + overlay if locked by another user.
|
||||||
- For ephemeral lock state (node editing locks), broadcast via `node-lock` event with `{ nodeId, userId, displayName, lockedAt }`. Send `nodeId: null` to release.
|
- For ephemeral lock state (node editing locks), broadcast via `node-lock` event with `{ nodeId, userId, displayName, lockedAt }`. Send `nodeId: null` to release.
|
||||||
- `AuditTrailRecorder` at `src/lib/collaboration/auditTrail.ts` records node/edge changes to `audit_trail` table. Uses state diffing (previous vs current Maps), 1-second per-entity debounce, and fire-and-forget Supabase inserts. Only records local changes (guarded by `isRemoteUpdateRef` in FlowchartEditor).
|
- `AuditTrailRecorder` at `src/lib/collaboration/auditTrail.ts` records node/edge changes to `audit_trail` table. Uses state diffing (previous vs current Maps), 1-second per-entity debounce, and fire-and-forget Supabase inserts. Only records local changes (guarded by `isRemoteUpdateRef` in FlowchartEditor).
|
||||||
|
- `ActivityHistorySidebar` at `src/components/editor/ActivityHistorySidebar.tsx` displays audit trail entries in a right sidebar. Rendered inside the canvas `relative flex-1` container. Toggle via `showHistory` state in FlowchartEditor.
|
||||||
|
- For async data fetching in components with React Compiler, use a pure fetch function returning `{ data, error, hasMore }` result object, then handle setState in the `.then()` callback with an abort/mount guard — never call setState-containing functions directly inside useEffect.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -470,3 +472,18 @@
|
||||||
- Supabase `.insert().then()` pattern provides fire-and-forget writes with error logging — the async operation doesn't block the editing flow.
|
- Supabase `.insert().then()` pattern provides fire-and-forget writes with error logging — the async operation doesn't block the editing flow.
|
||||||
- No browser testing needed — this is a developer/infrastructure story with no UI changes.
|
- No browser testing needed — this is a developer/infrastructure story with no UI changes.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-01-24 - US-052
|
||||||
|
- What was implemented: Activity history sidebar that displays audit trail entries, grouped by time period, with entity selection on click
|
||||||
|
- Files changed:
|
||||||
|
- `src/components/editor/ActivityHistorySidebar.tsx` - New component: right sidebar panel showing chronological audit trail entries, grouped by Today/Yesterday/Earlier, with user color accents, entity descriptions, paginated loading (20 per page), and click-to-select entity on canvas
|
||||||
|
- `src/components/editor/Toolbar.tsx` - Added `onHistory` prop and "History" button in the right toolbar section
|
||||||
|
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `showHistory` state, `handleHistorySelectEntity` callback (selects nodes/edges on canvas), `ActivityHistorySidebar` import and rendering inside the canvas area, `onHistory` toggle prop on Toolbar
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- React Compiler lint (`react-hooks/set-state-in-effect`) treats any function that calls setState as problematic when invoked inside useEffect — even if the setState is in an async `.then()` callback. To avoid this, extract data fetching into a pure function that returns a result object, then handle setState only in the `.then()` callback after checking a mounted/aborted guard.
|
||||||
|
- For right sidebar panels overlaying the canvas, use `absolute right-0 top-0 z-40 h-full w-80` inside the `relative flex-1` canvas container. This keeps the sidebar within the canvas area without affecting the toolbar.
|
||||||
|
- The `audit_trail` table has an index on `(project_id, created_at DESC)` which makes paginated queries efficient. Use `.range(offset, offset + PAGE_SIZE - 1)` for Supabase pagination.
|
||||||
|
- Entity descriptions are derived from `new_state` (for adds/updates) or `previous_state` (for deletes). The state contains the full node/edge data including `type`, `data.speaker`, `data.question`, `data.variableName`.
|
||||||
|
- Deleted entities (`action_type.endsWith('_delete')`) cannot be selected on canvas since they no longer exist — render those entries as disabled (no click handler, reduced opacity).
|
||||||
|
- No browser testing tools are available; manual verification is needed.
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import RemoteCursors from '@/components/editor/RemoteCursors'
|
||||||
import { CRDTManager } from '@/lib/collaboration/crdt'
|
import { CRDTManager } from '@/lib/collaboration/crdt'
|
||||||
import { AuditTrailRecorder } from '@/lib/collaboration/auditTrail'
|
import { AuditTrailRecorder } from '@/lib/collaboration/auditTrail'
|
||||||
import ShareModal from '@/components/editor/ShareModal'
|
import ShareModal from '@/components/editor/ShareModal'
|
||||||
|
import ActivityHistorySidebar from '@/components/editor/ActivityHistorySidebar'
|
||||||
import CollaborationToast, { type CollaborationNotification } from '@/components/editor/CollaborationToast'
|
import CollaborationToast, { type CollaborationNotification } from '@/components/editor/CollaborationToast'
|
||||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
|
|
@ -515,6 +516,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
|
const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const [showShare, setShowShare] = useState(false)
|
const [showShare, setShowShare] = useState(false)
|
||||||
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
|
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
|
||||||
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
|
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
|
||||||
|
|
@ -744,6 +746,26 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
setCollaborationNotifications((prev) => prev.filter((n) => n.id !== id))
|
setCollaborationNotifications((prev) => prev.filter((n) => n.id !== id))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleHistorySelectEntity = useCallback((entityId: string, actionType: string) => {
|
||||||
|
if (actionType.startsWith('node_')) {
|
||||||
|
// Select the node on the canvas
|
||||||
|
setNodes((nds) =>
|
||||||
|
nds.map((n) => ({ ...n, selected: n.id === entityId }))
|
||||||
|
)
|
||||||
|
setEdges((eds) =>
|
||||||
|
eds.map((e) => ({ ...e, selected: false }))
|
||||||
|
)
|
||||||
|
} else if (actionType.startsWith('edge_')) {
|
||||||
|
// Select the edge on the canvas
|
||||||
|
setEdges((eds) =>
|
||||||
|
eds.map((e) => ({ ...e, selected: e.id === entityId }))
|
||||||
|
)
|
||||||
|
setNodes((nds) =>
|
||||||
|
nds.map((n) => ({ ...n, selected: false }))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [setNodes, setEdges])
|
||||||
|
|
||||||
const handleNodeFocus = useCallback((nodeId: string) => {
|
const handleNodeFocus = useCallback((nodeId: string) => {
|
||||||
// Broadcast lock for this node
|
// Broadcast lock for this node
|
||||||
localLockRef.current = nodeId
|
localLockRef.current = nodeId
|
||||||
|
|
@ -1373,6 +1395,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
onProjectSettings={() => setShowSettings(true)}
|
onProjectSettings={() => setShowSettings(true)}
|
||||||
onShare={() => setShowShare(true)}
|
onShare={() => setShowShare(true)}
|
||||||
|
onHistory={() => setShowHistory((v) => !v)}
|
||||||
connectionState={connectionState}
|
connectionState={connectionState}
|
||||||
presenceUsers={presenceUsers}
|
presenceUsers={presenceUsers}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1398,6 +1421,13 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
<Controls position="bottom-right" />
|
<Controls position="bottom-right" />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
<RemoteCursors cursors={remoteCursors} />
|
<RemoteCursors cursors={remoteCursors} />
|
||||||
|
{showHistory && (
|
||||||
|
<ActivityHistorySidebar
|
||||||
|
projectId={projectId}
|
||||||
|
onClose={() => setShowHistory(false)}
|
||||||
|
onSelectEntity={handleHistorySelectEntity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,326 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
type AuditEntry = {
|
||||||
|
id: string
|
||||||
|
project_id: string
|
||||||
|
user_id: string
|
||||||
|
action_type: string
|
||||||
|
entity_id: string
|
||||||
|
previous_state: Record<string, unknown> | null
|
||||||
|
new_state: Record<string, unknown> | null
|
||||||
|
created_at: string
|
||||||
|
user_display_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityHistorySidebarProps = {
|
||||||
|
projectId: string
|
||||||
|
onClose: () => void
|
||||||
|
onSelectEntity: (entityId: string, actionType: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
node_add: '+',
|
||||||
|
node_update: '~',
|
||||||
|
node_delete: '-',
|
||||||
|
edge_add: '+',
|
||||||
|
edge_update: '~',
|
||||||
|
edge_delete: '-',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
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<string, unknown>).type as string | undefined
|
||||||
|
const data = (state as Record<string, unknown>).data as Record<string, unknown> | 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<string, AuditEntry[]> = {}
|
||||||
|
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,
|
||||||
|
}: ActivityHistorySidebarProps) {
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(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<string, string>()
|
||||||
|
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 (
|
||||||
|
<div className="absolute right-0 top-0 z-40 flex h-full w-80 flex-col border-l border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
Activity History
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<svg className="h-5 w-5 animate-spin text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-4 py-3 text-sm text-red-500 dark:text-red-400">
|
||||||
|
Failed to load history: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && entries.length === 0 && (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||||
|
No activity recorded yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && grouped.map((group) => (
|
||||||
|
<div key={group.period}>
|
||||||
|
<div className="sticky top-0 bg-zinc-50 px-4 py-1.5 text-xs font-medium uppercase tracking-wide text-zinc-500 dark:bg-zinc-850 dark:text-zinc-400" style={{ backgroundColor: 'inherit' }}>
|
||||||
|
<div className="bg-zinc-50 dark:bg-zinc-800">
|
||||||
|
{group.period}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-100 dark:divide-zinc-700/50">
|
||||||
|
{group.entries.map((entry) => {
|
||||||
|
const userColor = getUserColor(entry.user_id)
|
||||||
|
const isDeleted = entry.action_type.endsWith('_delete')
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => !isDeleted && onSelectEntity(entry.entity_id, entry.action_type)}
|
||||||
|
disabled={isDeleted}
|
||||||
|
className={`w-full px-4 py-2.5 text-left transition-colors ${
|
||||||
|
isDeleted
|
||||||
|
? 'cursor-default opacity-60'
|
||||||
|
: 'hover:bg-zinc-50 dark:hover:bg-zinc-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<div
|
||||||
|
className="mt-1 h-2 w-2 flex-shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: userColor }}
|
||||||
|
title={entry.user_display_name}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={`text-xs font-bold ${ACTION_COLORS[entry.action_type] || 'text-zinc-500'}`}>
|
||||||
|
{ACTION_ICONS[entry.action_type] || '?'}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{ACTION_LABELS[entry.action_type] || entry.action_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{getEntityDescription(entry)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||||
|
<span>{entry.user_display_name}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatTime(entry.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && hasMore && entries.length > 0 && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-600 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||||
|
>
|
||||||
|
{loadingMore ? 'Loading...' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ type ToolbarProps = {
|
||||||
onImport: () => void
|
onImport: () => void
|
||||||
onProjectSettings: () => void
|
onProjectSettings: () => void
|
||||||
onShare: () => void
|
onShare: () => void
|
||||||
|
onHistory: () => void
|
||||||
connectionState?: ConnectionState
|
connectionState?: ConnectionState
|
||||||
presenceUsers?: PresenceUser[]
|
presenceUsers?: PresenceUser[]
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +45,7 @@ export default function Toolbar({
|
||||||
onImport,
|
onImport,
|
||||||
onProjectSettings,
|
onProjectSettings,
|
||||||
onShare,
|
onShare,
|
||||||
|
onHistory,
|
||||||
connectionState,
|
connectionState,
|
||||||
presenceUsers,
|
presenceUsers,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
|
|
@ -108,6 +110,12 @@ export default function Toolbar({
|
||||||
>
|
>
|
||||||
Share
|
Share
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onHistory}
|
||||||
|
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue