Files
SelfHostedDrive/src/App.jsx
2026-04-08 14:56:52 +01:00

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;