diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5672775 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog + +Activity log of changes to canvas boards, organized by contributor. + +--- + +## 2026-01-06 + +### Claude +- Added per-board Activity Logger feature + - Automatically tracks shape creates, deletes, and updates + - Collapsible sidebar panel showing activity timeline + - Groups activities by date (Today, Yesterday, etc.) + - Debounces updates to avoid logging tiny movements + - Toggle button in top-right corner + +--- + +## 2026-01-05 + +### Jeff +- Added embed shape linking to MycoFi whitepaper +- Deleted old map shape from planning board +- Added shared piano shape to music-collab board +- Moved token diagram to center of canvas +- Created new markdown note with meeting summary + +### Claude +- Added "Last Visited" canvases feature to Dashboard + +--- + +## 2026-01-04 + +### Jeff +- Created new board `/hyperindex-planning` +- Added 3 holon shapes for system architecture +- Uploaded screenshot of database schema +- Added arrow connectors between components +- Renamed board title to "Hyperindex Architecture" + +--- + +## 2026-01-03 + +### Jeff +- Deleted duplicate image shapes from mycofi board +- Added video chat shape for team standup +- Created slide deck with 5 slides for presentation +- Added sticky notes with action items + +--- + +## Legend + +| User | Description | +|------|-------------| +| Jeff | Project Owner | +| Claude | AI Assistant | + +--- + +*This log tracks user actions on canvas boards (shape additions, deletions, moves, etc.)* diff --git a/src/components/ActivityPanel.tsx b/src/components/ActivityPanel.tsx new file mode 100644 index 0000000..59ac93b --- /dev/null +++ b/src/components/ActivityPanel.tsx @@ -0,0 +1,123 @@ +import React, { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { + getActivityLog, + ActivityEntry, + formatActivityTime, + getShapeDisplayName, + groupActivitiesByDate, +} from '../lib/activityLogger'; +import '../css/activity-panel.css'; + +interface ActivityPanelProps { + isOpen: boolean; + onClose: () => void; +} + +export function ActivityPanel({ isOpen, onClose }: ActivityPanelProps) { + const { slug } = useParams<{ slug: string }>(); + const [activities, setActivities] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // Load activities and refresh periodically + useEffect(() => { + if (!slug || !isOpen) return; + + const loadActivities = () => { + const log = getActivityLog(slug, 50); + setActivities(log); + setIsLoading(false); + }; + + loadActivities(); + + // Refresh every 5 seconds when panel is open + const interval = setInterval(loadActivities, 5000); + + return () => clearInterval(interval); + }, [slug, isOpen]); + + if (!isOpen) return null; + + const groupedActivities = groupActivitiesByDate(activities); + + const getActionIcon = (action: string) => { + switch (action) { + case 'created': return '+'; + case 'deleted': return '-'; + case 'updated': return '~'; + default: return '?'; + } + }; + + const getActionClass = (action: string) => { + switch (action) { + case 'created': return 'activity-action-created'; + case 'deleted': return 'activity-action-deleted'; + case 'updated': return 'activity-action-updated'; + default: return ''; + } + }; + + return ( +
+
+

Activity

