1445 lines
45 KiB
JavaScript
1445 lines
45 KiB
JavaScript
import { startTransition, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
|
import { driveData } from './data/driveData';
|
|
import { formatBytes } from './lib/fileSystemAccess';
|
|
import {
|
|
checkLocalApiAvailability,
|
|
clearSavedDirectoryPath,
|
|
loadSavedDirectoryPath,
|
|
saveDirectoryPath,
|
|
scanLocalDirectory,
|
|
} from './lib/localDriveApi';
|
|
|
|
const ROOT_ID = 'root';
|
|
const DEMO_STORAGE_BYTES = 38.88 * 1024 * 1024 * 1024;
|
|
const STORAGE_CAPACITY_BYTES = 200 * 1024 * 1024 * 1024;
|
|
|
|
const viewModes = [
|
|
{ id: 'list', label: 'List', Icon: ListIcon },
|
|
{ id: 'grid', label: 'Grid', Icon: GridIcon },
|
|
];
|
|
|
|
const primaryNavItems = [
|
|
{ id: 'home', label: 'Home', Icon: HomeIcon },
|
|
{ id: 'drive', label: 'My Drive', Icon: DriveFolderIcon },
|
|
];
|
|
|
|
const secondaryNavItems = [
|
|
{ id: 'shared', label: 'Shared with me', Icon: SharedIcon },
|
|
{ id: 'recent', label: 'Recent', Icon: RecentIcon },
|
|
{ id: 'starred', label: 'Starred', Icon: StarIcon },
|
|
{ id: 'spam', label: 'Spam', Icon: SpamIcon },
|
|
{ id: 'bin', label: 'Bin', Icon: TrashIcon },
|
|
];
|
|
|
|
const filterChips = ['Type', 'People', 'Modified'];
|
|
|
|
const typeLabels = {
|
|
doc: 'Document',
|
|
sheet: 'Spreadsheet',
|
|
slides: 'Presentation',
|
|
image: 'Image',
|
|
pdf: 'PDF',
|
|
video: 'Video',
|
|
zip: 'Archive',
|
|
file: 'File',
|
|
};
|
|
|
|
const previewCopy = {
|
|
folder: 'Browse the folder contents, inspect its structure, and jump deeper without leaving the current view.',
|
|
doc: 'A draft or reference document stored alongside the rest of the workspace.',
|
|
sheet: 'Structured data, planning, or tracking information in tabular form.',
|
|
slides: 'A presentation deck prepared for review, handoff, or sharing.',
|
|
image: 'A visual asset saved with the rest of the project materials.',
|
|
pdf: 'A fixed-format file ready to share or archive.',
|
|
video: 'A media file suitable for playback, review, or export.',
|
|
zip: 'A bundled archive containing exported or packaged files.',
|
|
file: 'A local file captured from the selected path.',
|
|
};
|
|
|
|
function defaultNotice(apiReady, apiChecked) {
|
|
if (!apiChecked) {
|
|
return {
|
|
tone: 'info',
|
|
title: 'Checking local bridge',
|
|
body: 'Looking for the bundled filesystem API before loading a local folder.',
|
|
};
|
|
}
|
|
|
|
if (!apiReady) {
|
|
return {
|
|
tone: 'warning',
|
|
title: 'Demo source active',
|
|
body: 'Start npm run dev or npm run start to browse a real local directory.',
|
|
};
|
|
}
|
|
|
|
return {
|
|
tone: 'info',
|
|
title: 'Sample data loaded',
|
|
body: 'Open Settings to replace the sample workspace with a local folder path.',
|
|
};
|
|
}
|
|
|
|
function findItemById(node, targetId) {
|
|
if (node.id === targetId) {
|
|
return node;
|
|
}
|
|
|
|
for (const child of node.children ?? []) {
|
|
const found = findItemById(child, targetId);
|
|
if (found) {
|
|
return found;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findPath(node, targetId, trail = []) {
|
|
const nextTrail = [...trail, node];
|
|
if (node.id === targetId) {
|
|
return nextTrail;
|
|
}
|
|
|
|
for (const child of node.children ?? []) {
|
|
const found = findPath(child, targetId, nextTrail);
|
|
if (found) {
|
|
return found;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function flattenItems(node, path = []) {
|
|
const nextPath = node.kind === 'folder' ? [...path, node] : path;
|
|
const entries = [];
|
|
|
|
for (const child of node.children ?? []) {
|
|
entries.push({ item: child, path: nextPath });
|
|
entries.push(...flattenItems(child, nextPath));
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function sortEntries(entries) {
|
|
return [...entries].sort((left, right) => {
|
|
if (left.item.kind !== right.item.kind) {
|
|
return left.item.kind === 'folder' ? -1 : 1;
|
|
}
|
|
|
|
return left.item.name.localeCompare(right.item.name);
|
|
});
|
|
}
|
|
|
|
function countDescendants(node) {
|
|
if (typeof node.folderCount === 'number' && typeof node.fileCount === 'number') {
|
|
return { folders: node.folderCount, files: node.fileCount };
|
|
}
|
|
|
|
return (node.children ?? []).reduce(
|
|
(totals, child) => {
|
|
if (child.kind === 'folder') {
|
|
const childTotals = countDescendants(child);
|
|
return {
|
|
folders: totals.folders + childTotals.folders + 1,
|
|
files: totals.files + childTotals.files,
|
|
};
|
|
}
|
|
|
|
return {
|
|
folders: totals.folders,
|
|
files: totals.files + 1,
|
|
};
|
|
},
|
|
{ folders: 0, files: 0 },
|
|
);
|
|
}
|
|
|
|
function typeLabel(type) {
|
|
return typeLabels[type] ?? 'File';
|
|
}
|
|
|
|
function getPreviewCopy(item) {
|
|
if (item.kind === 'folder') {
|
|
return previewCopy.folder;
|
|
}
|
|
|
|
return previewCopy[item.type] ?? previewCopy.file;
|
|
}
|
|
|
|
function getFootprint(item) {
|
|
if (item.kind !== 'folder') {
|
|
return item.size;
|
|
}
|
|
|
|
const totals = countDescendants(item);
|
|
if (!totals.folders && !totals.files) {
|
|
return 'Empty folder';
|
|
}
|
|
|
|
return `${totals.folders} folders, ${totals.files} files`;
|
|
}
|
|
|
|
function getTableSize(item) {
|
|
return item.kind === 'folder' ? '--' : item.size;
|
|
}
|
|
|
|
function formatLocation(path) {
|
|
return path.map((item) => item.name).join(' / ');
|
|
}
|
|
|
|
function getInitialExpandedFolders(tree) {
|
|
if (!tree) {
|
|
return new Set([ROOT_ID]);
|
|
}
|
|
|
|
return new Set([ROOT_ID]);
|
|
}
|
|
|
|
function getOwnerLabel(sourceMode, item) {
|
|
return sourceMode === 'local' ? 'Local' : item.owner;
|
|
}
|
|
|
|
function getAvatarInitials(label) {
|
|
const cleaned = label.trim();
|
|
if (!cleaned) {
|
|
return 'DR';
|
|
}
|
|
|
|
const parts = cleaned.split(/\s+/).filter(Boolean);
|
|
if (parts.length === 1) {
|
|
return parts[0].slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
return `${parts[0][0] ?? ''}${parts[1][0] ?? ''}`.toUpperCase();
|
|
}
|
|
|
|
function App() {
|
|
const [driveTree, setDriveTree] = useState(driveData);
|
|
const [currentFolderId, setCurrentFolderId] = useState(ROOT_ID);
|
|
const [selectedItemId, setSelectedItemId] = useState(ROOT_ID);
|
|
const [query, setQuery] = useState('');
|
|
const deferredQuery = useDeferredValue(query);
|
|
const [viewMode, setViewMode] = useState('list');
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
const [computersOpen, setComputersOpen] = useState(true);
|
|
const [expandedFolders, setExpandedFolders] = useState(() => getInitialExpandedFolders(driveData));
|
|
const [sourceMode, setSourceMode] = useState('demo');
|
|
const [sourceName, setSourceName] = useState('Sample workspace');
|
|
const [sourcePath, setSourcePath] = useState('');
|
|
const [draftPath, setDraftPath] = useState('');
|
|
const [isScanning, setIsScanning] = useState(false);
|
|
const [apiReady, setApiReady] = useState(false);
|
|
const [apiChecked, setApiChecked] = useState(false);
|
|
const [sourceError, setSourceError] = useState('');
|
|
const [connectionNotice, setConnectionNotice] = useState(() => defaultNotice(false, false));
|
|
|
|
function resetExplorerState(tree) {
|
|
setCurrentFolderId(ROOT_ID);
|
|
setSelectedItemId(ROOT_ID);
|
|
setQuery('');
|
|
setDetailsOpen(false);
|
|
setExpandedFolders(getInitialExpandedFolders(tree));
|
|
}
|
|
|
|
function openSettingsPanel() {
|
|
setDetailsOpen(false);
|
|
setSettingsOpen(true);
|
|
}
|
|
|
|
async function loadDirectoryFromPath(nextPath, options = {}) {
|
|
const { persist = true, closeSettings = true, skipReadyCheck = false } = options;
|
|
const trimmedPath = nextPath.trim();
|
|
|
|
if (!trimmedPath) {
|
|
setSourceError('Enter a directory path first.');
|
|
setConnectionNotice({
|
|
tone: 'warning',
|
|
title: 'Directory path required',
|
|
body: 'Enter a local directory path in Settings before trying to load the filesystem.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!apiReady && !skipReadyCheck) {
|
|
const message = 'The local filesystem API is not running. Start the app with npm run dev or npm run start.';
|
|
setSourceError(message);
|
|
setConnectionNotice({
|
|
tone: 'error',
|
|
title: 'Local API unavailable',
|
|
body: message,
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsScanning(true);
|
|
setSourceError('');
|
|
setConnectionNotice({
|
|
tone: 'info',
|
|
title: 'Scanning local path',
|
|
body: `Reading ${trimmedPath} from the local filesystem.`,
|
|
});
|
|
|
|
try {
|
|
const result = await scanLocalDirectory(trimmedPath);
|
|
const { tree, directoryPath, truncated, scannedEntries } = result;
|
|
|
|
if (persist) {
|
|
saveDirectoryPath(directoryPath);
|
|
}
|
|
|
|
startTransition(() => {
|
|
setDriveTree(tree);
|
|
setSourceMode('local');
|
|
setSourceName(tree.name);
|
|
setSourcePath(directoryPath);
|
|
setDraftPath(directoryPath);
|
|
resetExplorerState(tree);
|
|
});
|
|
|
|
setConnectionNotice(
|
|
truncated
|
|
? {
|
|
tone: 'warning',
|
|
title: 'Local path loaded with limits',
|
|
body: `Browsing ${directoryPath}. The scan stopped after ${scannedEntries} entries to keep the app responsive.`,
|
|
}
|
|
: {
|
|
tone: 'success',
|
|
title: 'Local path connected',
|
|
body: `Browsing ${directoryPath} directly from the local filesystem.`,
|
|
},
|
|
);
|
|
|
|
if (closeSettings) {
|
|
setSettingsOpen(false);
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unable to scan the local directory.';
|
|
setSourceError(message);
|
|
setConnectionNotice({
|
|
tone: 'error',
|
|
title: 'Local path failed',
|
|
body: message,
|
|
});
|
|
} finally {
|
|
setIsScanning(false);
|
|
}
|
|
}
|
|
|
|
async function refreshDirectory() {
|
|
if (!sourcePath || isScanning) {
|
|
return;
|
|
}
|
|
|
|
await loadDirectoryFromPath(sourcePath, { persist: true, closeSettings: false });
|
|
}
|
|
|
|
function handleSettingsSubmit(event) {
|
|
event.preventDefault();
|
|
void loadDirectoryFromPath(draftPath, { persist: true, closeSettings: true });
|
|
}
|
|
|
|
function useSampleData() {
|
|
setDriveTree(driveData);
|
|
setSourceMode('demo');
|
|
setSourceName('Sample workspace');
|
|
setSourcePath('');
|
|
setDraftPath('');
|
|
setSourceError('');
|
|
resetExplorerState(driveData);
|
|
clearSavedDirectoryPath();
|
|
setConnectionNotice(defaultNotice(apiReady, true));
|
|
setSettingsOpen(false);
|
|
}
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function initializeLocalBridge() {
|
|
setConnectionNotice(defaultNotice(false, false));
|
|
|
|
const available = await checkLocalApiAvailability();
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setApiReady(available);
|
|
setApiChecked(true);
|
|
|
|
if (!available) {
|
|
setConnectionNotice(defaultNotice(false, true));
|
|
return;
|
|
}
|
|
|
|
const savedPath = loadSavedDirectoryPath();
|
|
setDraftPath(savedPath);
|
|
|
|
if (!savedPath) {
|
|
setConnectionNotice(defaultNotice(true, true));
|
|
return;
|
|
}
|
|
|
|
setIsScanning(true);
|
|
try {
|
|
const result = await scanLocalDirectory(savedPath);
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setDriveTree(result.tree);
|
|
setSourceMode('local');
|
|
setSourceName(result.tree.name);
|
|
setSourcePath(result.directoryPath);
|
|
setDraftPath(result.directoryPath);
|
|
resetExplorerState(result.tree);
|
|
});
|
|
|
|
setConnectionNotice(
|
|
result.truncated
|
|
? {
|
|
tone: 'warning',
|
|
title: 'Saved path restored with limits',
|
|
body: `Browsing ${result.directoryPath}. The restored scan stopped after ${result.scannedEntries} entries to keep the app responsive.`,
|
|
}
|
|
: {
|
|
tone: 'success',
|
|
title: 'Saved path restored',
|
|
body: `Browsing ${result.directoryPath} directly from the local filesystem.`,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
const message = error instanceof Error ? error.message : 'Unable to restore the saved local directory.';
|
|
setSourceError(message);
|
|
setConnectionNotice({
|
|
tone: 'warning',
|
|
title: 'Saved path could not be restored',
|
|
body: message,
|
|
});
|
|
} finally {
|
|
if (!cancelled) {
|
|
setIsScanning(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void initializeLocalBridge();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const allEntries = useMemo(() => flattenItems(driveTree), [driveTree]);
|
|
const currentFolder = findItemById(driveTree, currentFolderId) ?? driveTree;
|
|
const breadcrumbs = findPath(driveTree, currentFolderId) ?? [driveTree];
|
|
const selectedItem = findItemById(driveTree, selectedItemId) ?? currentFolder;
|
|
const selectedPath = findPath(driveTree, selectedItem.id) ?? [driveTree];
|
|
const normalizedQuery = deferredQuery.trim().toLowerCase();
|
|
|
|
const searchResults = useMemo(() => {
|
|
if (!normalizedQuery) {
|
|
return [];
|
|
}
|
|
|
|
return sortEntries(
|
|
allEntries.filter(({ item, path }) => {
|
|
const searchable = [
|
|
item.name,
|
|
getOwnerLabel(sourceMode, item),
|
|
item.kind === 'folder' ? 'folder' : item.type,
|
|
path.map((entry) => entry.name).join(' '),
|
|
]
|
|
.join(' ')
|
|
.toLowerCase();
|
|
|
|
return searchable.includes(normalizedQuery);
|
|
}),
|
|
);
|
|
}, [allEntries, normalizedQuery, sourceMode]);
|
|
|
|
const folderEntries = useMemo(() => {
|
|
const children = currentFolder.children ?? [];
|
|
return sortEntries(children.map((item) => ({ item, path: breadcrumbs })));
|
|
}, [breadcrumbs, currentFolder]);
|
|
|
|
const rootFolders = useMemo(
|
|
() =>
|
|
sortEntries((driveTree.children ?? []).map((item) => ({ item, path: [driveTree] })))
|
|
.filter(({ item }) => item.kind === 'folder')
|
|
.map(({ item }) => item),
|
|
[driveTree],
|
|
);
|
|
|
|
const visibleEntries = normalizedQuery ? searchResults : folderEntries;
|
|
const currentFoldersCount = folderEntries.filter(({ item }) => item.kind === 'folder').length;
|
|
const currentFilesCount = folderEntries.length - currentFoldersCount;
|
|
const rootTotals = useMemo(() => countDescendants(driveTree), [driveTree]);
|
|
const storageBytes = sourceMode === 'local' ? driveTree.sizeBytes ?? 0 : DEMO_STORAGE_BYTES;
|
|
const storagePercent = Math.max(6, Math.min(100, (storageBytes / STORAGE_CAPACITY_BYTES) * 100));
|
|
const storageLabel = sourceMode === 'local' ? formatBytes(storageBytes) : '38.88 GB';
|
|
const boardTitle = normalizedQuery ? 'Search in Drive' : currentFolder.name;
|
|
const boardSummary = normalizedQuery
|
|
? `${visibleEntries.length} result${visibleEntries.length === 1 ? '' : 's'} for "${query.trim()}"`
|
|
: `${currentFoldersCount} folder${currentFoldersCount === 1 ? '' : 's'}, ${currentFilesCount} file${currentFilesCount === 1 ? '' : 's'}`;
|
|
const activeSourceTag = sourceMode === 'local' ? 'Local path' : 'Sample data';
|
|
|
|
function openFolder(folderId) {
|
|
const folder = findItemById(driveTree, folderId);
|
|
if (!folder || folder.kind !== 'folder') {
|
|
return;
|
|
}
|
|
|
|
setCurrentFolderId(folderId);
|
|
setSelectedItemId(folderId);
|
|
setQuery('');
|
|
setSidebarOpen(false);
|
|
setExpandedFolders((previous) => {
|
|
const next = new Set(previous);
|
|
const path = findPath(driveTree, folderId) ?? [];
|
|
path.forEach((entry) => {
|
|
if (entry.kind === 'folder') {
|
|
next.add(entry.id);
|
|
}
|
|
});
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function inspectItem(itemId) {
|
|
setSelectedItemId(itemId);
|
|
setDetailsOpen(true);
|
|
}
|
|
|
|
function activateEntry(item) {
|
|
setSelectedItemId(item.id);
|
|
|
|
if (item.kind === 'folder') {
|
|
openFolder(item.id);
|
|
return;
|
|
}
|
|
|
|
setDetailsOpen(true);
|
|
}
|
|
|
|
function toggleFolder(folderId) {
|
|
setExpandedFolders((previous) => {
|
|
const next = new Set(previous);
|
|
if (next.has(folderId)) {
|
|
next.delete(folderId);
|
|
} else {
|
|
next.add(folderId);
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="drive-app">
|
|
<aside className={`drive-sidebar ${sidebarOpen ? 'open' : ''}`}>
|
|
<div className="drive-brand">
|
|
<div className="drive-brand-mark" aria-hidden="true">
|
|
<DriveLogo />
|
|
</div>
|
|
<strong>Drive</strong>
|
|
<button className="mobile-close" type="button" onClick={() => setSidebarOpen(false)}>
|
|
Close
|
|
</button>
|
|
</div>
|
|
|
|
<button className="new-button" type="button" onClick={openSettingsPanel}>
|
|
<PlusIcon />
|
|
<span>New</span>
|
|
</button>
|
|
|
|
<nav className="drive-nav" aria-label="Primary navigation">
|
|
{primaryNavItems.map(({ id, label, Icon }) => {
|
|
const active = id === 'drive' && currentFolderId === ROOT_ID && !normalizedQuery;
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
className={`drive-nav-item ${active ? 'active' : ''}`}
|
|
onClick={() => openFolder(ROOT_ID)}
|
|
>
|
|
<Icon />
|
|
<span>{label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
<button
|
|
type="button"
|
|
className={`drive-nav-item ${computersOpen ? 'expanded' : ''}`}
|
|
onClick={() => setComputersOpen((open) => !open)}
|
|
>
|
|
<ComputerIcon />
|
|
<span>Computers</span>
|
|
<span className="nav-chevron" aria-hidden="true">
|
|
<ChevronDownIcon />
|
|
</span>
|
|
</button>
|
|
|
|
{computersOpen ? (
|
|
<div className="drive-tree-block">
|
|
<SidebarTree
|
|
nodes={rootFolders}
|
|
currentFolderId={currentFolderId}
|
|
expandedFolders={expandedFolders}
|
|
onOpenFolder={openFolder}
|
|
onToggle={toggleFolder}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</nav>
|
|
|
|
<div className="drive-nav secondary" aria-hidden="true">
|
|
{secondaryNavItems.map(({ id, label, Icon }) => (
|
|
<div className="drive-nav-item passive" key={id}>
|
|
<Icon />
|
|
<span>{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="storage-widget">
|
|
<div className="storage-title-row">
|
|
<CloudIcon />
|
|
<span>Storage</span>
|
|
</div>
|
|
<div className="storage-bar" aria-hidden="true">
|
|
<span style={{ width: `${storagePercent}%` }} />
|
|
</div>
|
|
<p className="storage-copy">{storageLabel} of 200 GB used</p>
|
|
<button className="storage-button" type="button" onClick={openSettingsPanel}>
|
|
Get more storage
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="drive-main">
|
|
<header className="drive-toolbar">
|
|
<button className="sidebar-toggle" type="button" onClick={() => setSidebarOpen((open) => !open)}>
|
|
<MenuIcon />
|
|
</button>
|
|
|
|
<label className="drive-search" htmlFor="drive-search">
|
|
<SearchIcon />
|
|
<input
|
|
id="drive-search"
|
|
type="search"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search in Drive"
|
|
/>
|
|
<span className="search-filter-icon" aria-hidden="true">
|
|
<SlidersIcon />
|
|
</span>
|
|
</label>
|
|
|
|
<div className="toolbar-actions">
|
|
<span className="toolbar-icon" aria-hidden="true">
|
|
<HelpIcon />
|
|
</span>
|
|
<button className="toolbar-icon" type="button" onClick={openSettingsPanel} aria-label="Open settings">
|
|
<GearIcon />
|
|
</button>
|
|
<span className="toolbar-icon" aria-hidden="true">
|
|
<SparkleIcon />
|
|
</span>
|
|
<span className="toolbar-icon" aria-hidden="true">
|
|
<AppsIcon />
|
|
</span>
|
|
<button className="profile-chip" type="button" onClick={() => setDetailsOpen(true)} aria-label="Open details">
|
|
<OwnerAvatar label={sourceMode === 'local' ? 'Local' : 'me'} compact />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="drive-board">
|
|
<div className="board-header">
|
|
<div className="board-title-stack">
|
|
<div className="board-title-row">
|
|
<h1>{boardTitle}</h1>
|
|
<ChevronDownIcon />
|
|
</div>
|
|
<BreadcrumbTrail trail={breadcrumbs} onOpenFolder={openFolder} />
|
|
</div>
|
|
|
|
<div className="board-header-actions">
|
|
<div className="view-switch" role="tablist" aria-label="View mode">
|
|
{viewModes.map(({ id, label, Icon }) => (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
className={viewMode === id ? 'active' : ''}
|
|
onClick={() => setViewMode(id)}
|
|
aria-label={label}
|
|
title={label}
|
|
>
|
|
<Icon />
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
className={`board-icon-button ${detailsOpen ? 'active' : ''}`}
|
|
type="button"
|
|
onClick={() => setDetailsOpen((open) => !open)}
|
|
aria-label="Toggle details"
|
|
>
|
|
<InfoIcon />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="filter-row">
|
|
{filterChips.map((label) => (
|
|
<FilterChip key={label} label={label} />
|
|
))}
|
|
<FilterChip label="Source" onClick={openSettingsPanel} />
|
|
</div>
|
|
|
|
<div className="board-meta">
|
|
<span className={`source-pill ${connectionNotice.tone}`}>{activeSourceTag}</span>
|
|
<p className="board-summary">{boardSummary}</p>
|
|
<p className="board-notice">
|
|
<strong>{connectionNotice.title}</strong>
|
|
<span>{connectionNotice.body}</span>
|
|
</p>
|
|
{isScanning ? <span className="status-chip">Scanning...</span> : null}
|
|
{normalizedQuery ? (
|
|
<button className="clear-search-button" type="button" onClick={() => setQuery('')}>
|
|
Clear search
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{visibleEntries.length === 0 ? (
|
|
<EmptyState query={query} onReset={() => setQuery('')} />
|
|
) : viewMode === 'list' ? (
|
|
<ListView
|
|
entries={visibleEntries}
|
|
selectedItemId={selectedItem.id}
|
|
showLocation={Boolean(normalizedQuery)}
|
|
sourceMode={sourceMode}
|
|
onActivate={activateEntry}
|
|
onInspect={inspectItem}
|
|
/>
|
|
) : (
|
|
<CardView
|
|
entries={visibleEntries}
|
|
selectedItemId={selectedItem.id}
|
|
showLocation={Boolean(normalizedQuery)}
|
|
sourceMode={sourceMode}
|
|
onActivate={activateEntry}
|
|
onInspect={inspectItem}
|
|
/>
|
|
)}
|
|
</section>
|
|
</main>
|
|
|
|
<aside className="drive-rail" aria-hidden="true">
|
|
<div className="rail-stack">
|
|
<span className="rail-icon rail-calendar">
|
|
<CalendarIcon />
|
|
</span>
|
|
<span className="rail-icon rail-note">
|
|
<KeepIcon />
|
|
</span>
|
|
<span className="rail-icon rail-task">
|
|
<TasksIcon />
|
|
</span>
|
|
</div>
|
|
<span className="rail-divider" />
|
|
<span className="rail-icon rail-plus">
|
|
<PlusIcon />
|
|
</span>
|
|
</aside>
|
|
|
|
<DetailsDrawer
|
|
item={selectedItem}
|
|
open={detailsOpen}
|
|
path={selectedPath}
|
|
sourceMode={sourceMode}
|
|
onClose={() => setDetailsOpen(false)}
|
|
onOpenFolder={(folderId) => {
|
|
openFolder(folderId);
|
|
setDetailsOpen(false);
|
|
}}
|
|
/>
|
|
|
|
<SettingsPanel
|
|
apiChecked={apiChecked}
|
|
apiReady={apiReady}
|
|
draftPath={draftPath}
|
|
isScanning={isScanning}
|
|
open={settingsOpen}
|
|
rootTotals={rootTotals}
|
|
sourceError={sourceError}
|
|
sourceMode={sourceMode}
|
|
sourceName={sourceName}
|
|
sourcePath={sourcePath}
|
|
onClose={() => setSettingsOpen(false)}
|
|
onDraftPathChange={setDraftPath}
|
|
onRefreshDirectory={refreshDirectory}
|
|
onSubmit={handleSettingsSubmit}
|
|
onUseSampleData={useSampleData}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SidebarTree({ nodes, currentFolderId, expandedFolders, onOpenFolder, onToggle, depth = 0 }) {
|
|
return nodes
|
|
.filter((node) => node.kind === 'folder')
|
|
.map((node) => {
|
|
const childFolders = (node.children ?? []).filter((child) => child.kind === 'folder');
|
|
const expanded = expandedFolders.has(node.id);
|
|
|
|
return (
|
|
<div className="tree-node" key={node.id}>
|
|
<div className={`tree-row ${currentFolderId === node.id ? 'active' : ''}`} style={{ '--depth': depth }}>
|
|
<button
|
|
className={`tree-expander ${childFolders.length ? '' : 'empty'} ${expanded ? 'expanded' : ''}`}
|
|
type="button"
|
|
onClick={() => childFolders.length && onToggle(node.id)}
|
|
aria-label={expanded ? `Collapse ${node.name}` : `Expand ${node.name}`}
|
|
>
|
|
<ChevronIcon />
|
|
</button>
|
|
<button className="tree-link" type="button" onClick={() => onOpenFolder(node.id)}>
|
|
<ItemIcon item={node} compact />
|
|
<span>{node.name}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{expanded && childFolders.length ? (
|
|
<SidebarTree
|
|
nodes={childFolders}
|
|
currentFolderId={currentFolderId}
|
|
expandedFolders={expandedFolders}
|
|
onOpenFolder={onOpenFolder}
|
|
onToggle={onToggle}
|
|
depth={depth + 1}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
});
|
|
}
|
|
|
|
function BreadcrumbTrail({ trail, onOpenFolder }) {
|
|
return (
|
|
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
|
{trail.map((node, index) => {
|
|
const isCurrent = index === trail.length - 1;
|
|
return (
|
|
<span className="breadcrumb-item" key={node.id}>
|
|
{isCurrent ? (
|
|
<span className="breadcrumb-current">{node.name}</span>
|
|
) : (
|
|
<button type="button" onClick={() => onOpenFolder(node.id)}>
|
|
{node.name}
|
|
</button>
|
|
)}
|
|
{isCurrent ? null : <ChevronRightIcon />}
|
|
</span>
|
|
);
|
|
})}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function FilterChip({ label, onClick }) {
|
|
return (
|
|
<button className="filter-chip" type="button" onClick={onClick}>
|
|
<span>{label}</span>
|
|
<ChevronDownIcon />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ListView({ entries, selectedItemId, showLocation, sourceMode, onActivate, onInspect }) {
|
|
return (
|
|
<div className="drive-list">
|
|
<div className="drive-list-head">
|
|
<span className="head-name">
|
|
Name
|
|
<span className="sort-indicator" aria-hidden="true">
|
|
<SortAscendingIcon />
|
|
</span>
|
|
</span>
|
|
<span>Owner</span>
|
|
<span>Date modified</span>
|
|
<span>File size</span>
|
|
<span className="head-sort">
|
|
<SortIcon />
|
|
Sort
|
|
</span>
|
|
</div>
|
|
<div className="drive-list-body">
|
|
{entries.map(({ item, path }) => {
|
|
const owner = getOwnerLabel(sourceMode, item);
|
|
|
|
return (
|
|
<div className={`drive-list-row ${selectedItemId === item.id ? 'selected' : ''}`} key={item.id}>
|
|
<button className="row-name-button" type="button" onClick={() => onActivate(item)}>
|
|
<ItemIcon item={item} compact />
|
|
<span className="row-name-copy">
|
|
<strong>{item.name}</strong>
|
|
{showLocation ? <span>{formatLocation(path)}</span> : null}
|
|
</span>
|
|
</button>
|
|
<span className="owner-cell">
|
|
<OwnerAvatar label={owner} compact />
|
|
<span>{owner}</span>
|
|
</span>
|
|
<span className="row-value">{item.modified}</span>
|
|
<span className="row-value">{getTableSize(item)}</span>
|
|
<button className="row-menu-button" type="button" onClick={() => onInspect(item.id)} aria-label={`Inspect ${item.name}`}>
|
|
<DotsIcon />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CardView({ entries, selectedItemId, showLocation, sourceMode, onActivate, onInspect }) {
|
|
return (
|
|
<div className="card-grid">
|
|
{entries.map(({ item, path }) => {
|
|
const owner = getOwnerLabel(sourceMode, item);
|
|
return (
|
|
<article className={`drive-card ${selectedItemId === item.id ? 'selected' : ''}`} key={item.id}>
|
|
<div className="drive-card-top">
|
|
<span className="card-icon-frame">
|
|
<ItemIcon item={item} />
|
|
</span>
|
|
<button className="row-menu-button" type="button" onClick={() => onInspect(item.id)} aria-label={`Inspect ${item.name}`}>
|
|
<DotsIcon />
|
|
</button>
|
|
</div>
|
|
<button className="drive-card-main" type="button" onClick={() => onActivate(item)}>
|
|
<strong>{item.name}</strong>
|
|
<span>{item.kind === 'folder' ? 'Folder' : typeLabel(item.type)}</span>
|
|
<span>{owner}</span>
|
|
<span>{showLocation ? formatLocation(path) : item.modified}</span>
|
|
</button>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ query, onReset }) {
|
|
return (
|
|
<div className="empty-state">
|
|
<h3>No results for "{query.trim()}"</h3>
|
|
<p>Try a folder name, file type, owner, or path. Search matches against the full active source.</p>
|
|
<button className="primary-button" type="button" onClick={onReset}>
|
|
Clear search
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DetailsDrawer({ item, open, path, sourceMode, onClose, onOpenFolder }) {
|
|
const owner = getOwnerLabel(sourceMode, item);
|
|
|
|
return (
|
|
<>
|
|
<div className={`drawer-scrim ${open ? 'open' : ''}`} onClick={onClose} aria-hidden={!open} />
|
|
<aside className={`details-drawer ${open ? 'open' : ''}`} aria-hidden={!open}>
|
|
<div className="drawer-header">
|
|
<div>
|
|
<p className="drawer-eyebrow">Selected item</p>
|
|
<h2>{item.name}</h2>
|
|
</div>
|
|
<button className="panel-close-button" type="button" onClick={onClose}>
|
|
Close
|
|
</button>
|
|
</div>
|
|
|
|
<div className={`drawer-preview preview-${item.kind === 'folder' ? 'folder' : item.type}`}>
|
|
<span className="drawer-preview-icon">
|
|
<ItemIcon item={item} />
|
|
</span>
|
|
</div>
|
|
|
|
<div className="drawer-copy">
|
|
<p>{getPreviewCopy(item)}</p>
|
|
</div>
|
|
|
|
<dl className="drawer-grid">
|
|
<div>
|
|
<dt>Type</dt>
|
|
<dd>{item.kind === 'folder' ? 'Folder' : typeLabel(item.type)}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Owner</dt>
|
|
<dd>{owner}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Modified</dt>
|
|
<dd>{item.modified}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Footprint</dt>
|
|
<dd>{getFootprint(item)}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Location</dt>
|
|
<dd>{formatLocation(path)}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
{item.kind === 'folder' ? (
|
|
<button className="primary-button" type="button" onClick={() => onOpenFolder(item.id)}>
|
|
Open folder
|
|
</button>
|
|
) : null}
|
|
</aside>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SettingsPanel({
|
|
apiChecked,
|
|
apiReady,
|
|
draftPath,
|
|
isScanning,
|
|
open,
|
|
rootTotals,
|
|
sourceError,
|
|
sourceMode,
|
|
sourceName,
|
|
sourcePath,
|
|
onClose,
|
|
onDraftPathChange,
|
|
onRefreshDirectory,
|
|
onSubmit,
|
|
onUseSampleData,
|
|
}) {
|
|
return (
|
|
<>
|
|
<div className={`settings-scrim ${open ? 'open' : ''}`} onClick={onClose} aria-hidden={!open} />
|
|
<aside className={`settings-panel ${open ? 'open' : ''}`} role="dialog" aria-modal="true" aria-hidden={!open}>
|
|
<div className="drawer-header">
|
|
<div>
|
|
<p className="drawer-eyebrow">Settings</p>
|
|
<h2>Folder source</h2>
|
|
</div>
|
|
<button className="panel-close-button" type="button" onClick={onClose}>
|
|
Close
|
|
</button>
|
|
</div>
|
|
|
|
<form className="settings-card" onSubmit={onSubmit}>
|
|
<div className="settings-status-row">
|
|
<span className={`source-pill ${apiReady ? 'success' : apiChecked ? 'warning' : 'info'}`}>
|
|
{apiChecked ? (apiReady ? 'Bridge connected' : 'Bridge unavailable') : 'Checking bridge'}
|
|
</span>
|
|
<strong>{sourceMode === 'local' ? sourceName : 'Sample workspace'}</strong>
|
|
</div>
|
|
|
|
<p className="settings-copy">
|
|
{apiReady
|
|
? 'Enter a local directory path and the app will scan it through the bundled Node filesystem API.'
|
|
: 'The local filesystem API is not reachable yet. Start the combined dev server, then load a local folder here.'}
|
|
</p>
|
|
|
|
<dl className="settings-grid">
|
|
<div>
|
|
<dt>Folders</dt>
|
|
<dd>{rootTotals.folders}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Files</dt>
|
|
<dd>{rootTotals.files}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Loaded path</dt>
|
|
<dd>{sourcePath || 'None'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Mode</dt>
|
|
<dd>{sourceMode === 'local' ? 'Live local path' : 'Demo data'}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<label className="settings-field" htmlFor="directory-path">
|
|
<span>Directory path</span>
|
|
<input
|
|
id="directory-path"
|
|
className="settings-input"
|
|
type="text"
|
|
value={draftPath}
|
|
onChange={(event) => onDraftPathChange(event.target.value)}
|
|
placeholder="C:\\Users\\you\\Documents"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
</label>
|
|
|
|
<div className="settings-actions">
|
|
<button className="primary-button" type="submit" disabled={!apiReady || isScanning || !draftPath.trim()}>
|
|
{sourceMode === 'local' ? 'Load new path' : 'Load path'}
|
|
</button>
|
|
<button className="secondary-button" type="button" onClick={onRefreshDirectory} disabled={!apiReady || !sourcePath || isScanning}>
|
|
Refresh path
|
|
</button>
|
|
<button className="secondary-button" type="button" onClick={onUseSampleData} disabled={sourceMode !== 'local'}>
|
|
Use sample data
|
|
</button>
|
|
</div>
|
|
|
|
<p className="settings-note">
|
|
This mode reads directly from the local filesystem through the bundled Node server. No upload step is required.
|
|
</p>
|
|
|
|
{sourceError ? <p className="settings-error">{sourceError}</p> : null}
|
|
</form>
|
|
</aside>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function OwnerAvatar({ label, compact = false }) {
|
|
return <span className={`owner-avatar ${compact ? 'compact' : ''}`}>{getAvatarInitials(label)}</span>;
|
|
}
|
|
|
|
function ItemIcon({ item, compact = false }) {
|
|
const iconType = item.kind === 'folder' ? 'folder' : item.type;
|
|
|
|
return (
|
|
<span className={`item-icon item-icon-${iconType} ${compact ? 'compact' : ''}`} aria-hidden="true">
|
|
{iconType === 'folder' ? (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
|
<path d="M3.5 8.5A2.5 2.5 0 0 1 6 6h4.4l1.6 1.8H18A2.5 2.5 0 0 1 20.5 10.3v5.2A2.5 2.5 0 0 1 18 18H6A2.5 2.5 0 0 1 3.5 15.5z" />
|
|
</svg>
|
|
) : iconType === 'doc' ? (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
|
<path d="M8 3.5h6l4 4V20A1.5 1.5 0 0 1 16.5 21.5h-8A1.5 1.5 0 0 1 7 20V5A1.5 1.5 0 0 1 8.5 3.5z" />
|
|
<path d="M14 3.5V8h4" />
|
|
<path d="M9.5 12h5M9.5 15h5M9.5 18h3.5" />
|
|
</svg>
|
|
) : iconType === 'sheet' ? (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
|
<rect x="5" y="4" width="14" height="16" rx="2" />
|
|
<path d="M5 9h14M10 9v11M14.5 9v11" />
|
|
</svg>
|
|
) : iconType === 'slides' ? (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
|
<rect x="4.5" y="5" width="15" height="10" rx="2" />
|
|
<path d="M12 15v4M8.5 19h7" />
|
|
</svg>
|
|
) : iconType === 'image' ? (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
|
<rect x="4" y="5" width="16" height="14" rx="2" />
|
|
<path d="M8 14l2.5-2.5L14 15l2-2 4 4" />
|
|
<circle cx="9" cy="9" r="1.2" fill="currentColor" stroke="none" />
|
|
</svg>
|
|
) : iconType === 'pdf' ? (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
|
<path d="M8 3.5h6l4 4V20A1.5 1.5 0 0 1 16.5 21.5h-8A1.5 1.5 0 0 1 7 20V5A1.5 1.5 0 0 1 8.5 3.5z" />
|
|
<path d="M14 3.5V8h4" />
|
|
<path d="M9.5 12h2.5a1.5 1.5 0 0 1 0 3H9.5zM14.5 12H16M14.5 15H16" />
|
|
</svg>
|
|
) : iconType === 'video' ? (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
|
<rect x="4" y="6" width="12" height="12" rx="2" />
|
|
<path d="M16 10.5l4-2v7l-4-2z" />
|
|
</svg>
|
|
) : (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
|
<path d="M8 4.5h8A1.5 1.5 0 0 1 17.5 6v12A1.5 1.5 0 0 1 16 19.5H8A1.5 1.5 0 0 1 6.5 18V6A1.5 1.5 0 0 1 8 4.5z" />
|
|
<path d="M9.5 9.5h5M9.5 13h5M9.5 16.5h3" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function DriveLogo() {
|
|
return (
|
|
<svg viewBox="0 0 36 32" fill="none" aria-hidden="true">
|
|
<path d="M12.2 2.5 2.6 18.9h8.6L20.8 2.5z" fill="#1a73e8" />
|
|
<path d="m20.8 2.5 9.6 16.4h-8.6L12.2 2.5z" fill="#34a853" />
|
|
<path d="M11.2 18.9H2.6l9.6 10.6h11.6l6.6-10.6h-8.6z" fill="#fbbc04" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SearchIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" aria-hidden="true">
|
|
<circle cx="11" cy="11" r="6" />
|
|
<path d="m16 16 4 4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SlidersIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M4 6h8M16 6h4M9 6a1.5 1.5 0 1 0 3 0 1.5 1.5 0 0 0-3 0ZM4 12h3M11 12h9M7 12a1.5 1.5 0 1 0 3 0 1.5 1.5 0 0 0-3 0ZM4 18h10M18 18h2M13 18a1.5 1.5 0 1 0 3 0 1.5 1.5 0 0 0-3 0Z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ChevronIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="m9 6 6 6-6 6" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ChevronDownIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ChevronRightIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="m10 7 5 5-5 5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ListIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M8 7h11M8 12h11M8 17h11" />
|
|
<path d="M4 7h.01M4 12h.01M4 17h.01" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function GridIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<rect x="4" y="4" width="6" height="6" rx="1" />
|
|
<rect x="14" y="4" width="6" height="6" rx="1" />
|
|
<rect x="4" y="14" width="6" height="6" rx="1" />
|
|
<rect x="14" y="14" width="6" height="6" rx="1" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function InfoIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="8" />
|
|
<path d="M12 10v5M12 7.5h.01" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function DotsIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<circle cx="12" cy="5" r="1.7" />
|
|
<circle cx="12" cy="12" r="1.7" />
|
|
<circle cx="12" cy="19" r="1.7" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function PlusIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" aria-hidden="true">
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function MenuIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function HomeIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="m4.5 10 7.5-6 7.5 6" />
|
|
<path d="M7 9.5V19h10V9.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function DriveFolderIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M3.5 8.5A2.5 2.5 0 0 1 6 6h4.2l1.8 2H18A2.5 2.5 0 0 1 20.5 10.5v5A2.5 2.5 0 0 1 18 18H6A2.5 2.5 0 0 1 3.5 15.5z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ComputerIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<rect x="4" y="5" width="16" height="10" rx="2" />
|
|
<path d="M9 19h6M12 15v4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SharedIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M9 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM16.5 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
|
|
<path d="M4 18.5a5 5 0 0 1 10 0M13 18.5a3.5 3.5 0 0 1 7 0" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function RecentIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="8" />
|
|
<path d="M12 8v5l3 2" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function StarIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="m12 4 2.4 4.9 5.4.8-3.9 3.8.9 5.5L12 16.9 7.2 19l.9-5.5L4.2 9.7l5.4-.8z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SpamIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M12 3.8 19 8v8l-7 4.2L5 16V8z" />
|
|
<path d="M12 8.5v4.5M12 16h.01" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function TrashIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M5 7h14M9 7V5h6v2M7 7l1 12h8l1-12" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CloudIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M7.5 18A4.5 4.5 0 1 1 8 9a5.5 5.5 0 0 1 10.5 2A3.5 3.5 0 0 1 18 18z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function HelpIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="8" />
|
|
<path d="M9.7 9.5a2.7 2.7 0 1 1 4.7 1.8c-.8.8-1.4 1.2-1.4 2.7" />
|
|
<path d="M12 17h.01" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function GearIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M12 8.5A3.5 3.5 0 1 0 12 15.5 3.5 3.5 0 0 0 12 8.5z" />
|
|
<path d="M4.8 13.3 3 12l1.8-1.3.5-2.1 2.1-.5L8.7 6l1.8.8 1.5-1.5 1.5 1.5L15.3 6l1.3 1.8 2.1.5.5 2.1L21 12l-1.8 1.3-.5 2.1-2.1.5-1.3 1.8-1.8-.8-1.5 1.5-1.5-1.5-1.8.8-1.3-1.8-2.1-.5z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SparkleIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="m12 3 1.5 5.5L19 10l-5.5 1.5L12 17l-1.5-5.5L5 10l5.5-1.5z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function AppsIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<circle cx="6" cy="6" r="1.5" />
|
|
<circle cx="12" cy="6" r="1.5" />
|
|
<circle cx="18" cy="6" r="1.5" />
|
|
<circle cx="6" cy="12" r="1.5" />
|
|
<circle cx="12" cy="12" r="1.5" />
|
|
<circle cx="18" cy="12" r="1.5" />
|
|
<circle cx="6" cy="18" r="1.5" />
|
|
<circle cx="12" cy="18" r="1.5" />
|
|
<circle cx="18" cy="18" r="1.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SortAscendingIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M12 18V6" />
|
|
<path d="m8 10 4-4 4 4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SortIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M7 6h10M7 12h7M7 18h4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CalendarIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<rect x="4" y="5" width="16" height="15" rx="2" />
|
|
<path d="M8 3.5v3M16 3.5v3M4 9h16" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function KeepIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="M8 4.5h8l2 4v4.5a6 6 0 0 1-12 0V8.5z" />
|
|
<path d="M10 18.5h4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function TasksIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
|
<path d="m5 7 2 2 3-4M5 13l2 2 3-4M12 7h7M12 13h7M12 19h7" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export default App; |