feat: add Last Visited canvases and per-board Activity Logger

- Add "Last Visited" section to Dashboard showing recent board visits
- Add per-board activity logging that tracks shape creates/deletes/updates
- Activity panel with collapsible sidebar, grouped by date
- Debounced update logging to skip tiny movements
- Full dark mode support for both features

New files:
- src/lib/visitedBoards.ts - Visit tracking service
- src/lib/activityLogger.ts - Activity logging service
- src/components/ActivityPanel.tsx - Activity panel UI
- src/css/activity-panel.css - Activity panel styles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-06 00:36:43 +01:00
parent 4974c0e303
commit ed61902fab
8 changed files with 1170 additions and 5 deletions

63
CHANGELOG.md Normal file
View File

@ -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.)*

View File

@ -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<ActivityEntry[]>([]);
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 (
<div className="activity-panel">
<div className="activity-panel-header">
<h3>Activity</h3>
<button className="activity-panel-close" onClick={onClose} title="Close">
&times;
</button>
</div>
<div className="activity-panel-content">
{isLoading ? (
<div className="activity-loading">Loading...</div>
) : activities.length === 0 ? (
<div className="activity-empty">
<div className="activity-empty-icon">~</div>
<p>No activity yet</p>
<p className="activity-empty-hint">Actions will appear here as you work</p>
</div>
) : (
<div className="activity-list">
{Array.from(groupedActivities.entries()).map(([dateGroup, entries]) => (
<div key={dateGroup} className="activity-group">
<div className="activity-group-header">{dateGroup}</div>
{entries.map((entry) => (
<div key={entry.id} className="activity-item">
<span className={`activity-icon ${getActionClass(entry.action)}`}>
{getActionIcon(entry.action)}
</span>
<div className="activity-details">
<span className="activity-text">
<span className="activity-user">{entry.user}</span>
{' '}
{entry.action === 'created' ? 'added' :
entry.action === 'deleted' ? 'deleted' : 'updated'}
{' '}
<span className="activity-shape">{getShapeDisplayName(entry.shapeType)}</span>
</span>
<span className="activity-time">{formatActivityTime(entry.timestamp)}</span>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
</div>
);
}
// Toggle button component for the toolbar
export function ActivityToggleButton({ onClick, isActive }: { onClick: () => void; isActive: boolean }) {
return (
<button
className={`activity-toggle-btn ${isActive ? 'active' : ''}`}
onClick={onClick}
title="Activity Log"
>
<span className="activity-toggle-icon">~</span>
</button>
);
}

296
src/css/activity-panel.css Normal file
View File

@ -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;
}
}

View File

@ -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;

229
src/lib/activityLogger.ts Normal file
View File

@ -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<string, string> = {
// 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<string, string> = {
'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<ActivityEntry, 'id' | 'timestamp'>
): 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<string, ActivityEntry[]> => {
const groups = new Map<string, ActivityEntry[]>();
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;
};

163
src/lib/visitedBoards.ts Normal file
View File

@ -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',
});
}
};

View File

@ -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<Editor | null>(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<string, number>();
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 */}
<div style={{ position: 'fixed', top: 12, right: 12, zIndex: 999 }}>
<ActivityToggleButton
onClick={() => setIsActivityPanelOpen(!isActivityPanelOpen)}
isActive={isActivityPanelOpen}
/>
</div>
{/* Activity Panel */}
<ActivityPanel
isOpen={isActivityPanelOpen}
onClose={() => setIsActivityPanelOpen(false)}
/>
</div>
</LiveImageProvider>
</ConnectionProvider>

View File

@ -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<StarredBoard[]>([]);
const [recentBoards, setRecentBoards] = useState<VisitedBoard[]>([]);
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() {
</header>
<div className="dashboard-content">
{/* Last Visited Section */}
<section className="recent-boards-section">
<div className="section-header">
<h2>Last Visited</h2>
<span className="board-count">{recentBoards.length}</span>
</div>
{isLoading ? (
<div className="loading">Loading...</div>
) : recentBoards.length === 0 ? (
<div className="recent-boards-empty">
<div className="recent-boards-empty-icon">🕐</div>
<p>No recently visited boards yet. Start exploring!</p>
</div>
) : (
<div className="recent-boards-row">
{recentBoards.map((board) => {
const screenshot = getBoardScreenshot(board.slug);
return (
<Link
key={board.slug}
to={`/board/${board.slug}/`}
className="recent-board-card"
>
<div className="recent-board-screenshot">
{screenshot ? (
<img
src={screenshot.dataUrl}
alt={`Screenshot of ${board.title}`}
/>
) : (
<div className="placeholder">📋</div>
)}
</div>
<div className="recent-board-info">
<h4 className="recent-board-title">{board.title}</h4>
<p className="recent-board-time">{formatRelativeTime(board.visitedAt)}</p>
</div>
</Link>
);
})}
</div>
)}
</section>
{/* Starred Boards Section */}
<section className="starred-boards-section">
<div className="section-header">
<h2>Starred Boards</h2>