feat(components): add GoogleDataBrowser popup modal
Phase 2 of Data Sovereignty Zone implementation: - Create GoogleDataBrowser component with service tabs (Gmail, Drive, Photos, Calendar) - Searchable item list with checkboxes for multi-select - Select All/Clear functionality - Dark mode support with consistent styling - "Add to Private Workspace" button - Privacy note explaining local-only encryption - Emits 'add-google-items-to-canvas' event for Board.tsx integration Integration with UserSettingsModal: - Import and render GoogleDataBrowser when "Open Data Browser" clicked - Handler for adding selected items to canvas 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d3f5d83b33
commit
e46ed88371
|
|
@ -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<GoogleService, string> = {
|
||||
gmail: '📧',
|
||||
drive: '📁',
|
||||
photos: '📷',
|
||||
calendar: '📅',
|
||||
};
|
||||
|
||||
const SERVICE_NAMES: Record<GoogleService, string> = {
|
||||
gmail: 'Gmail',
|
||||
drive: 'Drive',
|
||||
photos: 'Photos',
|
||||
calendar: 'Calendar',
|
||||
};
|
||||
|
||||
export function GoogleDataBrowser({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAddToCanvas,
|
||||
isDarkMode,
|
||||
}: GoogleDataBrowserProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google Data Browser Modal */}
|
||||
<GoogleDataBrowser
|
||||
isOpen={showGoogleDataBrowser}
|
||||
onClose={() => setShowGoogleDataBrowser(false)}
|
||||
onAddToCanvas={handleAddToCanvas}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue