diff --git a/src/components/VisibilityChangeManager.tsx b/src/components/VisibilityChangeManager.tsx new file mode 100644 index 0000000..0de7cb9 --- /dev/null +++ b/src/components/VisibilityChangeManager.tsx @@ -0,0 +1,200 @@ +import { useState, useEffect, useCallback } from 'react' +import { useEditor, TLShapeId } from 'tldraw' +import { VisibilityChangeModal, shouldSkipVisibilityPrompt, setSkipVisibilityPrompt } from './VisibilityChangeModal' +import { updateItemVisibility, ItemVisibility } from '../shapes/GoogleItemShapeUtil' +import { findPrivateWorkspace, isShapeInPrivateWorkspace } from '../shapes/PrivateWorkspaceShapeUtil' + +interface PendingChange { + shapeId: TLShapeId + currentVisibility: ItemVisibility + newVisibility: ItemVisibility + title: string +} + +export function VisibilityChangeManager() { + const editor = useEditor() + const [pendingChange, setPendingChange] = useState(null) + const [isDarkMode, setIsDarkMode] = useState(false) + + // Detect dark mode + useEffect(() => { + const checkDarkMode = () => { + setIsDarkMode(document.documentElement.classList.contains('dark')) + } + checkDarkMode() + + // Watch for class changes + const observer = new MutationObserver(checkDarkMode) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }) + + return () => observer.disconnect() + }, []) + + // Handle visibility change requests from GoogleItem shapes + useEffect(() => { + const handleVisibilityChangeRequest = (event: CustomEvent<{ + shapeId: TLShapeId + currentVisibility: ItemVisibility + newVisibility: ItemVisibility + title: string + }>) => { + const { shapeId, currentVisibility, newVisibility, title } = event.detail + + // Check if user has opted to skip prompts + if (shouldSkipVisibilityPrompt()) { + // Apply change immediately + updateItemVisibility(editor, shapeId, newVisibility) + return + } + + // Show confirmation modal + setPendingChange({ + shapeId, + currentVisibility, + newVisibility, + title, + }) + } + + window.addEventListener('request-visibility-change', handleVisibilityChangeRequest as EventListener) + return () => { + window.removeEventListener('request-visibility-change', handleVisibilityChangeRequest as EventListener) + } + }, [editor]) + + // Handle drag detection - check when items leave the Private Workspace + // Track GoogleItem positions to detect when they move outside workspace + useEffect(() => { + if (!editor) return + + // Track which GoogleItems were inside workspace at start of drag + const wasInWorkspace = new Map() + let isDragging = false + + // Record initial positions when pointer goes down + const handlePointerDown = () => { + const workspace = findPrivateWorkspace(editor) + if (!workspace) return + + const selectedIds = editor.getSelectedShapeIds() + wasInWorkspace.clear() + + for (const id of selectedIds) { + const shape = editor.getShape(id) + if (shape && shape.type === 'GoogleItem') { + const inWorkspace = isShapeInPrivateWorkspace(editor, id, workspace.id) + wasInWorkspace.set(id, inWorkspace) + } + } + isDragging = true + } + + // Check for visibility changes when pointer goes up + const handlePointerUp = () => { + if (!isDragging || wasInWorkspace.size === 0) { + isDragging = false + return + } + + const workspace = findPrivateWorkspace(editor) + if (!workspace) { + wasInWorkspace.clear() + isDragging = false + return + } + + // Check each tracked shape + wasInWorkspace.forEach((wasIn, id) => { + const shape = editor.getShape(id) + if (!shape || shape.type !== 'GoogleItem') return + + const isNowIn = isShapeInPrivateWorkspace(editor, id, workspace.id) + + // If shape was in workspace and is now outside, trigger visibility change + if (wasIn && !isNowIn) { + const itemShape = shape as any // GoogleItem shape + if (itemShape.props.visibility === 'local') { + // Trigger visibility change request + window.dispatchEvent(new CustomEvent('request-visibility-change', { + detail: { + shapeId: id, + currentVisibility: 'local', + newVisibility: 'shared', + title: itemShape.props.title || 'Untitled', + } + })) + } + } + }) + + wasInWorkspace.clear() + isDragging = false + } + + // Use DOM events for pointer tracking (more reliable with tldraw) + const canvas = document.querySelector('.tl-canvas') + if (canvas) { + canvas.addEventListener('pointerdown', handlePointerDown) + canvas.addEventListener('pointerup', handlePointerUp) + } + + return () => { + if (canvas) { + canvas.removeEventListener('pointerdown', handlePointerDown) + canvas.removeEventListener('pointerup', handlePointerUp) + } + } + }, [editor]) + + // Handle modal confirmation + const handleConfirm = useCallback((dontAskAgain: boolean) => { + if (!pendingChange) return + + // Update the shape visibility + updateItemVisibility(editor, pendingChange.shapeId, pendingChange.newVisibility) + + // Save preference if requested + if (dontAskAgain) { + setSkipVisibilityPrompt(true) + } + + setPendingChange(null) + }, [editor, pendingChange]) + + // Handle modal cancellation + const handleCancel = useCallback(() => { + if (!pendingChange) return + + // If this was triggered by drag, move the shape back inside the workspace + const workspace = findPrivateWorkspace(editor) + if (workspace) { + const shape = editor.getShape(pendingChange.shapeId) + if (shape) { + // Move shape back inside workspace bounds + editor.updateShape({ + id: pendingChange.shapeId, + type: shape.type, + x: workspace.x + 20, + y: workspace.y + 60, + }) + } + } + + setPendingChange(null) + }, [editor, pendingChange]) + + return ( + + ) +} diff --git a/src/components/VisibilityChangeModal.tsx b/src/components/VisibilityChangeModal.tsx new file mode 100644 index 0000000..663a23d --- /dev/null +++ b/src/components/VisibilityChangeModal.tsx @@ -0,0 +1,360 @@ +import { useState, useEffect, useRef } from 'react' + +interface VisibilityChangeModalProps { + isOpen: boolean + itemTitle: string + currentVisibility: 'local' | 'shared' + newVisibility: 'local' | 'shared' + onConfirm: (dontAskAgain: boolean) => void + onCancel: () => void + isDarkMode: boolean +} + +export function VisibilityChangeModal({ + isOpen, + itemTitle, + currentVisibility, + newVisibility, + onConfirm, + onCancel, + isDarkMode, +}: VisibilityChangeModalProps) { + const [dontAskAgain, setDontAskAgain] = useState(false) + const modalRef = useRef(null) + + // Handle escape key + useEffect(() => { + if (!isOpen) return + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel() + } + } + + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + }, [isOpen, onCancel]) + + // Dark mode colors + const colors = isDarkMode ? { + bg: '#1f2937', + cardBg: '#252525', + cardBorder: '#404040', + text: '#e4e4e7', + textMuted: '#a1a1aa', + textHeading: '#f4f4f5', + warningBg: 'rgba(251, 191, 36, 0.15)', + warningBorder: 'rgba(251, 191, 36, 0.3)', + warningText: '#fbbf24', + btnPrimaryBg: '#6366f1', + btnPrimaryText: '#ffffff', + btnSecondaryBg: '#333333', + btnSecondaryText: '#e4e4e4', + checkboxBg: '#333333', + checkboxBorder: '#555555', + localColor: '#6366f1', + sharedColor: '#22c55e', + } : { + bg: '#ffffff', + cardBg: '#f9fafb', + cardBorder: '#e5e7eb', + text: '#374151', + textMuted: '#6b7280', + textHeading: '#1f2937', + warningBg: 'rgba(251, 191, 36, 0.1)', + warningBorder: 'rgba(251, 191, 36, 0.3)', + warningText: '#92400e', + btnPrimaryBg: '#6366f1', + btnPrimaryText: '#ffffff', + btnSecondaryBg: '#f3f4f6', + btnSecondaryText: '#374151', + checkboxBg: '#ffffff', + checkboxBorder: '#d1d5db', + localColor: '#6366f1', + sharedColor: '#22c55e', + } + + if (!isOpen) return null + + const isSharing = currentVisibility === 'local' && newVisibility === 'shared' + + return ( +
+
e.stopPropagation()} + style={{ + backgroundColor: colors.bg, + borderRadius: '12px', + width: '90%', + maxWidth: '420px', + boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)', + border: `1px solid ${colors.cardBorder}`, + overflow: 'hidden', + }} + > + {/* Header */} +
+ + {isSharing ? '⚠️' : '🔒'} + +

+ {isSharing ? 'Change Visibility?' : 'Make Private?'} +

+
+ + {/* Content */} +
+

+ {isSharing + ? "You're about to make this item visible to others:" + : "You're about to make this item private:"} +

+ + {/* Item preview */} +
+ 📄 + + {itemTitle} + +
+ + {/* Current vs New state */} +
+
+ {isSharing ? '🔒' : '🌐'} + {isSharing ? 'Private' : 'Shared'} +
+ +
+ {isSharing ? '🌐' : '🔒'} + {isSharing ? 'Shared' : 'Private'} +
+
+ + {/* Warning for sharing */} + {isSharing && ( +
+

+ Note: Shared items will be visible to all collaborators + on this board and may be uploaded to cloud storage. +

+
+ )} + + {/* Info for making private */} + {!isSharing && ( +
+

+ Note: Private items are only visible to you and remain + encrypted in your browser. Other collaborators won't be able to see this item. +

+
+ )} + + {/* Don't ask again checkbox */} + +
+ + {/* Actions */} +
+ + +
+
+
+ ) +} + +// Session storage key for "don't ask again" preference +const DONT_ASK_KEY = 'visibility-change-dont-ask' + +export function shouldSkipVisibilityPrompt(): boolean { + try { + return sessionStorage.getItem(DONT_ASK_KEY) === 'true' + } catch { + return false + } +} + +export function setSkipVisibilityPrompt(skip: boolean): void { + try { + if (skip) { + sessionStorage.setItem(DONT_ASK_KEY, 'true') + } else { + sessionStorage.removeItem(DONT_ASK_KEY) + } + } catch { + // Ignore storage errors + } +} diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index dc268ce..dea74f7 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -53,6 +53,7 @@ import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUti import { PrivateWorkspaceShape } from "@/shapes/PrivateWorkspaceShapeUtil" import { PrivateWorkspaceTool } from "@/tools/PrivateWorkspaceTool" import { PrivateWorkspaceManager } from "@/components/PrivateWorkspaceManager" +import { VisibilityChangeManager } from "@/components/VisibilityChangeManager" import { GoogleItemShape } from "@/shapes/GoogleItemShapeUtil" import { GoogleItemTool } from "@/tools/GoogleItemTool" import { @@ -1111,6 +1112,7 @@ export function Board() { > + diff --git a/src/shapes/GoogleItemShapeUtil.tsx b/src/shapes/GoogleItemShapeUtil.tsx index 3531b4f..945060c 100644 --- a/src/shapes/GoogleItemShapeUtil.tsx +++ b/src/shapes/GoogleItemShapeUtil.tsx @@ -107,13 +107,14 @@ export class GoogleItemShape extends BaseBoxShapeUtil { return date.toLocaleDateString() } - const handleMakeShared = () => { + const handleToggleVisibility = () => { + const newVisibility = isLocal ? 'shared' : 'local' // Dispatch event for Phase 5 permission flow window.dispatchEvent(new CustomEvent('request-visibility-change', { detail: { shapeId: shape.id, currentVisibility: shape.props.visibility, - newVisibility: 'shared', + newVisibility, title: shape.props.title, } })) @@ -182,11 +183,11 @@ export class GoogleItemShape extends BaseBoxShapeUtil { }} title={isLocal ? 'Private - Only you can see (click to share)' - : 'Shared - Visible to collaborators' + : 'Shared - Visible to collaborators (click to make private)' } onClick={(e) => { e.stopPropagation() - if (isLocal) handleMakeShared() + handleToggleVisibility() }} onPointerDown={(e) => e.stopPropagation()} >