Feat: Live thumbnails for office files
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m26s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m26s
This commit is contained in:
parent
b80abdbefb
commit
80d60d540a
10 changed files with 244 additions and 106 deletions
|
|
@ -28,6 +28,7 @@ import FileInfoModal from './FileInfoModal';
|
|||
import SharedFilesPage from './SharedFilesPage';
|
||||
import OfficeHome from './OfficeHome';
|
||||
import OnlyOfficeEditor from './OnlyOfficeEditor';
|
||||
import ThumbnailImage from './ThumbnailImage';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
|
|
@ -172,40 +173,6 @@ function Tooltip({ children, text }: { children: React.ReactNode; text: string }
|
|||
);
|
||||
}
|
||||
|
||||
function ThumbnailImage({ checksum, fallbackIcon, downloadToken }: { checksum: string; fallbackIcon: React.ReactNode; downloadToken: string }) {
|
||||
const [errorCount, setErrorCount] = useState(0);
|
||||
const [key, setKey] = useState(0);
|
||||
const [showFallback, setShowFallback] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
key={key}
|
||||
src={downloadToken ? `${api.getThumbnailUrl(checksum, downloadToken)}${errorCount > 0 ? `&t=${key}` : ''}` : ''}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
style={{ display: showFallback ? 'none' : 'block' }}
|
||||
onError={(e) => {
|
||||
if (errorCount < 5) {
|
||||
e.currentTarget.style.display = 'none';
|
||||
setTimeout(() => {
|
||||
setErrorCount(c => c + 1);
|
||||
setKey(Date.now());
|
||||
}, 2000);
|
||||
} else {
|
||||
setShowFallback(true);
|
||||
}
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
e.currentTarget.style.display = 'block';
|
||||
setShowFallback(false);
|
||||
}}
|
||||
/>
|
||||
{showFallback && fallbackIcon}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
|
@ -1159,7 +1126,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) || file.mime_type?.startsWith('image/');
|
||||
const isVideo = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('video/');
|
||||
const isMedia = isImage || isVideo;
|
||||
const isOffice = ['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls'].includes(ext) || file.mime_type?.includes('officedocument');
|
||||
const isMedia = isImage || isVideo || isOffice;
|
||||
|
||||
if (ext === 'pdf') {
|
||||
return (
|
||||
|
|
@ -1169,31 +1137,31 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
);
|
||||
}
|
||||
|
||||
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]`}>
|
||||
<FileText className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ext === 'pptx' || ext === 'ppt') {
|
||||
return (
|
||||
<div className={`${large ? 'w-28 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]`}>
|
||||
<LayoutTemplate className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ext === 'xlsx' || ext === 'xls') {
|
||||
return (
|
||||
<div className={`${large ? 'w-28 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-[#107C41]`}>
|
||||
<FileSpreadsheet className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackIcon = (() => {
|
||||
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]`}>
|
||||
<FileText className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ext === 'pptx' || ext === 'ppt') {
|
||||
return (
|
||||
<div className={`${large ? 'w-28 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]`}>
|
||||
<LayoutTemplate className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ext === 'xlsx' || ext === 'xls') {
|
||||
return (
|
||||
<div className={`${large ? 'w-28 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-[#107C41]`}>
|
||||
<FileSpreadsheet className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded flex-shrink-0 aspect-square'} flex items-center justify-center shadow-sm text-white bg-[#3b82f6]`}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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;
|
||||
|
|
@ -11,6 +12,7 @@ interface FileItem {
|
|||
is_pinned: boolean;
|
||||
is_trashed: boolean;
|
||||
mod_time: string;
|
||||
checksum?: string;
|
||||
}
|
||||
|
||||
interface OfficeHomeProps {
|
||||
|
|
@ -63,6 +65,7 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinCha
|
|||
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';
|
||||
|
|
@ -118,6 +121,7 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinCha
|
|||
|
||||
useEffect(() => {
|
||||
fetchFiles();
|
||||
api.createDownloadToken().then(setDownloadToken).catch(console.error);
|
||||
}, [type]);
|
||||
|
||||
// Close context menu on click elsewhere
|
||||
|
|
@ -232,7 +236,13 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinCha
|
|||
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)' }}
|
||||
>
|
||||
<Icon className="w-16 h-16 text-3xl opacity-30 group-hover:opacity-60 transition-opacity" />
|
||||
{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} />
|
||||
</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]" />
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import api from '@/lib/api';
|
|||
interface SettingsData {
|
||||
theme: string;
|
||||
grid_size: string;
|
||||
thumbnail_images: string;
|
||||
thumbnail_videos: string;
|
||||
trash_auto_purge_days: string;
|
||||
}
|
||||
|
||||
|
|
@ -55,8 +53,6 @@ export default function SettingsPage({ onLogout }: SettingsPageProps) {
|
|||
const loaded = {
|
||||
theme: s.theme || 'dark',
|
||||
grid_size: s.grid_size || '200',
|
||||
thumbnail_images: s.thumbnail_images || 'true',
|
||||
thumbnail_videos: s.thumbnail_videos || 'true',
|
||||
trash_auto_purge_days: s.trash_auto_purge_days || '14',
|
||||
};
|
||||
setSettings(loaded);
|
||||
|
|
@ -200,35 +196,7 @@ export default function SettingsPage({ onLogout }: SettingsPageProps) {
|
|||
</form>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'thumbnails',
|
||||
title: 'Media & Thumbnails',
|
||||
icon: <ImageIcon className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.thumbnail_images === 'true'}
|
||||
onChange={(e) => setSettings({ ...settings, thumbnail_images: e.target.checked ? 'true' : 'false' })}
|
||||
className="w-4 h-4 rounded text-accent focus:ring-accent"
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>Generate thumbnails for images</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.thumbnail_videos === 'true'}
|
||||
onChange={(e) => setSettings({ ...settings, thumbnail_videos: e.target.checked ? 'true' : 'false' })}
|
||||
className="w-4 h-4 rounded text-accent focus:ring-accent"
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>Generate thumbnails for videos (requires FFmpeg)</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
id: 'trash',
|
||||
|
|
|
|||
42
frontend/src/components/ThumbnailImage.tsx
Normal file
42
frontend/src/components/ThumbnailImage.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { useState } from 'react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface ThumbnailImageProps {
|
||||
checksum: string;
|
||||
fallbackIcon: React.ReactNode;
|
||||
downloadToken: string;
|
||||
}
|
||||
|
||||
export default function ThumbnailImage({ checksum, fallbackIcon, downloadToken }: ThumbnailImageProps) {
|
||||
const [errorCount, setErrorCount] = useState(0);
|
||||
const [key, setKey] = useState(0);
|
||||
const [showFallback, setShowFallback] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
key={key}
|
||||
src={downloadToken ? `${api.getThumbnailUrl(checksum, downloadToken)}${errorCount > 0 ? `&t=${key}` : ''}` : ''}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
style={{ display: showFallback ? 'none' : 'block' }}
|
||||
onError={(e) => {
|
||||
if (errorCount < 5) {
|
||||
e.currentTarget.style.display = 'none';
|
||||
setTimeout(() => {
|
||||
setErrorCount(c => c + 1);
|
||||
setKey(Date.now());
|
||||
}, 2000);
|
||||
} else {
|
||||
setShowFallback(true);
|
||||
}
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
e.currentTarget.style.display = 'block';
|
||||
setShowFallback(false);
|
||||
}}
|
||||
/>
|
||||
{showFallback && fallbackIcon}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in a new issue