From 51850e75a26277fabf1b8fd5ceab24b47e19ef34 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 24 May 2026 13:13:40 -0700 Subject: [PATCH] Add OfficeHome with All Documents, context menu, custom icons, editor rename, text wrapping --- backend/main.go | 1 + frontend/src/components/FileExplorer.tsx | 19 ++ frontend/src/components/OfficeHome.tsx | 238 ++++++++++++++++--- frontend/src/components/OnlyOfficeEditor.tsx | 62 ++++- frontend/src/lib/api.ts | 5 + 5 files changed, 295 insertions(+), 30 deletions(-) diff --git a/backend/main.go b/backend/main.go index 61c2962..4948066 100644 --- a/backend/main.go +++ b/backend/main.go @@ -194,6 +194,7 @@ func main() { // OnlyOffice protected.Post("/onlyoffice/create", onlyOfficeHandler.CreateBlank) protected.Post("/onlyoffice/sign", onlyOfficeHandler.SignConfig) + protected.Get("/onlyoffice/files", onlyOfficeHandler.ListOfficeFiles) protected.Get("/files/folders-tree", fsHandler.GetFolderTree) diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx index 6681c74..70fdc26 100644 --- a/frontend/src/components/FileExplorer.tsx +++ b/frontend/src/components/FileExplorer.tsx @@ -1167,6 +1167,22 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { ); } + + if (ext === 'docx' || ext === 'doc') { + return ( +
+ W +
+ ); + } + + if (ext === 'pptx' || ext === 'ppt') { + return ( +
+ P +
+ ); + } const fallbackIcon = (() => { if (isImage) { @@ -1863,6 +1879,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { file={editingFile} type={editingFile.name.endsWith('.docx') ? 'word' : editingFile.name.endsWith('.pptx') ? 'slide' : 'word'} onClose={() => setEditingFile(null)} + onRename={(oldPath, newName, newPath) => { + setEditingFile(prev => prev ? { ...prev, name: newName, path: newPath } : null); + }} /> ) : (appMode === 'docs' || appMode === 'slides') && activeSection === 'files' ? ( void; } +// Custom Word icon (blue) +function WordIcon({ className }: { className?: string }) { + return ( +
+ W +
+ ); +} + +// Custom PowerPoint icon (orange) +function PptIcon({ className }: { className?: string }) { + return ( +
+ P +
+ ); +} + +interface ContextMenuState { + x: number; + y: number; + file: FileItem; +} + export default function OfficeHome({ type, onFileSelect, onCreateBlank }: OfficeHomeProps) { const [recentFiles, setRecentFiles] = useState([]); + const [allFiles, setAllFiles] = useState([]); const [loading, setLoading] = useState(true); + const [loadingAll, setLoadingAll] = useState(true); + const [contextMenu, setContextMenu] = useState(null); + const [renaming, setRenaming] = useState(null); // path of file being renamed + const [renameValue, setRenameValue] = useState(''); + const renameInputRef = useRef(null); - useEffect(() => { - // Fetch all files from root directory (as a simple way to find recent files of this type) - // Or we could use a search endpoint. For now we will fetch the root directory and filter. + const ext = type === 'docs' ? '.docx' : '.pptx'; + const fileTypeParam = type === 'docs' ? 'docx' : 'pptx'; + + const fetchFiles = () => { + // Fetch recent files from root directory api.listDirectory('.') .then(data => { const files: FileItem[] = data.files || []; - const ext = type === 'docs' ? '.docx' : '.pptx'; const filtered = files .filter(f => !f.is_dir && f.name.endsWith(ext) && !f.is_trashed) .sort((a, b) => new Date(b.mod_time).getTime() - new Date(a.mod_time).getTime()) @@ -41,8 +72,51 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank }: Office console.error(err); setLoading(false); }); + + // Fetch ALL office files recursively + api.listOfficeFiles(fileTypeParam) + .then(data => { + const files = (data.files || []).map((f: any) => ({ + ...f, + is_dir: false, + mime_type: '', + is_pinned: false, + is_trashed: false, + })); + // Sort by mod_time descending + files.sort((a: FileItem, b: FileItem) => new Date(b.mod_time).getTime() - new Date(a.mod_time).getTime()); + setAllFiles(files); + setLoadingAll(false); + }) + .catch(err => { + console.error(err); + setLoadingAll(false); + }); + }; + + useEffect(() => { + fetchFiles(); }, [type]); + // Close context menu on click elsewhere + useEffect(() => { + const handleClick = () => setContextMenu(null); + if (contextMenu) { + window.addEventListener('click', handleClick); + return () => window.removeEventListener('click', handleClick); + } + }, [contextMenu]); + + // Focus rename input + useEffect(() => { + if (renaming && renameInputRef.current) { + renameInputRef.current.focus(); + // Select the name without extension + const dotIdx = renameValue.lastIndexOf('.'); + renameInputRef.current.setSelectionRange(0, dotIdx > 0 ? dotIdx : renameValue.length); + } + }, [renaming]); + const handleCreateBlank = async () => { try { const res = await api.createOnlyOfficeFile(type === 'docs' ? 'word' : 'slide'); @@ -65,18 +139,89 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank }: Office } }; + const handleContextMenu = (e: React.MouseEvent, file: FileItem) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, file }); + }; + + const handleRename = async () => { + if (!renaming || !renameValue.trim()) { + setRenaming(null); + return; + } + + try { + await api.rename(renaming, renameValue); + setRenaming(null); + fetchFiles(); // Refresh + } catch (err) { + console.error("Failed to rename", err); + setRenaming(null); + } + }; + + const handleDelete = async (file: FileItem) => { + try { + await api.deleteFile(file.path); + fetchFiles(); // Refresh + } catch (err) { + console.error("Failed to delete", err); + } + }; + const title = type === 'docs' ? 'Documents' : 'Presentations'; - const Icon = type === 'docs' ? FileText : Presentation; - const colorClass = type === 'docs' ? 'text-blue-500' : 'text-amber-500'; + const Icon = type === 'docs' ? WordIcon : PptIcon; + const accentColor = type === 'docs' ? '#2B579A' : '#D24726'; + + const renderFileCard = (file: FileItem) => { + const isRenaming = renaming === file.path; + + return ( +
+ +
+ + {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={handleRename} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRename(); + if (e.key === 'Escape') setRenaming(null); + }} + className="text-sm font-medium w-full bg-transparent border-b border-blue-500 outline-none" + style={{ color: 'var(--color-text-primary)' }} + /> + ) : ( + + {file.name} + + )} +
+
+ ); + }; return (
- {/* Top Banner (Template Gallery placeholder) */} + {/* Top Banner */}
-

Start a new document

+

Start a new {type === 'docs' ? 'document' : 'presentation'}

- {/* Blank Document */}
) : (
- {recentFiles.map(file => ( -
- -
- - - {file.name} - -
-
- ))} + {recentFiles.map(file => renderFileCard(file))}
)}
+ + {/* All Documents */} +
+
+

