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
Some checks failed
Automated Container Build / build-and-push (push) Failing after 1m13s
This commit is contained in:
parent
e738a2cb72
commit
51850e75a2
5 changed files with 295 additions and 30 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue