Feat: Live thumbnails for office files
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m26s

This commit is contained in:
Elijah 2026-05-25 14:43:04 -07:00
parent b80abdbefb
commit 80d60d540a
10 changed files with 244 additions and 106 deletions

View file

@ -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]`}>

View file

@ -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]" />

View file

@ -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',

View 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}
</>
);
}