Initial commit

This commit is contained in:
2026-04-08 14:56:52 +01:00
commit 8eeb48fcd3
14 changed files with 6828 additions and 0 deletions

32
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,32 @@
- [x] Verify that the copilot-instructions.md file in the .github directory is created. Summary: Created in .github/.
- [x] Clarify Project Requirements
Summary: Building a React app in the current workspace with Vite and JavaScript.
- [x] Scaffold the Project
Summary: Created the Vite React project structure manually in the current folder and added the required package, Vite, and entry files.
- [x] Customize the Project
Summary: Built a Google Drive-inspired file browser with a folder tree, search, list and card views, a details panel, and a local-path settings flow backed by a Node filesystem API.
- [x] Install Required Extensions
Summary: No extensions were required by the project setup information.
- [x] Compile the Project
Summary: Installed npm dependencies, checked diagnostics, and verified a successful production build with npm run build.
- [x] Create and Run Task
Summary: Skipped because the standard npm scripts already cover the development and build workflows.
- [x] Launch the Project
Summary: Skipped automatic launch because the user did not request a debug session; npm run dev is available on request.
- [x] Ensure Documentation is Complete
Summary: Added README.md and updated this file to reflect the completed project state without HTML comments.
Project Notes
- Stack: Vite + React + JavaScript.
- Main UI: src/App.jsx and src/index.css.
- Local API bridge: server/index.js and src/lib/localDriveApi.js.
- Demo fallback data: src/data/driveData.js.

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
.DS_Store
*.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

27
README.md Normal file
View File

@@ -0,0 +1,27 @@
# Driveboard
Driveboard is a Vite + React app that presents a nested file structure with a Google Drive-inspired browsing experience.
## Features
- Browse a mock drive hierarchy from the sidebar tree or the main content area.
- Connect a local directory from the in-app Settings panel by entering a filesystem path.
- Search folders, files, file types, and locations across the whole active source.
- Switch between a compact list view and a card-based browsing view.
- Inspect the currently selected file or folder in the details panel.
- Use a responsive layout that works across desktop and mobile widths.
## Scripts
- `npm install` to install dependencies.
- `npm run dev` to start both the Vite frontend and the local filesystem API.
- `npm run build` to create a production build.
- `npm run preview` to preview the production build alongside the local filesystem API.
- `npm run start` to serve the built app and the local filesystem API from Node.
## Notes
- The sample fallback data still lives in `src/data/driveData.js`.
- Local filesystem access now runs through the bundled Node API in `server/index.js`.
- The selected directory path is stored in browser local storage and restored on the next launch when the local API is available.
- This avoids the browser upload flow because the app reads the directory from disk through the local backend instead of importing files into the page.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Driveboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2907
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "self-hosted-drive",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
"dev:client": "vite",
"dev:server": "node server/index.js",
"build": "vite build",
"preview": "concurrently \"npm:dev:server\" \"npm:preview:client\"",
"preview:client": "vite preview",
"start": "node server/index.js"
},
"dependencies": {
"express": "^5.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.0",
"concurrently": "^9.1.2",
"vite": "^7.0.0"
}
}

255
server/index.js Normal file
View File

