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

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>,
);