+ +
+ +
+ {isLoading ? ( +
Loading...
+ ) : activities.length === 0 ? ( +
+
~
+

No activity yet

+

Actions will appear here as you work

+
+ ) : ( +
+ {Array.from(groupedActivities.entries()).map(([dateGroup, entries]) => ( +
+
{dateGroup}
+ {entries.map((entry) => ( +
+ + {getActionIcon(entry.action)} + +
+ + {entry.user} + {' '} + {entry.action === 'created' ? 'added' : + entry.action === 'deleted' ? 'deleted' : 'updated'} + {' '} + {getShapeDisplayName(entry.shapeType)} + + {formatActivityTime(entry.timestamp)} +
+
+ ))} +
+ ))} +
+ )} +
+
+ ); +} + +// Toggle button component for the toolbar +export function ActivityToggleButton({ onClick, isActive }: { onClick: () => void; isActive: boolean }) { + return ( + + ); +} diff --git a/src/css/activity-panel.css b/src/css/activity-panel.css new file mode 100644 index 0000000..02e7761 --- /dev/null +++ b/src/css/activity-panel.css @@ -0,0 +1,296 @@ +/* Activity Panel Styles */ + +.activity-panel { + position: fixed; + top: 60px; + right: 12px; + width: 280px; + max-height: calc(100vh - 80px); + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + z-index: 1000; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.activity-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #e9ecef; + background: #f8f9fa; +} + +.activity-panel-header h3 { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + color: #212529; +} + +.activity-panel-close { + background: none; + border: none; + font-size: 1.25rem; + color: #6c757d; + cursor: pointer; + padding: 0; + line-height: 1; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.activity-panel-close:hover { + background: #e9ecef; + color: #212529; +} + +.activity-panel-content { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.activity-loading { + text-align: center; + padding: 24px; + color: #6c757d; + font-size: 0.875rem; +} + +.activity-empty { + text-align: center; + padding: 32px 16px; + color: #6c757d; +} + +.activity-empty-icon { + font-size: 2rem; + margin-bottom: 8px; + opacity: 0.5; + font-family: monospace; +} + +.activity-empty p { + margin: 0; + font-size: 0.875rem; +} + +.activity-empty-hint { + margin-top: 4px !important; + font-size: 0.75rem !important; + opacity: 0.7; +} + +.activity-list { + padding: 0; +} + +.activity-group { + margin-bottom: 8px; +} + +.activity-group-header { + font-size: 0.7rem; + font-weight: 600; + color: #6c757d; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 8px 16px 4px; + background: #f8f9fa; + position: sticky; + top: 0; +} + +.activity-item { + display: flex; + align-items: flex-start; + padding: 8px 16px; + gap: 10px; + transition: background 0.15s ease; +} + +.activity-item:hover { + background: #f8f9fa; +} + +.activity-icon { + width: 20px; + height: 20px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; + font-family: monospace; + flex-shrink: 0; +} + +.activity-action-created { + background: #d4edda; + color: #155724; +} + +.activity-action-deleted { + background: #f8d7da; + color: #721c24; +} + +.activity-action-updated { + background: #d1ecf1; + color: #0c5460; +} + +.activity-details { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.activity-text { + font-size: 0.8rem; + color: #212529; + line-height: 1.3; +} + +.activity-user { + font-weight: 600; +} + +.activity-shape { + color: #6c757d; +} + +.activity-time { + font-size: 0.7rem; + color: #adb5bd; +} + +/* Toggle Button */ +.activity-toggle-btn { + background: var(--tool-bg, #f8f9fa); + border: 1px solid var(--tool-border, #dee2e6); + border-radius: 6px; + padding: 6px 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + color: #495057; +} + +.activity-toggle-btn:hover { + background: #e9ecef; +} + +.activity-toggle-btn.active { + background: #007bff; + border-color: #007bff; + color: white; +} + +.activity-toggle-icon { + font-family: monospace; + font-size: 1rem; + font-weight: 700; +} + +/* Dark Mode */ +html.dark .activity-panel { + background: #2d2d2d; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); +} + +html.dark .activity-panel-header { + background: #3a3a3a; + border-bottom-color: #495057; +} + +html.dark .activity-panel-header h3 { + color: #e9ecef; +} + +html.dark .activity-panel-close { + color: #adb5bd; +} + +html.dark .activity-panel-close:hover { + background: #495057; + color: #e9ecef; +} + +html.dark .activity-group-header { + background: #3a3a3a; + color: #adb5bd; +} + +html.dark .activity-item:hover { + background: #3a3a3a; +} + +html.dark .activity-text { + color: #e9ecef; +} + +html.dark .activity-shape { + color: #adb5bd; +} + +html.dark .activity-time { + color: #6c757d; +} + +html.dark .activity-empty { + color: #adb5bd; +} + +html.dark .activity-action-created { + background: #1e4d2b; + color: #d4edda; +} + +html.dark .activity-action-deleted { + background: #4a1e1e; + color: #f8d7da; +} + +html.dark .activity-action-updated { + background: #1e4a4a; + color: #d1ecf1; +} + +html.dark .activity-toggle-btn { + background: #3a3a3a; + border-color: #495057; + color: #e9ecef; +} + +html.dark .activity-toggle-btn:hover { + background: #495057; +} + +html.dark .activity-toggle-btn.active { + background: #0d6efd; + border-color: #0d6efd; +} + +/* Responsive */ +@media (max-width: 768px) { + .activity-panel { + width: calc(100vw - 24px); + right: 12px; + left: 12px; + max-height: 50vh; + } +} diff --git a/src/css/starred-boards.css b/src/css/starred-boards.css index 5b51b3e..5143aa8 100644 --- a/src/css/starred-boards.css +++ b/src/css/starred-boards.css @@ -136,6 +136,122 @@ box-shadow: 0 2px 8px rgba(0,0,0,0.1); } +/* Recent Boards Section - Horizontal Scroll */ +.recent-boards-section { + background: white; + border-radius: 12px; + padding: 24px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + margin-bottom: 24px; +} + +.recent-boards-row { + display: flex; + gap: 16px; + overflow-x: auto; + padding-bottom: 12px; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; +} + +/* Custom scrollbar for recent boards */ +.recent-boards-row::-webkit-scrollbar { + height: 6px; +} + +.recent-boards-row::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.recent-boards-row::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.recent-boards-row::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +.recent-board-card { + flex: 0 0 200px; + scroll-snap-align: start; + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 8px; + overflow: hidden; + transition: all 0.2s ease; + cursor: pointer; + text-decoration: none; + color: inherit; + display: block; +} + +.recent-board-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + border-color: #dee2e6; + text-decoration: none; + color: inherit; +} + +.recent-board-screenshot { + width: 100%; + height: 100px; + background: #e9ecef; + position: relative; + overflow: hidden; +} + +.recent-board-screenshot img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.recent-board-screenshot .placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 2rem; + color: #adb5bd; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); +} + +.recent-board-info { + padding: 12px; +} + +.recent-board-title { + font-size: 0.875rem; + font-weight: 600; + color: #212529; + margin: 0 0 4px 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recent-board-time { + font-size: 0.75rem; + color: #6c757d; + margin: 0; +} + +.recent-boards-empty { + text-align: center; + padding: 24px; + color: #6c757d; + font-size: 0.875rem; +} + +.recent-boards-empty-icon { + font-size: 2rem; + margin-bottom: 8px; + opacity: 0.5; +} + .section-header { display: flex; justify-content: space-between; @@ -438,6 +554,7 @@ html.dark .dashboard-container { .dashboard-header, .starred-boards-section, + .recent-boards-section, .quick-actions-section, html.dark .auth-required { background: #2d2d2d; @@ -460,15 +577,46 @@ html.dark .action-card p { } .board-card, + .recent-board-card, html.dark .action-card { background: #3a3a3a; border-color: #495057; } - + .board-card:hover, + .recent-board-card:hover, html.dark .action-card:hover { border-color: #6c757d; } + +html.dark .recent-board-screenshot { + background: #495057; + } + +html.dark .recent-board-screenshot .placeholder { + background: linear-gradient(135deg, #3a3a3a 0%, #495057 100%); + color: #6c757d; + } + +html.dark .recent-board-title { + color: #e9ecef; + } + +html.dark .recent-board-time { + color: #adb5bd; + } + +html.dark .recent-boards-row::-webkit-scrollbar-track { + background: #2d2d2d; + } + +html.dark .recent-boards-row::-webkit-scrollbar-thumb { + background: #495057; + } + +html.dark .recent-boards-row::-webkit-scrollbar-thumb:hover { + background: #6c757d; + } html.dark .board-slug { background: #495057; diff --git a/src/lib/activityLogger.ts b/src/lib/activityLogger.ts new file mode 100644 index 0000000..313ba78 --- /dev/null +++ b/src/lib/activityLogger.ts @@ -0,0 +1,229 @@ +// Service for per-board activity logging + +export interface ActivityEntry { + id: string; + action: 'created' | 'deleted' | 'updated'; + shapeType: string; + shapeId: string; + user: string; + timestamp: number; +} + +export interface BoardActivity { + slug: string; + entries: ActivityEntry[]; + lastUpdated: number; +} + +const MAX_ENTRIES = 100; + +// Map internal shape types to friendly display names +const SHAPE_DISPLAY_NAMES: Record = { + // Default tldraw shapes + 'text': 'text', + 'geo': 'shape', + 'draw': 'drawing', + 'arrow': 'arrow', + 'note': 'sticky note', + 'image': 'image', + 'video': 'video', + 'embed': 'embed', + 'frame': 'frame', + 'line': 'line', + 'highlight': 'highlight', + 'bookmark': 'bookmark', + 'group': 'group', + // Custom shapes + 'ChatBox': 'chat box', + 'VideoChat': 'video chat', + 'Embed': 'embed', + 'Markdown': 'markdown note', + 'Slide': 'slide', + 'MycrozineTemplate': 'zine template', + 'MycroZineGenerator': 'zine generator', + 'Prompt': 'prompt', + 'ObsNote': 'Obsidian note', + 'Transcription': 'transcription', + 'Holon': 'holon', + 'HolonBrowser': 'holon browser', + 'ObsidianBrowser': 'Obsidian browser', + 'FathomMeetingsBrowser': 'Fathom browser', + 'FathomNote': 'Fathom note', + 'ImageGen': 'AI image', + 'VideoGen': 'AI video', + 'BlenderGen': '3D model', + 'Drawfast': 'drawfast', + 'Multmux': 'multmux', + 'MycelialIntelligence': 'mycelial AI', + 'PrivateWorkspace': 'private workspace', + 'GoogleItem': 'Google item', + 'Map': 'map', + 'WorkflowBlock': 'workflow block', + 'Calendar': 'calendar', + 'CalendarEvent': 'calendar event', +}; + +// Get action icons +const ACTION_ICONS: Record = { + 'created': '+', + 'deleted': '-', + 'updated': '~', +}; + +/** + * Get the activity log for a board + */ +export const getActivityLog = (slug: string, limit: number = 50): ActivityEntry[] => { + if (typeof window === 'undefined') return []; + + try { + const data = localStorage.getItem(`board_activity_${slug}`); + if (!data) return []; + + const parsed: BoardActivity = JSON.parse(data); + return (parsed.entries || []).slice(0, limit); + } catch (error) { + console.error('Error getting activity log:', error); + return []; + } +}; + +/** + * Log an activity entry for a board + */ +export const logActivity = ( + slug: string, + entry: Omit +): void => { + if (typeof window === 'undefined') return; + + try { + const entries = getActivityLog(slug, MAX_ENTRIES - 1); + + const newEntry: ActivityEntry = { + ...entry, + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: Date.now(), + }; + + // Add new entry at the beginning + entries.unshift(newEntry); + + // Prune to max size + const prunedEntries = entries.slice(0, MAX_ENTRIES); + + const data: BoardActivity = { + slug, + entries: prunedEntries, + lastUpdated: Date.now(), + }; + + localStorage.setItem(`board_activity_${slug}`, JSON.stringify(data)); + } catch (error) { + console.error('Error logging activity:', error); + } +}; + +/** + * Clear all activity for a board + */ +export const clearActivityLog = (slug: string): void => { + if (typeof window === 'undefined') return; + + try { + localStorage.removeItem(`board_activity_${slug}`); + } catch (error) { + console.error('Error clearing activity log:', error); + } +}; + +/** + * Get display name for a shape type + */ +export const getShapeDisplayName = (shapeType: string): string => { + return SHAPE_DISPLAY_NAMES[shapeType] || shapeType; +}; + +/** + * Get icon for an action + */ +export const getActionIcon = (action: string): string => { + return ACTION_ICONS[action] || '?'; +}; + +/** + * Format timestamp as relative time + */ +export const formatActivityTime = (timestamp: number): string => { + const now = Date.now(); + const diff = now - timestamp; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) { + return 'Just now'; + } else if (minutes < 60) { + return `${minutes}m ago`; + } else if (hours < 24) { + return `${hours}h ago`; + } else if (days === 1) { + return 'Yesterday'; + } else if (days < 7) { + return `${days}d ago`; + } else { + return new Date(timestamp).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + } +}; + +/** + * Format an activity entry as a human-readable string + */ +export const formatActivityEntry = (entry: ActivityEntry): string => { + const shapeName = getShapeDisplayName(entry.shapeType); + const action = entry.action === 'created' ? 'added' : + entry.action === 'deleted' ? 'deleted' : + 'updated'; + + return `${entry.user} ${action} ${shapeName}`; +}; + +/** + * Group activity entries by date + */ +export const groupActivitiesByDate = (entries: ActivityEntry[]): Map => { + const groups = new Map(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + for (const entry of entries) { + const entryDate = new Date(entry.timestamp); + entryDate.setHours(0, 0, 0, 0); + + let groupKey: string; + if (entryDate.getTime() === today.getTime()) { + groupKey = 'Today'; + } else if (entryDate.getTime() === yesterday.getTime()) { + groupKey = 'Yesterday'; + } else { + groupKey = entryDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + } + + if (!groups.has(groupKey)) { + groups.set(groupKey, []); + } + groups.get(groupKey)!.push(entry); + } + + return groups; +}; diff --git a/src/lib/visitedBoards.ts b/src/lib/visitedBoards.ts new file mode 100644 index 0000000..1b0eac9 --- /dev/null +++ b/src/lib/visitedBoards.ts @@ -0,0 +1,163 @@ +// Service for managing visited boards history + +export interface VisitedBoard { + slug: string; + title: string; + visitedAt: number; +} + +export interface VisitedBoardsData { + boards: VisitedBoard[]; + lastUpdated: number; +} + +const MAX_HISTORY_SIZE = 50; + +/** + * Get visited boards for a user + */ +export const getVisitedBoards = (username: string): VisitedBoard[] => { + if (typeof window === 'undefined') return []; + + try { + const data = localStorage.getItem(`visited_boards_${username}`); + if (!data) return []; + + const parsed: VisitedBoardsData = JSON.parse(data); + return parsed.boards || []; + } catch (error) { + console.error('Error getting visited boards:', error); + return []; + } +}; + +/** + * Record a board visit - adds or updates the visit timestamp + */ +export const recordBoardVisit = (username: string, slug: string, title?: string): void => { + if (typeof window === 'undefined') return; + + try { + let boards = getVisitedBoards(username); + + // Remove existing entry if present (we'll re-add at the front) + boards = boards.filter(board => board.slug !== slug); + + // Add new visit at the beginning + const newVisit: VisitedBoard = { + slug, + title: title || slug, + visitedAt: Date.now(), + }; + + boards.unshift(newVisit); + + // Prune to max size + if (boards.length > MAX_HISTORY_SIZE) { + boards = boards.slice(0, MAX_HISTORY_SIZE); + } + + // Save to localStorage + const data: VisitedBoardsData = { + boards, + lastUpdated: Date.now(), + }; + + localStorage.setItem(`visited_boards_${username}`, JSON.stringify(data)); + } catch (error) { + console.error('Error recording board visit:', error); + } +}; + +/** + * Get recently visited boards sorted by visit time (most recent first) + */ +export const getRecentlyVisitedBoards = (username: string, limit: number = 10): VisitedBoard[] => { + const boards = getVisitedBoards(username); + return boards + .sort((a, b) => b.visitedAt - a.visitedAt) + .slice(0, limit); +}; + +/** + * Remove a single board from visit history + */ +export const removeFromHistory = (username: string, slug: string): boolean => { + if (typeof window === 'undefined') return false; + + try { + const boards = getVisitedBoards(username); + const filteredBoards = boards.filter(board => board.slug !== slug); + + if (filteredBoards.length === boards.length) { + return false; // Board wasn't in history + } + + // Save to localStorage + const data: VisitedBoardsData = { + boards: filteredBoards, + lastUpdated: Date.now(), + }; + + localStorage.setItem(`visited_boards_${username}`, JSON.stringify(data)); + return true; + } catch (error) { + console.error('Error removing from history:', error); + return false; + } +}; + +/** + * Update the title for a visited board (useful when board title is loaded later) + */ +export const updateVisitedBoardTitle = (username: string, slug: string, title: string): void => { + if (typeof window === 'undefined') return; + + try { + const boards = getVisitedBoards(username); + const boardIndex = boards.findIndex(board => board.slug === slug); + + if (boardIndex !== -1) { + boards[boardIndex].title = title; + + const data: VisitedBoardsData = { + boards, + lastUpdated: Date.now(), + }; + + localStorage.setItem(`visited_boards_${username}`, JSON.stringify(data)); + } + } catch (error) { + console.error('Error updating visited board title:', error); + } +}; + +/** + * Format a timestamp as relative time (e.g., "2 hours ago", "Yesterday") + */ +export const formatRelativeTime = (timestamp: number): string => { + const now = Date.now(); + const diff = now - timestamp; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) { + return 'Just now'; + } else if (minutes < 60) { + return `${minutes}m ago`; + } else if (hours < 24) { + return `${hours}h ago`; + } else if (days === 1) { + return 'Yesterday'; + } else if (days < 7) { + return `${days}d ago`; + } else { + return new Date(timestamp).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + } +}; diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index bc2c8ac..af4c63a 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -157,7 +157,10 @@ function sanitizeIndex(index: any): IndexKey { const collections: Collection[] = [GraphLayoutCollection] import { useAuth } from "../context/AuthContext" import { updateLastVisited } from "../lib/starredBoards" +import { recordBoardVisit } from "../lib/visitedBoards" import { captureBoardScreenshot } from "../lib/screenshotService" +import { logActivity } from "../lib/activityLogger" +import { ActivityPanel, ActivityToggleButton } from "../components/ActivityPanel" import { WORKER_URL } from "../constants/workerUrl" @@ -542,6 +545,7 @@ export function Board() { const automergeHandle = (storeWithHandle as any).handle const { connectionState, isNetworkOnline } = storeWithHandle const [editor, setEditor] = useState(null) + const [isActivityPanelOpen, setIsActivityPanelOpen] = useState(false) // Update read-only state when permission changes after editor is mounted useEffect(() => { @@ -1055,10 +1059,11 @@ export function Board() { }; }, [editor, session.authed, session.username]); - // Track board visit for starred boards + // Track board visit for starred boards and visit history useEffect(() => { if (session.authed && session.username && roomId) { updateLastVisited(session.username, roomId); + recordBoardVisit(session.username, roomId); } }, [session.authed, session.username, roomId]); @@ -1123,6 +1128,80 @@ export function Board() { }; }, [editor, roomId, store.store]); + // Activity logging - track shape creates, deletes, and significant updates + useEffect(() => { + if (!editor || !roomId || !store.store) return; + + const username = session.username || 'Anonymous'; + + // Track which shapes we've logged updates for (to debounce) + const recentUpdates = new Map(); + const UPDATE_DEBOUNCE_MS = 2000; + + const unsubscribe = store.store.listen(({ changes, source }) => { + // Only track user actions, not remote sync + if (source !== 'user') return; + + // Log created shapes + for (const record of Object.values(changes.added)) { + if (record.typeName === 'shape') { + logActivity(roomId, { + action: 'created', + shapeType: (record as any).type, + shapeId: record.id, + user: username, + }); + } + } + + // Log deleted shapes + for (const record of Object.values(changes.removed)) { + if (record.typeName === 'shape') { + logActivity(roomId, { + action: 'deleted', + shapeType: (record as any).type, + shapeId: record.id, + user: username, + }); + } + } + + // Log significant updates (debounced to avoid logging every tiny movement) + for (const [before, after] of Object.values(changes.updated)) { + if (before.typeName === 'shape' && after.typeName === 'shape') { + const shapeId = after.id; + const now = Date.now(); + const lastUpdate = recentUpdates.get(shapeId) || 0; + + // Only log if we haven't logged this shape recently + if (now - lastUpdate > UPDATE_DEBOUNCE_MS) { + // Check if this is a significant update (not just position) + const beforeShape = before as any; + const afterShape = after as any; + + // Compare props (content changes) - skip if only x/y/rotation changed + const beforeProps = JSON.stringify(beforeShape.props || {}); + const afterProps = JSON.stringify(afterShape.props || {}); + + if (beforeProps !== afterProps) { + recentUpdates.set(shapeId, now); + logActivity(roomId, { + action: 'updated', + shapeType: afterShape.type, + shapeId: shapeId, + user: username, + }); + } + } + } + } + }, { source: "user", scope: "document" }); + + return () => { + unsubscribe(); + }; + }, [editor, roomId, store.store, session.username]); + // TLDraw has built-in undo/redo that works with the store // No need for custom undo/redo manager - TLDraw handles it automatically @@ -1414,6 +1493,18 @@ export function Board() { /> )} */} + {/* Activity Panel Toggle Button */} +
+ setIsActivityPanelOpen(!isActivityPanelOpen)} + isActive={isActivityPanelOpen} + /> +
+ {/* Activity Panel */} + setIsActivityPanelOpen(false)} + /> diff --git a/src/routes/Dashboard.tsx b/src/routes/Dashboard.tsx index 2b46ab8..be3f73c 100644 --- a/src/routes/Dashboard.tsx +++ b/src/routes/Dashboard.tsx @@ -3,21 +3,27 @@ import { Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { useNotifications } from '../context/NotificationContext'; import { getStarredBoards, unstarBoard, StarredBoard } from '../lib/starredBoards'; +import { getRecentlyVisitedBoards, VisitedBoard, formatRelativeTime } from '../lib/visitedBoards'; import { getBoardScreenshot, removeBoardScreenshot } from '../lib/screenshotService'; export function Dashboard() { const { session } = useAuth(); const { addNotification } = useNotifications(); const [starredBoards, setStarredBoards] = useState([]); + const [recentBoards, setRecentBoards] = useState([]); const [isLoading, setIsLoading] = useState(true); // Note: We don't redirect automatically - let the component show auth required message - // Load starred boards + // Load starred boards and recent visits useEffect(() => { if (session.authed && session.username) { - const boards = getStarredBoards(session.username); - setStarredBoards(boards); + const starred = getStarredBoards(session.username); + setStarredBoards(starred); + + const recent = getRecentlyVisitedBoards(session.username, 10); + setRecentBoards(recent); + setIsLoading(false); } }, [session.authed, session.username]); @@ -73,6 +79,52 @@ export function Dashboard() {
+ {/* Last Visited Section */} +
+
+

Last Visited

+ {recentBoards.length} +
+ + {isLoading ? ( +
Loading...
+ ) : recentBoards.length === 0 ? ( +
+
🕐
+

No recently visited boards yet. Start exploring!

+
+ ) : ( +
+ {recentBoards.map((board) => { + const screenshot = getBoardScreenshot(board.slug); + return ( + +
+ {screenshot ? ( + {`Screenshot + ) : ( +
📋
+ )} +
+
+

{board.title}

+

{formatRelativeTime(board.visitedAt)}

+
+ + ); + })} +
+ )} +
+ + {/* Starred Boards Section */}

Starred Boards