Compare commits
No commits in common. "03894d2146d7e7aeeb6fb9c1d559d6f8527919a1" and "37cd086ff01afaf92ef0951943062d50eedaf504" have entirely different histories.
03894d2146
...
37cd086ff0
|
|
@ -1,11 +1,9 @@
|
||||||
---
|
---
|
||||||
id: task-028
|
id: task-028
|
||||||
title: OSM Canvas Integration Foundation
|
title: OSM Canvas Integration Foundation
|
||||||
status: Done
|
status: To Do
|
||||||
assignee:
|
assignee: []
|
||||||
- '@claude'
|
|
||||||
created_date: '2025-12-04 21:12'
|
created_date: '2025-12-04 21:12'
|
||||||
updated_date: '2025-12-04 21:44'
|
|
||||||
labels:
|
labels:
|
||||||
- feature
|
- feature
|
||||||
- mapping
|
- mapping
|
||||||
|
|
@ -32,62 +30,10 @@ This is the foundation that task-024 (Route Planning) and other spatial features
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [x] #1 OSM raster tiles render as canvas background layer
|
- [ ] #1 OSM raster tiles render as canvas background layer
|
||||||
- [x] #2 Coordinate transformation functions (geo ↔ canvas) working accurately
|
- [ ] #2 Coordinate transformation functions (geo ↔ canvas) working accurately
|
||||||
- [x] #3 Zoom levels map to appropriate tile zoom levels
|
- [ ] #3 Zoom levels map to appropriate tile zoom levels
|
||||||
- [x] #4 Pan/zoom gestures work smoothly with tile loading
|
- [ ] #4 Pan/zoom gestures work smoothly with tile loading
|
||||||
- [x] #5 Shapes can be placed with lat/lng coordinates
|
- [ ] #5 Shapes can be placed with lat/lng coordinates
|
||||||
- [x] #6 Basic MapLibre GL or Leaflet integration pattern established
|
- [ ] #6 Basic MapLibre GL or Leaflet integration pattern established
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
|
||||||
## Progress (2025-12-04)
|
|
||||||
|
|
||||||
### Completed:
|
|
||||||
- Reviewed existing open-mapping module scaffolding
|
|
||||||
- Installed maplibre-gl npm package
|
|
||||||
- Created comprehensive geo-canvas coordinate transformation utilities (geoTransform.ts)
|
|
||||||
- GeoCanvasTransform class for bidirectional geo ↔ canvas transforms
|
|
||||||
- Web Mercator projection support
|
|
||||||
- Tile coordinate utilities
|
|
||||||
- Haversine distance calculations
|
|
||||||
|
|
||||||
### In Progress:
|
|
||||||
- Wiring up MapLibre GL JS in useMapInstance hook
|
|
||||||
- Creating MapShapeUtil for tldraw canvas integration
|
|
||||||
|
|
||||||
### Additional Progress:
|
|
||||||
- Fixed MapLibre attributionControl type issue
|
|
||||||
- Created MapShapeUtil.tsx with full tldraw integration
|
|
||||||
- Created MapTool.ts for placing map shapes
|
|
||||||
- Registered MapShape and MapTool in Board.tsx
|
|
||||||
- Map shape features:
|
|
||||||
- Resizable map window
|
|
||||||
- Interactive pan/zoom toggle
|
|
||||||
- Location presets (NYC, London, Tokyo, SF, Paris)
|
|
||||||
- Live coordinate display
|
|
||||||
- Pin to view support
|
|
||||||
- Tag system integration
|
|
||||||
|
|
||||||
### Completion Summary:
|
|
||||||
- All core OSM canvas integration foundation is complete
|
|
||||||
- MapShape can be placed on canvas via MapTool
|
|
||||||
- MapLibre GL JS renders OpenStreetMap tiles
|
|
||||||
- Coordinate transforms enable geo ↔ canvas mapping
|
|
||||||
- Ready for testing on dev server at localhost:5173
|
|
||||||
|
|
||||||
### Files Created/Modified:
|
|
||||||
- src/open-mapping/utils/geoTransform.ts (NEW)
|
|
||||||
- src/open-mapping/hooks/useMapInstance.ts (UPDATED with MapLibre)
|
|
||||||
- src/shapes/MapShapeUtil.tsx (NEW)
|
|
||||||
- src/tools/MapTool.ts (NEW)
|
|
||||||
- src/routes/Board.tsx (UPDATED with MapShape/MapTool)
|
|
||||||
- package.json (added maplibre-gl)
|
|
||||||
|
|
||||||
### Next Steps (task-024):
|
|
||||||
- Add OSRM routing backend
|
|
||||||
- Implement waypoint placement
|
|
||||||
- Route calculation and display
|
|
||||||
<!-- SECTION:NOTES:END -->
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
---
|
|
||||||
id: task-029
|
|
||||||
title: zkGPS Protocol Design
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2025-12-04 21:12'
|
|
||||||
labels:
|
|
||||||
- feature
|
|
||||||
- privacy
|
|
||||||
- cryptography
|
|
||||||
- research
|
|
||||||
dependencies: []
|
|
||||||
priority: medium
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Design and implement a zero-knowledge proof system for privacy-preserving location sharing. Enables users to prove location claims without revealing exact coordinates.
|
|
||||||
|
|
||||||
Key capabilities:
|
|
||||||
- Proximity proofs: Prove "I am within X distance of Y" without revealing exact location
|
|
||||||
- Region membership: Prove "I am in Central Park" without revealing which part
|
|
||||||
- Temporal proofs: Prove "I was in region R between T1 and T2"
|
|
||||||
- Group rendezvous: N people prove they are all nearby without revealing locations to each other
|
|
||||||
|
|
||||||
Technical approaches to evaluate:
|
|
||||||
- ZK-SNARKs (Groth16, PLONK) for succinct proofs
|
|
||||||
- Bulletproofs for range proofs on coordinates
|
|
||||||
- Geohash commitments for variable precision
|
|
||||||
- Homomorphic encryption for distance calculations
|
|
||||||
- Ring signatures for group privacy
|
|
||||||
|
|
||||||
Integration with canvas:
|
|
||||||
- Share location with configurable precision per trust circle
|
|
||||||
- Verify location claims from network participants
|
|
||||||
- Display verified presence without exact coordinates
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 Protocol specification document complete
|
|
||||||
- [ ] #2 Proof-of-concept proximity proof working
|
|
||||||
- [ ] #3 Geohash commitment scheme implemented
|
|
||||||
- [ ] #4 Trust circle precision configuration UI
|
|
||||||
- [ ] #5 Integration with canvas presence system
|
|
||||||
- [ ] #6 Performance benchmarks acceptable for real-time use
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
---
|
|
||||||
id: task-030
|
|
||||||
title: Mycelial Signal Propagation System
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2025-12-04 21:12'
|
|
||||||
labels:
|
|
||||||
- feature
|
|
||||||
- mapping
|
|
||||||
- intelligence
|
|
||||||
- research
|
|
||||||
dependencies: []
|
|
||||||
priority: medium
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Implement a biologically-inspired signal propagation system for the canvas network, modeling how information, attention, and value flow through the collaborative space like nutrients through mycelium.
|
|
||||||
|
|
||||||
Core concepts:
|
|
||||||
- Nodes: Points of interest, events, people, resources, discoveries
|
|
||||||
- Hyphae: Connections/paths between nodes (relationships, routes, attention threads)
|
|
||||||
- Signals: Urgency, relevance, trust, novelty gradients
|
|
||||||
- Behaviors: Gradient following, path optimization, emergence detection
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Signal emission when events/discoveries occur
|
|
||||||
- Decay with spatial, relational, and temporal distance
|
|
||||||
- Aggregation at nodes (multiple weak signals → strong signal)
|
|
||||||
- Spore dispersal pattern for notifications
|
|
||||||
- Resonance detection (unconnected focus on same location)
|
|
||||||
- Collective blindspot visualization (unmapped areas)
|
|
||||||
|
|
||||||
The map becomes a living organism that breathes with activity cycles and grows where attention focuses.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 Signal propagation algorithm implemented
|
|
||||||
- [ ] #2 Decay functions configurable (spatial, relational, temporal)
|
|
||||||
- [ ] #3 Visualization of signal gradients on canvas
|
|
||||||
- [ ] #4 Resonance detection alerts working
|
|
||||||
- [ ] #5 Spore-style notification system
|
|
||||||
- [ ] #6 Blindspot/unknown area highlighting
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
---
|
|
||||||
id: task-031
|
|
||||||
title: Alternative Map Lens System
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2025-12-04 21:12'
|
|
||||||
labels:
|
|
||||||
- feature
|
|
||||||
- mapping
|
|
||||||
- visualization
|
|
||||||
dependencies: []
|
|
||||||
priority: medium
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Implement multiple "lens" views that project different data dimensions onto the canvas coordinate space. The same underlying data can be viewed through different lenses.
|
|
||||||
|
|
||||||
Lens types:
|
|
||||||
- Geographic: Traditional OSM basemap, physical locations
|
|
||||||
- Temporal: Time as X-axis, events as nodes, time-scrubbing UI
|
|
||||||
- Attention: Heatmap of collective focus, nodes sized by current attention
|
|
||||||
- Incentive: Value gradients, token flows, MycoFi integration
|
|
||||||
- Relational: Social graph topology, force-directed layout
|
|
||||||
- Possibility: Branching futures, what-if scenarios, alternate timelines
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Smooth transitions between lens types
|
|
||||||
- Lens blending (e.g., 50% geographic + 50% attention)
|
|
||||||
- Temporal scrubber for historical playback
|
|
||||||
- Temporal portals (click location to see across time)
|
|
||||||
- Living maps that grow/fade based on attention
|
|
||||||
|
|
||||||
Each lens uses the same canvas shapes but transforms their positions and styling based on the active projection.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 Lens switcher UI implemented
|
|
||||||
- [ ] #2 Geographic lens working with OSM
|
|
||||||
- [ ] #3 Temporal lens with time scrubber
|
|
||||||
- [ ] #4 Attention heatmap visualization
|
|
||||||
- [ ] #5 Smooth transitions between lenses
|
|
||||||
- [ ] #6 Lens blending capability
|
|
||||||
- [ ] #7 Temporal portal feature (click to see history)
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
---
|
|
||||||
id: task-032
|
|
||||||
title: Privacy Gradient Trust Circle System
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2025-12-04 21:12'
|
|
||||||
labels:
|
|
||||||
- feature
|
|
||||||
- privacy
|
|
||||||
- social
|
|
||||||
dependencies: []
|
|
||||||
priority: medium
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Implement a non-binary privacy system where location and presence information is shared at different precision levels based on trust circles.
|
|
||||||
|
|
||||||
Trust circle levels (configurable):
|
|
||||||
- Intimate: Exact coordinates, real-time updates
|
|
||||||
- Close: Street/block level precision
|
|
||||||
- Friends: Neighborhood/district level
|
|
||||||
- Network: City/region only
|
|
||||||
- Public: Just "online" status or timezone
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Per-contact trust level configuration
|
|
||||||
- Group trust levels (share more with "coworkers" group)
|
|
||||||
- Automatic precision degradation over time
|
|
||||||
- Selective disclosure controls per-session
|
|
||||||
- Trust level visualization on map (concentric circles of precision)
|
|
||||||
- Integration with zkGPS for cryptographic enforcement
|
|
||||||
- Consent management and audit logs
|
|
||||||
|
|
||||||
The system should default to maximum privacy and require explicit opt-in to share more precise information.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 Trust circle configuration UI
|
|
||||||
- [ ] #2 Per-contact precision settings
|
|
||||||
- [ ] #3 Group-based trust levels
|
|
||||||
- [ ] #4 Precision degradation over time working
|
|
||||||
- [ ] #5 Visual representation of trust circles on map
|
|
||||||
- [ ] #6 Consent management interface
|
|
||||||
- [ ] #7 Integration points with zkGPS task
|
|
||||||
- [ ] #8 Privacy-by-default enforced
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
---
|
|
||||||
id: task-033
|
|
||||||
title: Version History & Reversion System with Visual Diffs
|
|
||||||
status: In Progress
|
|
||||||
assignee: []
|
|
||||||
created_date: '2025-12-04 21:44'
|
|
||||||
labels:
|
|
||||||
- feature
|
|
||||||
- version-control
|
|
||||||
- automerge
|
|
||||||
- r2
|
|
||||||
- ui
|
|
||||||
dependencies: []
|
|
||||||
priority: high
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Implement a comprehensive version history and reversion system that allows users to:
|
|
||||||
1. View and revert to historical board states
|
|
||||||
2. See visual diffs highlighting new/deleted shapes since their last visit
|
|
||||||
3. Walk through CRDT history step-by-step
|
|
||||||
4. Restore accidentally deleted shapes
|
|
||||||
|
|
||||||
Key features:
|
|
||||||
- Time rewind button next to the star dashboard button
|
|
||||||
- Popup menu showing historical versions
|
|
||||||
- Yellow glow on newly added shapes (first time user sees them)
|
|
||||||
- Dim grey on deleted shapes with "undo discard" option
|
|
||||||
- Permission-based (admin, editor, viewer)
|
|
||||||
- Integration with R2 backups and Automerge CRDT history
|
|
||||||
- Compare user's local state with server state to highlight diffs
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 Version history button renders next to star button with time-rewind icon
|
|
||||||
- [ ] #2 Clicking button opens popup showing list of historical versions
|
|
||||||
- [ ] #3 User can select a version to preview or revert to
|
|
||||||
- [ ] #4 Newly added shapes since last user visit have yellow glow effect
|
|
||||||
- [ ] #5 Deleted shapes show dimmed with 'undo discard' option
|
|
||||||
- [ ] #6 Version navigation respects user permissions (admin/editor/viewer)
|
|
||||||
- [ ] #7 Works with R2 backup snapshots for coarse-grained history
|
|
||||||
- [ ] #8 Leverages Automerge CRDT for fine-grained change tracking
|
|
||||||
- [ ] #9 User's last-seen state stored in localStorage for diff comparison
|
|
||||||
- [ ] #10 Visual effects are subtle and non-intrusive
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
@ -1,300 +0,0 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useEditor, TLShapeId } from 'tldraw';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
getDeletedShapes,
|
|
||||||
removeDeletedShape,
|
|
||||||
DeletedShape,
|
|
||||||
formatRelativeTime,
|
|
||||||
} from '../lib/versionHistory';
|
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
|
||||||
|
|
||||||
interface DeletedShapesOverlayProps {
|
|
||||||
show?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DeletedShapesOverlay - Shows ghost representations of deleted shapes
|
|
||||||
* and provides a floating indicator with restore options
|
|
||||||
*/
|
|
||||||
export const DeletedShapesOverlay: React.FC<DeletedShapesOverlayProps> = ({
|
|
||||||
show = true,
|
|
||||||
}) => {
|
|
||||||
const editor = useEditor();
|
|
||||||
const { session } = useAuth();
|
|
||||||
const { slug } = useParams<{ slug: string }>();
|
|
||||||
const { canRestoreDeleted } = usePermissions(slug || '');
|
|
||||||
const [deletedShapes, setDeletedShapes] = useState<DeletedShape[]>([]);
|
|
||||||
const [showPanel, setShowPanel] = useState(false);
|
|
||||||
const [visible, setVisible] = useState(show);
|
|
||||||
|
|
||||||
// Load deleted shapes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!session.authed || !session.username || !slug) return;
|
|
||||||
const deleted = getDeletedShapes(session.username, slug);
|
|
||||||
setDeletedShapes(deleted);
|
|
||||||
}, [session.authed, session.username, slug]);
|
|
||||||
|
|
||||||
// Listen for toggle events
|
|
||||||
useEffect(() => {
|
|
||||||
const handleToggle = (event: CustomEvent<boolean>) => {
|
|
||||||
setVisible(event.detail);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShapeRestored = (event: CustomEvent<TLShapeId>) => {
|
|
||||||
if (!session.username || !slug) return;
|
|
||||||
removeDeletedShape(session.username, slug, event.detail);
|
|
||||||
setDeletedShapes(prev => prev.filter(s => s.id !== event.detail));
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('toggle-deleted-shapes', handleToggle as EventListener);
|
|
||||||
window.addEventListener('shape-restored', handleShapeRestored as EventListener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('toggle-deleted-shapes', handleToggle as EventListener);
|
|
||||||
window.removeEventListener('shape-restored', handleShapeRestored as EventListener);
|
|
||||||
};
|
|
||||||
}, [session.username, slug]);
|
|
||||||
|
|
||||||
// Restore a deleted shape
|
|
||||||
const handleRestore = useCallback((deletedShape: DeletedShape) => {
|
|
||||||
if (!editor || !session.username || !slug) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
editor.createShape({
|
|
||||||
id: deletedShape.id,
|
|
||||||
type: deletedShape.type,
|
|
||||||
x: deletedShape.x,
|
|
||||||
y: deletedShape.y,
|
|
||||||
props: deletedShape.props,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// Remove from deleted list
|
|
||||||
removeDeletedShape(session.username, slug, deletedShape.id);
|
|
||||||
setDeletedShapes(prev => prev.filter(s => s.id !== deletedShape.id));
|
|
||||||
|
|
||||||
// Close panel if no more deleted shapes
|
|
||||||
if (deletedShapes.length <= 1) {
|
|
||||||
setShowPanel(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error restoring shape:', error);
|
|
||||||
}
|
|
||||||
}, [editor, session.username, slug, deletedShapes.length]);
|
|
||||||
|
|
||||||
// Restore all deleted shapes
|
|
||||||
const handleRestoreAll = useCallback(() => {
|
|
||||||
if (!editor || !session.username || !slug) return;
|
|
||||||
|
|
||||||
deletedShapes.forEach(deletedShape => {
|
|
||||||
try {
|
|
||||||
editor.createShape({
|
|
||||||
id: deletedShape.id,
|
|
||||||
type: deletedShape.type,
|
|
||||||
x: deletedShape.x,
|
|
||||||
y: deletedShape.y,
|
|
||||||
props: deletedShape.props,
|
|
||||||
} as any);
|
|
||||||
removeDeletedShape(session.username, slug, deletedShape.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error restoring shape:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setDeletedShapes([]);
|
|
||||||
setShowPanel(false);
|
|
||||||
}, [editor, session.username, slug, deletedShapes]);
|
|
||||||
|
|
||||||
// Dismiss all deleted shapes (clear from storage)
|
|
||||||
const handleDismissAll = useCallback(() => {
|
|
||||||
if (!session.username || !slug) return;
|
|
||||||
|
|
||||||
deletedShapes.forEach(d => {
|
|
||||||
removeDeletedShape(session.username!, slug!, d.id);
|
|
||||||
});
|
|
||||||
setDeletedShapes([]);
|
|
||||||
setShowPanel(false);
|
|
||||||
}, [session.username, slug, deletedShapes]);
|
|
||||||
|
|
||||||
// Don't render if no deleted shapes or not visible
|
|
||||||
if (!visible || deletedShapes.length === 0 || !session.authed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Floating Indicator */}
|
|
||||||
<button
|
|
||||||
className="deleted-shapes-floating-indicator"
|
|
||||||
onClick={() => setShowPanel(!showPanel)}
|
|
||||||
title={`${deletedShapes.length} deleted shape${deletedShapes.length > 1 ? 's' : ''} can be restored`}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 16 16" fill="currentColor">
|
|
||||||
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z"/>
|
|
||||||
</svg>
|
|
||||||
<span>{deletedShapes.length} deleted</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Restore Panel */}
|
|
||||||
{showPanel && (
|
|
||||||
<>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 9999,
|
|
||||||
}}
|
|
||||||
onClick={() => setShowPanel(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Panel */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: '130px',
|
|
||||||
right: '20px',
|
|
||||||
width: '280px',
|
|
||||||
maxHeight: '400px',
|
|
||||||
backgroundColor: 'var(--bg-color, #fff)',
|
|
||||||
border: '1px solid var(--border-color, #e1e4e8)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
|
|
||||||
zIndex: 10001,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderBottom: '1px solid var(--border-color, #e1e4e8)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>
|
|
||||||
Deleted Shapes
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPanel(false)}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px',
|
|
||||||
opacity: 0.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
||||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deleted shapes list */}
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '12px' }}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
{deletedShapes.map((deleted) => (
|
|
||||||
<div
|
|
||||||
key={deleted.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '8px 10px',
|
|
||||||
border: '1px solid #ef4444',
|
|
||||||
borderRadius: '6px',
|
|
||||||
backgroundColor: 'rgba(239, 68, 68, 0.05)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: '14px', opacity: 0.5 }}>
|
|
||||||
{deleted.type === 'draw' ? '✏️' :
|
|
||||||
deleted.type === 'text' ? '📝' :
|
|
||||||
deleted.type === 'image' ? '🖼️' :
|
|
||||||
deleted.type === 'embed' ? '🔗' :
|
|
||||||
'📦'}
|
|
||||||
</span>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<p style={{ fontSize: '12px', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{deleted.type}
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: '10px', color: '#666', margin: '2px 0 0 0' }}>
|
|
||||||
{formatRelativeTime(deleted.deletedAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canRestoreDeleted && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleRestore(deleted)}
|
|
||||||
style={{
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: '10px',
|
|
||||||
backgroundColor: '#10b981',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Restore
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '12px',
|
|
||||||
borderTop: '1px solid var(--border-color, #e1e4e8)',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{canRestoreDeleted && (
|
|
||||||
<button
|
|
||||||
onClick={handleRestoreAll}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
backgroundColor: '#10b981',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Restore All
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleDismissAll}
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
fontSize: '12px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: '#666',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '6px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
flex: canRestoreDeleted ? undefined : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
import { useEditor } from 'tldraw';
|
|
||||||
import { VersionHistoryPanel } from './VersionHistoryPanel';
|
|
||||||
|
|
||||||
interface VersionHistoryButtonProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Version History Button - displays next to the star button
|
|
||||||
* Opens a popup panel showing historical versions and diff controls
|
|
||||||
*/
|
|
||||||
const VersionHistoryButton: React.FC<VersionHistoryButtonProps> = ({ className = '' }) => {
|
|
||||||
const { slug } = useParams<{ slug: string }>();
|
|
||||||
const { session } = useAuth();
|
|
||||||
const editor = useEditor();
|
|
||||||
const [showPanel, setShowPanel] = useState(false);
|
|
||||||
const [hasNewChanges, setHasNewChanges] = useState(false);
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Check for new changes indicator
|
|
||||||
useEffect(() => {
|
|
||||||
if (!session.authed || !session.username || !slug) return;
|
|
||||||
|
|
||||||
// Check if there are unseen changes
|
|
||||||
const lastSeenKey = `canvas_version_state_${session.username}_${slug}`;
|
|
||||||
const lastSeen = localStorage.getItem(lastSeenKey);
|
|
||||||
|
|
||||||
if (lastSeen) {
|
|
||||||
try {
|
|
||||||
const state = JSON.parse(lastSeen);
|
|
||||||
const currentShapes = editor?.getCurrentPageShapes() || [];
|
|
||||||
|
|
||||||
// If shape count differs, there are changes
|
|
||||||
if (currentShapes.length !== state.shapeIds.length) {
|
|
||||||
setHasNewChanges(true);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [session.authed, session.username, slug, editor]);
|
|
||||||
|
|
||||||
// Close panel when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (
|
|
||||||
panelRef.current &&
|
|
||||||
!panelRef.current.contains(event.target as Node) &&
|
|
||||||
buttonRef.current &&
|
|
||||||
!buttonRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setShowPanel(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (showPanel) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [showPanel]);
|
|
||||||
|
|
||||||
// Don't show if not authenticated
|
|
||||||
if (!session.authed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
setShowPanel(!showPanel);
|
|
||||||
if (!showPanel) {
|
|
||||||
// Clear new changes indicator when opening
|
|
||||||
setHasNewChanges(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<button
|
|
||||||
ref={buttonRef}
|
|
||||||
onClick={handleToggle}
|
|
||||||
className={`toolbar-btn version-history-button ${className} ${showPanel ? 'active' : ''}`}
|
|
||||||
title="Version History"
|
|
||||||
style={{ position: 'relative' }}
|
|
||||||
>
|
|
||||||
{/* Time rewind icon */}
|
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
||||||
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z"/>
|
|
||||||
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z"/>
|
|
||||||
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z"/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* New changes indicator dot */}
|
|
||||||
{hasNewChanges && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-2px',
|
|
||||||
right: '-2px',
|
|
||||||
width: '8px',
|
|
||||||
height: '8px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: '#fbbf24',
|
|
||||||
border: '1px solid white',
|
|
||||||
boxShadow: '0 0 4px rgba(251, 191, 36, 0.5)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Version History Panel */}
|
|
||||||
{showPanel && (
|
|
||||||
<div ref={panelRef}>
|
|
||||||
<VersionHistoryPanel
|
|
||||||
boardId={slug || ''}
|
|
||||||
userId={session.username || ''}
|
|
||||||
onClose={() => setShowPanel(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default VersionHistoryButton;
|
|
||||||
|
|
@ -1,511 +0,0 @@
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { useEditor, TLShape, TLShapeId } from 'tldraw';
|
|
||||||
import {
|
|
||||||
computeShapeDiff,
|
|
||||||
getDeletedShapes,
|
|
||||||
saveLastSeenState,
|
|
||||||
formatRelativeTime,
|
|
||||||
formatTimestamp,
|
|
||||||
DeletedShape,
|
|
||||||
VersionSnapshot,
|
|
||||||
} from '../lib/versionHistory';
|
|
||||||
import { WORKER_URL } from '../constants/workerUrl';
|
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
|
||||||
|
|
||||||
interface VersionHistoryPanelProps {
|
|
||||||
boardId: string;
|
|
||||||
userId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabType = 'changes' | 'versions' | 'deleted';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Version History Panel - shows recent changes, version snapshots, and deleted shapes
|
|
||||||
*/
|
|
||||||
export const VersionHistoryPanel: React.FC<VersionHistoryPanelProps> = ({
|
|
||||||
boardId,
|
|
||||||
userId,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const editor = useEditor();
|
|
||||||
const { canRevert, canRestoreDeleted, canMarkAsSeen, role } = usePermissions(boardId);
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('changes');
|
|
||||||
const [versions, setVersions] = useState<VersionSnapshot[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [showNewShapeGlow, setShowNewShapeGlow] = useState(true);
|
|
||||||
const [showDeletedShapes, setShowDeletedShapes] = useState(true);
|
|
||||||
|
|
||||||
// Compute current diff
|
|
||||||
const diff = useMemo(() => {
|
|
||||||
if (!editor) return { newShapes: [], deletedShapes: [], modifiedShapes: [] };
|
|
||||||
const shapes = editor.getCurrentPageShapes();
|
|
||||||
return computeShapeDiff(userId, boardId, shapes);
|
|
||||||
}, [editor, userId, boardId]);
|
|
||||||
|
|
||||||
// Get deleted shapes
|
|
||||||
const deletedShapes = useMemo(() => {
|
|
||||||
return getDeletedShapes(userId, boardId);
|
|
||||||
}, [userId, boardId]);
|
|
||||||
|
|
||||||
// Fetch R2 version snapshots
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchVersions = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${WORKER_URL}/api/versions/${boardId}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json() as { versions?: VersionSnapshot[] };
|
|
||||||
setVersions(data.versions || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching versions:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (activeTab === 'versions') {
|
|
||||||
fetchVersions();
|
|
||||||
}
|
|
||||||
}, [boardId, activeTab]);
|
|
||||||
|
|
||||||
// Toggle new shape highlighting
|
|
||||||
const handleToggleNewShapeGlow = () => {
|
|
||||||
setShowNewShapeGlow(!showNewShapeGlow);
|
|
||||||
// Dispatch event to toggle visual effects
|
|
||||||
window.dispatchEvent(new CustomEvent('toggle-new-shape-glow', { detail: !showNewShapeGlow }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle deleted shape visibility
|
|
||||||
const handleToggleDeletedShapes = () => {
|
|
||||||
setShowDeletedShapes(!showDeletedShapes);
|
|
||||||
window.dispatchEvent(new CustomEvent('toggle-deleted-shapes', { detail: !showDeletedShapes }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mark current state as "seen"
|
|
||||||
const handleMarkAsSeen = () => {
|
|
||||||
if (!editor) return;
|
|
||||||
const shapes = editor.getCurrentPageShapes();
|
|
||||||
saveLastSeenState(userId, boardId, shapes);
|
|
||||||
// Force re-render by closing and reopening
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Restore a deleted shape
|
|
||||||
const handleRestoreShape = (deletedShape: DeletedShape) => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create a new shape with the deleted shape's properties
|
|
||||||
editor.createShape({
|
|
||||||
id: deletedShape.id,
|
|
||||||
type: deletedShape.type,
|
|
||||||
x: deletedShape.x,
|
|
||||||
y: deletedShape.y,
|
|
||||||
props: deletedShape.props,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// Remove from deleted list
|
|
||||||
window.dispatchEvent(new CustomEvent('shape-restored', { detail: deletedShape.id }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error restoring shape:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Navigate to a shape
|
|
||||||
const handleNavigateToShape = (shapeId: TLShapeId) => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
const shape = editor.getShape(shapeId);
|
|
||||||
if (!shape) return;
|
|
||||||
|
|
||||||
// Center viewport on the shape
|
|
||||||
const bounds = editor.getShapePageBounds(shape);
|
|
||||||
if (bounds) {
|
|
||||||
editor.zoomToBounds(bounds, {
|
|
||||||
animation: { duration: 300 },
|
|
||||||
inset: 100,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select the shape
|
|
||||||
editor.setSelectedShapes([shapeId]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Revert to a version
|
|
||||||
const handleRevertToVersion = async (version: VersionSnapshot) => {
|
|
||||||
if (!editor || !version) return;
|
|
||||||
|
|
||||||
const confirmRevert = window.confirm(
|
|
||||||
`Revert to version from ${formatTimestamp(version.timestamp)}?\n\nThis will replace the current board state with the selected version.`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmRevert) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const response = await fetch(`${WORKER_URL}/api/versions/${boardId}/${version.id}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'revert' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Reload the page to get the reverted state
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
console.error('Failed to revert version');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reverting to version:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="version-history-panel"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 'calc(100% + 8px)',
|
|
||||||
right: 0,
|
|
||||||
width: '320px',
|
|
||||||
maxHeight: '70vh',
|
|
||||||
backgroundColor: 'var(--bg-color, #fff)',
|
|
||||||
border: '1px solid var(--border-color, #e1e4e8)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
|
|
||||||
zIndex: 100000,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderBottom: '1px solid var(--border-color, #e1e4e8)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>
|
|
||||||
Version History
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
backgroundColor: role === 'admin' ? '#8b5cf6' : role === 'editor' ? '#3b82f6' : '#6b7280',
|
|
||||||
color: 'white',
|
|
||||||
textTransform: 'capitalize',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{role}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px',
|
|
||||||
opacity: 0.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
||||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
borderBottom: '1px solid var(--border-color, #e1e4e8)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(['changes', 'versions', 'deleted'] as TabType[]).map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '10px',
|
|
||||||
border: 'none',
|
|
||||||
background: activeTab === tab ? 'var(--color-muted-2, #f5f5f5)' : 'transparent',
|
|
||||||
borderBottom: activeTab === tab ? '2px solid var(--color-primary, #3b82f6)' : '2px solid transparent',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: activeTab === tab ? 600 : 400,
|
|
||||||
textTransform: 'capitalize',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
{tab === 'changes' && diff.newShapes.length > 0 && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: '4px',
|
|
||||||
backgroundColor: '#fbbf24',
|
|
||||||
color: '#000',
|
|
||||||
borderRadius: '10px',
|
|
||||||
padding: '2px 6px',
|
|
||||||
fontSize: '10px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{diff.newShapes.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{tab === 'deleted' && deletedShapes.length > 0 && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: '4px',
|
|
||||||
backgroundColor: '#ef4444',
|
|
||||||
color: '#fff',
|
|
||||||
borderRadius: '10px',
|
|
||||||
padding: '2px 6px',
|
|
||||||
fontSize: '10px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{deletedShapes.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '12px' }}>
|
|
||||||
{/* Changes Tab */}
|
|
||||||
{activeTab === 'changes' && (
|
|
||||||
<div>
|
|
||||||
{/* Controls */}
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showNewShapeGlow}
|
|
||||||
onChange={handleToggleNewShapeGlow}
|
|
||||||
/>
|
|
||||||
Highlight new shapes
|
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showDeletedShapes}
|
|
||||||
onChange={handleToggleDeletedShapes}
|
|
||||||
/>
|
|
||||||
Show deleted shapes
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* New Shapes */}
|
|
||||||
{diff.newShapes.length > 0 ? (
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<h4 style={{ fontSize: '11px', color: '#666', marginBottom: '8px', textTransform: 'uppercase' }}>
|
|
||||||
New Shapes ({diff.newShapes.length})
|
|
||||||
</h4>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
|
||||||
{diff.newShapes.map((shapeId) => {
|
|
||||||
const shape = editor?.getShape(shapeId);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={shapeId}
|
|
||||||
onClick={() => handleNavigateToShape(shapeId)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '8px 10px',
|
|
||||||
border: '1px solid #fbbf24',
|
|
||||||
borderRadius: '6px',
|
|
||||||
backgroundColor: 'rgba(251, 191, 36, 0.1)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'left',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: '14px' }}>
|
|
||||||
{shape?.type === 'draw' ? '✏️' :
|
|
||||||
shape?.type === 'text' ? '📝' :
|
|
||||||
shape?.type === 'image' ? '🖼️' :
|
|
||||||
shape?.type === 'embed' ? '🔗' :
|
|
||||||
'📦'}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: '12px', flex: 1 }}>
|
|
||||||
{shape?.type || 'Shape'}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: '10px', color: '#666' }}>→</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p style={{ fontSize: '12px', color: '#666', textAlign: 'center', padding: '20px' }}>
|
|
||||||
No new shapes since your last visit
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mark as Seen Button */}
|
|
||||||
{canMarkAsSeen && (
|
|
||||||
<button
|
|
||||||
onClick={handleMarkAsSeen}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px',
|
|
||||||
backgroundColor: 'var(--color-primary, #3b82f6)',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Mark All as Seen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Versions Tab */}
|
|
||||||
{activeTab === 'versions' && (
|
|
||||||
<div>
|
|
||||||
{isLoading ? (
|
|
||||||
<p style={{ fontSize: '12px', color: '#666', textAlign: 'center', padding: '20px' }}>
|
|
||||||
Loading versions...
|
|
||||||
</p>
|
|
||||||
) : versions.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
{versions.map((version) => (
|
|
||||||
<div
|
|
||||||
key={version.id}
|
|
||||||
style={{
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid var(--border-color, #e1e4e8)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
backgroundColor: 'var(--color-muted-2, #f9f9f9)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontSize: '12px', fontWeight: 500, margin: 0 }}>
|
|
||||||
{formatRelativeTime(version.timestamp)}
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: '10px', color: '#666', margin: '2px 0 0 0' }}>
|
|
||||||
{formatTimestamp(version.timestamp)} • {version.shapeCount} shapes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canRevert && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleRevertToVersion(version)}
|
|
||||||
style={{
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: '10px',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Revert
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p style={{ fontSize: '12px', color: '#666', textAlign: 'center', padding: '20px' }}>
|
|
||||||
No saved versions available
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Deleted Tab */}
|
|
||||||
{activeTab === 'deleted' && (
|
|
||||||
<div>
|
|
||||||
{deletedShapes.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
{deletedShapes.map((deleted) => (
|
|
||||||
<div
|
|
||||||
key={deleted.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid #ef4444',
|
|
||||||
borderRadius: '8px',
|
|
||||||
backgroundColor: 'rgba(239, 68, 68, 0.05)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: '14px', opacity: 0.5 }}>
|
|
||||||
{deleted.type === 'draw' ? '✏️' :
|
|
||||||
deleted.type === 'text' ? '📝' :
|
|
||||||
deleted.type === 'image' ? '🖼️' :
|
|
||||||
deleted.type === 'embed' ? '🔗' :
|
|
||||||
'📦'}
|
|
||||||
</span>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<p style={{ fontSize: '12px', margin: 0 }}>{deleted.type}</p>
|
|
||||||
<p style={{ fontSize: '10px', color: '#666', margin: '2px 0 0 0' }}>
|
|
||||||
Deleted {formatRelativeTime(deleted.deletedAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canRestoreDeleted && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleRestoreShape(deleted)}
|
|
||||||
style={{
|
|
||||||
padding: '4px 10px',
|
|
||||||
fontSize: '10px',
|
|
||||||
backgroundColor: '#10b981',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Restore
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p style={{ fontSize: '12px', color: '#666', textAlign: 'center', padding: '20px' }}>
|
|
||||||
No recently deleted shapes
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
@import url("reset.css");
|
@import url("reset.css");
|
||||||
@import url("version-history.css");
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--border-radius: 10px;
|
--border-radius: 10px;
|
||||||
|
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
/**
|
|
||||||
* Version History Visual Effects
|
|
||||||
*
|
|
||||||
* These styles create the visual diff highlighting for:
|
|
||||||
* - New shapes (yellow glow animation)
|
|
||||||
* - Deleted shapes (dimmed with restore option)
|
|
||||||
* - Modified shapes (subtle blue indicator)
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* === New Shape Glow Effect === */
|
|
||||||
|
|
||||||
@keyframes newShapeGlow {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.7);
|
|
||||||
filter: drop-shadow(0 0 0 rgba(251, 191, 36, 0));
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 0 20px 8px rgba(251, 191, 36, 0.4);
|
|
||||||
filter: drop-shadow(0 0 10px rgba(251, 191, 36, 0.5));
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0);
|
|
||||||
filter: drop-shadow(0 0 0 rgba(251, 191, 36, 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes newShapePulse {
|
|
||||||
0%, 100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Applied to shape containers with new shapes */
|
|
||||||
.tl-shape.shape-new {
|
|
||||||
animation: newShapeGlow 2s ease-in-out infinite, newShapePulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glow indicator overlay for new shapes */
|
|
||||||
.shape-new-indicator {
|
|
||||||
position: absolute;
|
|
||||||
top: -4px;
|
|
||||||
right: -4px;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid white;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
z-index: 1000;
|
|
||||||
animation: pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.2);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Deleted Shape Ghost Effect === */
|
|
||||||
|
|
||||||
.shape-deleted-ghost {
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.25;
|
|
||||||
filter: grayscale(100%);
|
|
||||||
border: 2px dashed #9ca3af;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shape-deleted-ghost:hover {
|
|
||||||
opacity: 0.5;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shape-deleted-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shape-deleted-ghost:hover .shape-deleted-overlay {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restore-deleted-button {
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: #10b981;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restore-deleted-button:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Modified Shape Indicator === */
|
|
||||||
|
|
||||||
.tl-shape.shape-modified {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-shape.shape-modified::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -2px;
|
|
||||||
left: -2px;
|
|
||||||
right: -2px;
|
|
||||||
bottom: -2px;
|
|
||||||
border: 2px solid rgba(59, 130, 246, 0.3);
|
|
||||||
border-radius: 4px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shape-modified-indicator {
|
|
||||||
position: absolute;
|
|
||||||
top: -4px;
|
|
||||||
left: -4px;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
background: #3b82f6;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid white;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Version History Button === */
|
|
||||||
|
|
||||||
.version-history-button {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 6px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease, transform 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-history-button:hover {
|
|
||||||
background-color: var(--color-muted-2, rgba(0,0,0,0.05));
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-history-button.active {
|
|
||||||
background-color: var(--color-muted-2, rgba(0,0,0,0.08));
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-history-button:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Version History Panel === */
|
|
||||||
|
|
||||||
.version-history-panel {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-history-panel::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-history-panel::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-history-panel::-webkit-scrollbar-thumb {
|
|
||||||
background: #d1d5db;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-history-panel::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Timeline View (for version stepping) === */
|
|
||||||
|
|
||||||
.version-timeline {
|
|
||||||
position: relative;
|
|
||||||
padding-left: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-timeline::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 8px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 2px;
|
|
||||||
background: var(--border-color, #e5e7eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-timeline-item {
|
|
||||||
position: relative;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-timeline-item::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -18px;
|
|
||||||
top: 12px;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
background: white;
|
|
||||||
border: 2px solid var(--color-primary, #3b82f6);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-timeline-item.current::before {
|
|
||||||
background: var(--color-primary, #3b82f6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Dark Mode Support === */
|
|
||||||
|
|
||||||
.dark .version-history-panel {
|
|
||||||
background-color: var(--bg-color, #1f2937);
|
|
||||||
border-color: var(--border-color, #374151);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .shape-deleted-ghost {
|
|
||||||
border-color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .version-history-button:hover {
|
|
||||||
background-color: rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .version-history-panel::-webkit-scrollbar-thumb {
|
|
||||||
background: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .version-history-panel::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Animation for Fading Out Glow === */
|
|
||||||
|
|
||||||
.shape-new.glow-fading {
|
|
||||||
animation: newShapeGlowFadeOut 3s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes newShapeGlowFadeOut {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 15px 5px rgba(251, 191, 36, 0.4);
|
|
||||||
filter: drop-shadow(0 0 8px rgba(251, 191, 36, 0.5));
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0);
|
|
||||||
filter: drop-shadow(0 0 0 rgba(251, 191, 36, 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Deleted Shape Floating Indicator === */
|
|
||||||
|
|
||||||
.deleted-shapes-floating-indicator {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 80px;
|
|
||||||
right: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: rgba(239, 68, 68, 0.9);
|
|
||||||
color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
z-index: 10000;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleted-shapes-floating-indicator:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 16px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleted-shapes-floating-indicator svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Transition Animations === */
|
|
||||||
|
|
||||||
.version-panel-enter {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-panel-enter-active {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
transition: opacity 200ms ease-out, transform 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-panel-exit {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-panel-exit-active {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
transition: opacity 150ms ease-in, transform 150ms ease-in;
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
/**
|
|
||||||
* usePermissions Hook
|
|
||||||
*
|
|
||||||
* React hook for accessing user permissions on a board.
|
|
||||||
* Integrates with AuthContext and permission utilities.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
import {
|
|
||||||
getUserPermissionContext,
|
|
||||||
UserPermissionContext,
|
|
||||||
BoardPermission,
|
|
||||||
BoardRole,
|
|
||||||
} from '../lib/permissions';
|
|
||||||
|
|
||||||
export interface UsePermissionsReturn extends UserPermissionContext {
|
|
||||||
userId: string | undefined;
|
|
||||||
boardId: string;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
// Convenience permission checks
|
|
||||||
canView: boolean;
|
|
||||||
canEdit: boolean;
|
|
||||||
canDelete: boolean;
|
|
||||||
canRevert: boolean;
|
|
||||||
canRestoreDeleted: boolean;
|
|
||||||
canViewHistory: boolean;
|
|
||||||
canMarkAsSeen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get current user's permissions for a board
|
|
||||||
*/
|
|
||||||
export function usePermissions(boardId: string): UsePermissionsReturn {
|
|
||||||
const { session } = useAuth();
|
|
||||||
const userId = session.authed ? session.username : undefined;
|
|
||||||
|
|
||||||
const permissionContext = useMemo(() => {
|
|
||||||
return getUserPermissionContext(userId || '', boardId);
|
|
||||||
}, [userId, boardId]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
boardId,
|
|
||||||
isAuthenticated: session.authed,
|
|
||||||
loading: session.loading,
|
|
||||||
...permissionContext,
|
|
||||||
// Flatten permissions for convenience
|
|
||||||
canView: permissionContext.permissions.canView,
|
|
||||||
canEdit: permissionContext.permissions.canEdit,
|
|
||||||
canDelete: permissionContext.permissions.canDelete,
|
|
||||||
canRevert: permissionContext.permissions.canRevert,
|
|
||||||
canRestoreDeleted: permissionContext.permissions.canRestoreDeleted,
|
|
||||||
canViewHistory: permissionContext.permissions.canViewHistory,
|
|
||||||
canMarkAsSeen: permissionContext.permissions.canMarkAsSeen,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default usePermissions;
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
/**
|
|
||||||
* useVersionHistory Hook
|
|
||||||
*
|
|
||||||
* Manages version history state, tracks shape changes, and applies
|
|
||||||
* visual effects to highlight new, modified, and deleted shapes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
|
||||||
import { Editor, TLShape, TLShapeId, TLRecord } from 'tldraw';
|
|
||||||
import {
|
|
||||||
computeShapeDiff,
|
|
||||||
saveLastSeenState,
|
|
||||||
getLastSeenState,
|
|
||||||
storeDeletedShape,
|
|
||||||
removeDeletedShape,
|
|
||||||
getDeletedShapes,
|
|
||||||
ShapeDiff,
|
|
||||||
DeletedShape,
|
|
||||||
NEW_SHAPE_GLOW_DURATION,
|
|
||||||
} from '../lib/versionHistory';
|
|
||||||
|
|
||||||
export interface VersionHistoryState {
|
|
||||||
diff: ShapeDiff;
|
|
||||||
deletedShapes: DeletedShape[];
|
|
||||||
showNewShapeGlow: boolean;
|
|
||||||
showDeletedShapes: boolean;
|
|
||||||
isFirstVisit: boolean;
|
|
||||||
lastSeenTimestamp: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseVersionHistoryOptions {
|
|
||||||
userId: string;
|
|
||||||
boardId: string;
|
|
||||||
editor: Editor | null;
|
|
||||||
autoSaveOnChange?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseVersionHistoryReturn extends VersionHistoryState {
|
|
||||||
markAsSeen: () => void;
|
|
||||||
toggleNewShapeGlow: (show: boolean) => void;
|
|
||||||
toggleDeletedShapes: (show: boolean) => void;
|
|
||||||
restoreShape: (deletedShape: DeletedShape) => void;
|
|
||||||
navigateToShape: (shapeId: TLShapeId) => void;
|
|
||||||
refreshDiff: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to manage version history and visual diff highlighting
|
|
||||||
*/
|
|
||||||
export function useVersionHistory({
|
|
||||||
userId,
|
|
||||||
boardId,
|
|
||||||
editor,
|
|
||||||
autoSaveOnChange = false,
|
|
||||||
}: UseVersionHistoryOptions): UseVersionHistoryReturn {
|
|
||||||
const [diff, setDiff] = useState<ShapeDiff>({
|
|
||||||
newShapes: [],
|
|
||||||
deletedShapes: [],
|
|
||||||
modifiedShapes: [],
|
|
||||||
});
|
|
||||||
const [deletedShapes, setDeletedShapes] = useState<DeletedShape[]>([]);
|
|
||||||
const [showNewShapeGlow, setShowNewShapeGlow] = useState(true);
|
|
||||||
const [showDeletedShapes, setShowDeletedShapes] = useState(true);
|
|
||||||
const [isFirstVisit, setIsFirstVisit] = useState(true);
|
|
||||||
const [lastSeenTimestamp, setLastSeenTimestamp] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Track shapes that should lose their glow effect after timeout
|
|
||||||
const glowTimeoutRef = useRef<Map<TLShapeId, NodeJS.Timeout>>(new Map());
|
|
||||||
const previousShapeIdsRef = useRef<Set<TLShapeId>>(new Set());
|
|
||||||
|
|
||||||
// Compute initial diff on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor || !userId || !boardId) return;
|
|
||||||
|
|
||||||
const lastSeen = getLastSeenState(userId, boardId);
|
|
||||||
setIsFirstVisit(!lastSeen);
|
|
||||||
setLastSeenTimestamp(lastSeen?.lastSeenTimestamp || null);
|
|
||||||
|
|
||||||
const shapes = editor.getCurrentPageShapes();
|
|
||||||
const computed = computeShapeDiff(userId, boardId, shapes);
|
|
||||||
setDiff(computed);
|
|
||||||
|
|
||||||
const deleted = getDeletedShapes(userId, boardId);
|
|
||||||
setDeletedShapes(deleted);
|
|
||||||
|
|
||||||
// Store current shape IDs for tracking deletions
|
|
||||||
previousShapeIdsRef.current = new Set(shapes.map(s => s.id));
|
|
||||||
}, [editor, userId, boardId]);
|
|
||||||
|
|
||||||
// Listen for store changes to detect new/deleted shapes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor || !userId || !boardId) return;
|
|
||||||
|
|
||||||
const handleStoreChange = () => {
|
|
||||||
const shapes = editor.getCurrentPageShapes();
|
|
||||||
const currentIds = new Set(shapes.map(s => s.id));
|
|
||||||
const previousIds = previousShapeIdsRef.current;
|
|
||||||
|
|
||||||
// Detect newly deleted shapes (were in previous, not in current)
|
|
||||||
previousIds.forEach(id => {
|
|
||||||
if (!currentIds.has(id)) {
|
|
||||||
// Shape was deleted - try to get its last known state
|
|
||||||
// Note: The shape is already gone from the editor, so we can't get it
|
|
||||||
// This is handled by the deletion tracking in the shape lifecycle
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update previous IDs
|
|
||||||
previousShapeIdsRef.current = currentIds;
|
|
||||||
|
|
||||||
// Recompute diff
|
|
||||||
const computed = computeShapeDiff(userId, boardId, shapes);
|
|
||||||
setDiff(computed);
|
|
||||||
|
|
||||||
// Auto-save if enabled
|
|
||||||
if (autoSaveOnChange) {
|
|
||||||
saveLastSeenState(userId, boardId, shapes);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const unsubscribe = editor.store.listen(handleStoreChange, {
|
|
||||||
source: 'all',
|
|
||||||
scope: 'document',
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [editor, userId, boardId, autoSaveOnChange]);
|
|
||||||
|
|
||||||
// Apply visual classes to new shapes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor || !showNewShapeGlow) return;
|
|
||||||
|
|
||||||
// Apply glow class to new shapes
|
|
||||||
diff.newShapes.forEach(shapeId => {
|
|
||||||
const element = document.querySelector(`[data-shape-id="${shapeId}"]`);
|
|
||||||
if (element) {
|
|
||||||
element.classList.add('shape-new');
|
|
||||||
|
|
||||||
// Set up timeout to remove glow after duration
|
|
||||||
const existingTimeout = glowTimeoutRef.current.get(shapeId);
|
|
||||||
if (existingTimeout) {
|
|
||||||
clearTimeout(existingTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
element.classList.add('glow-fading');
|
|
||||||
setTimeout(() => {
|
|
||||||
element.classList.remove('shape-new', 'glow-fading');
|
|
||||||
}, 3000); // Match CSS animation duration
|
|
||||||
glowTimeoutRef.current.delete(shapeId);
|
|
||||||
}, NEW_SHAPE_GLOW_DURATION);
|
|
||||||
|
|
||||||
glowTimeoutRef.current.set(shapeId, timeout);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Cleanup timeouts
|
|
||||||
glowTimeoutRef.current.forEach(timeout => clearTimeout(timeout));
|
|
||||||
};
|
|
||||||
}, [editor, diff.newShapes, showNewShapeGlow]);
|
|
||||||
|
|
||||||
// Listen for custom events from the panel
|
|
||||||
useEffect(() => {
|
|
||||||
const handleToggleGlow = (event: CustomEvent<boolean>) => {
|
|
||||||
setShowNewShapeGlow(event.detail);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleDeleted = (event: CustomEvent<boolean>) => {
|
|
||||||
setShowDeletedShapes(event.detail);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShapeRestored = (event: CustomEvent<TLShapeId>) => {
|
|
||||||
removeDeletedShape(userId, boardId, event.detail);
|
|
||||||
setDeletedShapes(prev => prev.filter(s => s.id !== event.detail));
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('toggle-new-shape-glow', handleToggleGlow as EventListener);
|
|
||||||
window.addEventListener('toggle-deleted-shapes', handleToggleDeleted as EventListener);
|
|
||||||
window.addEventListener('shape-restored', handleShapeRestored as EventListener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('toggle-new-shape-glow', handleToggleGlow as EventListener);
|
|
||||||
window.removeEventListener('toggle-deleted-shapes', handleToggleDeleted as EventListener);
|
|
||||||
window.removeEventListener('shape-restored', handleShapeRestored as EventListener);
|
|
||||||
};
|
|
||||||
}, [userId, boardId]);
|
|
||||||
|
|
||||||
// Mark current state as seen
|
|
||||||
const markAsSeen = useCallback(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
const shapes = editor.getCurrentPageShapes();
|
|
||||||
saveLastSeenState(userId, boardId, shapes);
|
|
||||||
setDiff({
|
|
||||||
newShapes: [],
|
|
||||||
deletedShapes: [],
|
|
||||||
modifiedShapes: [],
|
|
||||||
});
|
|
||||||
setLastSeenTimestamp(Date.now());
|
|
||||||
setIsFirstVisit(false);
|
|
||||||
}, [editor, userId, boardId]);
|
|
||||||
|
|
||||||
// Toggle glow visibility
|
|
||||||
const toggleNewShapeGlow = useCallback((show: boolean) => {
|
|
||||||
setShowNewShapeGlow(show);
|
|
||||||
if (!show) {
|
|
||||||
// Remove glow from all shapes
|
|
||||||
document.querySelectorAll('.shape-new').forEach(el => {
|
|
||||||
el.classList.remove('shape-new', 'glow-fading');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Toggle deleted shapes visibility
|
|
||||||
const toggleDeletedShapes = useCallback((show: boolean) => {
|
|
||||||
setShowDeletedShapes(show);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Restore a deleted shape
|
|
||||||
const restoreShape = useCallback((deletedShape: DeletedShape) => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
editor.createShape({
|
|
||||||
id: deletedShape.id,
|
|
||||||
type: deletedShape.type,
|
|
||||||
x: deletedShape.x,
|
|
||||||
y: deletedShape.y,
|
|
||||||
props: deletedShape.props,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
removeDeletedShape(userId, boardId, deletedShape.id);
|
|
||||||
setDeletedShapes(prev => prev.filter(s => s.id !== deletedShape.id));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error restoring shape:', error);
|
|
||||||
}
|
|
||||||
}, [editor, userId, boardId]);
|
|
||||||
|
|
||||||
// Navigate to a shape
|
|
||||||
const navigateToShape = useCallback((shapeId: TLShapeId) => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
const shape = editor.getShape(shapeId);
|
|
||||||
if (!shape) return;
|
|
||||||
|
|
||||||
const bounds = editor.getShapePageBounds(shape);
|
|
||||||
if (bounds) {
|
|
||||||
editor.zoomToBounds(bounds, {
|
|
||||||
animation: { duration: 300 },
|
|
||||||
inset: 100,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.setSelectedShapes([shapeId]);
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
// Refresh diff manually
|
|
||||||
const refreshDiff = useCallback(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
const shapes = editor.getCurrentPageShapes();
|
|
||||||
const computed = computeShapeDiff(userId, boardId, shapes);
|
|
||||||
setDiff(computed);
|
|
||||||
}, [editor, userId, boardId]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
diff,
|
|
||||||
deletedShapes,
|
|
||||||
showNewShapeGlow,
|
|
||||||
showDeletedShapes,
|
|
||||||
isFirstVisit,
|
|
||||||
lastSeenTimestamp,
|
|
||||||
markAsSeen,
|
|
||||||
toggleNewShapeGlow,
|
|
||||||
toggleDeletedShapes,
|
|
||||||
restoreShape,
|
|
||||||
navigateToShape,
|
|
||||||
refreshDiff,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track shape deletions by intercepting the delete operation
|
|
||||||
* This is meant to be called from the Editor's onMount or similar
|
|
||||||
*/
|
|
||||||
export function trackShapeDeletions(
|
|
||||||
editor: Editor,
|
|
||||||
userId: string,
|
|
||||||
boardId: string
|
|
||||||
): () => void {
|
|
||||||
// Store shapes before they're deleted
|
|
||||||
const handleBeforeDelete = (records: TLRecord[]) => {
|
|
||||||
records.forEach(record => {
|
|
||||||
if (record.typeName === 'shape') {
|
|
||||||
const shape = record as TLShape;
|
|
||||||
storeDeletedShape(userId, boardId, shape);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen for store changes with before state
|
|
||||||
const unsubscribe = editor.store.listen(
|
|
||||||
(entry) => {
|
|
||||||
// Check for deleted shapes
|
|
||||||
if (entry.changes.removed) {
|
|
||||||
const removedRecords = Object.values(entry.changes.removed);
|
|
||||||
handleBeforeDelete(removedRecords as TLRecord[]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ source: 'user', scope: 'document' }
|
|
||||||
);
|
|
||||||
|
|
||||||
return unsubscribe;
|
|
||||||
}
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
/**
|
|
||||||
* Permission Types and Utilities
|
|
||||||
*
|
|
||||||
* Defines roles and permissions for board access control.
|
|
||||||
* Currently uses localStorage-based role assignment per board.
|
|
||||||
* Can be extended to integrate with server-side permission checking.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type BoardRole = 'admin' | 'editor' | 'viewer';
|
|
||||||
|
|
||||||
export interface BoardPermission {
|
|
||||||
canView: boolean;
|
|
||||||
canEdit: boolean;
|
|
||||||
canDelete: boolean;
|
|
||||||
canRevert: boolean;
|
|
||||||
canRestoreDeleted: boolean;
|
|
||||||
canViewHistory: boolean;
|
|
||||||
canMarkAsSeen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Permission matrix by role
|
|
||||||
*/
|
|
||||||
const ROLE_PERMISSIONS: Record<BoardRole, BoardPermission> = {
|
|
||||||
admin: {
|
|
||||||
canView: true,
|
|
||||||
canEdit: true,
|
|
||||||
canDelete: true,
|
|
||||||
canRevert: true,
|
|
||||||
canRestoreDeleted: true,
|
|
||||||
canViewHistory: true,
|
|
||||||
canMarkAsSeen: true,
|
|
||||||
},
|
|
||||||
editor: {
|
|
||||||
canView: true,
|
|
||||||
canEdit: true,
|
|
||||||
canDelete: true,
|
|
||||||
canRevert: false, // Editors cannot revert to old versions
|
|
||||||
canRestoreDeleted: true,
|
|
||||||
canViewHistory: true,
|
|
||||||
canMarkAsSeen: true,
|
|
||||||
},
|
|
||||||
viewer: {
|
|
||||||
canView: true,
|
|
||||||
canEdit: false,
|
|
||||||
canDelete: false,
|
|
||||||
canRevert: false,
|
|
||||||
canRestoreDeleted: false,
|
|
||||||
canViewHistory: true, // Viewers can see history but not act on it
|
|
||||||
canMarkAsSeen: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get permissions for a given role
|
|
||||||
*/
|
|
||||||
export function getPermissionsForRole(role: BoardRole): BoardPermission {
|
|
||||||
return ROLE_PERMISSIONS[role];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage key for user role per board
|
|
||||||
*/
|
|
||||||
function getRoleStorageKey(userId: string, boardId: string): string {
|
|
||||||
return `board_role_${userId}_${boardId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the user's role for a specific board
|
|
||||||
* Falls back to 'editor' if not set (default for authenticated users)
|
|
||||||
*/
|
|
||||||
export function getUserBoardRole(userId: string, boardId: string): BoardRole {
|
|
||||||
if (!userId) return 'viewer'; // Unauthenticated users are viewers
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(getRoleStorageKey(userId, boardId));
|
|
||||||
if (stored && ['admin', 'editor', 'viewer'].includes(stored)) {
|
|
||||||
return stored as BoardRole;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Error reading board role:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: check if user is the board creator
|
|
||||||
const boardCreator = getBoardCreator(boardId);
|
|
||||||
if (boardCreator === userId) {
|
|
||||||
return 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'editor'; // Default for authenticated users
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the user's role for a specific board (admin-only operation)
|
|
||||||
*/
|
|
||||||
export function setUserBoardRole(userId: string, boardId: string, role: BoardRole): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(getRoleStorageKey(userId, boardId), role);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving board role:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage key for board creator
|
|
||||||
*/
|
|
||||||
function getCreatorStorageKey(boardId: string): string {
|
|
||||||
return `board_creator_${boardId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the creator of a board
|
|
||||||
*/
|
|
||||||
export function getBoardCreator(boardId: string): string | null {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(getCreatorStorageKey(boardId));
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Error reading board creator:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the creator of a board (called when board is created)
|
|
||||||
*/
|
|
||||||
export function setBoardCreator(boardId: string, userId: string): void {
|
|
||||||
try {
|
|
||||||
const existing = getBoardCreator(boardId);
|
|
||||||
if (!existing) {
|
|
||||||
localStorage.setItem(getCreatorStorageKey(boardId), userId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving board creator:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user has a specific permission on a board
|
|
||||||
*/
|
|
||||||
export function hasPermission(
|
|
||||||
userId: string,
|
|
||||||
boardId: string,
|
|
||||||
permission: keyof BoardPermission
|
|
||||||
): boolean {
|
|
||||||
const role = getUserBoardRole(userId, boardId);
|
|
||||||
const permissions = getPermissionsForRole(role);
|
|
||||||
return permissions[permission];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all permissions for a user on a board
|
|
||||||
*/
|
|
||||||
export function getUserBoardPermissions(userId: string, boardId: string): BoardPermission {
|
|
||||||
const role = getUserBoardRole(userId, boardId);
|
|
||||||
return getPermissionsForRole(role);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React hook-friendly permission checker
|
|
||||||
* Returns permission object and role for a user on a board
|
|
||||||
*/
|
|
||||||
export interface UserPermissionContext {
|
|
||||||
role: BoardRole;
|
|
||||||
permissions: BoardPermission;
|
|
||||||
isAdmin: boolean;
|
|
||||||
isEditor: boolean;
|
|
||||||
isViewer: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUserPermissionContext(userId: string, boardId: string): UserPermissionContext {
|
|
||||||
const role = getUserBoardRole(userId, boardId);
|
|
||||||
const permissions = getPermissionsForRole(role);
|
|
||||||
|
|
||||||
return {
|
|
||||||
role,
|
|
||||||
permissions,
|
|
||||||
isAdmin: role === 'admin',
|
|
||||||
isEditor: role === 'editor',
|
|
||||||
isViewer: role === 'viewer',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,285 +0,0 @@
|
||||||
/**
|
|
||||||
* Version History System
|
|
||||||
*
|
|
||||||
* Tracks user's last-seen board state and computes diffs to highlight
|
|
||||||
* new, modified, and deleted shapes. Integrates with R2 backups for
|
|
||||||
* coarse-grained history and Automerge CRDT for fine-grained history.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { TLShape, TLShapeId } from 'tldraw';
|
|
||||||
|
|
||||||
// === Types ===
|
|
||||||
|
|
||||||
export interface UserBoardState {
|
|
||||||
userId: string;
|
|
||||||
boardId: string;
|
|
||||||
lastSeenTimestamp: number;
|
|
||||||
shapeIds: string[];
|
|
||||||
shapeHashes: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShapeDiff {
|
|
||||||
newShapes: TLShapeId[]; // Shapes added since last visit
|
|
||||||
deletedShapes: DeletedShape[]; // Shapes removed since last visit
|
|
||||||
modifiedShapes: TLShapeId[]; // Shapes changed since last visit
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeletedShape {
|
|
||||||
id: TLShapeId;
|
|
||||||
type: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
props: Record<string, unknown>;
|
|
||||||
deletedAt: number;
|
|
||||||
deletedBy?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VersionSnapshot {
|
|
||||||
id: string;
|
|
||||||
timestamp: number;
|
|
||||||
source: 'automerge' | 'r2';
|
|
||||||
shapeCount: number;
|
|
||||||
label?: string;
|
|
||||||
actorId?: string; // For Automerge heads
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Storage Keys ===
|
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'canvas_version_';
|
|
||||||
|
|
||||||
function getStateKey(userId: string, boardId: string): string {
|
|
||||||
return `${STORAGE_PREFIX}state_${userId}_${boardId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDeletedKey(userId: string, boardId: string): string {
|
|
||||||
return `${STORAGE_PREFIX}deleted_${userId}_${boardId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Hash Utilities ===
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a simple hash for a shape's content to detect modifications
|
|
||||||
*/
|
|
||||||
function hashShape(shape: TLShape): string {
|
|
||||||
// Create a deterministic string from shape properties that matter
|
|
||||||
const relevant = {
|
|
||||||
type: shape.type,
|
|
||||||
x: Math.round(shape.x * 100) / 100,
|
|
||||||
y: Math.round(shape.y * 100) / 100,
|
|
||||||
rotation: shape.rotation,
|
|
||||||
props: shape.props,
|
|
||||||
};
|
|
||||||
return simpleHash(JSON.stringify(relevant));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple string hash for quick comparison (DJB2 algorithm)
|
|
||||||
*/
|
|
||||||
function simpleHash(str: string): string {
|
|
||||||
let hash = 5381;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return (hash >>> 0).toString(16);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === State Management ===
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the user's last-seen state for a board
|
|
||||||
*/
|
|
||||||
export function getLastSeenState(userId: string, boardId: string): UserBoardState | null {
|
|
||||||
try {
|
|
||||||
const key = getStateKey(userId, boardId);
|
|
||||||
const stored = localStorage.getItem(key);
|
|
||||||
if (!stored) return null;
|
|
||||||
return JSON.parse(stored);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading last-seen state:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save the current state as the user's last-seen state
|
|
||||||
*/
|
|
||||||
export function saveLastSeenState(
|
|
||||||
userId: string,
|
|
||||||
boardId: string,
|
|
||||||
shapes: TLShape[]
|
|
||||||
): void {
|
|
||||||
try {
|
|
||||||
const state: UserBoardState = {
|
|
||||||
userId,
|
|
||||||
boardId,
|
|
||||||
lastSeenTimestamp: Date.now(),
|
|
||||||
shapeIds: shapes.map(s => s.id),
|
|
||||||
shapeHashes: shapes.reduce((acc, shape) => {
|
|
||||||
acc[shape.id] = hashShape(shape);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, string>),
|
|
||||||
};
|
|
||||||
|
|
||||||
const key = getStateKey(userId, boardId);
|
|
||||||
localStorage.setItem(key, JSON.stringify(state));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving last-seen state:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a deleted shape for potential restoration
|
|
||||||
*/
|
|
||||||
export function storeDeletedShape(
|
|
||||||
userId: string,
|
|
||||||
boardId: string,
|
|
||||||
shape: TLShape,
|
|
||||||
deletedBy?: string
|
|
||||||
): void {
|
|
||||||
try {
|
|
||||||
const key = getDeletedKey(userId, boardId);
|
|
||||||
const stored = localStorage.getItem(key);
|
|
||||||
const deletedShapes: DeletedShape[] = stored ? JSON.parse(stored) : [];
|
|
||||||
|
|
||||||
// Add the newly deleted shape
|
|
||||||
deletedShapes.push({
|
|
||||||
id: shape.id,
|
|
||||||
type: shape.type,
|
|
||||||
x: shape.x,
|
|
||||||
y: shape.y,
|
|
||||||
props: shape.props as Record<string, unknown>,
|
|
||||||
deletedAt: Date.now(),
|
|
||||||
deletedBy,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep only last 100 deleted shapes per board to prevent storage bloat
|
|
||||||
const trimmed = deletedShapes.slice(-100);
|
|
||||||
localStorage.setItem(key, JSON.stringify(trimmed));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error storing deleted shape:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get deleted shapes that can be restored
|
|
||||||
*/
|
|
||||||
export function getDeletedShapes(userId: string, boardId: string): DeletedShape[] {
|
|
||||||
try {
|
|
||||||
const key = getDeletedKey(userId, boardId);
|
|
||||||
const stored = localStorage.getItem(key);
|
|
||||||
if (!stored) return [];
|
|
||||||
return JSON.parse(stored);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading deleted shapes:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a deleted shape from storage (after restoration)
|
|
||||||
*/
|
|
||||||
export function removeDeletedShape(userId: string, boardId: string, shapeId: TLShapeId): void {
|
|
||||||
try {
|
|
||||||
const key = getDeletedKey(userId, boardId);
|
|
||||||
const stored = localStorage.getItem(key);
|
|
||||||
if (!stored) return;
|
|
||||||
|
|
||||||
const deletedShapes: DeletedShape[] = JSON.parse(stored);
|
|
||||||
const filtered = deletedShapes.filter(s => s.id !== shapeId);
|
|
||||||
localStorage.setItem(key, JSON.stringify(filtered));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error removing deleted shape:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Diff Computation ===
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the difference between last-seen state and current shapes
|
|
||||||
*/
|
|
||||||
export function computeShapeDiff(
|
|
||||||
userId: string,
|
|
||||||
boardId: string,
|
|
||||||
currentShapes: TLShape[]
|
|
||||||
): ShapeDiff {
|
|
||||||
const lastSeen = getLastSeenState(userId, boardId);
|
|
||||||
|
|
||||||
// If no last-seen state, nothing is "new" - first visit
|
|
||||||
if (!lastSeen) {
|
|
||||||
return {
|
|
||||||
newShapes: [],
|
|
||||||
deletedShapes: [],
|
|
||||||
modifiedShapes: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastSeenIds = new Set(lastSeen.shapeIds);
|
|
||||||
const currentIds = new Set(currentShapes.map(s => s.id));
|
|
||||||
|
|
||||||
// Find new shapes (in current but not in last-seen)
|
|
||||||
const newShapes = currentShapes
|
|
||||||
.filter(s => !lastSeenIds.has(s.id))
|
|
||||||
.map(s => s.id);
|
|
||||||
|
|
||||||
// Find modified shapes (in both, but hash changed)
|
|
||||||
const modifiedShapes = currentShapes
|
|
||||||
.filter(s => {
|
|
||||||
if (!lastSeenIds.has(s.id)) return false;
|
|
||||||
const oldHash = lastSeen.shapeHashes[s.id];
|
|
||||||
const newHash = hashShape(s);
|
|
||||||
return oldHash !== newHash;
|
|
||||||
})
|
|
||||||
.map(s => s.id);
|
|
||||||
|
|
||||||
// Get stored deleted shapes (shapes that were in last-seen but removed)
|
|
||||||
const deletedShapes = getDeletedShapes(userId, boardId)
|
|
||||||
.filter(d => lastSeenIds.has(d.id) && !currentIds.has(d.id));
|
|
||||||
|
|
||||||
return {
|
|
||||||
newShapes,
|
|
||||||
deletedShapes,
|
|
||||||
modifiedShapes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Version Snapshot Management ===
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a timestamp into a human-readable relative time
|
|
||||||
*/
|
|
||||||
export function 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 (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
|
|
||||||
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
|
||||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
|
||||||
return 'Just now';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a timestamp into a readable date string
|
|
||||||
*/
|
|
||||||
export function formatTimestamp(timestamp: number): string {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
return date.toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Effect Duration ===
|
|
||||||
|
|
||||||
// How long new shape glow should last (in ms)
|
|
||||||
export const NEW_SHAPE_GLOW_DURATION = 30000; // 30 seconds
|
|
||||||
|
|
||||||
// How long to show deleted shapes before auto-hiding
|
|
||||||
export const DELETED_SHAPE_VISIBLE_DURATION = 60000; // 1 minute
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { SettingsDialog } from "./SettingsDialog"
|
||||||
import { useAuth } from "../context/AuthContext"
|
import { useAuth } from "../context/AuthContext"
|
||||||
import LoginButton from "../components/auth/LoginButton"
|
import LoginButton from "../components/auth/LoginButton"
|
||||||
import StarBoardButton from "../components/StarBoardButton"
|
import StarBoardButton from "../components/StarBoardButton"
|
||||||
import VersionHistoryButton from "../components/VersionHistoryButton"
|
|
||||||
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
|
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
|
||||||
import { HolonBrowser } from "../components/HolonBrowser"
|
import { HolonBrowser } from "../components/HolonBrowser"
|
||||||
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
|
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
|
||||||
|
|
@ -535,7 +534,6 @@ export function CustomToolbar() {
|
||||||
>
|
>
|
||||||
<LoginButton className="toolbar-btn" />
|
<LoginButton className="toolbar-btn" />
|
||||||
<StarBoardButton className="toolbar-btn" />
|
<StarBoardButton className="toolbar-btn" />
|
||||||
<VersionHistoryButton className="toolbar-btn" />
|
|
||||||
|
|
||||||
{session.authed && (
|
{session.authed && (
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import {
|
||||||
useValue,
|
useValue,
|
||||||
} from "tldraw"
|
} from "tldraw"
|
||||||
import { SlidesPanel } from "@/slides/SlidesPanel"
|
import { SlidesPanel } from "@/slides/SlidesPanel"
|
||||||
import { DeletedShapesOverlay } from "@/components/DeletedShapesOverlay"
|
|
||||||
|
|
||||||
// Custom People Menu component for showing connected users
|
// Custom People Menu component for showing connected users
|
||||||
function CustomPeopleMenu() {
|
function CustomPeopleMenu() {
|
||||||
|
|
@ -234,7 +233,6 @@ function CustomInFrontOfCanvas() {
|
||||||
<MycelialIntelligenceBar />
|
<MycelialIntelligenceBar />
|
||||||
<FocusLockIndicator />
|
<FocusLockIndicator />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<DeletedShapesOverlay />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
166
worker/worker.ts
166
worker/worker.ts
|
|
@ -905,7 +905,7 @@ router
|
||||||
try {
|
try {
|
||||||
// Simple test to check R2 access
|
// Simple test to check R2 access
|
||||||
const testResult = await env.TLDRAW_BUCKET.list({ prefix: 'rooms/', limit: 1 })
|
const testResult = await env.TLDRAW_BUCKET.list({ prefix: 'rooms/', limit: 1 })
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'R2 access test successful',
|
message: 'R2 access test successful',
|
||||||
|
|
@ -925,170 +925,6 @@ router
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// === Version History API ===
|
|
||||||
.get("/api/versions/:roomId", async (request, env) => {
|
|
||||||
try {
|
|
||||||
const roomId = request.params.roomId
|
|
||||||
if (!roomId) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Room ID is required' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// List all version snapshots from the backups folder
|
|
||||||
const backupPrefix = `backups/${roomId}/`
|
|
||||||
const listResult = await env.TLDRAW_BUCKET.list({ prefix: backupPrefix })
|
|
||||||
|
|
||||||
const versions = await Promise.all(
|
|
||||||
listResult.objects.map(async (obj) => {
|
|
||||||
// Extract timestamp from backup filename (format: backups/roomId/timestamp.json)
|
|
||||||
const filename = obj.key.replace(backupPrefix, '')
|
|
||||||
const timestamp = parseInt(filename.replace('.json', ''), 10)
|
|
||||||
|
|
||||||
// Try to get shape count from the backup
|
|
||||||
let shapeCount = 0
|
|
||||||
try {
|
|
||||||
const backupData = await env.TLDRAW_BUCKET.get(obj.key)
|
|
||||||
if (backupData) {
|
|
||||||
const json = JSON.parse(await backupData.text())
|
|
||||||
if (json.store) {
|
|
||||||
shapeCount = Object.values(json.store).filter((r: any) =>
|
|
||||||
r && typeof r === 'object' && (r as any).typeName === 'shape'
|
|
||||||
).length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors getting shape count
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: obj.key,
|
|
||||||
timestamp: isNaN(timestamp) ? obj.uploaded.getTime() : timestamp,
|
|
||||||
source: 'r2' as const,
|
|
||||||
shapeCount,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Sort by timestamp descending (newest first)
|
|
||||||
versions.sort((a, b) => b.timestamp - a.timestamp)
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ versions }), {
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Version list failed:', error)
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Failed to list versions',
|
|
||||||
message: (error as Error).message
|
|
||||||
}), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.get("/api/versions/:roomId/:versionId", async (request, env) => {
|
|
||||||
try {
|
|
||||||
const { roomId, versionId } = request.params
|
|
||||||
if (!roomId || !versionId) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Room ID and Version ID are required' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the specific version from R2
|
|
||||||
// versionId might be the full key or just the timestamp
|
|
||||||
const key = versionId.startsWith('backups/') ? versionId : `backups/${roomId}/${versionId}.json`
|
|
||||||
const versionData = await env.TLDRAW_BUCKET.get(key)
|
|
||||||
|
|
||||||
if (!versionData) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Version not found' }), {
|
|
||||||
status: 404,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await versionData.text()
|
|
||||||
return new Response(json, {
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Get version failed:', error)
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Failed to get version',
|
|
||||||
message: (error as Error).message
|
|
||||||
}), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.post("/api/versions/:roomId/:versionId", async (request, env) => {
|
|
||||||
try {
|
|
||||||
const { roomId, versionId } = request.params
|
|
||||||
if (!roomId || !versionId) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Room ID and Version ID are required' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse request body to check action
|
|
||||||
const body = await request.json().catch(() => ({})) as { action?: string }
|
|
||||||
if (body.action !== 'revert') {
|
|
||||||
return new Response(JSON.stringify({ error: 'Invalid action. Use action: "revert"' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the version to revert to
|
|
||||||
const key = versionId.startsWith('backups/') ? versionId : `backups/${roomId}/${versionId}.json`
|
|
||||||
const versionData = await env.TLDRAW_BUCKET.get(key)
|
|
||||||
|
|
||||||
if (!versionData) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Version not found' }), {
|
|
||||||
status: 404,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a backup of the current state before reverting
|
|
||||||
const currentRoom = await env.TLDRAW_BUCKET.get(`rooms/${roomId}`)
|
|
||||||
if (currentRoom) {
|
|
||||||
const backupTimestamp = Date.now()
|
|
||||||
const backupKey = `backups/${roomId}/${backupTimestamp}-pre-revert.json`
|
|
||||||
await env.TLDRAW_BUCKET.put(backupKey, await currentRoom.text())
|
|
||||||
console.log(`Created pre-revert backup: ${backupKey}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overwrite the current room data with the version data
|
|
||||||
const versionJson = await versionData.text()
|
|
||||||
await env.TLDRAW_BUCKET.put(`rooms/${roomId}`, versionJson)
|
|
||||||
|
|
||||||
console.log(`Reverted room ${roomId} to version ${versionId}`)
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
message: `Successfully reverted to version`,
|
|
||||||
roomId,
|
|
||||||
versionId
|
|
||||||
}), {
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Revert failed:', error)
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Failed to revert to version',
|
|
||||||
message: (error as Error).message
|
|
||||||
}), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle scheduled events (cron jobs)
|
// Handle scheduled events (cron jobs)
|
||||||
export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) {
|
export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue