Add OfficeHome with All Documents, context menu, custom icons, editor rename, text wrapping
Some checks failed
Automated Container Build / build-and-push (push) Failing after 1m13s

This commit is contained in:
Elijah 2026-05-24 13:13:40 -07:00
parent e738a2cb72
commit 51850e75a2
5 changed files with 295 additions and 30 deletions

View file

@ -1167,6 +1167,22 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</div>
);
}
if (ext === 'docx' || ext === 'doc') {
return (
<div className={`${large ? 'w-20 h-20 rounded-2xl text-2xl' : 'w-5 h-5 rounded text-[6px] flex-shrink-0 aspect-square'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#2B579A]`}>
W
</div>
);
}
if (ext === 'pptx' || ext === 'ppt') {
return (
<div className={`${large ? 'w-20 h-20 rounded-2xl text-2xl' : 'w-5 h-5 rounded text-[6px] flex-shrink-0 aspect-square'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#D24726]`}>
P
</div>
);
}
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' ? (
<OfficeHome

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Plus, FileText, Presentation } from 'lucide-react';
import React, { useState, useEffect, useRef } from 'react';
import { Plus, Pencil, Trash2 } from 'lucide-react';
import api from '@/lib/api';
interface FileItem {
@ -19,17 +19,48 @@ interface OfficeHomeProps {
onCreateBlank: (file: FileItem) => void;
}
// Custom Word icon (blue)
function WordIcon({ className }: { className?: string }) {
return (
<div className={`flex items-center justify-center font-black tracking-tighter text-white bg-[#2B579A] rounded-lg ${className || ''}`}>
W
</div>
);
}
// Custom PowerPoint icon (orange)
function PptIcon({ className }: { className?: string }) {
return (
<div className={`flex items-center justify-center font-black tracking-tighter text-white bg-[#D24726] rounded-lg ${className || ''}`}>
P
</div>
);
}
interface ContextMenuState {
x: number;
y: number;
file: FileItem;
}
export default function OfficeHome({ type, onFileSelect, onCreateBlank }: OfficeHomeProps) {
const [recentFiles, setRecentFiles] = useState<FileItem[]>([]);
const [allFiles, setAllFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadingAll, setLoadingAll] = useState(true);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [renaming, setRenaming] = useState<string | null>(null); // path of file being renamed
const [renameValue, setRenameValue] = useState('');
const renameInputRef = useRef<HTMLInputElement>(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 (
<div key={file.path} className="flex flex-col gap-2 group">
<button
onClick={() => !isRenaming && onFileSelect(file)}
onContextMenu={(e) => handleContextMenu(e, file)}
className="w-full aspect-[1/1.4] bg-white rounded-lg border border-gray-200 group-hover:border-blue-400 transition-colors flex items-center justify-center shadow-sm overflow-hidden relative"
>
<Icon className="w-16 h-16 text-3xl opacity-30 group-hover:opacity-60 transition-opacity" />
</button>
<div className="flex items-start gap-2 min-w-0">
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5 text-[10px]" />
{isRenaming ? (
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => 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)' }}
/>
) : (
<span
className="text-sm font-medium leading-tight break-words"
style={{ color: 'var(--color-text-primary)', wordBreak: 'break-word' }}
title={file.name}
>
{file.name}
</span>
)}
</div>
</div>
);
};
return (
<div className="flex-1 overflow-y-auto" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
{/* Top Banner (Template Gallery placeholder) */}
{/* Top Banner */}
<div className="py-8 px-10" style={{ backgroundColor: 'var(--color-bg-secondary)' }}>
<div className="max-w-6xl mx-auto">
<h2 className="text-lg font-medium mb-4" style={{ color: 'var(--color-text-primary)' }}>Start a new document</h2>
<h2 className="text-lg font-medium mb-4" style={{ color: 'var(--color-text-primary)' }}>Start a new {type === 'docs' ? 'document' : 'presentation'}</h2>
<div className="flex gap-4">
{/* Blank Document */}
<div className="flex flex-col gap-3">
<button
onClick={handleCreateBlank}
@ -101,26 +246,65 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank }: Office
<div className="text-sm text-gray-500">No recent {title.toLowerCase()} found.</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
{recentFiles.map(file => (
<div key={file.path} className="flex flex-col gap-2 group">
<button
onClick={() => onFileSelect(file)}
className="w-full aspect-[1/1.4] bg-white rounded-lg border border-gray-200 group-hover:border-blue-500 transition-colors flex items-center justify-center shadow-sm overflow-hidden"
>
<Icon className={`w-16 h-16 ${colorClass} opacity-20 group-hover:opacity-100 transition-opacity`} />
</button>
<div className="flex items-center gap-2">
<Icon className={`w-4 h-4 flex-shrink-0 ${colorClass}`} />
<span className="text-sm font-medium truncate" style={{ color: 'var(--color-text-primary)' }} title={file.name}>
{file.name}
</span>
</div>
</div>
))}
{recentFiles.map(file => renderFileCard(file))}
</div>
)}
</div>
</div>
{/* All Documents */}
<div className="py-8 px-10 border-t" style={{ borderColor: 'var(--color-border)' }}>
<div className="max-w-6xl mx-auto">
<h2 className="text-lg font-medium mb-6" style={{ color: 'var(--color-text-primary)' }}>All {title.toLowerCase()}</h2>
{loadingAll ? (
<div className="text-sm text-gray-500">Loading...</div>
) : allFiles.length === 0 ? (
<div className="text-sm text-gray-500">No {title.toLowerCase()} found in your drive.</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
{allFiles.map(file => renderFileCard(file))}
</div>
)}
</div>
</div>
{/* Context Menu */}
{contextMenu && (
<div
className="fixed z-50 min-w-[160px] py-1 rounded-lg shadow-lg border"
style={{
top: contextMenu.y,
left: contextMenu.x,
backgroundColor: 'var(--color-bg-elevated, #fff)',
borderColor: 'var(--color-border, #e5e7eb)',
}}
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-black/5 transition-colors"
style={{ color: 'var(--color-text-primary)' }}
onClick={() => {
setRenaming(contextMenu.file.path);
setRenameValue(contextMenu.file.name);
setContextMenu(null);
}}
>
<Pencil className="w-4 h-4" />
Rename
</button>
<button
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-black/5 transition-colors text-red-500"
onClick={() => {
handleDelete(contextMenu.file);
setContextMenu(null);
}}
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
)}
</div>
);
}