@@ -0,0 +1,255 @@
import express from 'express';
import { existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PORT = Number(process.env.PORT ?? 3001);
const MAX_ENTRIES = Number(process.env.DRIVEBOARD_MAX_ENTRIES ?? 12000);
const LOCAL_OWNER = 'Local file system';
const imageExtensions = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif']);
const videoExtensions = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v']);
const pdfExtensions = new Set(['pdf']);
const archiveExtensions = new Set(['zip', 'rar', '7z', 'tar', 'gz']);
const spreadsheetExtensions = new Set(['xls', 'xlsx', 'csv', 'tsv', 'ods']);
const slideExtensions = new Set(['ppt', 'pptx', 'key']);
const documentExtensions = new Set(['doc', 'docx', 'txt', 'md', 'rtf', 'odt']);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
const distPath = path.join(projectRoot, 'dist');
function compareDirents(left, right) {
if (left.isDirectory() !== right.isDirectory()) {
return left.isDirectory() ? -1 : 1;
}
return left.name.localeCompare(right.name);
}
function createItemId(kind, pathSegments) {
return `${kind}:${pathSegments.join('/')}`;
}
function inferFileType(fileName) {
const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
if (imageExtensions.has(extension)) {
return 'image';
}
if (videoExtensions.has(extension)) {
return 'video';
}
if (pdfExtensions.has(extension)) {
return 'pdf';
}
if (archiveExtensions.has(extension)) {
return 'zip';
}
if (spreadsheetExtensions.has(extension)) {
return 'sheet';
}
if (slideExtensions.has(extension)) {
return 'slides';
}
if (documentExtensions.has(extension)) {
return 'doc';
}
return 'file';
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const digits = value >= 10 || unitIndex === 0 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unitIndex]}`;
}
function formatTimestamp(timestamp) {
if (!timestamp) {
return 'Unavailable';
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(timestamp));
}
async function readFileNode(fullPath, pathSegments) {
const stat = await fs.stat(fullPath);
return {
id: createItemId('file', pathSegments),
name: path.basename(fullPath),
kind: 'file',
type: inferFileType(path.basename(fullPath)),
owner: LOCAL_OWNER,
modified: formatTimestamp(stat.mtimeMs),
modifiedMs: stat.mtimeMs,
size: formatBytes(stat.size),
sizeBytes: stat.size,
};
}
async function readDirectoryNode(fullPath, pathSegments, state) {
const directoryStat = await fs.stat(fullPath);
const children = [];
let folderCount = 0;
let fileCount = 0;
let sizeBytes = 0;
let latestModified = directoryStat.mtimeMs ?? 0;
const entries = await fs.readdir(fullPath, { withFileTypes: true });
entries.sort(compareDirents);
for (const entry of entries) {
if (state.entryCount >= state.maxEntries) {
state.truncated = true;
break;
}
if (entry.isSymbolicLink()) {
continue;
}
const entryFullPath = path.join(fullPath, entry.name);
const entryPathSegments = [...pathSegments, entry.name];
state.entryCount += 1;
try {
if (entry.isDirectory()) {
const childFolder = await readDirectoryNode(entryFullPath, entryPathSegments, state);
children.push(childFolder);
folderCount += childFolder.folderCount + 1;
fileCount += childFolder.fileCount;
sizeBytes += childFolder.sizeBytes ?? 0;
latestModified = Math.max(latestModified, childFolder.modifiedMs ?? 0);
continue;
}
if (entry.isFile()) {
const fileNode = await readFileNode(entryFullPath, entryPathSegments);
children.push(fileNode);
fileCount += 1;
sizeBytes += fileNode.sizeBytes ?? 0;
latestModified = Math.max(latestModified, fileNode.modifiedMs ?? 0);
}
} catch (error) {
state.warnings.push(`Skipped ${entryFullPath}: ${error.message}`);
}
}
return {
id: pathSegments.length ? createItemId('folder', pathSegments) : 'root',
name: pathSegments[pathSegments.length - 1] || path.basename(fullPath) || fullPath,
kind: 'folder',
owner: LOCAL_OWNER,
modified: formatTimestamp(latestModified),
modifiedMs: latestModified,
size: formatBytes(sizeBytes),
sizeBytes,
folderCount,
fileCount,
children,
};
}
async function scanLocalDirectory(directoryPath) {
const resolvedPath = path.resolve(directoryPath);
const stat = await fs.stat(resolvedPath);
if (!stat.isDirectory()) {
const error = new Error('The provided path is not a directory.');
error.statusCode = 400;
throw error;
}
const state = {
entryCount: 0,
maxEntries: MAX_ENTRIES,
truncated: false,
warnings: [],
};
const tree = await readDirectoryNode(resolvedPath, [], state);
return {
tree: {
...tree,
id: 'root',
name: path.basename(resolvedPath) || resolvedPath,
},
directoryPath: resolvedPath,
truncated: state.truncated,
scannedEntries: state.entryCount,
warnings: state.warnings.slice(0, 5),
};
}
const app = express();
app.use(express.json({ limit: '1mb' }));
app.get('/api/health', (_request, response) => {
response.json({ ok: true });
});
app.post('/api/filesystem/scan', async (request, response) => {
const directoryPath = typeof request.body?.directoryPath === 'string' ? request.body.directoryPath.trim() : '';
if (!directoryPath) {
response.status(400).json({ error: 'Directory path is required.' });
return;
}
try {
const result = await scanLocalDirectory(directoryPath);
response.json(result);
} catch (error) {
if (error?.code === 'ENOENT') {
response.status(404).json({ error: `Directory not found: ${path.resolve(directoryPath)}` });
return;
}
if (error?.code === 'EACCES' || error?.code === 'EPERM') {
response.status(403).json({ error: `Permission denied for: ${path.resolve(directoryPath)}` });
return;
}
response.status(error?.statusCode ?? 500).json({ error: error?.message ?? 'Unable to scan the requested directory.' });
}
});
if (existsSync(distPath)) {
app.use(express.static(distPath));
app.get(/^(?!\/api).*/, (_request, response) => {
response.sendFile(path.join(distPath, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`Driveboard local filesystem API listening on http://127.0.0.1:${PORT}`);
});

