Improve media indexing and processing flow
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { useMemo } from "react";
|
||||
import { useGalleryStore } from "../store";
|
||||
|
||||
function ProgressBar({ value }: { value: number }) {
|
||||
return (
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-400 transition-all duration-300"
|
||||
style={{ width: `${Math.max(0, Math.min(100, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundTasks() {
|
||||
const folders = useGalleryStore((state) => state.folders);
|
||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
return folders
|
||||
.map((folder) => {
|
||||
const index = indexingProgress[folder.id];
|
||||
const jobs = mediaJobProgress[folder.id];
|
||||
const pendingMediaWork = (jobs?.thumbnail_pending ?? 0) + (jobs?.metadata_pending ?? 0);
|
||||
|
||||
if (!index && pendingMediaWork === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const indexPercent = index && index.total > 0 ? (index.indexed / index.total) * 100 : 0;
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
index,
|
||||
jobs,
|
||||
pendingMediaWork,
|
||||
indexPercent,
|
||||
};
|
||||
})
|
||||
.filter((task) => task !== null);
|
||||
}, [folders, indexingProgress, mediaJobProgress]);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-white/5 bg-gray-950/40 px-5 py-2 backdrop-blur-xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Background Tasks</h3>
|
||||
<span className="text-xs text-gray-500">{tasks.length} active</span>
|
||||
</div>
|
||||
<div className="grid gap-2 xl:grid-cols-2">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="rounded-xl border border-white/8 bg-white/[0.03] px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">{task.name}</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{task.index && !task.index.done
|
||||
? `${task.index.indexed.toLocaleString()} of ${task.index.total.toLocaleString()} scanned`
|
||||
: `${task.pendingMediaWork.toLocaleString()} media jobs remaining`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-[11px] text-gray-400">
|
||||
{task.jobs?.thumbnail_pending ? <div>{task.jobs.thumbnail_pending.toLocaleString()} thumbnails</div> : null}
|
||||
{task.jobs?.metadata_pending ? <div>{task.jobs.metadata_pending.toLocaleString()} metadata</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.index && !task.index.done ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<ProgressBar value={task.indexPercent} />
|
||||
<p className="truncate text-[11px] text-gray-500">{task.index.current_file || "Scanning..."}</p>
|
||||
</div>
|
||||
) : task.pendingMediaWork > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<ProgressBar value={0} />
|
||||
<p className="text-[11px] text-gray-500">Processing thumbnails and metadata</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,20 @@ function RatingStars({ rating }: { rating: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number | null): string | null {
|
||||
if (!durationMs || durationMs <= 0) return null;
|
||||
const totalSeconds = Math.floor(durationMs / 1000);
|
||||
const seconds = totalSeconds % 60;
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function ContextMenu({
|
||||
x,
|
||||
y,
|
||||
@@ -174,6 +188,11 @@ function ImageTile({
|
||||
{image.media_kind}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{image.media_kind === "video" && image.duration_ms ? (
|
||||
<div className="rounded-full border border-white/10 bg-black/35 px-2 py-1 text-[10px] font-medium text-white/80">
|
||||
{formatDuration(image.duration_ms)}
|
||||
</div>
|
||||
) : null}
|
||||
{image.favorite ? (
|
||||
<div className="rounded-full border border-white/10 bg-black/35 p-1 text-rose-300">
|
||||
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -196,7 +215,12 @@ function ImageTile({
|
||||
}
|
||||
|
||||
export function Gallery() {
|
||||
const { images, loadMoreImages, openImage, totalImages, loadingImages, zoomPreset } = useGalleryStore();
|
||||
const images = useGalleryStore((state) => state.images);
|
||||
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages);
|
||||
const openImage = useGalleryStore((state) => state.openImage);
|
||||
const totalImages = useGalleryStore((state) => state.totalImages);
|
||||
const loadingImages = useGalleryStore((state) => state.loadingImages);
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
|
||||
|
||||
+96
-49
@@ -18,8 +18,26 @@ function formatDate(iso: string | null): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number | null): string {
|
||||
if (!durationMs || durationMs <= 0) return "Pending / unavailable";
|
||||
const totalSeconds = Math.floor(durationMs / 1000);
|
||||
const seconds = totalSeconds % 60;
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function Lightbox() {
|
||||
const { selectedImage, closeImage, images, openImage, updateImageDetails } = useGalleryStore();
|
||||
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
||||
const closeImage = useGalleryStore((state) => state.closeImage);
|
||||
const images = useGalleryStore((state) => state.images);
|
||||
const openImage = useGalleryStore((state) => state.openImage);
|
||||
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const imageViewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -93,58 +111,61 @@ export function Lightbox() {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<motion.div
|
||||
key={selectedImage.id}
|
||||
className="flex flex-1 flex-col"
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-1 flex-col" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={imageViewportRef}
|
||||
className="group relative flex flex-1 items-center justify-center overflow-auto p-10"
|
||||
>
|
||||
{selectedImage.media_kind === "video" ? (
|
||||
<video
|
||||
src={convertFileSrc(selectedImage.path)}
|
||||
controls
|
||||
className="max-h-full max-w-full rounded-2xl shadow-2xl"
|
||||
style={{ maxHeight: "calc(100vh - 10rem)" }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={convertFileSrc(selectedImage.path)}
|
||||
alt={selectedImage.filename}
|
||||
className="max-w-full rounded-2xl shadow-2xl"
|
||||
style={{
|
||||
maxHeight: "calc(100vh - 10rem)",
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
|
||||
<button
|
||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
|
||||
<button
|
||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||
onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={selectedImage.id}
|
||||
className="flex items-center justify-center"
|
||||
initial={{ opacity: 0.3, scale: 0.985 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0.3, scale: 0.985 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
>
|
||||
{selectedImage.media_kind === "video" ? (
|
||||
<video
|
||||
src={convertFileSrc(selectedImage.path)}
|
||||
controls
|
||||
className="max-h-full max-w-full rounded-2xl shadow-2xl"
|
||||
style={{ maxHeight: "calc(100vh - 10rem)" }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={convertFileSrc(selectedImage.path)}
|
||||
alt={selectedImage.filename}
|
||||
className="max-w-full rounded-2xl shadow-2xl"
|
||||
style={{
|
||||
maxHeight: "calc(100vh - 10rem)",
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
|
||||
<button
|
||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
|
||||
<button
|
||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||
onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 p-5">
|
||||
@@ -217,6 +238,32 @@ export function Lightbox() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedImage.media_kind === "video" ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Duration</p>
|
||||
<p className="text-white">{formatDuration(selectedImage.duration_ms)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Video codec</p>
|
||||
<p className="text-white">{selectedImage.video_codec ?? "Pending / unavailable"}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Audio codec</p>
|
||||
<p className="text-white">{selectedImage.audio_codec ?? "None / unavailable"}</p>
|
||||
</div>
|
||||
|
||||
{selectedImage.metadata_error ? (
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Metadata</p>
|
||||
<p className="text-amber-300">{selectedImage.metadata_error}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Type</p>
|
||||
<p className="text-white">{selectedImage.mime_type}</p>
|
||||
@@ -248,7 +295,7 @@ export function Lightbox() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute right-80 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
|
||||
|
||||
@@ -72,17 +72,15 @@ const FILTER_OPTIONS: { value: MediaFilter; label: string }[] = [
|
||||
export function MenuBar() {
|
||||
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
addFolder,
|
||||
reindexFolder,
|
||||
selectedFolderId,
|
||||
zoomPreset,
|
||||
setZoomPreset,
|
||||
mediaFilter,
|
||||
setMediaFilter,
|
||||
favoritesOnly,
|
||||
setFavoritesOnly,
|
||||
} = useGalleryStore();
|
||||
const addFolder = useGalleryStore((state) => state.addFolder);
|
||||
const reindexFolder = useGalleryStore((state) => state.reindexFolder);
|
||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||
const mediaFilter = useGalleryStore((state) => state.mediaFilter);
|
||||
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
|
||||
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
|
||||
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
|
||||
@@ -95,8 +95,12 @@ function FolderItem({
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { folders, selectedFolderId, addFolder, indexingProgress, totalImages, selectFolder } =
|
||||
useGalleryStore();
|
||||
const folders = useGalleryStore((state) => state.folders);
|
||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||
const addFolder = useGalleryStore((state) => state.addFolder);
|
||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||
const totalImages = useGalleryStore((state) => state.totalImages);
|
||||
const selectFolder = useGalleryStore((state) => state.selectFolder);
|
||||
|
||||
const handleAddFolder = async () => {
|
||||
const selected = await open({
|
||||
|
||||
+14
-16
@@ -34,22 +34,20 @@ function FilterChip({
|
||||
}
|
||||
|
||||
export function Toolbar() {
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
sort,
|
||||
setSort,
|
||||
totalImages,
|
||||
loadedCount,
|
||||
selectedFolderId,
|
||||
folders,
|
||||
mediaFilter,
|
||||
setMediaFilter,
|
||||
favoritesOnly,
|
||||
setFavoritesOnly,
|
||||
zoomPreset,
|
||||
setZoomPreset,
|
||||
} = useGalleryStore();
|
||||
const search = useGalleryStore((state) => state.search);
|
||||
const setSearch = useGalleryStore((state) => state.setSearch);
|
||||
const sort = useGalleryStore((state) => state.sort);
|
||||
const setSort = useGalleryStore((state) => state.setSort);
|
||||
const totalImages = useGalleryStore((state) => state.totalImages);
|
||||
const loadedCount = useGalleryStore((state) => state.loadedCount);
|
||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||
const folders = useGalleryStore((state) => state.folders);
|
||||
const mediaFilter = useGalleryStore((state) => state.mediaFilter);
|
||||
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
|
||||
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
|
||||
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||
|
||||
const [searchValue, setSearchValue] = useState(search);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
Reference in New Issue
Block a user