All checks were successful
Automated Container Build / build-and-push (push) Successful in 38s
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { Plus, Pencil, Trash2, Pin, FileText, LayoutTemplate, FileSpreadsheet } from 'lucide-react';
|
|
import api from '@/lib/api';
|
|
import ThumbnailImage from './ThumbnailImage';
|
|
|
|
interface FileItem {
|
|
name: string;
|
|
path: string;
|
|
is_dir: boolean;
|
|
size: number;
|
|
mime_type: string;
|
|
is_pinned: boolean;
|
|
is_trashed: boolean;
|
|
mod_time: string;
|
|
checksum?: string;
|
|
}
|
|
|
|
interface OfficeHomeProps {
|
|
type: 'docs' | 'slides' | 'sheets';
|
|
onFileSelect: (file: FileItem) => void;
|
|
onCreateBlank: (file: FileItem) => void;
|
|
onPinChange?: () => void;
|
|
}
|
|
|
|
// Custom Word icon (blue)
|
|
function WordIcon({ className }: { className?: string }) {
|
|
return (
|
|
<div className={`flex items-center justify-center text-white bg-[#2B579A] rounded-lg ${className || ''}`}>
|
|
<FileText className="w-[55%] h-[55%]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Custom PowerPoint icon (orange)
|
|
function PptIcon({ className }: { className?: string }) {
|
|
return (
|
|
<div className={`flex items-center justify-center text-white bg-[#D24726] rounded-lg ${className || ''}`}>
|
|
<LayoutTemplate className="w-[55%] h-[55%]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Custom Excel icon (green)
|
|
function SheetIcon({ className }: { className?: string }) {
|
|
return (
|
|
<div className={`flex items-center justify-center text-white bg-[#107C41] rounded-lg ${className || ''}`}>
|
|
<FileSpreadsheet className="w-[55%] h-[55%]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ContextMenuState {
|
|
x: number;
|
|
y: number;
|
|
file: FileItem;
|
|
}
|
|
|
|
export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinChange }: OfficeHomeProps) {
|
|
const [recentFiles, setRecentFiles] = useState<FileItem[]>([]);
|
|
const [pinnedFiles, setPinnedFiles] = 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);
|
|
const [downloadToken, setDownloadToken] = useState('');
|
|
|
|
const ext = type === 'docs' ? '.docx' : type === 'slides' ? '.pptx' : '.xlsx';
|
|
const fileTypeParam = type === 'docs' ? 'docx' : type === 'slides' ? 'pptx' : 'xlsx';
|
|
|
|
const fetchFiles = () => {
|
|
// Fetch recent files from root directory
|
|
api.listDirectory('.')
|
|
.then(data => {
|
|
const files: FileItem[] = data.files || [];
|
|
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())
|
|
.slice(0, 12);
|
|
setRecentFiles(filtered);
|
|
setLoading(false);
|
|
})
|
|
.catch(err => {
|
|
console.error(err);
|
|
setLoading(false);
|
|
});
|
|
|
|
// Fetch pinned files
|
|
api.listPinned()
|
|
.then(data => {
|
|
const files: FileItem[] = data.items || [];
|
|
const filtered = files
|
|
.filter(f => f.name.endsWith(ext) && !f.is_trashed)
|
|
.sort((a, b) => new Date(b.mod_time).getTime() - new Date(a.mod_time).getTime());
|
|
setPinnedFiles(filtered);
|
|
})
|
|
.catch(console.error);
|
|
|
|
// 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();
|
|
api.createDownloadToken().then(setDownloadToken).catch(console.error);
|
|
|
|
// Refresh files periodically to catch any debounced thumbnail updates from recent edits
|
|
const timer = setInterval(() => {
|
|
fetchFiles();
|
|
}, 15000);
|
|
return () => clearInterval(timer);
|
|
}, [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' : type === 'slides' ? 'slide' : 'cell');
|
|
const { path, name } = res;
|
|
|
|
const newFile: FileItem = {
|
|
name,
|
|
path,
|
|
is_dir: false,
|
|
size: 0,
|
|
mime_type: type === 'docs' ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' :
|
|
type === 'slides' ? 'application/vnd.openxmlformats-officedocument.presentationml.presentation' :
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
is_pinned: false,
|
|
is_trashed: false,
|
|
mod_time: new Date().toISOString()
|
|
};
|
|
|
|
onCreateBlank(newFile);
|
|
} catch (err) {
|
|
console.error("Failed to create document", err);
|
|
}
|
|
};
|
|
|
|
const handleContextMenu = (e: React.MouseEvent, file: FileItem) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setContextMenu({ x: e.clientX, y: e.clientY, file });
|
|
};
|
|
|
|
const handlePin = async (file: FileItem) => {
|
|
try {
|
|
await api.togglePin(file.path);
|
|
fetchFiles();
|
|
if (onPinChange) onPinChange();
|
|
} catch (err) {
|
|
console.error("Failed to pin", err);
|
|
}
|
|
};
|
|
|
|
const handleRename = async () => {
|
|
let trimmed = renameValue.trim();
|
|
if (!renaming || !trimmed) {
|
|
setRenaming(null);
|
|
return;
|
|
}
|
|
|
|
// Determine the current file's extension to append
|
|
const currentFile = recentFiles.find(f => f.path === renaming) || allFiles.find(f => f.path === renaming);
|
|
if (currentFile) {
|
|
const extMatch = currentFile.name.match(/\.[^.]+$/);
|
|
const currentExt = extMatch ? extMatch[0] : '';
|
|
if (!trimmed.toLowerCase().endsWith(currentExt.toLowerCase())) {
|
|
trimmed += currentExt;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await api.rename(renaming, trimmed);
|
|
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' : type === 'slides' ? 'Presentations' : 'Spreadsheets';
|
|
const Icon = type === 'docs' ? WordIcon : type === 'slides' ? PptIcon : SheetIcon;
|
|
const accentColor = type === 'docs' ? '#2B579A' : type === 'slides' ? '#D24726' : '#107C41';
|
|
|
|
const renderFileCard = (file: FileItem) => {
|
|
const isRenaming = renaming === file.path;
|
|
const extMatch = file.name.match(/\.[^.]+$/);
|
|
const baseName = extMatch ? file.name.slice(0, -extMatch[0].length) : file.name;
|
|
|
|
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 ${type === 'docs' ? 'aspect-[1/1.4]' : 'aspect-[1.4/1]'} rounded-lg border hover:border-blue-500 transition-colors flex items-center justify-center shadow-sm overflow-hidden relative`}
|
|
style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}
|
|
>
|
|
{file.checksum ? (
|
|
<div className="absolute inset-0 w-full h-full flex items-center justify-center z-0">
|
|
<ThumbnailImage checksum={file.checksum} fallbackIcon={<Icon className="w-16 h-16 text-3xl opacity-30 group-hover:opacity-60 transition-opacity" />} downloadToken={downloadToken} isDocument={true} />
|
|
</div>
|
|
) : (
|
|
<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}
|
|
>
|
|
{baseName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
|
{/* 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 {type === 'docs' ? 'document' : type === 'slides' ? 'presentation' : 'spreadsheet'}</h2>
|
|
<div className="flex gap-4">
|
|
<div className="flex flex-col gap-3">
|
|
<button
|
|
onClick={handleCreateBlank}
|
|
className={`${type === 'docs' ? 'w-40 h-52' : 'w-52 h-40'} rounded-lg border hover:border-blue-500 transition-colors flex items-center justify-center shadow-sm group`}
|
|
style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}
|
|
>
|
|
<Plus className="w-12 h-12 text-gray-300 group-hover:text-blue-500 transition-colors" />
|
|
</button>
|
|
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>Blank</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{/* Recent Documents */}
|
|
<div className="py-8 px-10">
|
|
<div className="max-w-6xl mx-auto">
|
|
<h2 className="text-lg font-medium mb-6" style={{ color: 'var(--color-text-primary)' }}>Recent {title.toLowerCase()}</h2>
|
|
|
|
{loading ? (
|
|
<div className="text-sm text-gray-500">Loading...</div>
|
|
) : recentFiles.length === 0 ? (
|
|
<div className="text-sm text-gray-500">No recent {title.toLowerCase()} found.</div>
|
|
) : (
|
|
<div className={`grid ${type === 'slides' ? 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5' : 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6'} gap-6`}>
|
|
{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 ${type === 'slides' ? 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5' : '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={() => {
|
|
handlePin(contextMenu.file);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<Pin className="w-4 h-4" style={{ color: 'var(--color-text-secondary)' }} />
|
|
{contextMenu.file.is_pinned || pinnedFiles.find(f => f.path === contextMenu.file.path) ? 'Unpin' : 'Pin'}
|
|
</button>
|
|
<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={() => {
|
|
const extMatch = contextMenu.file.name.match(/\.[^.]+$/);
|
|
const baseName = extMatch ? contextMenu.file.name.slice(0, -extMatch[0].length) : contextMenu.file.name;
|
|
setRenaming(contextMenu.file.path);
|
|
setRenameValue(baseName);
|
|
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>
|
|
);
|
|
}
|