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:
parent
4974c0e303
commit
ed61902fab
|
|
@ -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.)*
|
||||
|
|
@ -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">
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue