diff options
| author | haoyuren <13851610112@163.com> | 2026-03-13 16:48:56 -0500 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-03-13 16:48:56 -0500 |
| commit | c309944494eb2de63bf9b35ea722d50b52e688a3 (patch) | |
| tree | 043d7a4cbacfa6416a5f6215146ab83f64157ae9 | |
| parent | eb0c0f1a9fc06d05ea0ecaa9361e0beb49eee68e (diff) | |
Add drag-and-drop file upload, fix project creation modal and API endpoints
- Add file upload to Overleaf projects via multipart POST with correct
"name" text field (server reads filename from req.body.name, not from
Content-Disposition filename)
- Add drag-and-drop support in file tree panel with visual feedback
- Replace window.prompt() with custom modal for new project creation
(prompt() returns null in Electron)
- Fix API endpoints: /api/project/new → /project/new
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | src/main/index.ts | 77 | ||||
| -rw-r--r-- | src/preload/index.ts | 5 | ||||
| -rw-r--r-- | src/renderer/src/App.css | 5 | ||||
| -rw-r--r-- | src/renderer/src/App.tsx | 11 | ||||
| -rw-r--r-- | src/renderer/src/components/FileTree.tsx | 52 | ||||
| -rw-r--r-- | src/renderer/src/components/ProjectList.tsx | 37 |
6 files changed, 178 insertions, 9 deletions
diff --git a/src/main/index.ts b/src/main/index.ts index 0925c01..7d70238 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,6 +4,7 @@ import { app, BrowserWindow, ipcMain, dialog, shell, net } from 'electron' import { join, basename } from 'path' import { readFile, writeFile } from 'fs/promises' +import { createReadStream } from 'fs' import { spawn } from 'child_process' import * as pty from 'node-pty' import { OverleafSocket, type RootFolder, type SubFolder, type JoinDocResult } from './overleafSocket' @@ -840,7 +841,7 @@ ipcMain.handle('overleaf:listProjects', async () => { ipcMain.handle('overleaf:createProject', async (_e, name: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } - const result = await overleafFetch('/api/project/new', { + const result = await overleafFetch('/project/new', { method: 'POST', body: JSON.stringify({ projectName: name }) }) @@ -874,7 +875,7 @@ ipcMain.handle('overleaf:uploadProject', async () => { return new Promise((resolve) => { const req = net.request({ method: 'POST', - url: 'https://www.overleaf.com/api/project/new/upload' + url: 'https://www.overleaf.com/project/new/upload' }) req.setHeader('Cookie', overleafSessionCookie) req.setHeader('Content-Type', `multipart/form-data; boundary=${boundary}`) @@ -940,6 +941,78 @@ ipcMain.handle('overleaf:createFolder', async (_e, projectId: string, parentFold return { success: result.ok, data: result.data, message: result.ok ? '' : `HTTP ${result.status}` } }) +// ── Upload file to project (binary or text) ─────────────────── +ipcMain.handle('project:uploadFile', async (_e, projectId: string, folderId: string, filePath: string, fileName: string) => { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + + try { + const fileData = await readFile(filePath) + const ext = fileName.split('.').pop()?.toLowerCase() || '' + const mimeMap: Record<string, string> = { + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', + svg: 'image/svg+xml', pdf: 'application/pdf', eps: 'application/postscript', + zip: 'application/zip', bmp: 'image/bmp', tiff: 'image/tiff', + tex: 'text/x-tex', bib: 'text/x-bibtex', txt: 'text/plain', csv: 'text/csv', + sty: 'text/x-tex', cls: 'text/x-tex', md: 'text/markdown', + } + const mime = mimeMap[ext] || 'application/octet-stream' + const boundary = '----FormBoundary' + Math.random().toString(36).slice(2) + + // Build multipart body matching Overleaf's expected format: + // 1. "name" text field (required — server reads filename from req.body.name) + // 2. "type" text field + // 3. "qqfile" file field (fieldName must be "qqfile" for multer) + const parts: Buffer[] = [] + // name field + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="name"\r\n\r\n${fileName}\r\n`)) + // type field + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="type"\r\n\r\n${mime}\r\n`)) + // qqfile field + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="qqfile"; filename="${fileName}"\r\nContent-Type: ${mime}\r\n\r\n`)) + parts.push(fileData) + parts.push(Buffer.from(`\r\n--${boundary}--\r\n`)) + + const body = Buffer.concat(parts) + + return new Promise<{ success: boolean; message?: string }>((resolve) => { + const req = net.request({ + method: 'POST', + url: `https://www.overleaf.com/project/${projectId}/upload?folder_id=${folderId}` + }) + req.setHeader('Cookie', overleafSessionCookie) + req.setHeader('Content-Type', `multipart/form-data; boundary=${boundary}`) + req.setHeader('User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36') + req.setHeader('Accept', 'application/json') + req.setHeader('Referer', `https://www.overleaf.com/project/${projectId}`) + req.setHeader('Origin', 'https://www.overleaf.com') + if (overleafCsrfToken) req.setHeader('x-csrf-token', overleafCsrfToken) + + let resBody = '' + req.on('response', (res) => { + res.on('data', (chunk: Buffer) => { resBody += chunk.toString() }) + res.on('end', () => { + console.log('[upload] status:', res.statusCode, 'body:', resBody.slice(0, 300)) + try { + const data = JSON.parse(resBody) + if (data.success !== false && !data.error) { + resolve({ success: true }) + } else { + resolve({ success: false, message: data.error || 'Upload failed' }) + } + } catch { + resolve({ success: false, message: `HTTP ${res.statusCode}: ${resBody.slice(0, 200)}` }) + } + }) + }) + req.on('error', (e) => resolve({ success: false, message: String(e) })) + req.write(body) + req.end() + }) + } catch (e) { + return { success: false, message: String(e) } + } +}) + // Fetch comment ranges from ALL docs (for ReviewPanel) ipcMain.handle('ot:fetchAllCommentContexts', async () => { if (!overleafSock?.projectData) return { success: false } diff --git a/src/preload/index.ts b/src/preload/index.ts index d36c9e7..1bf97b3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 Yuren Hao // Licensed under AGPL-3.0 - see LICENSE file -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer, webUtils } from 'electron' import { createHash } from 'crypto' const api = { @@ -136,6 +136,9 @@ const api = { ipcRenderer.invoke('overleaf:createDoc', projectId, parentFolderId, name) as Promise<{ success: boolean; data?: unknown; message?: string }>, overleafCreateFolder: (projectId: string, parentFolderId: string, name: string) => ipcRenderer.invoke('overleaf:createFolder', projectId, parentFolderId, name) as Promise<{ success: boolean; data?: unknown; message?: string }>, + uploadFileToProject: (projectId: string, folderId: string, filePath: string, fileName: string) => + ipcRenderer.invoke('project:uploadFile', projectId, folderId, filePath, fileName) as Promise<{ success: boolean; message?: string }>, + getPathForFile: (file: File) => webUtils.getPathForFile(file), sha1: (text: string): string => createHash('sha1').update(text).digest('hex'), // File sync bridge diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index 63dc8bf..c6d8126 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -303,6 +303,11 @@ html, body, #root { background: var(--bg-secondary); user-select: none; } +.file-tree-dragover { + outline: 2px dashed #4ECDA0; + outline-offset: -2px; + background: rgba(78, 205, 160, 0.08); +} .file-tree-header { display: flex; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index df9e251..869bdae 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -53,6 +53,17 @@ export default function App() { const [checkingSession, setCheckingSession] = useState(true) + // Prevent Electron from navigating to dropped files + useEffect(() => { + const prevent = (e: DragEvent) => e.preventDefault() + document.addEventListener('dragover', prevent) + document.addEventListener('drop', prevent) + return () => { + document.removeEventListener('dragover', prevent) + document.removeEventListener('drop', prevent) + } + }, []) + // Check session on startup useEffect(() => { window.api.overleafHasWebSession().then(({ loggedIn }) => { diff --git a/src/renderer/src/components/FileTree.tsx b/src/renderer/src/components/FileTree.tsx index 0dda560..a95b0c3 100644 --- a/src/renderer/src/components/FileTree.tsx +++ b/src/renderer/src/components/FileTree.tsx @@ -263,6 +263,51 @@ export default function FileTree() { closeMenu() } + const [dragOver, setDragOver] = useState(false) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragOver(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragOver(false) + }, []) + + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragOver(false) + + const projectId = useAppStore.getState().overleafProjectId + const folderId = useAppStore.getState().rootFolderId + if (!projectId || !folderId) return + + const droppedFiles = e.dataTransfer.files + if (droppedFiles.length === 0) return + + for (let i = 0; i < droppedFiles.length; i++) { + const file = droppedFiles[i] + const filePath = window.api.getPathForFile(file) + const fileName = file.name + + useAppStore.getState().setStatusMessage(`Uploading ${fileName}...`) + const result = await window.api.uploadFileToProject(projectId, folderId, filePath, fileName) + if (result.success) { + useAppStore.getState().setStatusMessage(`Uploaded ${fileName}`) + } else { + useAppStore.getState().setStatusMessage(`Upload failed: ${result.message}`) + return + } + } + + // Refresh file tree + await reconnectProject(projectId) + }, []) + const handleOpenInOverleaf = () => { const projectId = useAppStore.getState().overleafProjectId if (projectId) { @@ -272,7 +317,12 @@ export default function FileTree() { } return ( - <div className="file-tree"> + <div + className={`file-tree${dragOver ? ' file-tree-dragover' : ''}`} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > <div className="file-tree-header"> <span>FILES</span> </div> diff --git a/src/renderer/src/components/ProjectList.tsx b/src/renderer/src/components/ProjectList.tsx index a3c9c37..58a6bb0 100644 --- a/src/renderer/src/components/ProjectList.tsx +++ b/src/renderer/src/components/ProjectList.tsx @@ -30,6 +30,8 @@ export default function ProjectList({ onOpenProject }: Props) { const [busyText, setBusyText] = useState('') const [sortBy, setSortBy] = useState<SortKey>('lastUpdated') const [sortOrder, setSortOrder] = useState<SortOrder>('desc') + const [showNewProject, setShowNewProject] = useState(false) + const [newProjectName, setNewProjectName] = useState('Untitled Project') const { setStatusMessage } = useAppStore() const loadProjects = useCallback(async () => { @@ -76,18 +78,20 @@ export default function ProjectList({ onOpenProject }: Props) { } const handleCreateProject = async () => { - const name = prompt('Project name:', 'Untitled Project') - if (!name?.trim()) return + const name = newProjectName.trim() + if (!name) return + setShowNewProject(false) setError('') setBusy(true) setBusyText('Creating project...') - const result = await window.api.overleafCreateProject(name.trim()) + const result = await window.api.overleafCreateProject(name) setBusy(false) if (result.success && result.projectId) { - setStatusMessage(`Created "${name.trim()}"`) + setStatusMessage(`Created "${name}"`) + setNewProjectName('Untitled Project') loadProjects() } else { setError(result.message || 'Failed to create project') @@ -225,7 +229,7 @@ export default function ProjectList({ onOpenProject }: Props) { placeholder="Search projects..." autoFocus /> - <button className="btn btn-primary btn-sm" onClick={handleCreateProject}> + <button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(true)}> New Project </button> <button className="btn btn-secondary btn-sm" onClick={handleUploadProject}> @@ -284,6 +288,29 @@ export default function ProjectList({ onOpenProject }: Props) { </> )} </div> + {showNewProject && ( + <div className="modal-overlay" onClick={() => setShowNewProject(false)}> + <div className="modal-box" onClick={(e) => e.stopPropagation()}> + <h3 style={{ margin: '0 0 12px' }}>New Project</h3> + <input + type="text" + className="projects-search" + value={newProjectName} + onChange={(e) => setNewProjectName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateProject() + if (e.key === 'Escape') setShowNewProject(false) + }} + autoFocus + style={{ width: '100%', marginBottom: 12 }} + /> + <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}> + <button className="btn btn-secondary btn-sm" onClick={() => setShowNewProject(false)}>Cancel</button> + <button className="btn btn-primary btn-sm" onClick={handleCreateProject}>Create</button> + </div> + </div> + </div> + )} </div> ) } |
