summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main/index.ts77
-rw-r--r--src/preload/index.ts5
-rw-r--r--src/renderer/src/App.css5
-rw-r--r--src/renderer/src/App.tsx11
-rw-r--r--src/renderer/src/components/FileTree.tsx52
-rw-r--r--src/renderer/src/components/ProjectList.tsx37
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>
)
}