Initial commit
This commit is contained in:
32
.github/copilot-instructions.md
vendored
Normal file
32
.github/copilot-instructions.md
vendored
Normal 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
8
.gitignore
vendored
Normal 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
27
README.md
Normal 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
12
index.html
Normal 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
2907
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
255
server/index.js
Normal 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
1445
src/App.jsx
Normal file
File diff suppressed because it is too large
Load Diff
297
src/data/driveData.js
Normal file
297
src/data/driveData.js
Normal 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
1364
src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
372
src/lib/fileSystemAccess.js
Normal file
372
src/lib/fileSystemAccess.js
Normal 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
58
src/lib/localDriveApi.js
Normal 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
10
src/main.jsx
Normal 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
16
vite.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user