From 3b7476f5375f83599d439aa9f54ff7131f0b6fbe Mon Sep 17 00:00:00 2001 From: Shen Wang Date: Thu, 20 Nov 2025 13:43:59 -0500 Subject: [PATCH 1/6] Add user system feature --- COPILOT.md | 86 ++++++++++++++++++++++ bash.exe.stackdump | 29 ++++++++ client/src/contexts/UserContext.js | 111 +++++++++++++++++++++++++++++ client/src/views/Files.js | 100 ++++++++++++++++++++++++++ client/src/views/Welcome.js | 94 ++++++++++++++++++++++++ 5 files changed, 420 insertions(+) create mode 100644 COPILOT.md create mode 100644 bash.exe.stackdump create mode 100644 client/src/contexts/UserContext.js create mode 100644 client/src/views/Files.js create mode 100644 client/src/views/Welcome.js diff --git a/COPILOT.md b/COPILOT.md new file mode 100644 index 0000000..f8cdfc1 --- /dev/null +++ b/COPILOT.md @@ -0,0 +1,86 @@ +**Project Summary** +- **Name:** pytc-client — Desktop client and services for Pytorch Connectomics +- **Purpose:** Electron + React desktop client that interacts with FastAPI backend services and the `pytorch_connectomics` library for connectomics workflows. + +**Top-level Structure** +- **`client/`**: Electron + React frontend (CRA). Important files: + - `client/package.json` — frontend scripts (`start`, `build`, `electron`). + - `client/main.js` — Electron entrypoint. + - `client/src/` — React source; components in `client/src/components/`. +- **`server_api/`**: FastAPI application (API server) + - `server_api/main.py` — API entrypoint. + - `server_api/requirements.txt` — Python deps for API. +- **`server_pytc/`**: PyTC worker service used for model inference or background tasks + - `server_pytc/main.py` — worker entrypoint. +- **`pytorch_connectomics/`**: Library (local package) with models, configs, and utilities used by the worker. + - `pytorch_connectomics/setup.py` — library install entry. + - `pytorch_connectomics/configs/` — example YAML config files. +- **`docker/`**: Docker resources for containerized backend; root `docker-compose.yaml` / `Dockerfile` exist. +- **`scripts/`**: Helper scripts + - `scripts/bootstrap.sh` / `scripts/bootstrap.ps1` — one-time install/bootstrap + - `start.sh` / `start.bat` — start the full stack (servers + Electron client) +- **`docs/`, `notebooks/`, `tests/`**: documentation, Jupyter notebooks, and unit tests respectively. + +**Important Root Files** +- `package.json` (root): convenience npm scripts added to operate on the `client/` folder from repository root. +- `README.md`: project setup and running instructions. + +**Common Developer Commands** +- Bootstrap (one-time): +```bash +./scripts/bootstrap.sh # macOS / Linux +scripts\bootstrap.ps1 # Windows PowerShell +``` +- Start full stack (desktop + services): +```bash +./start.sh # macOS / Linux +start.bat # Windows CMD +``` +- Build frontend from repo root (convenience scripts added): +```bash +npm run client:install # install dependencies inside `client/` +npm run client:build # build production frontend in `client/build` +npm run client:dev # run frontend dev server (react-scripts start) +npm run client:electron # run Electron (`electron .`) from `client/` +``` +- Manual backend run (dev): +```bash +python -m venv .venv +# activate venv +source .venv/bin/activate # macOS / Linux +.\.venv\Scripts\activate # Windows PowerShell +pip install -r server_api/requirements.txt +python server_api/main.py +python server_pytc/main.py +``` +- Tests: +```bash +pytest -q +``` +- Docker backend (build & run): +```bash +docker compose build backend +docker compose up backend +docker compose down +``` + +**Where to Make Changes** +- Frontend UI / components: edit `client/src/` and update `client/package.json` scripts as needed. +- Electron behavior: edit `client/main.js` and electron-specific code. +- API endpoints & backend logic: edit `server_api/` (FastAPI) and `server_pytc/` for worker behavior. +- Models/configs: `pytorch_connectomics/` contains models, configs, and experiment YAMLs. + +**CI / Automation Tips** +- CI jobs can call `npm --prefix client run build` (same as `npm run client:build`) to build frontend from repo root. +- For parallel dev workflows (backend + frontend concurrently), consider adding `concurrently` or `npm-run-all` as dev dependencies and a `dev` script in root. + +**Notes & Recommendations** +- Root `package.json` now includes `client:*` convenience scripts that use `npm --prefix client` to be cross-platform. +- `client/package.json` uses `react-scripts` and defines `build` and `electron` scripts; keep those in sync when modifying frontend tooling. +- Use `start.sh` / `start.bat` for the supported, repository-provided startup flow when possible. + +**Contact / Reference** +- See `README.md` for more detailed setup and video demo link. +- Use `tests/` for examples of expected behavior and for regression checks. + +(Generated by Copilot assistant for developer reference.) \ No newline at end of file diff --git a/bash.exe.stackdump b/bash.exe.stackdump new file mode 100644 index 0000000..e7b5590 --- /dev/null +++ b/bash.exe.stackdump @@ -0,0 +1,29 @@ +Stack trace: +Frame Function Args +0007FFFFBBB0 00021006118E (00021028DEE8, 000210272B3E, 0007FFFFBBB0, 0007FFFFAAB0) msys-2.0.dll+0x2118E +0007FFFFBBB0 0002100469BA (000000000000, 000000000000, 000000000000, 0007FFFFBE88) msys-2.0.dll+0x69BA +0007FFFFBBB0 0002100469F2 (00021028DF99, 0007FFFFBA68, 0007FFFFBBB0, 000000000000) msys-2.0.dll+0x69F2 +0007FFFFBBB0 00021006A41E (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A41E +0007FFFFBBB0 00021006A545 (0007FFFFBBC0, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A545 +0007FFFFBE90 00021006B9A5 (0007FFFFBBC0, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2B9A5 +End of stack trace +Loaded modules: +000100400000 bash.exe +7FFA3F430000 ntdll.dll +7FFA3E6F0000 KERNEL32.DLL +7FFA3CF90000 KERNELBASE.dll +7FFA3EDD0000 USER32.dll +7FFA3D290000 win32u.dll +000210040000 msys-2.0.dll +7FFA3D510000 GDI32.dll +7FFA3CE70000 gdi32full.dll +7FFA3CAC0000 msvcp_win.dll +7FFA3CB60000 ucrtbase.dll +7FFA3D450000 advapi32.dll +7FFA3D8B0000 msvcrt.dll +7FFA3D730000 sechost.dll +7FFA3E4E0000 RPCRT4.dll +7FFA3CE40000 bcrypt.dll +7FFA3C2A0000 CRYPTBASE.DLL +7FFA3D370000 bcryptPrimitives.dll +7FFA3EC80000 IMM32.DLL diff --git a/client/src/contexts/UserContext.js b/client/src/contexts/UserContext.js new file mode 100644 index 0000000..520e30a --- /dev/null +++ b/client/src/contexts/UserContext.js @@ -0,0 +1,111 @@ +import React, { createContext, useState, useEffect } from 'react' +import localforage from 'localforage' + +export const UserContext = createContext(null) + +const USERS_KEY = 'pytc_users' +const CURRENT_USER_KEY = 'pytc_current_user' + +async function hashString (str) { + if (!str) return '' + const enc = new TextEncoder() + const data = enc.encode(str) + const hash = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hash)) + return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') +} + +export const UserContextWrapper = ({ children }) => { + const [users, setUsers] = useState([]) + const [currentUserId, setCurrentUserId] = useState(null) + const [isLoaded, setIsLoaded] = useState(false) + + useEffect(() => { + let mounted = true + const load = async () => { + await localforage.removeItem(CURRENT_USER_KEY) // Always sign out on app start + const stored = await localforage.getItem(USERS_KEY) + setUsers(stored || []) + setCurrentUserId(null) + setIsLoaded(true) + } + load() + return () => { mounted = false } + }, []) + + useEffect(() => { + if (isLoaded) { + localforage.setItem(USERS_KEY, users).catch(() => {}) + } + }, [users, isLoaded]) + + useEffect(() => { + if (isLoaded) { + localforage.setItem(CURRENT_USER_KEY, currentUserId).catch(() => {}) + } + }, [currentUserId, isLoaded]) + + const createUser = async (name, password) => { + if (!name) throw new Error('Name required') + const exists = users.find(u => u.name === name) + if (exists) throw new Error('User already exists') + const id = (crypto && crypto.randomUUID) ? crypto.randomUUID() : String(Date.now()) + const pwdHash = await hashString(password || '') + const newUser = { + id, + name, + passwordHash: pwdHash, + files: [null, null, null], + createdAt: new Date().toISOString() + } + const next = [...users, newUser] + setUsers(next) + setCurrentUserId(id) + return newUser + } + + const authenticate = async (name, password) => { + const user = users.find(u => u.name === name) + if (!user) throw new Error('User not found') + const pwdHash = await hashString(password || '') + if (pwdHash !== user.passwordHash) throw new Error('Invalid credentials') + setCurrentUserId(user.id) + return user + } + + const signout = async () => { + setCurrentUserId(null) + } + + const getCurrentUser = () => users.find(u => u.id === currentUserId) || null + + const setUserFile = (index, fileMeta) => { + const user = users.find(u => u.id === currentUserId) + if (!user) throw new Error('No user signed in') + const next = users.map(u => { + if (u.id !== currentUserId) return u + const files = [...(u.files || [null, null, null])] + files[index] = fileMeta + return { ...u, files } + }) + setUsers(next) + } + + return ( + + {children} + + ) +} + +export default UserContextWrapper diff --git a/client/src/views/Files.js b/client/src/views/Files.js new file mode 100644 index 0000000..171abb3 --- /dev/null +++ b/client/src/views/Files.js @@ -0,0 +1,100 @@ +import React, { useContext, useState } from 'react' +import { UserContext } from '../contexts/UserContext' +import { Card, Typography, List, Button, Upload, Modal, Input, message } from 'antd' +import { UploadOutlined, EditOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons' + +const { Title } = Typography + +function Files () { + const { getCurrentUser, setUserFile } = useContext(UserContext) + const user = getCurrentUser() + const files = user?.files || [null, null, null] + const [renameIdx, setRenameIdx] = useState(null) + const [renameVal, setRenameVal] = useState('') + + const handleUpload = (idx, info) => { + if (info.file.status === 'done' || info.file.status === 'removed' || info.file.originFileObj) { + const fileMeta = { + name: info.file.name, + size: info.file.size, + type: info.file.type, + lastModified: info.file.lastModified, + } + setUserFile(idx, fileMeta) + message.success('File uploaded') + } + } + + const handleDelete = (idx) => { + setUserFile(idx, null) + message.success('File deleted') + } + + const openRename = (idx) => { + setRenameIdx(idx) + setRenameVal(files[idx]?.name || '') + } + const handleRename = () => { + if (renameIdx !== null && renameVal) { + const file = files[renameIdx] + if (file) { + setUserFile(renameIdx, { ...file, name: renameVal }) + message.success('File renamed') + } + } + setRenameIdx(null) + setRenameVal('') + } + + return ( + + Your Files + { + const file = files[idx] + return ( + + File {idx + 1}} + actions={[ + false} + onChange={info => handleUpload(idx, info)} + > + + , + file ? : null, + file ? : null + ]} + > + {file ? ( +
+
Name: {file.name}
+
Size: {file.size} bytes
+
Type: {file.type}
+
+ ) : ( +
Empty
+ )} +
+
+ ) + }} + /> + setRenameIdx(null)} + onOk={handleRename} + > + setRenameVal(e.target.value)} placeholder='New file name' /> + +
+ ) +} + +export default Files diff --git a/client/src/views/Welcome.js b/client/src/views/Welcome.js new file mode 100644 index 0000000..6d33006 --- /dev/null +++ b/client/src/views/Welcome.js @@ -0,0 +1,94 @@ +import React, { useState, useContext, useEffect } from 'react' +import { Button, Typography, Space, Modal, Form, Input, message } from 'antd' +import { UserContext } from '../contexts/UserContext' + +const { Title, Paragraph } = Typography + +function Welcome () { + const { createUser, authenticate } = useContext(UserContext) + const [isSignInOpen, setSignInOpen] = useState(false) + const [isSignUpOpen, setSignUpOpen] = useState(false) + + useEffect(() => { + console.log('Debug: Welcome component rendered') + }, []) + + const onSignUp = async (values) => { + try { + await createUser(values.name, values.password) + message.success('Account created — signed in') + setSignUpOpen(false) + } catch (e) { + message.error(e.message || 'Failed to create account') + } + } + + const onSignIn = async (values) => { + try { + await authenticate(values.name, values.password) + message.success('Signed in') + setSignInOpen(false) + } catch (e) { + message.error(e.message || 'Sign in failed') + } + } + + return ( +
+
+ {/* Debug message removed */} + Pytorch Connectomics + + A desktop client for connectomics workflows — visualize data, run inference, and manage experiments. + + + Welcome — get started by signing in or creating a new account. + + + + + + + setSignInOpen(false)} + footer={null} + > +
+ + + + + + + + + +
+
+ + setSignUpOpen(false)} + footer={null} + > +
+ + + + + + + + + +
+
+
+
+ ) +} + +export default Welcome From c019e13513f6e84c065e72ffae6694f641892bcf Mon Sep 17 00:00:00 2001 From: Shen Wang Date: Thu, 20 Nov 2025 20:04:35 -0500 Subject: [PATCH 2/6] welcoming page + user manager --- DEVLOG.md | 80 ++++++++++++ client/src/contexts/UserContext.js | 72 ++++++----- client/src/views/Files.js | 105 ++-------------- client/src/views/FilesManager.js | 189 +++++++++++++++++++++++++++++ client/src/views/Welcome.js | 79 +++++++----- 5 files changed, 362 insertions(+), 163 deletions(-) create mode 100644 DEVLOG.md create mode 100644 client/src/views/FilesManager.js diff --git a/DEVLOG.md b/DEVLOG.md new file mode 100644 index 0000000..fcf43a6 --- /dev/null +++ b/DEVLOG.md @@ -0,0 +1,80 @@ +# DEVLOG.md — pytc-client Development Log + +This file records the major development steps, design decisions, and UI/UX changes made to the pytc-client project. It is formatted for easy reading by humans and AI agents. + +--- + +## Project Context +- **Repo:** PytorchConnectomics/pytc-client +- **Frontend:** React + Electron (Ant Design) +- **Backend:** FastAPI (Python), local user management for prototype + +--- + +## Major Features & Changes + +### 1. Welcome Page +- Added a full-screen Welcome page as the app's entry point. +- Includes project name, intro, and warm message. +- Two buttons: "Sign in" and "Sign up". +- Styled to resemble cursor.com (modern, clean, gradient background). +- Welcome page is always shown on app start (automatic sign out). + +### 2. Local User Management +- Implemented `UserContext` using React Context and localforage. +- User model: `{ id, name, passwordHash, files: [file1, file2, file3], createdAt }` +- Passwords hashed with SHA-256 (Web Crypto API, prototype only). +- Sign-in and sign-up modals (Ant Design) wired to local user manager. +- Automatic sign out on app start (always shows Welcome). + +### 3. Main App Navigation +- After login, user sees main app view (tabs: Visualization, Model Training, Model Inference, Tensorboard, Files). +- "Welcome" tab removed from main menu after login. +- Navigation to Welcome page is blocked after login. + +### 4. Files Tab (Google Drive-like) +- Files tab shows three file slots per user. +- Each slot displays file info (name, size, type) or "Empty". +- Upload, rename, and delete actions for each slot (Ant Design components). +- Upload is local only; rename uses modal; delete clears slot. + +### 5. Debugging & Build Process +- Debug banners/messages added and removed for troubleshooting. +- Reminder: Electron app uses static build (`client/build/`), so `npm run build` is required after code changes. +- Hot reload only works in browser dev mode (`npm start`). + +--- + +## Known Issues & Fixes +- [x] Welcome page not showing: fixed by auto sign out on app start. +- [x] "Welcome" tab visible after login: removed from menu. +- [x] Debug messages visible: removed. +- [x] Modals not working in Electron: fixed after proper build/restart. + +--- + +## Next Steps / TODOs +- [ ] Add multi-file support or previews in Files tab. +- [ ] Add manual sign-out button in main app view. +- [ ] Integrate backend user management (FastAPI, JWT, etc.) for production. +- [ ] Add user profile editing and avatar upload. +- [ ] Improve file upload to support actual file storage (not just metadata). + +--- + +## How to Develop & Test +- Make code changes in `client/src/`. +- Run `npm --prefix client run build` to update the Electron app. +- Start the app with `./start.bat`. +- For live development, use `npm start` (browser only). + +--- + +## AI Agent Notes +- All major UI/UX changes, context, and user flows are documented here. +- Use this file to bootstrap further development, onboarding, or automation. +- For backend integration, see FastAPI endpoints and user model notes above. + +--- + +_Last updated: 2025-11-20_ diff --git a/client/src/contexts/UserContext.js b/client/src/contexts/UserContext.js index 520e30a..8c38bb4 100644 --- a/client/src/contexts/UserContext.js +++ b/client/src/contexts/UserContext.js @@ -1,43 +1,49 @@ -import React, { createContext, useState, useEffect } from 'react' -import localforage from 'localforage' +import React, { createContext, useState, useCallback } from 'react'; +import localforage from 'localforage'; -export const UserContext = createContext(null) +export const UserContext = createContext(); -const USERS_KEY = 'pytc_users' -const CURRENT_USER_KEY = 'pytc_current_user' +function hashPassword(password) { + // Simple SHA-256 hash (prototype) + return window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(password)).then(buf => + Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('') + ); +// ...existing code... -async function hashString (str) { - if (!str) return '' - const enc = new TextEncoder() - const data = enc.encode(str) - const hash = await crypto.subtle.digest('SHA-256', data) - const hashArray = Array.from(new Uint8Array(hash)) - return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') -} +export const UserProvider = ({ children }) => { + const [currentUser, setCurrentUser] = useState(null); -export const UserContextWrapper = ({ children }) => { - const [users, setUsers] = useState([]) - const [currentUserId, setCurrentUserId] = useState(null) - const [isLoaded, setIsLoaded] = useState(false) + const autoSignOut = useCallback(() => { + setCurrentUser(null); + }, []); - useEffect(() => { - let mounted = true - const load = async () => { - await localforage.removeItem(CURRENT_USER_KEY) // Always sign out on app start - const stored = await localforage.getItem(USERS_KEY) - setUsers(stored || []) - setCurrentUserId(null) - setIsLoaded(true) + const signIn = async (name, password) => { + const users = (await localforage.getItem('users')) || []; + const hash = await hashPassword(password); + const user = users.find(u => u.name === name && u.passwordHash === hash); + if (user) { + setCurrentUser(user); + return true; } - load() - return () => { mounted = false } - }, []) + return false; + }; - useEffect(() => { - if (isLoaded) { - localforage.setItem(USERS_KEY, users).catch(() => {}) - } - }, [users, isLoaded]) + const signUp = async (name, password) => { + const users = (await localforage.getItem('users')) || []; + if (users.find(u => u.name === name)) return false; + const hash = await hashPassword(password); + const newUser = { id: Date.now(), name, passwordHash: hash, files: {}, createdAt: Date.now() }; + await localforage.setItem('users', [...users, newUser]); + setCurrentUser(newUser); + return true; + }; + + return ( + + {children} + + ); +} useEffect(() => { if (isLoaded) { diff --git a/client/src/views/Files.js b/client/src/views/Files.js index 171abb3..1c44a0c 100644 --- a/client/src/views/Files.js +++ b/client/src/views/Files.js @@ -1,100 +1,11 @@ -import React, { useContext, useState } from 'react' -import { UserContext } from '../contexts/UserContext' -import { Card, Typography, List, Button, Upload, Modal, Input, message } from 'antd' -import { UploadOutlined, EditOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons' - -const { Title } = Typography - -function Files () { - const { getCurrentUser, setUserFile } = useContext(UserContext) - const user = getCurrentUser() - const files = user?.files || [null, null, null] - const [renameIdx, setRenameIdx] = useState(null) - const [renameVal, setRenameVal] = useState('') - - const handleUpload = (idx, info) => { - if (info.file.status === 'done' || info.file.status === 'removed' || info.file.originFileObj) { - const fileMeta = { - name: info.file.name, - size: info.file.size, - type: info.file.type, - lastModified: info.file.lastModified, - } - setUserFile(idx, fileMeta) - message.success('File uploaded') - } - } - - const handleDelete = (idx) => { - setUserFile(idx, null) - message.success('File deleted') - } - - const openRename = (idx) => { - setRenameIdx(idx) - setRenameVal(files[idx]?.name || '') - } - const handleRename = () => { - if (renameIdx !== null && renameVal) { - const file = files[renameIdx] - if (file) { - setUserFile(renameIdx, { ...file, name: renameVal }) - message.success('File renamed') - } - } - setRenameIdx(null) - setRenameVal('') - } +import React from 'react'; +import FilesManager from './FilesManager'; +export default function Files() { return ( - - Your Files - { - const file = files[idx] - return ( - - File {idx + 1}} - actions={[ - false} - onChange={info => handleUpload(idx, info)} - > - - , - file ? : null, - file ? : null - ]} - > - {file ? ( -
-
Name: {file.name}
-
Size: {file.size} bytes
-
Type: {file.type}
-
- ) : ( -
Empty
- )} -
-
- ) - }} - /> - setRenameIdx(null)} - onOk={handleRename} - > - setRenameVal(e.target.value)} placeholder='New file name' /> - -
- ) +
+

