b8d009c973
github/actions/ci GitHub Actions CI finished: success
Only require a worker to be paused if it currently has pending work for that folder, instead of demanding all four worker flags be true. The background tasks panel only lets you toggle stages it shows, so the old check stayed out of sync after pausing everything visible. Also grants start-dragging and start-resize-dragging window permissions.
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
import { useState, type MouseEvent } from 'react'
|
|
import { Reorder, useDragControls } from 'framer-motion'
|
|
import { open } from '@tauri-apps/plugin-dialog'
|
|
import { useGalleryStore, type Folder, type IndexProgress } from '../../store'
|
|
import { ContextMenu, MenuItem, MenuSeparator } from '../menu'
|
|
import { InlineConfirm } from '../InlineConfirm'
|
|
import { InlineRename } from '../InlineRename'
|
|
import { Tooltip } from '../Tooltip'
|
|
import { CloseIcon, FolderIcon } from '../icons'
|
|
|
|
export function FolderItem({
|
|
folder,
|
|
selected,
|
|
progress,
|
|
customOrdering,
|
|
dragging,
|
|
onDragStart,
|
|
onDragEnd,
|
|
onKeyboardMove,
|
|
}: {
|
|
folder: Folder
|
|
selected: boolean
|
|
progress: IndexProgress | undefined
|
|
customOrdering: boolean
|
|
dragging: boolean
|
|
onDragStart: (pointerY: number) => void
|
|
onDragEnd: () => void
|
|
onKeyboardMove: (direction: -1 | 1) => void
|
|
}) {
|
|
const dragControls = useDragControls()
|
|
const {
|
|
selectFolder,
|
|
removeFolder,
|
|
reindexFolder,
|
|
renameFolder,
|
|
updateFolderPath,
|
|
toggleMutedFolder,
|
|
} = useGalleryStore()
|
|
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds)
|
|
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused)
|
|
const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id])
|
|
const folderJobs = useGalleryStore((state) => state.mediaJobProgress[folder.id])
|
|
const isMuted = mutedFolderIds.includes(folder.id)
|
|
// "Fully paused" means every worker that currently has pending work is
|
|
// paused. Workers with nothing pending are ignored — the background tasks
|
|
// panel only lets you toggle the stages it actually shows, so requiring
|
|
// every worker key to be literally true left this permanently out of sync
|
|
// (menu kept offering "Pause" after the visible work was already paused).
|
|
const hasPendingWork =
|
|
(folderJobs?.thumbnail_pending ?? 0) > 0 ||
|
|
(folderJobs?.metadata_pending ?? 0) > 0 ||
|
|
(folderJobs?.embedding_pending ?? 0) > 0 ||
|
|
(folderJobs?.tagging_pending ?? 0) > 0
|
|
const isPausedAll = hasPendingWork
|
|
? ((folderJobs?.thumbnail_pending ?? 0) === 0 || !!folderWorkers?.thumbnail) &&
|
|
((folderJobs?.metadata_pending ?? 0) === 0 || !!folderWorkers?.metadata) &&
|
|
((folderJobs?.embedding_pending ?? 0) === 0 || !!folderWorkers?.embedding) &&
|
|
((folderJobs?.tagging_pending ?? 0) === 0 || !!folderWorkers?.tagging)
|
|
: !!folderWorkers &&
|
|
folderWorkers.thumbnail &&
|
|
folderWorkers.metadata &&
|
|
folderWorkers.embedding &&
|
|
folderWorkers.tagging
|
|
const isIndexing = progress && !progress.done
|
|
const isMissing = !!folder.scan_error && !isIndexing
|
|
|
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
|
const [renaming, setRenaming] = useState(false)
|
|
const [confirmingRemoval, setConfirmingRemoval] = useState(false)
|
|
|
|
const handleContextMenu = (e: MouseEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setContextMenu({ x: e.clientX, y: e.clientY })
|
|
}
|
|
|
|
const handleLocateFolder = async () => {
|
|
const picked = await open({
|
|
directory: true,
|
|
multiple: false,
|
|
title: `Locate "${folder.name}"`,
|
|
})
|
|
if (picked && typeof picked === 'string') {
|
|
await updateFolderPath(folder.id, picked)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Reorder.Item
|
|
as="div"
|
|
value={folder.id}
|
|
drag={customOrdering ? 'y' : false}
|
|
dragControls={dragControls}
|
|
dragListener={false}
|
|
dragElastic={0.08}
|
|
onDragEnd={onDragEnd}
|
|
layout
|
|
transition={{ layout: { type: 'spring', stiffness: 520, damping: 38, mass: 0.55 } }}
|
|
className={`relative z-0 ${dragging ? 'z-20' : ''}`}
|
|
style={{ position: 'relative' }}
|
|
>
|
|
<div
|
|
className={`group relative flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 transition-all duration-150 ${
|
|
selected ? 'bg-white/8 text-white' : 'text-gray-500 hover:bg-white/5 hover:text-gray-200'
|
|
} ${dragging ? 'scale-[1.02] bg-white/[0.11] text-white shadow-xl ring-1 shadow-black/25 ring-white/15' : ''}`}
|
|
onClick={() => !renaming && selectFolder(folder.id)}
|
|
onContextMenu={handleContextMenu}
|
|
>
|
|
{customOrdering ? (
|
|
<Tooltip label={`Drag to reorder ${folder.name}`} followCursor delay={1000}>
|
|
<button
|
|
type="button"
|
|
aria-label={`Reorder ${folder.name}`}
|
|
className={`-ml-1 flex h-7 w-5 shrink-0 touch-none items-center justify-center rounded-md transition-colors ${
|
|
dragging
|
|
? 'cursor-grabbing bg-white/10 text-gray-300'
|
|
: 'cursor-grab text-gray-700 hover:bg-white/[0.06] hover:text-gray-400'
|
|
}`}
|
|
onPointerDown={(event) => {
|
|
event.stopPropagation()
|
|
onDragStart(event.clientY)
|
|
dragControls.start(event)
|
|
}}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
onKeyboardMove(event.key === 'ArrowUp' ? -1 : 1)
|
|
}}
|
|
>
|
|
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
|
|
<circle cx="3" cy="3" r="1" />
|
|
<circle cx="9" cy="3" r="1" />
|
|
<circle cx="3" cy="9" r="1" />
|
|
<circle cx="9" cy="9" r="1" />
|
|
</svg>
|
|
</button>
|
|
</Tooltip>
|
|
) : null}
|
|
{isMissing ? (
|
|
<span className="shrink-0 text-amber-400">
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
) : (
|
|
<FolderIcon className="h-3.5 w-3.5 shrink-0" />
|
|
)}
|
|
|
|
<div className="min-w-0 flex-1">
|
|
{renaming ? (
|
|
<InlineRename
|
|
name={folder.name}
|
|
onRename={(next) => renameFolder(folder.id, next)}
|
|
onClose={() => setRenaming(false)}
|
|
/>
|
|
) : (
|
|
<div
|
|
className={`truncate text-[13px] leading-tight font-medium ${selected ? 'text-white' : ''}`}
|
|
>
|
|
{folder.name}
|
|
</div>
|
|
)}
|
|
{isIndexing ? (
|
|
<>
|
|
<div className="mt-0.5 text-[11px] text-gray-600">
|
|
{progress.indexed}/{progress.total}
|
|
</div>
|
|
<div className="mt-1.5 h-px overflow-hidden rounded bg-white/10">
|
|
<div
|
|
className="h-full bg-blue-500 transition-all duration-300"
|
|
style={{
|
|
width: `${progress.total > 0 ? (progress.indexed / progress.total) * 100 : 0}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="mt-0.5 text-[11px] text-gray-600">
|
|
{folder.image_count.toLocaleString()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Hover action buttons */}
|
|
{!renaming &&
|
|
(confirmingRemoval ? (
|
|
<InlineConfirm
|
|
onConfirm={() => {
|
|
void removeFolder(folder.id)
|
|
setConfirmingRemoval(false)
|
|
}}
|
|
onCancel={() => setConfirmingRemoval(false)}
|
|
/>
|
|
) : (
|
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<Tooltip label="Reindex" anchorToCursor>
|
|
<button
|
|
className="rounded p-1 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-300"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void reindexFolder(folder.id)
|
|
}}
|
|
>
|
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.75}
|
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label="Remove from app" anchorToCursor>
|
|
<button
|
|
className="rounded p-1 text-gray-600 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setConfirmingRemoval(true)
|
|
}}
|
|
>
|
|
<CloseIcon className="h-3 w-3" strokeWidth={1.75} />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{isMissing && (
|
|
<div className="mx-2 mb-1 rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2">
|
|
<p className="mb-1.5 text-[11px] font-medium text-amber-400">Folder not found</p>
|
|
<p className="mb-2 text-[10px] leading-snug text-gray-500">
|
|
This folder may have been moved or renamed. Locate it to resume, or remove it from the
|
|
app.
|
|
</p>
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
className="flex-1 rounded-md bg-amber-500/20 px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-amber-500/30 hover:text-amber-200"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void handleLocateFolder()
|
|
}}
|
|
>
|
|
Locate Folder
|
|
</button>
|
|
<button
|
|
className="flex-1 rounded-md bg-white/5 px-2 py-1 text-[10px] font-medium text-gray-400 transition-colors hover:bg-red-500/15 hover:text-red-400"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setConfirmingRemoval(true)
|
|
}}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{contextMenu && (
|
|
<ContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
size="sm"
|
|
onClose={() => setContextMenu(null)}
|
|
>
|
|
<MenuItem label="Reindex" onSelect={() => void reindexFolder(folder.id)} />
|
|
<MenuItem label="Rename" onSelect={() => setRenaming(true)} />
|
|
<MenuItem
|
|
label={isPausedAll ? 'Resume background work' : 'Pause background work'}
|
|
onSelect={() => setAllWorkersPaused(folder.id, !isPausedAll)}
|
|
/>
|
|
<MenuItem
|
|
label={isMuted ? 'Unmute notifications' : 'Mute notifications'}
|
|
onSelect={() => toggleMutedFolder(folder.id)}
|
|
/>
|
|
{folder.scan_error ? (
|
|
<MenuItem label="Locate Folder" onSelect={() => void handleLocateFolder()} />
|
|
) : null}
|
|
<MenuSeparator />
|
|
<MenuItem label="Remove from app" danger onSelect={() => setConfirmingRemoval(true)} />
|
|
</ContextMenu>
|
|
)}
|
|
</Reorder.Item>
|
|
)
|
|
}
|