canvas-website/src/components/GoogleExportBrowser.tsx

585 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react';
import { GoogleDataService, type GoogleService, type ShareableItem } from '../lib/google';
interface GoogleExportBrowserProps {
isOpen: boolean;
onClose: () => void;
onAddToCanvas: (items: ShareableItem[], position: { x: number; y: number }) => void;
isDarkMode: boolean;
}
const SERVICE_ICONS: Record<GoogleService, string> = {
gmail: '📧',
drive: '📁',
photos: '📷',
calendar: '📅',
};
const SERVICE_NAMES: Record<GoogleService, string> = {
gmail: 'Gmail',
drive: 'Drive',
photos: 'Photos',
calendar: 'Calendar',
};
export function GoogleExportBrowser({
isOpen,
onClose,
onAddToCanvas,
isDarkMode,
}: GoogleExportBrowserProps) {
const modalRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState<GoogleService>('gmail');
const [items, setItems] = useState<ShareableItem[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [serviceCounts, setServiceCounts] = useState<Record<GoogleService, number>>({
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 (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100002,
}}
>
<div
ref={modalRef}
style={{
backgroundColor: colors.bg,
borderRadius: '12px',
width: '90%',
maxWidth: '600px',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: `1px solid ${colors.cardBorder}`,
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: `1px solid ${colors.cardBorder}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '20px' }}>🔐</span>
<h2 style={{ fontSize: '16px', fontWeight: '600', color: colors.textHeading, margin: 0 }}>
Your Private Data
</h2>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: '20px',
cursor: 'pointer',
color: colors.textMuted,
padding: '4px',
}}
>
×
</button>
</div>
{/* Service tabs */}
<div
style={{
display: 'flex',
gap: '8px',
padding: '12px 20px',
borderBottom: `1px solid ${colors.cardBorder}`,
}}
>
{(['gmail', 'drive', 'photos', 'calendar'] as GoogleService[]).map((service) => (
<button
key={service}
onClick={() => setActiveTab(service)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
fontSize: '13px',
fontWeight: '500',
backgroundColor: activeTab === service ? colors.tabActiveBg : colors.tabInactiveBg,
color: activeTab === service ? colors.tabActiveText : colors.tabInactiveText,
transition: 'all 0.15s ease',
}}
>
<span>{SERVICE_ICONS[service]}</span>
<span>{SERVICE_NAMES[service]}</span>
{serviceCounts[service] > 0 && (
<span
style={{
fontSize: '11px',
padding: '2px 6px',
borderRadius: '10px',
backgroundColor: activeTab === service ? 'rgba(255,255,255,0.2)' : colors.cardBorder,
}}
>
{serviceCounts[service]}
</span>
)}
</button>
))}
</div>
{/* Search and actions */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 20px',
borderBottom: `1px solid ${colors.cardBorder}`,
}}
>
<div style={{ flex: 1, position: 'relative' }}>
<span
style={{
position: 'absolute',
left: '12px',
top: '50%',
transform: 'translateY(-50%)',
fontSize: '14px',
}}
>
🔍
</span>
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => 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',
}}
/>
</div>
<button
onClick={selectAll}
style={{
padding: '8px 12px',
borderRadius: '8px',
border: `1px solid ${colors.cardBorder}`,
backgroundColor: colors.btnSecondaryBg,
color: colors.btnSecondaryText,
fontSize: '12px',
cursor: 'pointer',
}}
>
{selectedIds.size === filteredItems.length && filteredItems.length > 0
? 'Clear'
: 'Select All'}
</button>
</div>
{/* Items list */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '8px 12px',
}}
>
{loading ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
color: colors.textMuted,
}}
>
Loading...
</div>
) : filteredItems.length === 0 ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
color: colors.textMuted,
}}
>
<span style={{ fontSize: '32px', marginBottom: '12px' }}>{SERVICE_ICONS[activeTab]}</span>
<p style={{ fontSize: '14px' }}>No {SERVICE_NAMES[activeTab]} data imported yet</p>
<a
href="/google"
style={{ fontSize: '13px', color: '#3b82f6', marginTop: '8px' }}
>
Import data
</a>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{filteredItems.map((item) => (
<div
key={item.id}
onClick={() => 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 */}
<div
style={{
width: '18px',
height: '18px',
borderRadius: '4px',
border: `2px solid ${selectedIds.has(item.id) ? '#6366f1' : colors.cardBorder}`,
backgroundColor: selectedIds.has(item.id) ? '#6366f1' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{selectedIds.has(item.id) && (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="white"
strokeWidth="2"
>
<path d="M2 6l3 3 5-6" />
</svg>
)}
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '13px',
fontWeight: '500',
color: colors.text,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{item.title}
</div>
{item.preview && (
<div
style={{
fontSize: '12px',
color: colors.textMuted,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
marginTop: '2px',
}}
>
{item.preview}
</div>
)}
</div>
{/* Date */}
<div
style={{
fontSize: '11px',
color: colors.textMuted,
flexShrink: 0,
}}
>
{formatDate(item.date)}
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderTop: `1px solid ${colors.cardBorder}`,
}}
>
<div style={{ fontSize: '13px', color: colors.textMuted }}>
{selectedIds.size > 0 ? `${selectedIds.size} item${selectedIds.size > 1 ? 's' : ''} selected` : 'Select items to add'}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={onClose}
style={{
padding: '10px 16px',
borderRadius: '8px',
border: `1px solid ${colors.cardBorder}`,
backgroundColor: colors.btnSecondaryBg,
color: colors.btnSecondaryText,
fontSize: '13px',
fontWeight: '500',
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={handleAddToCanvas}
disabled={selectedIds.size === 0}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '10px 16px',
borderRadius: '8px',
border: 'none',
backgroundColor: selectedIds.size > 0 ? colors.btnPrimaryBg : colors.btnSecondaryBg,
color: selectedIds.size > 0 ? colors.btnPrimaryText : colors.textMuted,
fontSize: '13px',
fontWeight: '500',
cursor: selectedIds.size > 0 ? 'pointer' : 'not-allowed',
opacity: selectedIds.size > 0 ? 1 : 0.6,
}}
>
<span>🔒</span>
Add to Private Workspace
</button>
</div>
</div>
{/* Privacy note */}
<div
style={{
padding: '12px 20px',
backgroundColor: colors.cardBg,
borderTop: `1px solid ${colors.cardBorder}`,
borderRadius: '0 0 12px 12px',
}}
>
<p
style={{
fontSize: '11px',
color: colors.textMuted,
textAlign: 'center',
margin: 0,
}}
>
🔒 Private = Only you can see (encrypted in browser) Drag outside Private Workspace to share
</p>
</div>
</div>
</div>
);
}