mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(web): show modality thumbnails for non-image files in the strip (#245)
The multi-file thumbnail strip rendered an <img> with the file blob URL for every entry, so audio, video, and document files showed a broken image icon with the filename as alt text instead of a preview. Render an <img> only when there is a real image to show (a processed preview, a processed image output, or an image-modality original). Otherwise show a modality icon (waveform / film / document) plus the file's format label, using the entry's existing previewKind.
This commit is contained in:
@@ -1,17 +1,55 @@
|
|||||||
import { CheckCircle2, Loader2, XCircle } from "lucide-react";
|
import {
|
||||||
|
AudioLines,
|
||||||
|
CheckCircle2,
|
||||||
|
File as FileIcon,
|
||||||
|
FileText,
|
||||||
|
Film,
|
||||||
|
Loader2,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { FileEntry } from "@/stores/file-store";
|
import type { FileEntry, PreviewKind } from "@/stores/file-store";
|
||||||
|
|
||||||
const BROWSER_IMG_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"]);
|
const BROWSER_IMG_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"]);
|
||||||
|
|
||||||
function thumbnailSrc(entry: FileEntry): string {
|
/**
|
||||||
|
* A renderable <img> source for the thumbnail, or null when the entry has no
|
||||||
|
* image to show (e.g. audio/video/document originals). Processed previews and
|
||||||
|
* processed image outputs are always real images, so they win when present.
|
||||||
|
*/
|
||||||
|
function thumbnailImageSrc(entry: FileEntry): string | null {
|
||||||
if (entry.processedPreviewUrl) return entry.processedPreviewUrl;
|
if (entry.processedPreviewUrl) return entry.processedPreviewUrl;
|
||||||
if (entry.processedUrl) {
|
if (entry.processedUrl) {
|
||||||
if (entry.processedUrl.startsWith("blob:")) return entry.processedUrl;
|
if (entry.processedUrl.startsWith("blob:")) return entry.processedUrl;
|
||||||
const ext = decodeURIComponent(entry.processedUrl).split(".").pop()?.toLowerCase() ?? "";
|
const ext = decodeURIComponent(entry.processedUrl).split(".").pop()?.toLowerCase() ?? "";
|
||||||
if (BROWSER_IMG_EXTS.has(ext)) return entry.processedUrl;
|
if (BROWSER_IMG_EXTS.has(ext)) return entry.processedUrl;
|
||||||
}
|
}
|
||||||
return entry.blobUrl;
|
// The original blob only renders as an image for image-modality files;
|
||||||
|
// pointing an <img> at an audio/video/pdf blob just shows a broken icon.
|
||||||
|
if (entry.previewKind === "image") return entry.blobUrl;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDER_ICON: Record<Exclude<PreviewKind, "image">, typeof FileIcon> = {
|
||||||
|
audio: AudioLines,
|
||||||
|
video: Film,
|
||||||
|
document: FileText,
|
||||||
|
none: FileIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Icon + format label shown when a file has no image thumbnail. */
|
||||||
|
function ThumbnailPlaceholder({ entry }: { entry: FileEntry }) {
|
||||||
|
const kind = entry.previewKind === "image" ? "none" : entry.previewKind;
|
||||||
|
const Icon = PLACEHOLDER_ICON[kind];
|
||||||
|
const ext = (entry.file.name.split(".").pop() ?? "").toUpperCase().slice(0, 4);
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center gap-0.5 bg-muted">
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{ext && (
|
||||||
|
<span className="text-[8px] font-semibold leading-none text-muted-foreground">{ext}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThumbnailStripProps {
|
interface ThumbnailStripProps {
|
||||||
@@ -42,6 +80,7 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
|
|||||||
const isSelected = i === selectedIndex;
|
const isSelected = i === selectedIndex;
|
||||||
const isCompleted = entry.status === "completed";
|
const isCompleted = entry.status === "completed";
|
||||||
const isFailed = entry.status === "failed";
|
const isFailed = entry.status === "failed";
|
||||||
|
const imgSrc = thumbnailImageSrc(entry);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={entry.file.name}
|
key={entry.file.name}
|
||||||
@@ -60,13 +99,15 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
|
|||||||
<div className="w-full h-full flex items-center justify-center bg-muted">
|
<div className="w-full h-full flex items-center justify-center bg-muted">
|
||||||
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : imgSrc ? (
|
||||||
<img
|
<img
|
||||||
src={thumbnailSrc(entry)}
|
src={imgSrc}
|
||||||
alt={entry.file.name}
|
alt={entry.file.name}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<ThumbnailPlaceholder entry={entry} />
|
||||||
)}
|
)}
|
||||||
{isCompleted && (
|
{isCompleted && (
|
||||||
<div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full flex items-center justify-center">
|
<div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
|
|||||||
Reference in New Issue
Block a user