View file

@ -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<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [config, setConfig] = useState<any>(null);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(file.name);
const renameInputRef = useRef<HTMLInputElement>(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 (
<div className="flex-1 w-full h-full flex flex-col" style={{ backgroundColor: '#fff' }}>
<div className="flex items-center justify-between px-4 py-2 bg-gray-100 border-b border-gray-200">
<span className="font-medium text-sm text-gray-700">{file.name}</span>
{isRenaming ? (
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => 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
/>
) : (
<button
onClick={() => {
setIsRenaming(true);
setRenameValue(file.name);
setTimeout(() => {
if (renameInputRef.current) {
renameInputRef.current.focus();
const dotIdx = file.name.lastIndexOf('.');
renameInputRef.current.setSelectionRange(0, dotIdx > 0 ? dotIdx : file.name.length);
}
}, 50);
}}
className="font-medium text-sm text-gray-700 hover:text-blue-600 hover:underline cursor-pointer transition-colors"
title="Click to rename"
>
{file.name}
</button>
)}
<button
onClick={onClose}
className="px-3 py-1 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded text-sm transition-colors"

View file

@ -485,6 +485,11 @@ class ApiClient {
});
return res.json();
}
async listOfficeFiles(type: 'docx' | 'pptx') {
const res = await this.request(`/api/onlyoffice/files?type=${type}`);
return res.json();
}
}
export const api = new ApiClient();