All {title.toLowerCase()}

+ + {loadingAll ? ( +
Loading...
+ ) : allFiles.length === 0 ? ( +
No {title.toLowerCase()} found in your drive.
+ ) : ( +
+ {allFiles.map(file => renderFileCard(file))} +
+ )} +
+
+ + {/* Context Menu */} + {contextMenu && ( +
e.stopPropagation()} + > + + +
+ )}
); } diff --git a/frontend/src/components/OnlyOfficeEditor.tsx b/frontend/src/components/OnlyOfficeEditor.tsx index 0493833..1bae143 100644 --- a/frontend/src/components/OnlyOfficeEditor.tsx +++ b/frontend/src/components/OnlyOfficeEditor.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { DocumentEditor } from '@onlyoffice/document-editor-react'; import api from '@/lib/api'; @@ -17,12 +17,37 @@ interface OnlyOfficeEditorProps { file: FileItem; type: 'word' | 'cell' | 'slide'; onClose: () => void; + onRename?: (oldPath: string, newName: string, newPath: string) => void; } -export default function OnlyOfficeEditor({ file, type, onClose }: OnlyOfficeEditorProps) { +export default function OnlyOfficeEditor({ file, type, onClose, onRename }: OnlyOfficeEditorProps) { const [downloadToken, setDownloadToken] = useState(null); const [loadError, setLoadError] = useState(null); const [config, setConfig] = useState(null); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(file.name); + const renameInputRef = useRef(null); + + const handleRename = async () => { + const trimmed = renameValue.trim(); + if (!trimmed || trimmed === file.name) { + setRenameValue(file.name); + setIsRenaming(false); + return; + } + try { + const res = await api.rename(file.path, trimmed); + // Build new path from the response or derive it + const dir = file.path.includes('/') ? file.path.substring(0, file.path.lastIndexOf('/') + 1) : ''; + const newPath = dir + trimmed; + onRename?.(file.path, trimmed, newPath); + setIsRenaming(false); + } catch (err) { + console.error('Failed to rename', err); + setRenameValue(file.name); + setIsRenaming(false); + } + }; useEffect(() => { // We need a short-lived download token to pass to the Document Server @@ -106,7 +131,38 @@ export default function OnlyOfficeEditor({ file, type, onClose }: OnlyOfficeEdit return (
- {file.name} + {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={handleRename} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRename(); + if (e.key === 'Escape') { setRenameValue(file.name); setIsRenaming(false); } + }} + className="font-medium text-sm text-gray-700 bg-white border border-blue-400 rounded px-2 py-0.5 outline-none min-w-[200px]" + autoFocus + /> + ) : ( + + )}