Files

+ +
+ ); } - -export default Files diff --git a/client/src/views/FilesManager.js b/client/src/views/FilesManager.js new file mode 100644 index 0000000..e5fbd36 --- /dev/null +++ b/client/src/views/FilesManager.js @@ -0,0 +1,189 @@ +import React, { useState } from 'react'; +import { Card, Button, Input, Modal, List, Upload, Checkbox, message } from 'antd'; +import { PlusOutlined, DeleteOutlined, EditOutlined, FolderOpenOutlined, FileImageOutlined, UploadOutlined } from '@ant-design/icons'; + +function initialFolders() { + return [ + { key: 'root', title: 'My Drive', children: [] }, + ]; +} + +function FilesManager() { + const [folders, setFolders] = useState(initialFolders()); + const [selectedFolder, setSelectedFolder] = useState('root'); + const [files, setFiles] = useState({ root: [] }); + const [showFolderModal, setShowFolderModal] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [renameFolderKey, setRenameFolderKey] = useState(null); + const [renameFolderName, setRenameFolderName] = useState(''); + const [selectedFiles, setSelectedFiles] = useState([]); + + // Folder helpers + const addFolder = () => { + if (!newFolderName.trim()) return; + const key = `${Date.now()}`; + setFolders(folders => [ + ...folders, + { key, title: newFolderName, children: [] }, + ]); + setFiles(f => ({ ...f, [key]: [] })); + setNewFolderName(''); + setShowFolderModal(false); + message.success('Folder created'); + }; + + const deleteFolder = key => { + if (key === 'root') return; + setFolders(folders => folders.filter(f => f.key !== key)); + setFiles(f => { + const copy = { ...f }; + delete copy[key]; + return copy; + }); + if (selectedFolder === key) setSelectedFolder('root'); + message.success('Folder deleted'); + }; + + const renameFolder = () => { + setFolders(folders => folders.map(f => f.key === renameFolderKey ? { ...f, title: renameFolderName } : f)); + setRenameFolderKey(null); + setRenameFolderName(''); + message.success('Folder renamed'); + }; + + // File helpers + const handleUpload = info => { + if (info.file.status === 'done' || info.file.originFileObj) { + const fileMeta = { + key: `${Date.now()}`, + name: info.file.name, + size: info.file.size, + type: info.file.type, + lastModified: info.file.lastModified, + }; + setFiles(f => ({ ...f, [selectedFolder]: [...(f[selectedFolder] || []), fileMeta] })); + message.success('File uploaded'); + } + }; + + const handleDeleteFile = key => { + setFiles(f => ({ + ...f, + [selectedFolder]: (f[selectedFolder] || []).filter(file => file.key !== key), + })); + setSelectedFiles(sel => sel.filter(k => k !== key)); + message.success('File deleted'); + }; + + const handleRenameFile = (key, newName) => { + setFiles(f => ({ + ...f, + [selectedFolder]: (f[selectedFolder] || []).map(file => file.key === key ? { ...file, name: newName } : file), + })); + message.success('File renamed'); + }; + + const handleMultiDelete = () => { + setFiles(f => ({ + ...f, + [selectedFolder]: (f[selectedFolder] || []).filter(file => !selectedFiles.includes(file.key)), + })); + setSelectedFiles([]); + message.success('Files deleted'); + }; + + // UI + return ( + +
+ {/* Folder sidebar */} +
+
Folders
+ ( + } onClick={() => { setRenameFolderKey(folder.key); setRenameFolderName(folder.title); }}>Rename, + , + ] : []} + onClick={() => setSelectedFolder(folder.key)} + > + {folder.title} + + )} + /> + +
+ {/* Files in folder */} +
+
Files in "{folders.find(f => f.key === selectedFolder)?.title}"
+ false} + onChange={handleUpload} + multiple + > + + + {selectedFiles.length > 0 && ( + + )} + ( + } onClick={() => { + Modal.confirm({ + title: 'Rename File', + content: file.newName = e.target.value} />, + onOk: () => handleRenameFile(file.key, file.newName || file.name), + }); + }}>Rename, + , + ]} + > + { + setSelectedFiles(sel => e.target.checked ? [...sel, file.key] : sel.filter(k => k !== file.key)); + }} + style={{ marginRight: 8 }} + /> + + {file.name} + {file.size} bytes + + )} + /> +
+
+ {/* Folder modals */} + setShowFolderModal(false)} + onOk={addFolder} + > + setNewFolderName(e.target.value)} placeholder='Folder name' /> + + setRenameFolderKey(null)} + onOk={renameFolder} + > + setRenameFolderName(e.target.value)} placeholder='New folder name' /> + +
+ ); +} + +export default FilesManager; diff --git a/client/src/views/Welcome.js b/client/src/views/Welcome.js index 6d33006..ed70042 100644 --- a/client/src/views/Welcome.js +++ b/client/src/views/Welcome.js @@ -1,43 +1,56 @@ -import React, { useState, useContext, useEffect } from 'react' -import { Button, Typography, Space, Modal, Form, Input, message } from 'antd' -import { UserContext } from '../contexts/UserContext' +import React, { useState, useContext, useEffect } from 'react'; +import { Modal, Button, Input, Card, Typography, message } from 'antd'; +import { UserContext } from '../contexts/UserContext'; -const { Title, Paragraph } = Typography +const { Title, Paragraph } = Typography; -function Welcome () { - const { createUser, authenticate } = useContext(UserContext) - const [isSignInOpen, setSignInOpen] = useState(false) - const [isSignUpOpen, setSignUpOpen] = useState(false) +function Welcome() { + const { signIn, signUp, autoSignOut } = useContext(UserContext); + const [showSignIn, setShowSignIn] = useState(false); + const [showSignUp, setShowSignUp] = useState(false); + const [signInName, setSignInName] = useState(''); + const [signInPassword, setSignInPassword] = useState(''); + const [signUpName, setSignUpName] = useState(''); + const [signUpPassword, setSignUpPassword] = useState(''); useEffect(() => { - console.log('Debug: Welcome component rendered') - }, []) + autoSignOut(); + }, [autoSignOut]); - const onSignUp = async (values) => { - try { - await createUser(values.name, values.password) - message.success('Account created — signed in') - setSignUpOpen(false) - } catch (e) { - message.error(e.message || 'Failed to create account') - } - } - - const onSignIn = async (values) => { - try { - await authenticate(values.name, values.password) - message.success('Signed in') - setSignInOpen(false) - } catch (e) { - message.error(e.message || 'Sign in failed') - } - } + const handleSignIn = async () => { + const ok = await signIn(signInName, signInPassword); + if (!ok) message.error('Invalid credentials'); + setShowSignIn(false); + }; + const handleSignUp = async () => { + const ok = await signUp(signUpName, signUpPassword); + if (!ok) message.error('Sign up failed'); + setShowSignUp(false); + }; return ( -
-
- {/* Debug message removed */} - Pytorch Connectomics +
+ + Pytc Client + + Welcome to Pytc Client!
A modern interface for connectomics workflows.
Sign in or sign up to get started. +
+ + +
+ setShowSignIn(false)} onOk={handleSignIn} okText='Sign In'> + setSignInName(e.target.value)} style={{ marginBottom: 12 }} /> + setSignInPassword(e.target.value)} /> + + setShowSignUp(false)} onOk={handleSignUp} okText='Sign Up'> + setSignUpName(e.target.value)} style={{ marginBottom: 12 }} /> + setSignUpPassword(e.target.value)} /> + +
+ ); +} + +export default Welcome; A desktop client for connectomics workflows — visualize data, run inference, and manage experiments. From 4d577050a982027010c7d4369b801f908713cf04 Mon Sep 17 00:00:00 2001 From: Shen Wang Date: Tue, 25 Nov 2025 16:12:51 -0500 Subject: [PATCH 3/6] frontend js backend py connected --- .gitignore | 3 +- DEVLOG.md | 35 +- client/.env | 6 +- client/main.js | 4 +- client/package-lock.json | 86 +- client/package.json | 5 +- client/src/App.js | 27 +- client/src/contexts/UserContext.js | 173 ++-- client/src/views/FilesManager.js | 902 +++++++++++++++--- client/src/views/Views.js | 175 +--- client/src/views/Welcome.js | 210 ++-- client/src/views/Workspace.js | 133 +++ server_api/auth/database.py | 20 + server_api/auth/models.py | 74 ++ server_api/auth/router.py | 181 ++++ server_api/auth/utils.py | 27 + server_api/main.py | 12 + server_api/requirements.txt | 4 + sql_app.db | Bin 0 -> 32768 bytes .../aa41ada2-9ee0-42be-b65c-4ad4b636c16e.pdf | Bin 0 -> 141193 bytes .../9bd86436-5a80-4a0c-a014-daef1aad612b.xlsx | Bin 0 -> 9677 bytes .../2b64fb56-cfdd-491c-83b3-761997045880.txt | 32 + .../999bb717-fbec-40bf-9138-43091ddb75a4.xlsx | Bin 0 -> 9677 bytes 23 files changed, 1611 insertions(+), 498 deletions(-) create mode 100644 client/src/views/Workspace.js create mode 100644 server_api/auth/database.py create mode 100644 server_api/auth/models.py create mode 100644 server_api/auth/router.py create mode 100644 server_api/auth/utils.py create mode 100644 sql_app.db create mode 100644 uploads/1/aa41ada2-9ee0-42be-b65c-4ad4b636c16e.pdf create mode 100644 uploads/2/9bd86436-5a80-4a0c-a014-daef1aad612b.xlsx create mode 100644 uploads/3/2b64fb56-cfdd-491c-83b3-761997045880.txt create mode 100644 uploads/3/999bb717-fbec-40bf-9138-43091ddb75a4.xlsx diff --git a/.gitignore b/.gitignore index 74c2474..ac1261b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,6 @@ pytorch_connectomics/ .coverage coverage.xml dist/ -.env -.env.* + *.log .github/workflows/docker-test.yml diff --git a/DEVLOG.md b/DEVLOG.md index fcf43a6..02ae7d0 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -13,6 +13,20 @@ This file records the major development steps, design decisions, and UI/UX chang ## Major Features & Changes +### [2025-11-21] Backend User Management +* **Backend Auth**: Integrated FastAPI with `python-jose` (JWT) and `passlib` (bcrypt) for secure authentication. +* **Database**: Added SQLite database (`sql_app.db`) with SQLAlchemy models for Users. +* **Frontend Integration**: Updated `UserContext` to communicate with backend endpoints (`/register`, `/token`, `/users/me`) instead of local storage. +* **Dependencies**: Added `python-jose`, `passlib`, `sqlalchemy` to requirements. + +### [2025-11-21] Advanced File Management & UI Polish +* **Manual Sign Out**: Added a sign-out button to the header, allowing users to return to the Welcome screen. +* **File Previews**: Implemented a preview modal for files (images and text) triggered by double-click or context menu. +* **Multi-Select & Drag Selection**: Added drag selection box and keyboard shortcuts (Ctrl/Shift) for selecting multiple files. +* **Enhanced Drag & Drop**: Enabled moving multiple selected files/folders at once, including within the same parent directory. +* **Context Menu Enhancements**: Updated context menu to handle multiple selections (bulk Copy/Delete) and hide "Preview" for multi-select. +* **Bug Fixes**: Resolved issues with drag selection (single item) and Electron path handling on Windows. + ### 1. Welcome Page - Added a full-screen Welcome page as the app's entry point. - Includes project name, intro, and warm message. @@ -20,12 +34,11 @@ This file records the major development steps, design decisions, and UI/UX chang - Styled to resemble cursor.com (modern, clean, gradient background). - Welcome page is always shown on app start (automatic sign out). -### 2. Local User Management -- Implemented `UserContext` using React Context and localforage. -- User model: `{ id, name, passwordHash, files: [file1, file2, file3], createdAt }` -- Passwords hashed with SHA-256 (Web Crypto API, prototype only). -- Sign-in and sign-up modals (Ant Design) wired to local user manager. -- Automatic sign out on app start (always shows Welcome). +### 2. Backend User Management (New) +- Replaced local storage with production-ready backend auth. +- Users are stored in `server_api/sql_app.db` (SQLite). +- Passwords are hashed with bcrypt. +- JWT tokens used for session management (stored in localStorage). ### 3. Main App Navigation - After login, user sees main app view (tabs: Visualization, Model Training, Model Inference, Tensorboard, Files). @@ -50,13 +63,15 @@ This file records the major development steps, design decisions, and UI/UX chang - [x] "Welcome" tab visible after login: removed from menu. - [x] Debug messages visible: removed. - [x] Modals not working in Electron: fixed after proper build/restart. +- [x] Drag selection not selecting single items: fixed. +- [x] Electron "ERR_FILE_NOT_FOUND": fixed path separator in main.js. --- ## Next Steps / TODOs -- [ ] Add multi-file support or previews in Files tab. -- [ ] Add manual sign-out button in main app view. -- [ ] Integrate backend user management (FastAPI, JWT, etc.) for production. +- [x] Add multi-file support or previews in Files tab. +- [x] Add manual sign-out button in main app view. +- [x] Integrate backend user management (FastAPI, JWT, etc.) for production. - [ ] Add user profile editing and avatar upload. - [ ] Improve file upload to support actual file storage (not just metadata). @@ -77,4 +92,4 @@ This file records the major development steps, design decisions, and UI/UX chang --- -_Last updated: 2025-11-20_ +_Last updated: 2025-11-21_ diff --git a/client/.env b/client/.env index e92841b..9db1133 100644 --- a/client/.env +++ b/client/.env @@ -1,2 +1,4 @@ -REACT_APP_API_PROTOCOL=http -REACT_APP_API_URL=localhost:4242 +SKIP_PREFLIGHT_CHECK=true +REACT_APP_SERVER_PROTOCOL=http +REACT_APP_SERVER_URL=localhost:4242 +PORT=3001 diff --git a/client/main.js b/client/main.js index e3bfae7..ddc0d9f 100644 --- a/client/main.js +++ b/client/main.js @@ -7,7 +7,7 @@ require('electron-reload')(__dirname, { let mainWindow -function createWindow () { +function createWindow() { mainWindow = new BrowserWindow({ width: 800, height: 600, @@ -18,7 +18,7 @@ function createWindow () { }) mainWindow.loadURL(url.format({ - pathname: path.join(__dirname, './build/index.html'), + pathname: path.join(__dirname, 'build', 'index.html'), protocol: 'file:', slashes: true })) diff --git a/client/package-lock.json b/client/package-lock.json index dd9df65..cfd80fa 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.16.7", + "babel-loader": "8.2.2", "cross-env": "^7.0.3", "electron-reload": "^2.0.0-alpha.1", "prettier": "^3.3.2" @@ -5704,12 +5705,14 @@ } }, "node_modules/babel-loader": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", - "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.2.tgz", + "integrity": "sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g==", + "dev": true, + "license": "MIT", "dependencies": { "find-cache-dir": "^3.3.1", - "loader-utils": "^2.0.0", + "loader-utils": "^1.4.0", "make-dir": "^3.1.0", "schema-utils": "^2.6.5" }, @@ -5721,10 +5724,39 @@ "webpack": ">=2" } }, + "node_modules/babel-loader/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/babel-loader/node_modules/loader-utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/babel-loader/node_modules/schema-utils": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.5", "ajv": "^6.12.4", @@ -6286,9 +6318,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001643", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001643.tgz", - "integrity": "sha512-ERgWGNleEilSrHM6iUz/zJNSQTP8Mr21wDWpdgvRwcTXGAq6jMtOUPP4dqFPTdKqZ2wKTdtB+uucZ3MRpAUSmg==", + "version": "1.0.30001756", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", + "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", "funding": [ { "type": "opencollective", @@ -6302,7 +6334,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/case-sensitive-paths-webpack-plugin": { "version": "2.4.0", @@ -17360,6 +17393,25 @@ } } }, + "node_modules/react-scripts/node_modules/babel-loader": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.4.1.tgz", + "integrity": "sha512-nXzRChX+Z1GoE6yWavBQg6jDslyFF3SDjl2paADuoQtQW10JqShJt62R6eJQ5m/pjJFDT8xgKIWSP85OY8eXeA==", + "license": "MIT", + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.4", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, "node_modules/react-scripts/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -17384,6 +17436,24 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/react-scripts/node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/react-scripts/node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", diff --git a/client/package.json b/client/package.json index dbe998a..71742ff 100644 --- a/client/package.json +++ b/client/package.json @@ -52,6 +52,7 @@ }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.16.7", + "babel-loader": "8.2.2", "cross-env": "^7.0.3", "electron-reload": "^2.0.0-alpha.1", "prettier": "^3.3.2" @@ -60,6 +61,6 @@ "nth-check": "$nth-check", "resolve-url-loader": "^5.0.0", "svgo": "^3.3.2", - "webpack-dev-server": "^5.2.1" + "webpack-dev-server": "^4.15.0" } -} +} \ No newline at end of file diff --git a/client/src/App.js b/client/src/App.js index 9e605b8..26e308e 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -1,7 +1,9 @@ import { useContext, useEffect, useState } from 'react' import './App.css' import Views from './views/Views' +import Welcome from './views/Welcome' import { AppContext, ContextWrapper } from './contexts/GlobalContext' +import UserContextWrapper, { UserContext } from './contexts/UserContext' import { YamlContextWrapper } from './contexts/YamlContext' function CacheBootstrapper ({ children }) { @@ -30,17 +32,24 @@ function CacheBootstrapper ({ children }) { return children } +function MainContent () { + const { currentUser } = useContext(UserContext) + return currentUser ? : +} + function App () { return ( - - - -
- -
-
-
-
+ + + + +
+ +
+
+
+
+
) } diff --git a/client/src/contexts/UserContext.js b/client/src/contexts/UserContext.js index 8c38bb4..f846fe2 100644 --- a/client/src/contexts/UserContext.js +++ b/client/src/contexts/UserContext.js @@ -1,117 +1,98 @@ -import React, { createContext, useState, useCallback } from 'react'; -import localforage from 'localforage'; +import React, { createContext, useState, useCallback, useEffect } from 'react'; export const UserContext = createContext(); -function hashPassword(password) { - // Simple SHA-256 hash (prototype) - return window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(password)).then(buf => - Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('') - ); -// ...existing code... +const API_URL = 'http://localhost:4242'; -export const UserProvider = ({ children }) => { +const UserContextWrapper = ({ children }) => { const [currentUser, setCurrentUser] = useState(null); + const [token, setToken] = useState(localStorage.getItem('token')); const autoSignOut = useCallback(() => { setCurrentUser(null); + setToken(null); + localStorage.removeItem('token'); }, []); - const signIn = async (name, password) => { - const users = (await localforage.getItem('users')) || []; - const hash = await hashPassword(password); - const user = users.find(u => u.name === name && u.passwordHash === hash); - if (user) { - setCurrentUser(user); - return true; - } - return false; - }; - - const signUp = async (name, password) => { - const users = (await localforage.getItem('users')) || []; - if (users.find(u => u.name === name)) return false; - const hash = await hashPassword(password); - const newUser = { id: Date.now(), name, passwordHash: hash, files: {}, createdAt: Date.now() }; - await localforage.setItem('users', [...users, newUser]); - setCurrentUser(newUser); - return true; - }; - - return ( - - {children} - - ); -} - + // Check token on mount useEffect(() => { - if (isLoaded) { - localforage.setItem(CURRENT_USER_KEY, currentUserId).catch(() => {}) - } - }, [currentUserId, isLoaded]) + const checkUser = async () => { + if (token) { + try { + const response = await fetch(`${API_URL}/users/me`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (response.ok) { + const userData = await response.json(); + setCurrentUser({ ...userData, name: userData.username }); // Map username to name for compatibility + } else { + autoSignOut(); + } + } catch (error) { + console.error("Failed to fetch user", error); + autoSignOut(); + } + } + }; + checkUser(); + }, [token, autoSignOut]); - const createUser = async (name, password) => { - if (!name) throw new Error('Name required') - const exists = users.find(u => u.name === name) - if (exists) throw new Error('User already exists') - const id = (crypto && crypto.randomUUID) ? crypto.randomUUID() : String(Date.now()) - const pwdHash = await hashString(password || '') - const newUser = { - id, - name, - passwordHash: pwdHash, - files: [null, null, null], - createdAt: new Date().toISOString() - } - const next = [...users, newUser] - setUsers(next) - setCurrentUserId(id) - return newUser - } + const signIn = async (username, password) => { + try { + const formData = new FormData(); + formData.append('username', username); + formData.append('password', password); - const authenticate = async (name, password) => { - const user = users.find(u => u.name === name) - if (!user) throw new Error('User not found') - const pwdHash = await hashString(password || '') - if (pwdHash !== user.passwordHash) throw new Error('Invalid credentials') - setCurrentUserId(user.id) - return user - } + const response = await fetch(`${API_URL}/token`, { + method: 'POST', + body: formData, + }); - const signout = async () => { - setCurrentUserId(null) - } + if (response.ok) { + const data = await response.json(); + setToken(data.access_token); + localStorage.setItem('token', data.access_token); + // Fetch user details immediately + const userRes = await fetch(`${API_URL}/users/me`, { + headers: { Authorization: `Bearer ${data.access_token}` } + }); + if (userRes.ok) { + const userData = await userRes.json(); + setCurrentUser({ ...userData, name: userData.username }); + return true; + } + } + return false; + } catch (error) { + console.error("Sign in error", error); + return false; + } + }; - const getCurrentUser = () => users.find(u => u.id === currentUserId) || null + const signUp = async (username, password) => { + try { + const response = await fetch(`${API_URL}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); - const setUserFile = (index, fileMeta) => { - const user = users.find(u => u.id === currentUserId) - if (!user) throw new Error('No user signed in') - const next = users.map(u => { - if (u.id !== currentUserId) return u - const files = [...(u.files || [null, null, null])] - files[index] = fileMeta - return { ...u, files } - }) - setUsers(next) - } + if (response.ok) { + // Auto sign in after registration + return await signIn(username, password); + } + return false; + } catch (error) { + console.error("Sign up error", error); + return false; + } + }; return ( - + {children} - ) -} + ); +}; -export default UserContextWrapper +export default UserContextWrapper; diff --git a/client/src/views/FilesManager.js b/client/src/views/FilesManager.js index e5fbd36..19c19bf 100644 --- a/client/src/views/FilesManager.js +++ b/client/src/views/FilesManager.js @@ -1,188 +1,772 @@ -import React, { useState } from 'react'; -import { Card, Button, Input, Modal, List, Upload, Checkbox, message } from 'antd'; -import { PlusOutlined, DeleteOutlined, EditOutlined, FolderOpenOutlined, FileImageOutlined, UploadOutlined } from '@ant-design/icons'; - -function initialFolders() { - return [ - { key: 'root', title: 'My Drive', children: [] }, - ]; -} +import React, { useState, useEffect, useRef } from 'react'; +import { Button, Input, Modal, message, Menu, Breadcrumb, Empty, Image } from 'antd'; +import { FolderFilled, FileOutlined, FileTextOutlined, HomeOutlined, ArrowUpOutlined, AppstoreOutlined, BarsOutlined, UploadOutlined, EyeOutlined } from '@ant-design/icons'; +import axios from 'axios'; + +// API base URL (adjust via env vars if needed) +const API_BASE = `${process.env.REACT_APP_SERVER_PROTOCOL || 'http'}://${process.env.REACT_APP_SERVER_URL || 'localhost:4243'}`; + +// Configure axios to include JWT token +axios.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Transform backend file list into UI state +const transformFiles = (fileList) => { + const folders = []; + const files = {}; + fileList.forEach((f) => { + if (f.is_folder) { + folders.push({ key: String(f.id), title: f.name, parent: f.path === 'root' ? 'root' : String(f.path), is_folder: true }); + } else { + const parentKey = f.path || 'root'; + if (!files[parentKey]) files[parentKey] = []; + files[parentKey].push({ key: String(f.id), name: f.name, size: f.size, type: f.type, is_folder: false }); + } + }); + if (!folders.find((f) => f.key === 'root')) { + folders.unshift({ key: 'root', title: 'My Drive', parent: null }); + } + return { folders, files }; +}; function FilesManager() { - const [folders, setFolders] = useState(initialFolders()); - const [selectedFolder, setSelectedFolder] = useState('root'); - const [files, setFiles] = useState({ root: [] }); - const [showFolderModal, setShowFolderModal] = useState(false); - const [newFolderName, setNewFolderName] = useState(''); - const [renameFolderKey, setRenameFolderKey] = useState(null); - const [renameFolderName, setRenameFolderName] = useState(''); - const [selectedFiles, setSelectedFiles] = useState([]); - - // Folder helpers - const addFolder = () => { - if (!newFolderName.trim()) return; - const key = `${Date.now()}`; - setFolders(folders => [ - ...folders, - { key, title: newFolderName, children: [] }, - ]); - setFiles(f => ({ ...f, [key]: [] })); - setNewFolderName(''); - setShowFolderModal(false); - message.success('Folder created'); - }; - - const deleteFolder = key => { - if (key === 'root') return; - setFolders(folders => folders.filter(f => f.key !== key)); - setFiles(f => { - const copy = { ...f }; - delete copy[key]; - return copy; + const [folders, setFolders] = useState([]); + const [files, setFiles] = useState({}); + const [currentFolder, setCurrentFolder] = useState('root'); + const [viewMode, setViewMode] = useState('grid'); // 'grid' or 'list' + const [selectedItems, setSelectedItems] = useState([]); + const [clipboard, setClipboard] = useState({ items: [], action: null }); // copy / move + const [editingItem, setEditingItem] = useState(null); + const [newItemType, setNewItemType] = useState(null); + const [tempName, setTempName] = useState(''); + const inputRef = useRef(null); + const [contextMenu, setContextMenu] = useState(null); + const [previewFile, setPreviewFile] = useState(null); + const [propertiesData, setPropertiesData] = useState(null); + const [selectionBox, setSelectionBox] = useState(null); + const containerRef = useRef(null); + const itemRefs = useRef({}); + const isDragSelecting = useRef(false); + + // Focus input when editing starts + useEffect(() => { + if (editingItem && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editingItem]); + + // Load initial data + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const fetchFiles = async () => { + try { + const res = await axios.get(`${API_BASE}/files`, { withCredentials: true }); + const { folders: flds, files: fls } = transformFiles(res.data); + setFolders(flds); + setFiles(fls); + } catch (err) { + console.error('Failed to load files', err); + message.error('Could not load files'); + } + }; + fetchFiles(); + }, []); + + const getCurrentFolderObj = () => folders.find((f) => f.key === currentFolder); + // eslint-disable-next-line no-loop-func + const getBreadcrumbs = () => { + const path = []; + let curr = getCurrentFolderObj(); + while (curr) { + path.unshift(curr); + curr = folders.find((f) => f.key === curr.parent); + } + return path; + }; + + const handleNavigate = (key) => { + setCurrentFolder(key); + setSelectedItems([]); + setEditingItem(null); + setNewItemType(null); + }; + + const handleUp = () => { + const curr = getCurrentFolderObj(); + if (curr && curr.parent) handleNavigate(curr.parent); + }; + + // Folder creation + const startCreateFolder = () => { + const key = `new_folder_${Date.now()}`; + setNewItemType('folder'); + setEditingItem(key); + setTempName(''); + }; + + const finishCreateFolder = async () => { + if (!tempName.trim()) { + setEditingItem(null); + setNewItemType(null); + return; + } + try { + const payload = { name: tempName, path: currentFolder }; + const res = await axios.post(`${API_BASE}/files/folder`, payload, { withCredentials: true }); + const newFolder = res.data; + setFolders([...folders, { key: String(newFolder.id), title: newFolder.name, parent: newFolder.path }]); + setFiles({ ...files, [String(newFolder.id)]: [] }); + message.success('Folder created'); + } catch (err) { + console.error(err); + message.error('Failed to create folder'); + } + setEditingItem(null); + setNewItemType(null); + }; + + // Rename + const startRename = (key, currentName) => { + setEditingItem(key); + setTempName(currentName); + }; + + const finishRename = async () => { + if (!tempName.trim()) { + setEditingItem(null); + return; + } + const key = editingItem; + const isFolder = folders.some((f) => f.key === key); + try { + await axios.put(`${API_BASE}/files/${key}`, { name: tempName, path: isFolder ? undefined : currentFolder }, { withCredentials: true }); + if (isFolder) { + setFolders(folders.map((f) => (f.key === key ? { ...f, title: tempName } : f))); + } else { + setFiles((prev) => ({ + ...prev, + [currentFolder]: prev[currentFolder].map((f) => (f.key === key ? { ...f, name: tempName } : f)), + })); + } + message.success('Renamed successfully'); + } catch (err) { + console.error(err); + message.error('Rename failed'); + } + setEditingItem(null); + }; + + // Delete + const handleDelete = async (keys = selectedItems) => { + if (keys.length === 0) return; + try { + await Promise.all(keys.map((id) => axios.delete(`${API_BASE}/files/${id}`, { withCredentials: true }))); + const folderIds = keys.filter((k) => folders.some((f) => f.key === k)); + const fileIds = keys.filter((k) => !folderIds.includes(k)); + setFolders(folders.filter((f) => !folderIds.includes(f.key))); + setFiles((prev) => { + const newFiles = { ...prev }; + Object.keys(newFiles).forEach((fk) => { + newFiles[fk] = newFiles[fk].filter((f) => !fileIds.includes(f.key)); + }); + return newFiles; + }); + setSelectedItems([]); + message.success(`Deleted ${keys.length} items`); + } catch (err) { + console.error(err); + message.error('Delete failed'); + } + }; + + // Copy / Paste (simple copy creates a new entry via folder endpoint for demo) + const handleCopy = (keys = selectedItems) => { + if (keys.length === 0) return; + setClipboard({ items: keys, action: 'copy' }); + message.info('Copied to clipboard'); + }; + + const handlePaste = async () => { + if (!clipboard.items.length) return; + if (clipboard.action === 'copy') { + const newEntries = []; + for (const id of clipboard.items) { + const orig = Object.values(files).flat().find((f) => f.key === id); + if (!orig) continue; + const payload = { name: `Copy of ${orig.name}`, path: currentFolder }; + try { + const res = await axios.post(`${API_BASE}/files/folder`, payload, { withCredentials: true }); + newEntries.push({ key: String(res.data.id), name: payload.name, size: orig.size, type: orig.type }); + } catch (err) { + console.error('Paste error', err); + } + } + if (newEntries.length) { + setFiles((prev) => ({ ...prev, [currentFolder]: [...(prev[currentFolder] || []), ...newEntries] })); + message.success('Pasted items'); + } + } + }; + + // Preview + const handlePreview = (key) => { + const file = (files[currentFolder] || []).find((f) => f.key === key); + if (file) setPreviewFile(file); + }; + + // Properties + const handleProperties = (keys = selectedItems) => { + if (keys.length === 0) return; + + if (keys.length === 1) { + // Single item - show detailed info + const key = keys[0]; + const folder = folders.find((f) => f.key === key); + const file = Object.values(files).flat().find((f) => f.key === key); + const item = folder || file; + + if (item) { + setPropertiesData({ + type: 'single', + name: item.title || item.name, + isFolder: !!folder, + size: item.size || 'N/A', + fileType: item.type || 'Folder', + created: new Date().toLocaleString(), // Backend should provide this + modified: new Date().toLocaleString(), // Backend should provide this + }); + } + } else { + // Multiple items - show summary + const folderKeys = keys.filter((k) => folders.some((f) => f.key === k)); + const fileKeys = keys.filter((k) => !folderKeys.includes(k)); + + // Calculate total size (simplified - would need backend support for accurate calculation) + let totalSize = 0; + fileKeys.forEach((key) => { + const file = Object.values(files).flat().find((f) => f.key === key); + if (file && file.size) { + // Parse size string (e.g., "1.5MB" or "500KB") + const sizeStr = String(file.size); + const match = sizeStr.match(/([0-9.]+)\s*(KB|MB|GB)/i); + if (match) { + const value = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + if (unit === 'KB') totalSize += value; + else if (unit === 'MB') totalSize += value * 1024; + else if (unit === 'GB') totalSize += value * 1024 * 1024; + } + } + }); + + const totalSizeStr = totalSize > 1024 + ? `${(totalSize / 1024).toFixed(2)} MB` + : `${totalSize.toFixed(2)} KB`; + + setPropertiesData({ + type: 'multiple', + totalCount: keys.length, + folderCount: folderKeys.length, + fileCount: fileKeys.length, + totalSize: totalSizeStr, + }); + } + }; + + // Drag & Drop + const handleDragStart = (e, key, type) => { + let itemsToDrag = selectedItems; + if (!selectedItems.includes(key)) { + itemsToDrag = [key]; + setSelectedItems([key]); + } + e.dataTransfer.setData('text/plain', JSON.stringify({ keys: itemsToDrag })); + }; + + const handleDragOver = (e) => e.preventDefault(); + + const handleDrop = async (e, targetFolderKey) => { + e.preventDefault(); + const dataStr = e.dataTransfer.getData('text/plain'); + if (!dataStr) return; + const { keys } = JSON.parse(dataStr); + if (!keys || keys.length === 0) return; + if (keys.includes(targetFolderKey)) return; + let moved = 0; + for (const key of keys) { + const isFolder = folders.some((f) => f.key === key); + try { + await axios.put(`${API_BASE}/files/${key}`, { path: targetFolderKey }); + if (isFolder) { + setFolders((prev) => prev.map((f) => (f.key === key ? { ...f, parent: targetFolderKey } : f))); + } else { + setFiles((prev) => { + const sourceFolder = Object.keys(prev).find((fk) => prev[fk].some((f) => f.key === key)); + if (!sourceFolder) return prev; + const fileObj = prev[sourceFolder].find((f) => f.key === key); + const newSource = prev[sourceFolder].filter((f) => f.key !== key); + const newTarget = [...(prev[targetFolderKey] || []), fileObj]; + return { ...prev, [sourceFolder]: newSource, [targetFolderKey]: newTarget }; + }); + } + moved++; + } catch (err) { + console.error('Move error', err); + } + } + if (moved) { + message.success(`Moved ${moved} items`); + setSelectedItems([]); + } + }; + + // Selection box handling + const handleMouseDown = (e) => { + if (e.target !== containerRef.current && e.target.className !== 'file-manager-content') return; + isDragSelecting.current = false; + if (!e.ctrlKey && !e.shiftKey) setSelectedItems([]); + const rect = containerRef.current.getBoundingClientRect(); + setSelectionBox({ + startX: e.clientX - rect.left, + startY: e.clientY - rect.top + containerRef.current.scrollTop, + currentX: e.clientX - rect.left, + currentY: e.clientY - rect.top + containerRef.current.scrollTop, + initialSelected: e.ctrlKey ? [...selectedItems] : [], }); - if (selectedFolder === key) setSelectedFolder('root'); - message.success('Folder deleted'); }; - const renameFolder = () => { - setFolders(folders => folders.map(f => f.key === renameFolderKey ? { ...f, title: renameFolderName } : f)); - setRenameFolderKey(null); - setRenameFolderName(''); - message.success('Folder renamed'); + const handleMouseMove = (e) => { + if (!selectionBox) return; + const rect = containerRef.current.getBoundingClientRect(); + const currentX = e.clientX - rect.left; + const currentY = e.clientY - rect.top + containerRef.current.scrollTop; + if (Math.abs(currentX - selectionBox.startX) > 5 || Math.abs(currentY - selectionBox.startY) > 5) { + isDragSelecting.current = true; + } + setSelectionBox((prev) => ({ ...prev, currentX, currentY })); + const left = Math.min(selectionBox.startX, currentX); + const top = Math.min(selectionBox.startY, currentY); + const width = Math.abs(currentX - selectionBox.startX); + const height = Math.abs(currentY - selectionBox.startY); + const newSelected = []; + Object.keys(itemRefs.current).forEach((key) => { + const el = itemRefs.current[key]; + if (!el) return; + const itemRect = el.getBoundingClientRect(); + const containerRect = containerRef.current.getBoundingClientRect(); + const itemLeft = itemRect.left - containerRect.left; + const itemTop = itemRect.top - containerRect.top + containerRef.current.scrollTop; + if ( + left < itemLeft + itemRect.width && + left + width > itemLeft && + top < itemTop + itemRect.height && + top + height > itemTop + ) { + newSelected.push(key); + } + }); + const merged = Array.from(new Set([...selectionBox.initialSelected, ...newSelected])); + setSelectedItems(merged); }; - // File helpers - const handleUpload = info => { - if (info.file.status === 'done' || info.file.originFileObj) { - const fileMeta = { - key: `${Date.now()}`, - name: info.file.name, - size: info.file.size, - type: info.file.type, - lastModified: info.file.lastModified, - }; - setFiles(f => ({ ...f, [selectedFolder]: [...(f[selectedFolder] || []), fileMeta] })); - message.success('File uploaded'); + const handleMouseUp = () => setSelectionBox(null); + + // Keyboard shortcuts + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const handleKeyDown = (e) => { + if (editingItem) { + if (e.key === 'Enter') newItemType ? finishCreateFolder() : finishRename(); + if (e.key === 'Escape') { + setEditingItem(null); + setNewItemType(null); + } + return; + } + if (e.target.tagName === 'INPUT') return; + if (e.key === 'Delete') handleDelete(); + if (e.ctrlKey && e.key === 'c') handleCopy(); + if (e.ctrlKey && e.key === 'v') handlePaste(); + if (e.ctrlKey && e.key === 'a') { + e.preventDefault(); + const allKeys = [ + ...folders.filter((f) => f.parent === currentFolder).map((f) => f.key), + ...(files[currentFolder] || []).map((f) => f.key), + ]; + setSelectedItems(allKeys); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedItems, clipboard, currentFolder, folders, files, editingItem, newItemType, tempName]); + + // Context menu handling + const handleContextMenu = (e, type, key) => { + e.preventDefault(); + e.stopPropagation(); + if (type === 'item') { + if (!selectedItems.includes(key)) setSelectedItems([key]); + } else if (type === 'container') { + if (e.target === containerRef.current || e.target.className === 'file-manager-content') setSelectedItems([]); } + setContextMenu({ x: e.clientX, y: e.clientY, type, key }); }; - const handleDeleteFile = key => { - setFiles(f => ({ - ...f, - [selectedFolder]: (f[selectedFolder] || []).filter(file => file.key !== key), - })); - setSelectedFiles(sel => sel.filter(k => k !== key)); - message.success('File deleted'); + useEffect(() => { + const handleClick = () => setContextMenu(null); + window.addEventListener('click', handleClick); + return () => window.removeEventListener('click', handleClick); + }, []); + + const renderItem = (item, type) => { + const isSelected = selectedItems.includes(item.key); + const isEditing = editingItem === item.key; + const icon = type === 'folder' ? : ; + return ( +
(itemRefs.current[item.key] = el)} + draggable={!isEditing} + onDragStart={(e) => handleDragStart(e, item.key, type)} + onDragOver={type === 'folder' ? handleDragOver : undefined} + onDrop={type === 'folder' ? (e) => handleDrop(e, item.key) : undefined} + onContextMenu={(e) => handleContextMenu(e, 'item', item.key)} + onClick={(e) => { + e.stopPropagation(); + if (e.ctrlKey) { + setSelectedItems((prev) => (isSelected ? prev.filter((k) => k !== item.key) : [...prev, item.key])); + } else if (e.shiftKey && selectedItems.length) { + setSelectedItems((prev) => [...prev, item.key]); + } else { + setSelectedItems([item.key]); + } + }} + onDoubleClick={() => { + if (isEditing) return; + if (type === 'folder') handleNavigate(item.key); + else handlePreview(item.key); + }} + style={{ + width: viewMode === 'grid' ? 100 : '100%', + padding: 8, + margin: viewMode === 'grid' ? 8 : 0, + textAlign: viewMode === 'grid' ? 'center' : 'left', + cursor: 'pointer', + borderRadius: 4, + backgroundColor: isSelected ? '#e6f7ff' : 'transparent', + border: isSelected ? '1px solid #1890ff' : '1px solid transparent', + display: viewMode === 'list' ? 'flex' : 'block', + alignItems: 'center', + userSelect: 'none', + }} + > +
+ {React.cloneElement(icon, { style: { fontSize: viewMode === 'grid' ? 48 : 24, color: type === 'folder' ? '#1890ff' : '#555' } })} +
+ {isEditing ? ( + setTempName(e.target.value)} + onBlur={() => (newItemType ? finishCreateFolder() : finishRename())} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + style={{ width: viewMode === 'grid' ? '100%' : 300 }} + /> + ) : ( +
{item.title || item.name}
+ )} +
+ ); }; - const handleRenameFile = (key, newName) => { - setFiles(f => ({ - ...f, - [selectedFolder]: (f[selectedFolder] || []).map(file => file.key === key ? { ...file, name: newName } : file), - })); - message.success('File renamed'); + const renderNewFolderPlaceholder = () => { + if (newItemType !== 'folder') return null; + return ( +
+
+ +
+ setTempName(e.target.value)} + onBlur={finishCreateFolder} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + style={{ width: viewMode === 'grid' ? '100%' : 300 }} + /> +
+ ); + }; + + const currentFolders = folders.filter((f) => f.parent === currentFolder); + const currentFiles = files[currentFolder] || []; + + const getContextMenuItems = () => { + if (contextMenu?.type === 'container') { + return [ + { key: 'new_folder', label: 'Create Folder', icon: }, + { key: 'upload', label: 'Upload File...', icon: }, + ]; + } + const items = []; + // Only show preview for files, not folders + if (selectedItems.length === 1) { + const selectedKey = selectedItems[0]; + const isFolder = folders.some((f) => f.key === selectedKey); + if (!isFolder) { + items.push({ key: 'preview', label: 'Preview', icon: }); + } + } + items.push( + { key: 'rename', label: 'Rename', icon: , disabled: selectedItems.length > 1 }, + { key: 'copy', label: `Copy${selectedItems.length > 1 ? ` (${selectedItems.length})` : ''}`, icon: }, + { key: 'delete', label: `Delete${selectedItems.length > 1 ? ` (${selectedItems.length})` : ''}`, danger: true, icon: }, + { type: 'divider' }, + { key: 'properties', label: 'Properties', icon: } + ); + return items; }; - const handleMultiDelete = () => { - setFiles(f => ({ - ...f, - [selectedFolder]: (f[selectedFolder] || []).filter(file => !selectedFiles.includes(file.key)), - })); - setSelectedFiles([]); - message.success('Files deleted'); + const handleUploadClick = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.onchange = async (e) => { + const filesSelected = Array.from(e.target.files); + for (const file of filesSelected) { + const form = new FormData(); + form.append('file', file); + form.append('path', currentFolder); + try { + const res = await axios.post(`${API_BASE}/files/upload`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + const newFile = res.data; + setFiles((prev) => ({ + ...prev, + [currentFolder]: [...(prev[currentFolder] || []), { key: String(newFile.id), name: newFile.name, size: newFile.size, type: newFile.type }], + })); + message.success(`${file.name} uploaded`); + } catch (err) { + console.error('Upload error', err); + message.error(`Failed to upload ${file.name}`); + } + } + }; + input.click(); }; - // UI return ( - -
- {/* Folder sidebar */} -
-
Folders
- ( - } onClick={() => { setRenameFolderKey(folder.key); setRenameFolderName(folder.title); }}>Rename, - , - ] : []} - onClick={() => setSelectedFolder(folder.key)} - > - {folder.title} - - )} +
{ + if (isDragSelecting.current) { + isDragSelecting.current = false; + return; + } + setSelectedItems([]); + setContextMenu(null); + }} + onContextMenu={(e) => handleContextMenu(e, 'container')} + > + {/* Toolbar & Breadcrumbs */} +
+
+ + {/* Content Area */} +
+ {currentFolders.length === 0 && currentFiles.length === 0 && !newItemType && ( +
+ +
+ )} + {currentFolders.map((f) => renderItem(f, 'folder'))} + {renderNewFolderPlaceholder()} + {currentFiles.map((f) => renderItem(f, 'file'))} + {selectionBox && ( +
- -
- {/* Files in folder */} -
-
Files in "{folders.find(f => f.key === selectedFolder)?.title}"
- false} - onChange={handleUpload} - multiple - > - - - {selectedFiles.length > 0 && ( - - )} - ( - } onClick={() => { - Modal.confirm({ - title: 'Rename File', - content: file.newName = e.target.value} />, - onOk: () => handleRenameFile(file.key, file.newName || file.name), - }); - }}>Rename, - , - ]} - > - { - setSelectedFiles(sel => e.target.checked ? [...sel, file.key] : sel.filter(k => k !== file.key)); - }} - style={{ marginRight: 8 }} - /> - - {file.name} - {file.size} bytes - - )} + )} +
+ + {/* Context Menu */} + {contextMenu && ( +
+ { + setContextMenu(null); + if (key === 'new_folder') startCreateFolder(); + if (key === 'upload') handleUploadClick(); + if (key === 'rename') { + const item = folders.find((f) => f.key === contextMenu.key) || (files[currentFolder] || []).find((f) => f.key === contextMenu.key); + startRename(contextMenu.key, item.title || item.name); + } + if (key === 'copy') handleCopy(selectedItems.length > 0 ? selectedItems : [contextMenu.key]); + if (key === 'delete') handleDelete(selectedItems.length > 0 ? selectedItems : [contextMenu.key]); + if (key === 'preview') handlePreview(contextMenu.key); + if (key === 'properties') handleProperties(selectedItems.length > 0 ? selectedItems : [contextMenu.key]); + }} + items={getContextMenuItems()} />
-
- {/* Folder modals */} + )} + + {/* Preview Modal */} setShowFolderModal(false)} - onOk={addFolder} + title={previewFile?.name} + open={!!previewFile} + onCancel={() => setPreviewFile(null)} + footer={null} + width={800} > - setNewFolderName(e.target.value)} placeholder='Folder name' /> + {previewFile && ( +
+ {previewFile.type?.startsWith('image') ? ( + {previewFile.name} + ) : ( +
+
{previewFile.name === 'readme.txt' ? "This is a dummy text file content.\n\nIn a real app, this would fetch the file content from the server." : "Preview not available for this file type."}
+
+ )} +
+ )}
+ + {/* Properties Modal */} setRenameFolderKey(null)} - onOk={renameFolder} + title="Properties" + open={!!propertiesData} + onCancel={() => setPropertiesData(null)} + footer={[ + , + ]} + width={500} > - setRenameFolderName(e.target.value)} placeholder='New folder name' /> + {propertiesData && propertiesData.type === 'single' && ( +
+
+ {propertiesData.isFolder ? ( + + ) : ( + + )} +
+
{propertiesData.name}
+
{propertiesData.isFolder ? 'Folder' : 'File'}
+
+
+
+
+ Type: + {propertiesData.fileType} +
+
+ Size: + {propertiesData.size} +
+
+ Created: + {propertiesData.created} +
+
+ Modified: + {propertiesData.modified} +
+
+
+ )} + {propertiesData && propertiesData.type === 'multiple' && ( +
+
Selection Summary
+
+
+ Total Items: + {propertiesData.totalCount} +
+
+ Folders: + {propertiesData.folderCount} +
+
+ Files: + {propertiesData.fileCount} +
+
+ Total Size: + {propertiesData.totalSize} +
+
+
+ )}
- +
); } diff --git a/client/src/views/Views.js b/client/src/views/Views.js index c77ae9c..1feeca3 100644 --- a/client/src/views/Views.js +++ b/client/src/views/Views.js @@ -1,150 +1,49 @@ -import React, { useState, useEffect } from 'react' -import DataLoader from './DataLoader' -import Visualization from '../views/Visualization' -import ModelTraining from '../views/ModelTraining' -import ModelInference from '../views/ModelInference' -import Monitoring from '../views/Monitoring' -import Chatbot from '../components/Chatbot' -import { Layout, Menu, Button } from 'antd' -import { MessageOutlined } from '@ant-design/icons' -import { getNeuroglancerViewer } from '../utils/api' +import React, { useState, useContext } from 'react' +import { Layout, Menu, Avatar, Typography, Space, Button, Tooltip } from 'antd' +import { FolderOpenOutlined, DesktopOutlined, UserOutlined, LogoutOutlined } from '@ant-design/icons' +import FilesManager from './FilesManager' +import Workspace from './Workspace' +import { UserContext } from '../contexts/UserContext' -const { Content, Sider } = Layout +const { Content } = Layout +const { Text } = Typography -function Views () { - const [current, setCurrent] = useState('visualization') - const [viewers, setViewers] = useState([]) - const [isLoading, setIsLoading] = useState(false) - const [isInferring, setIsInferring] = useState(false) - const [isChatOpen, setIsChatOpen] = useState(false) - console.log(viewers) - - const onClick = (e) => { - setCurrent(e.key) - } +function Views() { + const [current, setCurrent] = useState('files') + const { currentUser, autoSignOut } = useContext(UserContext) const items = [ - { label: 'Visualization', key: 'visualization' }, - { label: 'Model Training', key: 'training' }, - { label: 'Model Inference', key: 'inference' }, - { label: 'Tensorboard', key: 'monitoring' } + { label: 'File Management', key: 'files', icon: }, + { label: 'Work Space', key: 'workspace', icon: } ] - const renderMenu = () => { - if (current === 'visualization') { - return - } else if (current === 'training') { - return - } else if (current === 'monitoring') { - return - } else if (current === 'inference') { - return - } - } - - const [collapsed, setCollapsed] = useState(false) - - const fetchNeuroglancerViewer = async ( - currentImage, - currentLabel, - scales - ) => { - setIsLoading(true) - try { - const viewerId = currentImage.uid + currentLabel.uid + JSON.stringify(scales) - let updatedViewers = viewers - const exists = viewers.find( - // (viewer) => viewer.key === currentImage.uid + currentLabel.uid - (viewer) => viewer.key === viewerId - ) - // console.log(exists, viewers) - if (exists) { - updatedViewers = viewers.filter((viewer) => viewer.key !== viewerId) - } - const res = await getNeuroglancerViewer( - currentImage, - currentLabel, - scales - ) - const newUrl = res.replace(/\/\/[^:/]+/, '//localhost') - console.log('Current Viewer at ', newUrl) - - setViewers([ - ...updatedViewers, - { - key: viewerId, - title: currentImage.name + ' & ' + currentLabel.name, - viewer: newUrl - } - ]) - setIsLoading(false) - } catch (e) { - console.log(e) - setIsLoading(false) - } + const onClick = (e) => { + setCurrent(e.key) } - useEffect(() => { // This function makes sure that the inferring will continue when current tab changes - if (current === 'inference' && isInferring) { - console.log('Inference process is continuing...') - } - }, [current, isInferring]) - return ( - - {isLoading - ? (
Loading the viewer ...
) - : ( - <> - setCollapsed(value)} - theme='light' - collapsedWidth='0' - > - - - - - - {renderMenu()} - - - {isChatOpen ? ( - - setIsChatOpen(false)} /> - - ) : ( -
+ + {current === 'files' ? : } + ) } diff --git a/client/src/views/Welcome.js b/client/src/views/Welcome.js index ed70042..5970858 100644 --- a/client/src/views/Welcome.js +++ b/client/src/views/Welcome.js @@ -1,8 +1,9 @@ import React, { useState, useContext, useEffect } from 'react'; -import { Modal, Button, Input, Card, Typography, message } from 'antd'; +import { Card, Button, Input, Modal, Typography, message, Space } from 'antd'; +import { UserOutlined, LockOutlined, RocketOutlined } from '@ant-design/icons'; import { UserContext } from '../contexts/UserContext'; -const { Title, Paragraph } = Typography; +const { Title, Text } = Typography; function Welcome() { const { signIn, signUp, autoSignOut } = useContext(UserContext); @@ -22,6 +23,7 @@ function Welcome() { if (!ok) message.error('Invalid credentials'); setShowSignIn(false); }; + const handleSignUp = async () => { const ok = await signUp(signUpName, signUpPassword); if (!ok) message.error('Sign up failed'); @@ -29,79 +31,147 @@ function Welcome() { }; return ( -
- - Pytc Client - - Welcome to Pytc Client!
A modern interface for connectomics workflows.
Sign in or sign up to get started. -
- - +
+ +
+
+ +
+ PyTC Client + + Advanced Connectomics Workflow Interface + +
+ + + + + + +
+ + © {new Date().getFullYear()} PyTC Client. All rights reserved. + +
- setShowSignIn(false)} onOk={handleSignIn} okText='Sign In'> - setSignInName(e.target.value)} style={{ marginBottom: 12 }} /> - setSignInPassword(e.target.value)} /> + + {/* Sign In Modal */} + Sign In
} + open={showSignIn} + onCancel={() => setShowSignIn(false)} + onOk={handleSignIn} + okText='Sign In' + centered + width={360} + okButtonProps={{ style: { background: '#764ba2', borderColor: '#764ba2' } }} + > + + } + value={signInName} + onChange={e => setSignInName(e.target.value)} + /> + } + value={signInPassword} + onChange={e => setSignInPassword(e.target.value)} + onPressEnter={handleSignIn} + /> + - setShowSignUp(false)} onOk={handleSignUp} okText='Sign Up'> - setSignUpName(e.target.value)} style={{ marginBottom: 12 }} /> - setSignUpPassword(e.target.value)} /> + + {/* Sign Up Modal */} + Create Account
} + open={showSignUp} + onCancel={() => setShowSignUp(false)} + onOk={handleSignUp} + okText='Sign Up' + centered + width={360} + okButtonProps={{ style: { background: '#764ba2', borderColor: '#764ba2' } }} + > + + } + value={signUpName} + onChange={e => setSignUpName(e.target.value)} + /> + } + value={signUpPassword} + onChange={e => setSignUpPassword(e.target.value)} + onPressEnter={handleSignUp} + /> +
); } export default Welcome; - - A desktop client for connectomics workflows — visualize data, run inference, and manage experiments. - - - Welcome — get started by signing in or creating a new account. - - - - - - - setSignInOpen(false)} - footer={null} - > -
- - - - - - - - - -
-
- - setSignUpOpen(false)} - footer={null} - > -
- - - - - - - - - -
-
-
-
- ) -} - -export default Welcome diff --git a/client/src/views/Workspace.js b/client/src/views/Workspace.js new file mode 100644 index 0000000..99c177c --- /dev/null +++ b/client/src/views/Workspace.js @@ -0,0 +1,133 @@ +import React, { useState, useEffect } from 'react' +import DataLoader from './DataLoader' +import Visualization from '../views/Visualization' +import ModelTraining from '../views/ModelTraining' +import ModelInference from '../views/ModelInference' +import Monitoring from '../views/Monitoring' +import Chatbot from '../components/Chatbot' +import { Layout, Menu, Button } from 'antd' +import { MessageOutlined } from '@ant-design/icons' +import { getNeuroglancerViewer } from '../utils/api' + +const { Content, Sider } = Layout + +function Workspace() { + const [current, setCurrent] = useState('visualization') + const [viewers, setViewers] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isInferring, setIsInferring] = useState(false) + const [isChatOpen, setIsChatOpen] = useState(false) + const [collapsed, setCollapsed] = useState(false) + + const onClick = (e) => { + setCurrent(e.key) + } + + const items = [ + { label: 'Visualization', key: 'visualization' }, + { label: 'Model Training', key: 'training' }, + { label: 'Model Inference', key: 'inference' }, + { label: 'Tensorboard', key: 'monitoring' } + ] + + const renderMenu = () => { + if (current === 'visualization') { + return + } else if (current === 'training') { + return + } else if (current === 'monitoring') { + return + } else if (current === 'inference') { + return + } + } + + const fetchNeuroglancerViewer = async ( + currentImage, + currentLabel, + scales + ) => { + setIsLoading(true) + try { + const viewerId = currentImage.uid + currentLabel.uid + JSON.stringify(scales) + let updatedViewers = viewers + const exists = viewers.find( + (viewer) => viewer.key === viewerId + ) + if (exists) { + updatedViewers = viewers.filter((viewer) => viewer.key !== viewerId) + } + const res = await getNeuroglancerViewer( + currentImage, + currentLabel, + scales + ) + const newUrl = res.replace(/\/\/[^:/]+/, '//localhost') + console.log('Current Viewer at ', newUrl) + + setViewers([ + ...updatedViewers, + { + key: viewerId, + title: currentImage.name + ' & ' + currentLabel.name, + viewer: newUrl + } + ]) + setIsLoading(false) + } catch (e) { + console.log(e) + setIsLoading(false) + } + } + + useEffect(() => { + if (current === 'inference' && isInferring) { + console.log('Inference process is continuing...') + } + }, [current, isInferring]) + + return ( + + {isLoading + ? (
Loading the viewer ...
) + : ( + <> + setCollapsed(value)} + theme='light' + collapsedWidth='0' + > + + + + + + {renderMenu()} + + + {isChatOpen ? ( + + setIsChatOpen(false)} /> + + ) : ( + + + ); + } + + // Display viewer in iframe + return ( +
+
+
+ {viewerUrl ? ( +