From 84c6bf834cbf0b52e13e8b66b51b1755ac813acc Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 4 Dec 2025 17:52:54 -0800 Subject: [PATCH] feat: Add GoogleItemShape with privacy badges (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Privacy-aware item shapes for Google Export data: - GoogleItemShapeUtil: Custom shape for Google items with: - Visual distinction: dashed border + shaded overlay for LOCAL items - Solid border for SHARED items - Privacy badge (🔒 local, 🌐 shared) in top-right corner - Click badge to trigger visibility change (Phase 5) - Service icon, title, preview, date display - Optional thumbnail support for photos - Dark mode support - GoogleItemTool: Tool for creating GoogleItem shapes - Updated ShareableItem type to include `service` and `thumbnailUrl` - Updated usePrivateWorkspace hook to create GoogleItem shapes instead of placeholder text shapes Items added from GoogleExportBrowser now appear as proper GoogleItem shapes with privacy indicators inside the workspace. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/hooks/usePrivateWorkspace.ts | 41 ++-- src/lib/google/share.ts | 4 + src/lib/google/types.ts | 2 + src/routes/Board.tsx | 4 + src/shapes/GoogleItemShapeUtil.tsx | 322 +++++++++++++++++++++++++++++ src/tools/GoogleItemTool.ts | 11 + 6 files changed, 367 insertions(+), 17 deletions(-) create mode 100644 src/shapes/GoogleItemShapeUtil.tsx create mode 100644 src/tools/GoogleItemTool.ts diff --git a/src/hooks/usePrivateWorkspace.ts b/src/hooks/usePrivateWorkspace.ts index 6d15c0c..6ca699f 100644 --- a/src/hooks/usePrivateWorkspace.ts +++ b/src/hooks/usePrivateWorkspace.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState } from 'react' import { Editor, createShapeId, TLShapeId } from 'tldraw' import { IPrivateWorkspaceShape, findPrivateWorkspace } from '../shapes/PrivateWorkspaceShapeUtil' -import type { ShareableItem } from '../lib/google' +import { IGoogleItemShape } from '../shapes/GoogleItemShapeUtil' +import type { ShareableItem, GoogleService } from '../lib/google' const WORKSPACE_STORAGE_KEY = 'private-workspace-visible' @@ -114,28 +115,34 @@ export function usePrivateWorkspace({ editor }: UsePrivateWorkspaceOptions) { // Calculate starting position inside workspace const startX = workspace.x + 20 const startY = workspace.y + 60 // Below header - const itemSpacing = 100 + const itemWidth = 220 + const itemHeight = 90 + const itemSpacingX = itemWidth + 10 + const itemSpacingY = itemHeight + 10 + const itemsPerRow = Math.max(1, Math.floor((workspace.props.w - 40) / itemSpacingX)) - // Create placeholder shapes for each item - // In Phase 4, these will be proper PrivateItemShape with visibility tracking + // Create GoogleItem shapes for each item items.forEach((item, index) => { const itemId = createShapeId() - const col = index % 3 - const row = Math.floor(index / 3) + const col = index % itemsPerRow + const row = Math.floor(index / itemsPerRow) - // For now, create text shapes as placeholders - // Phase 4 will replace with proper GoogleItemShape - editor.createShape({ + // Create GoogleItem shape with privacy badge + editor.createShape({ id: itemId, - type: 'text', - x: startX + col * itemSpacing, - y: startY + row * itemSpacing, + type: 'GoogleItem', + x: startX + col * itemSpacingX, + y: startY + row * itemSpacingY, props: { - text: `🔒 ${item.title}`, - size: 's', - font: 'sans', - color: 'violet', - autoSize: true, + w: itemWidth, + h: item.thumbnailUrl ? 140 : itemHeight, + itemId: item.id, + service: item.service as GoogleService, + title: item.title, + preview: item.preview, + date: item.date, + thumbnailUrl: item.thumbnailUrl, + visibility: 'local', // Always start as local/private }, }) }) diff --git a/src/lib/google/share.ts b/src/lib/google/share.ts index cb4c8fb..4bba49c 100644 --- a/src/lib/google/share.ts +++ b/src/lib/google/share.ts @@ -146,6 +146,7 @@ export class ShareService { items.push({ type: 'email', + service: 'gmail', id: email.id, title: subject || '(No Subject)', preview: snippet, @@ -173,6 +174,7 @@ export class ShareService { items.push({ type: 'document', + service: 'drive', id: doc.id, title: name || 'Untitled', date: doc.modifiedTime @@ -199,6 +201,7 @@ export class ShareService { items.push({ type: 'photo', + service: 'photos', id: photo.id, title: filename || 'Untitled', date: photo.creationTime @@ -226,6 +229,7 @@ export class ShareService { items.push({ type: 'event', + service: 'calendar', id: event.id, title: summary || 'Untitled Event', date: event.startTime diff --git a/src/lib/google/types.ts b/src/lib/google/types.ts index 79bb098..d920c25 100644 --- a/src/lib/google/types.ts +++ b/src/lib/google/types.ts @@ -134,10 +134,12 @@ export interface StorageQuotaInfo { // Share Item for Board export interface ShareableItem { type: 'email' | 'document' | 'photo' | 'event'; + service: GoogleService; // Source service id: string; title: string; // Decrypted for display preview?: string; // Decrypted snippet/preview date: number; + thumbnailUrl?: string; // For photos/documents with previews } // Google Service Types diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index b6b4273..dc268ce 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -53,6 +53,8 @@ import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUti import { PrivateWorkspaceShape } from "@/shapes/PrivateWorkspaceShapeUtil" import { PrivateWorkspaceTool } from "@/tools/PrivateWorkspaceTool" import { PrivateWorkspaceManager } from "@/components/PrivateWorkspaceManager" +import { GoogleItemShape } from "@/shapes/GoogleItemShapeUtil" +import { GoogleItemTool } from "@/tools/GoogleItemTool" import { lockElement, unlockElement, @@ -146,6 +148,7 @@ const customShapeUtils = [ MultmuxShape, MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility PrivateWorkspaceShape, // Private zone for Google Export data sovereignty + GoogleItemShape, // Individual items from Google Export with privacy badges ] const customTools = [ ChatBoxTool, @@ -164,6 +167,7 @@ const customTools = [ VideoGenTool, MultmuxTool, PrivateWorkspaceTool, + GoogleItemTool, ] // Debug: Log tool and shape registration info diff --git a/src/shapes/GoogleItemShapeUtil.tsx b/src/shapes/GoogleItemShapeUtil.tsx new file mode 100644 index 0000000..3531b4f --- /dev/null +++ b/src/shapes/GoogleItemShapeUtil.tsx @@ -0,0 +1,322 @@ +import { useState } from "react" +import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, TLShapeId } from "tldraw" +import type { GoogleService } from "../lib/google" + +// Visibility state for data sovereignty +export type ItemVisibility = 'local' | 'shared' + +export type IGoogleItemShape = TLBaseShape< + "GoogleItem", + { + w: number + h: number + // Item metadata + itemId: string + service: GoogleService + title: string + preview?: string + date: number + thumbnailUrl?: string + // Visibility state + visibility: ItemVisibility + // Original encrypted reference + encryptedRef?: string + } +> + +// Service icons +const SERVICE_ICONS: Record = { + gmail: '📧', + drive: '📁', + photos: '📷', + calendar: '📅', +} + +export class GoogleItemShape extends BaseBoxShapeUtil { + static override type = "GoogleItem" as const + + // Primary color for Google items + static readonly LOCAL_COLOR = "#6366f1" // Indigo for local/private + static readonly SHARED_COLOR = "#22c55e" // Green for shared + + getDefaultProps(): IGoogleItemShape["props"] { + return { + w: 200, + h: 80, + itemId: '', + service: 'gmail', + title: 'Untitled', + preview: '', + date: Date.now(), + visibility: 'local', // Default to local/private + } + } + + override canResize() { + return true + } + + indicator(shape: IGoogleItemShape) { + const isLocal = shape.props.visibility === 'local' + return ( + + ) + } + + component(shape: IGoogleItemShape) { + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) + const isLocal = shape.props.visibility === 'local' + + // Detect dark mode + const isDarkMode = typeof document !== 'undefined' && + document.documentElement.classList.contains('dark') + + const colors = isDarkMode ? { + bg: isLocal ? 'rgba(99, 102, 241, 0.15)' : '#1f2937', + border: isLocal ? 'rgba(99, 102, 241, 0.4)' : 'rgba(34, 197, 94, 0.4)', + text: '#e4e4e7', + textMuted: '#a1a1aa', + badgeBg: isLocal ? '#4f46e5' : '#16a34a', + overlay: isLocal ? 'rgba(99, 102, 241, 0.08)' : 'transparent', + } : { + bg: isLocal ? 'rgba(99, 102, 241, 0.08)' : '#ffffff', + border: isLocal ? 'rgba(99, 102, 241, 0.3)' : 'rgba(34, 197, 94, 0.4)', + text: '#1f2937', + textMuted: '#6b7280', + badgeBg: isLocal ? '#6366f1' : '#22c55e', + overlay: isLocal ? 'rgba(99, 102, 241, 0.05)' : 'transparent', + } + + // Format date + const formatDate = (timestamp: number) => { + const date = new Date(timestamp) + const now = new Date() + const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)) + + if (diffDays === 0) return 'Today' + if (diffDays === 1) return 'Yesterday' + if (diffDays < 7) return `${diffDays}d ago` + return date.toLocaleDateString() + } + + const handleMakeShared = () => { + // Dispatch event for Phase 5 permission flow + window.dispatchEvent(new CustomEvent('request-visibility-change', { + detail: { + shapeId: shape.id, + currentVisibility: shape.props.visibility, + newVisibility: 'shared', + title: shape.props.title, + } + })) + } + + return ( + +
+ {/* Privacy overlay for local items */} + {isLocal && ( +
+ )} + + {/* Privacy badge */} +
{ + e.stopPropagation() + if (isLocal) handleMakeShared() + }} + onPointerDown={(e) => e.stopPropagation()} + > + {isLocal ? '🔒' : '🌐'} +
+ + {/* Content */} +
+ {/* Service icon and title */} +
+ + {SERVICE_ICONS[shape.props.service]} + + + {shape.props.title} + +
+ + {/* Preview text */} + {shape.props.preview && ( +
+ {shape.props.preview} +
+ )} + + {/* Date */} +
+ {formatDate(shape.props.date)} +
+
+ + {/* Thumbnail (if available) */} + {shape.props.thumbnailUrl && shape.props.h > 100 && ( +
+ )} +
+ + ) + } +} + +// Helper to create a GoogleItemShape from a ShareableItem +export function createGoogleItemProps( + item: { + id: string + service: GoogleService + title: string + preview?: string + date: number + thumbnailUrl?: string + }, + visibility: ItemVisibility = 'local' +): Partial { + return { + itemId: item.id, + service: item.service, + title: item.title, + preview: item.preview, + date: item.date, + thumbnailUrl: item.thumbnailUrl, + visibility, + w: 220, + h: item.thumbnailUrl ? 140 : 80, + } +} + +// Helper to update visibility +export function updateItemVisibility( + editor: any, + shapeId: TLShapeId, + visibility: ItemVisibility +) { + const shape = editor.getShape(shapeId) + if (shape && shape.type === 'GoogleItem') { + editor.updateShape({ + id: shapeId, + type: 'GoogleItem', + props: { + ...shape.props, + visibility, + }, + }) + } +} diff --git a/src/tools/GoogleItemTool.ts b/src/tools/GoogleItemTool.ts new file mode 100644 index 0000000..e8445fd --- /dev/null +++ b/src/tools/GoogleItemTool.ts @@ -0,0 +1,11 @@ +import { BaseBoxShapeTool, TLEventHandlers } from "tldraw" + +export class GoogleItemTool extends BaseBoxShapeTool { + static override id = "GoogleItem" + shapeType = "GoogleItem" + override initial = "idle" + + override onComplete: TLEventHandlers["onComplete"] = () => { + this.editor.setCurrentTool('select') + } +}