1445
src/App.jsx Normal file

File diff suppressed because it is too large Load Diff

297
src/data/driveData.js Normal file
View File

@@ -0,0 +1,297 @@
export const driveData = {
id: 'root',
name: 'My Drive',
kind: 'folder',
owner: 'me',
modified: 'Today',
children: [
{
id: 'projects',
name: '01 Projects',
kind: 'folder',
owner: 'me',
modified: '21 Jan',
children: [
{
id: 'project-alpha',
name: 'Alpha Launch',
kind: 'folder',
owner: 'me',
modified: '24 Jan',
children: [
{
id: 'alpha-brief',
name: 'Launch Brief',
kind: 'file',
type: 'doc',
owner: 'me',
modified: '24 Jan',
size: '1.2 MB',
},
{
id: 'alpha-tracker',
name: 'Milestone Tracker',
kind: 'file',
type: 'sheet',
owner: 'me',
modified: '23 Jan',
size: '468 KB',
},
{
id: 'alpha-deck',
name: 'Launch Deck',
kind: 'file',
type: 'slides',
owner: 'me',
modified: '22 Jan',
size: '4.9 MB',
},
],
},
{
id: 'site-refresh',
name: 'Site Refresh',
kind: 'folder',
owner: 'me',
modified: '22 Jan',
children: [
{
id: 'site-audit',
name: 'UI Inventory',
kind: 'file',
type: 'pdf',
owner: 'me',
modified: '22 Jan',
size: '2.1 MB',
},
{
id: 'site-moodboard',
name: 'Reference Shots',
kind: 'file',
type: 'image',
owner: 'me',
modified: '21 Jan',
size: '12.4 MB',
},
],
},
{
id: 'client-handoff',
name: 'Client Handoff.zip',
kind: 'file',
type: 'zip',
owner: 'me',
modified: '21 Jan',
size: '78 MB',
},
],
},
{
id: 'areas',
name: '02 Areas',
kind: 'folder',
owner: 'me',
modified: '21 Jan',
children: [
{
id: 'finance',
name: 'Finance',
kind: 'folder',
owner: 'me',
modified: '20 Jan',
children: [
{
id: 'budget-2026',
name: 'Budget 2026',
kind: 'file',
type: 'sheet',
owner: 'me',
modified: '20 Jan',
size: '512 KB',
},
{
id: 'forecast-pdf',
name: 'Quarter Forecast',
kind: 'file',
type: 'pdf',
owner: 'me',
modified: '19 Jan',
size: '1.9 MB',
},
],
},
{
id: 'operations',
name: 'Operations',
kind: 'folder',
owner: 'me',
modified: '19 Jan',
children: [
{
id: 'ops-sop',
name: 'SOP Manual',
kind: 'file',
type: 'doc',
owner: 'me',
modified: '19 Jan',
size: '934 KB',
},
{
id: 'vendor-list',
name: 'Vendor List',
kind: 'file',
type: 'sheet',
owner: 'me',
modified: '18 Jan',
size: '402 KB',
},
],
},
{
id: 'people-ops',
name: 'People Ops',
kind: 'folder',
owner: 'me',
modified: '18 Jan',
children: [
{
id: 'onboarding-pack',
name: 'Onboarding Pack',
kind: 'file',
type: 'pdf',
owner: 'me',
modified: '18 Jan',
size: '3.6 MB',
},
],
},
],
},
{
id: 'resources',
name: '03 Resources',
kind: 'folder',
owner: 'me',
modified: '21 Jan',
children: [
{
id: 'templates',
name: 'Templates',
kind: 'folder',
owner: 'me',
modified: '17 Jan',
children: [
{
id: 'project-template',
name: 'Project Template',
kind: 'file',
type: 'doc',
owner: 'me',
modified: '17 Jan',
size: '690 KB',
},
{
id: 'kickoff-checklist',
name: 'Kickoff Checklist',
kind: 'file',
type: 'doc',
owner: 'me',
modified: '16 Jan',
size: '418 KB',
},
],
},
{
id: 'brand-assets',
name: 'Brand Assets',
kind: 'folder',
owner: 'me',
modified: '16 Jan',
children: [
{
id: 'logo-pack',
name: 'Logo Pack',
kind: 'file',
type: 'zip',
owner: 'me',
modified: '16 Jan',
size: '24 MB',
},
{
id: 'team-photo',
name: 'Team Photo',
kind: 'file',
type: 'image',
owner: 'me',
modified: '16 Jan',
size: '8.3 MB',
},
],
},
{
id: 'research-library',
name: 'Research Library',
kind: 'folder',
owner: 'me',
modified: '15 Jan',
children: [
{
id: 'discovery-notes',
name: 'Discovery Notes',
kind: 'file',
type: 'doc',
owner: 'me',
modified: '15 Jan',
size: '1.1 MB',
},
],
},
],
},
{
id: 'archive',
name: '04 Archive',
kind: 'folder',
owner: 'me',
modified: '21 Jan',
children: [
{
id: 'archive-2025',
name: '2025',
kind: 'folder',
owner: 'me',
modified: '14 Jan',
children: [
{
id: 'handoff-recording',
name: 'Handoff Recording',
kind: 'file',
type: 'video',
owner: 'me',
modified: '14 Jan',
size: '214 MB',
},
],
},
{
id: 'archive-2024',
name: '2024',
kind: 'folder',
owner: 'me',
modified: '10 Jan',
children: [
{
id: 'legacy-site',
name: 'Legacy Site Export',
kind: 'file',
type: 'zip',
owner: 'me',
modified: '10 Jan',
size: '62 MB',
},
],
},
],
},
],
};

