diff --git a/src/components/GoogleDataBrowser.tsx b/src/components/GoogleDataBrowser.tsx new file mode 100644 index 0000000..9a78ac6 --- /dev/null +++ b/src/components/GoogleDataBrowser.tsx @@ -0,0 +1,584 @@ +import { useState, useEffect, useRef } from 'react'; +import { GoogleDataService, type GoogleService, type ShareableItem } from '../lib/google'; + +interface GoogleDataBrowserProps { + isOpen: boolean; + onClose: () => void; + onAddToCanvas: (items: ShareableItem[], position: { x: number; y: number }) => void; + isDarkMode: boolean; +} + +const SERVICE_ICONS: Record = { + gmail: '📧', + drive: '📁', + photos: '📷', + calendar: '📅', +}; + +const SERVICE_NAMES: Record = { + gmail: 'Gmail', + drive: 'Drive', + photos: 'Photos', + calendar: 'Calendar', +}; + +export function GoogleDataBrowser({ + isOpen, + onClose, + onAddToCanvas, + isDarkMode, +}: GoogleDataBrowserProps) { + const modalRef = useRef(null); + const [activeTab, setActiveTab] = useState('gmail'); + const [items, setItems] = useState([]); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [serviceCounts, setServiceCounts] = useState>({ + gmail: 0, + drive: 0, + photos: 0, + calendar: 0, + }); + + // Dark mode aware colors + const colors = isDarkMode ? { + bg: '#1a1a1a', + cardBg: '#252525', + cardBorder: '#404040', + text: '#e4e4e4', + textMuted: '#a1a1aa', + textHeading: '#f4f4f5', + hoverBg: '#333333', + selectedBg: 'rgba(99, 102, 241, 0.2)', + selectedBorder: 'rgba(99, 102, 241, 0.5)', + tabActiveBg: '#3b82f6', + tabActiveText: '#ffffff', + tabInactiveBg: '#333333', + tabInactiveText: '#a1a1aa', + inputBg: '#333333', + inputBorder: '#404040', + btnPrimaryBg: '#6366f1', + btnPrimaryText: '#ffffff', + btnSecondaryBg: '#333333', + btnSecondaryText: '#e4e4e4', + } : { + bg: '#ffffff', + cardBg: '#f9fafb', + cardBorder: '#e5e7eb', + text: '#374151', + textMuted: '#6b7280', + textHeading: '#1f2937', + hoverBg: '#f3f4f6', + selectedBg: 'rgba(99, 102, 241, 0.1)', + selectedBorder: 'rgba(99, 102, 241, 0.4)', + tabActiveBg: '#3b82f6', + tabActiveText: '#ffffff', + tabInactiveBg: '#f3f4f6', + tabInactiveText: '#6b7280', + inputBg: '#ffffff', + inputBorder: '#e5e7eb', + btnPrimaryBg: '#6366f1', + btnPrimaryText: '#ffffff', + btnSecondaryBg: '#f3f4f6', + btnSecondaryText: '#374151', + }; + + // Load items when tab changes + useEffect(() => { + if (!isOpen) return; + + const loadItems = async () => { + setLoading(true); + setItems([]); + setSelectedIds(new Set()); + + try { + const service = GoogleDataService.getInstance(); + const shareService = service.getShareService(); + + if (shareService) { + const shareableItems = await shareService.listShareableItems(activeTab, 100); + setItems(shareableItems); + } + + // Also update counts + const counts = await service.getStoredCounts(); + setServiceCounts(counts); + } catch (error) { + console.error('Failed to load items:', error); + } finally { + setLoading(false); + } + }; + + loadItems(); + }, [isOpen, activeTab]); + + // Handle escape key and click outside + useEffect(() => { + if (!isOpen) return; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('keydown', handleEscape); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, onClose]); + + // Toggle item selection + const toggleSelection = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + // Select/deselect all + const selectAll = () => { + if (selectedIds.size === filteredItems.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(filteredItems.map((i) => i.id))); + } + }; + + // Filter items by search query + const filteredItems = items.filter((item) => { + if (!searchQuery.trim()) return true; + const query = searchQuery.toLowerCase(); + return ( + item.title.toLowerCase().includes(query) || + (item.preview && item.preview.toLowerCase().includes(query)) + ); + }); + + // Handle add to canvas + const handleAddToCanvas = () => { + const selectedItems = items.filter((i) => selectedIds.has(i.id)); + if (selectedItems.length === 0) return; + + // Calculate center of viewport for placement + const position = { x: 200, y: 200 }; // Default position, will be adjusted by caller + onAddToCanvas(selectedItems, position); + onClose(); + }; + + // 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} days ago`; + return date.toLocaleDateString(); + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+ 🔐 +

+ Your Private Data +

+
+ +
+ + {/* Service tabs */} +
+ {(['gmail', 'drive', 'photos', 'calendar'] as GoogleService[]).map((service) => ( + + ))} +
+ + {/* Search and actions */} +
+
+ + 🔍 + + setSearchQuery(e.target.value)} + style={{ + width: '100%', + padding: '8px 12px 8px 36px', + borderRadius: '8px', + border: `1px solid ${colors.inputBorder}`, + backgroundColor: colors.inputBg, + color: colors.text, + fontSize: '13px', + outline: 'none', + }} + /> +
+ +
+ + {/* Items list */} +
+ {loading ? ( +
+ Loading... +
+ ) : filteredItems.length === 0 ? ( +
+ {SERVICE_ICONS[activeTab]} +

No {SERVICE_NAMES[activeTab]} data imported yet

+ + Import data → + +
+ ) : ( +
+ {filteredItems.map((item) => ( +
toggleSelection(item.id)} + style={{ + display: 'flex', + alignItems: 'center', + gap: '12px', + padding: '10px 12px', + borderRadius: '8px', + cursor: 'pointer', + backgroundColor: selectedIds.has(item.id) ? colors.selectedBg : 'transparent', + border: selectedIds.has(item.id) + ? `1px solid ${colors.selectedBorder}` + : '1px solid transparent', + transition: 'all 0.15s ease', + }} + onMouseEnter={(e) => { + if (!selectedIds.has(item.id)) { + e.currentTarget.style.backgroundColor = colors.hoverBg; + } + }} + onMouseLeave={(e) => { + if (!selectedIds.has(item.id)) { + e.currentTarget.style.backgroundColor = 'transparent'; + } + }} + > + {/* Checkbox */} +
+ {selectedIds.has(item.id) && ( + + + + )} +
+ + {/* Content */} +
+
+ {item.title} +
+ {item.preview && ( +
+ {item.preview} +
+ )} +
+ + {/* Date */} +
+ {formatDate(item.date)} +
+
+ ))} +
+ )} +
+ + {/* Footer */} +
+
+ {selectedIds.size > 0 ? `${selectedIds.size} item${selectedIds.size > 1 ? 's' : ''} selected` : 'Select items to add'} +
+
+ + +
+
+ + {/* Privacy note */} +
+