1364
src/index.css Normal file

File diff suppressed because it is too large Load Diff

372
src/lib/fileSystemAccess.js Normal file
View File

@@ -0,0 +1,372 @@
const DB_NAME = 'driveboard';
const STORE_NAME = 'settings';
const DIRECTORY_HANDLE_KEY = 'directory-handle';
const DIRECTORY_NAME_KEY = 'directory-name';
const LOCAL_OWNER = 'Local file system';
const imageExtensions = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif']);
const videoExtensions = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v']);
const pdfExtensions = new Set(['pdf']);
const archiveExtensions = new Set(['zip', 'rar', '7z', 'tar', 'gz']);
const spreadsheetExtensions = new Set(['xls', 'xlsx', 'csv', 'tsv', 'ods']);
const slideExtensions = new Set(['ppt', 'pptx', 'key']);
const documentExtensions = new Set(['doc', 'docx', 'txt', 'md', 'rtf', 'odt']);
function openDatabase() {
return new Promise((resolve, reject) => {
if (typeof indexedDB === 'undefined') {
reject(new Error('This browser does not expose IndexedDB.'));
return;
}
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('Unable to open browser storage.'));
});
}
async function readSetting(key) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const request = transaction.objectStore(STORE_NAME).get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('Unable to read browser storage.'));
transaction.oncomplete = () => db.close();
transaction.onerror = () => reject(transaction.error ?? new Error('Browser storage transaction failed.'));
});
}
async function writeSetting(key, value) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const request = transaction.objectStore(STORE_NAME).put(value, key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error ?? new Error('Unable to write browser storage.'));
transaction.oncomplete = () => db.close();
transaction.onerror = () => reject(transaction.error ?? new Error('Browser storage transaction failed.'));
});
}
async function deleteSetting(key) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const request = transaction.objectStore(STORE_NAME).delete(key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error ?? new Error('Unable to clear browser storage.'));
transaction.oncomplete = () => db.close();
transaction.onerror = () => reject(transaction.error ?? new Error('Browser storage transaction failed.'));
});
}
function createItemId(kind, pathSegments) {
return `${kind}:${pathSegments.join('/')}`;
}
function compareChildren(left, right) {
if (left.kind !== right.kind) {
return left.kind === 'folder' ? -1 : 1;
}
return left.name.localeCompare(right.name);
}
function inferFileType(fileName) {
const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
if (imageExtensions.has(extension)) {
return 'image';
}
if (videoExtensions.has(extension)) {
return 'video';
}
if (pdfExtensions.has(extension)) {
return 'pdf';
}
if (archiveExtensions.has(extension)) {
return 'zip';
}
if (spreadsheetExtensions.has(extension)) {
return 'sheet';
}
if (slideExtensions.has(extension)) {
return 'slides';
}
if (documentExtensions.has(extension)) {
return 'doc';
}
return 'file';
}
function formatTimestamp(timestamp) {
if (!timestamp) {
return 'Unavailable';
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(timestamp));
}
async function readFileNode(handle, pathSegments) {
const file = await handle.getFile();
return {
id: createItemId('file', pathSegments),
name: handle.name,
kind: 'file',
type: inferFileType(handle.name),
owner: LOCAL_OWNER,
modified: formatTimestamp(file.lastModified),
modifiedMs: file.lastModified ?? 0,
size: formatBytes(file.size ?? 0),
sizeBytes: file.size ?? 0,
};
}
function createFolderNode(id, name) {
return {
id,
name,
kind: 'folder',
owner: LOCAL_OWNER,
modified: 'Unavailable',
modifiedMs: 0,
size: '0 B',
sizeBytes: 0,
folderCount: 0,
fileCount: 0,
children: [],
};
}
function createFileNodeFromFile(file, pathSegments) {
return {
id: createItemId('file', pathSegments),
name: file.name,
kind: 'file',
type: inferFileType(file.name),
owner: LOCAL_OWNER,
modified: formatTimestamp(file.lastModified),
modifiedMs: file.lastModified ?? 0,
size: formatBytes(file.size ?? 0),
sizeBytes: file.size ?? 0,
};
}
function finalizeDirectoryNode(node) {
let sizeBytes = 0;
let latestModified = 0;
let folderCount = 0;
let fileCount = 0;
node.children.forEach((child) => {
if (child.kind === 'folder') {
finalizeDirectoryNode(child);
folderCount += child.folderCount + 1;
fileCount += child.fileCount;
sizeBytes += child.sizeBytes ?? 0;
latestModified = Math.max(latestModified, child.modifiedMs ?? 0);
return;
}
fileCount += 1;
sizeBytes += child.sizeBytes ?? 0;
latestModified = Math.max(latestModified, child.modifiedMs ?? 0);
});
node.children.sort(compareChildren);
node.folderCount = folderCount;
node.fileCount = fileCount;
node.sizeBytes = sizeBytes;
node.size = formatBytes(sizeBytes);
node.modifiedMs = latestModified;
node.modified = formatTimestamp(latestModified);
}
async function readDirectoryNode(handle, pathSegments = []) {
const children = [];
let sizeBytes = 0;
let latestModified = 0;
let folderCount = 0;
let fileCount = 0;
for await (const entry of handle.values()) {
const entryPath = [...pathSegments, entry.name];
if (entry.kind === 'directory') {
const childFolder = await readDirectoryNode(entry, entryPath);
children.push(childFolder);
folderCount += childFolder.folderCount + 1;
fileCount += childFolder.fileCount;
sizeBytes += childFolder.sizeBytes ?? 0;
latestModified = Math.max(latestModified, childFolder.modifiedMs ?? 0);
continue;
}
const fileNode = await readFileNode(entry, entryPath);
children.push(fileNode);
fileCount += 1;
sizeBytes += fileNode.sizeBytes ?? 0;
latestModified = Math.max(latestModified, fileNode.modifiedMs ?? 0);
}
children.sort(compareChildren);
return {
id: pathSegments.length ? createItemId('folder', pathSegments) : 'root',
name: pathSegments[pathSegments.length - 1] ?? handle.name,
kind: 'folder',
owner: LOCAL_OWNER,
modified: formatTimestamp(latestModified),
modifiedMs: latestModified,
size: formatBytes(sizeBytes),
sizeBytes,
folderCount,
fileCount,
children,
};
}
export function supportsDirectoryPicker() {
return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
}
export async function saveDirectoryHandle(handle) {
await Promise.all([
writeSetting(DIRECTORY_HANDLE_KEY, handle),
writeSetting(DIRECTORY_NAME_KEY, handle.name),
]);
}
export async function loadStoredDirectoryAccess() {
const [handle, name] = await Promise.all([
readSetting(DIRECTORY_HANDLE_KEY),
readSetting(DIRECTORY_NAME_KEY),
]);
return { handle, name };
}
export async function clearStoredDirectoryAccess() {
await Promise.all([
deleteSetting(DIRECTORY_HANDLE_KEY),
deleteSetting(DIRECTORY_NAME_KEY),
]);
}
export async function hasDirectoryPermission(handle, request = false) {
if (!handle?.queryPermission) {
return true;
}
let permission = await handle.queryPermission({ mode: 'read' });
if (permission !== 'granted' && request && handle.requestPermission) {
permission = await handle.requestPermission({ mode: 'read' });
}
return permission === 'granted';
}
export async function buildDriveTreeFromHandle(handle) {
const root = await readDirectoryNode(handle, []);
return {
...root,
id: 'root',
name: handle.name,
};
}
export function buildDriveTreeFromFileList(fileList) {
const files = Array.from(fileList ?? []);
if (!files.length) {
throw new Error('No files were selected. If you picked an empty folder, use Chrome or Edge for direct folder access.');
}
const firstRelativePath = files[0].webkitRelativePath || files[0].name;
const rootName = firstRelativePath.split('/').filter(Boolean)[0] || 'Selected folder';
const root = createFolderNode('root', rootName);
const folders = new Map([['', root]]);
files.forEach((file) => {
const relativePath = file.webkitRelativePath || `${rootName}/${file.name}`;
const pathSegments = relativePath.split('/').filter(Boolean);
const [, ...segmentsWithinRoot] = pathSegments;
const fileName = segmentsWithinRoot.pop();
if (!fileName) {
return;
}
let currentFolder = root;
const folderTrail = [];
segmentsWithinRoot.forEach((segment) => {
folderTrail.push(segment);
const key = folderTrail.join('/');
if (!folders.has(key)) {
const nextFolder = createFolderNode(createItemId('folder', [...folderTrail]), segment);
folders.set(key, nextFolder);
currentFolder.children.push(nextFolder);
}
currentFolder = folders.get(key);
});
currentFolder.children.push(createFileNodeFromFile(file, [...segmentsWithinRoot, fileName]));
});
finalizeDirectoryNode(root);
root.id = 'root';
root.name = rootName;
return root;
}
export function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const digits = value >= 10 || unitIndex === 0 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unitIndex]}`;
}

58
src/lib/localDriveApi.js Normal file
View File

@@ -0,0 +1,58 @@
const LAST_DIRECTORY_PATH_KEY = 'driveboard:last-directory-path';
export async function checkLocalApiAvailability() {
try {
const response = await fetch('/api/health', {
headers: {
Accept: 'application/json',
},
});
return response.ok;
} catch {
return false;
}
}
export async function scanLocalDirectory(directoryPath) {
const response = await fetch('/api/filesystem/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ directoryPath }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error ?? 'Unable to scan the local directory.');
}
return payload;
}
export function loadSavedDirectoryPath() {
if (typeof window === 'undefined') {
return '';
}
return window.localStorage.getItem(LAST_DIRECTORY_PATH_KEY) ?? '';
}
export function saveDirectoryPath(directoryPath) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(LAST_DIRECTORY_PATH_KEY, directoryPath);
}
export function clearSavedDirectoryPath() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(LAST_DIRECTORY_PATH_KEY);
}

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

16
vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://127.0.0.1:3001',
},
},
preview: {
proxy: {
'/api': 'http://127.0.0.1:3001',
},
},
});