+ 🔒 Private = Only you can see (encrypted in browser) • Drag outside Private Workspace to share +

+
+
+
+ ); +} diff --git a/src/ui/UserSettingsModal.tsx b/src/ui/UserSettingsModal.tsx index 6d29163..76617bd 100644 --- a/src/ui/UserSettingsModal.tsx +++ b/src/ui/UserSettingsModal.tsx @@ -4,7 +4,8 @@ import { useDialogs } from "tldraw" import { SettingsDialog } from "./SettingsDialog" import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey" import { linkEmailToAccount, checkEmailStatus, type LookupResult } from "../lib/auth/cryptidEmailService" -import { GoogleDataService, type GoogleService } from "../lib/google" +import { GoogleDataService, type GoogleService, type ShareableItem } from "../lib/google" +import { GoogleDataBrowser } from "../components/GoogleDataBrowser" // AI tool model configurations const AI_TOOLS = [ @@ -304,6 +305,17 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use // Calculate total imported items const totalGoogleItems = Object.values(googleCounts).reduce((a, b) => a + b, 0) + // Handle adding items to canvas from Google Data Browser + const handleAddToCanvas = async (items: ShareableItem[], position: { x: number; y: number }) => { + // For now, emit a custom event that Board.tsx can listen to + // In Phase 3, this will add items to the Private Workspace zone + window.dispatchEvent(new CustomEvent('add-google-items-to-canvas', { + detail: { items, position } + })); + setShowGoogleDataBrowser(false); + onClose(); + } + // Handle escape key and click outside useEffect(() => { const handleEscape = (e: KeyboardEvent) => { @@ -975,6 +987,14 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use )} + + {/* Google Data Browser Modal */} + setShowGoogleDataBrowser(false)} + onAddToCanvas={handleAddToCanvas} + isDarkMode={isDarkMode} + /> ) }