feat: bulk action progress — header pill + per-thumbnail states
- New bulkAction store: tracks active/label/detail state for the pill and a Map<uid, pending|done|error> for per-tile overlays - Extract StatusPill.svelte from IndexerStatusPill (generic active/label/detail props); IndexerStatusPill becomes a one-line wrapper - +layout.svelte: render a second StatusPill driven by bulkAction store, alongside the indexer pill in the AnimatedMule header - BulkActionBar: extend withBusy with optional BulkConfig (ids/label/doneLabel); pending tiles dim + spinner on start, green checkmark flashes for 400ms before cache invalidation removes them; red overlay on error, auto-clears after 2s - onApprove/batchEdit: wire onProgress callback to setDetail so the pill shows the filename currently being processed during fan-out keep operations - batch.ts: add completedId as third arg to onProgress (backwards-compatible) - PhotoTile: derive bulkState from store; pending/done/error overlays sit above the selection tint; hover-video guarded against pending tiles Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,49 +1,8 @@
|
|||||||
<!--
|
<!-- PhotoPrism indexer status pill. Driven by the indexer store, which
|
||||||
Compact status pill that appears in the header while PhotoPrism's
|
subscribes to PhotoPrism's WS channel. Delegates rendering to StatusPill. -->
|
||||||
indexer is doing work. Driven by the indexer store, which subscribes
|
|
||||||
to PhotoPrism's WS channel. Renders nothing when idle so it never
|
|
||||||
steals header real estate from the user.
|
|
||||||
|
|
||||||
The `detail` (current path/file) is exposed via `title` rather than
|
|
||||||
rendered inline — the pill stays narrow even on slow flashes through
|
|
||||||
a deep library, and hover surfaces the detail for users who care.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { indexer } from '$lib/stores/indexer.svelte';
|
import { indexer } from '$lib/stores/indexer.svelte';
|
||||||
import { Loader2 } from 'lucide-svelte';
|
import StatusPill from './StatusPill.svelte';
|
||||||
|
|
||||||
// PhotoPrism's `fileName` arrives as the full relative path
|
|
||||||
// (`subdir/IMG_0554.HEIC.jpg`). The basename is enough for inline
|
|
||||||
// recognition; the full path stays in the `title` for users who hover.
|
|
||||||
const basename = $derived.by(() => {
|
|
||||||
const d = indexer.detail;
|
|
||||||
if (!d) return '';
|
|
||||||
const i = d.lastIndexOf('/');
|
|
||||||
return i >= 0 ? d.slice(i + 1) : d;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if indexer.active || indexer.label}
|
<StatusPill active={indexer.active} label={indexer.label} detail={indexer.detail} />
|
||||||
<div
|
|
||||||
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
|
|
||||||
title={indexer.detail ?? indexer.label}
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{#if indexer.active}
|
|
||||||
<Loader2 class="h-3 w-3 animate-spin text-primary" />
|
|
||||||
{/if}
|
|
||||||
<span class="whitespace-nowrap">{indexer.label}</span>
|
|
||||||
{#if basename}
|
|
||||||
<!-- Fixed-width slot so the pill stops shrinking/growing as
|
|
||||||
PhotoPrism rattles through files of different name lengths.
|
|
||||||
`w-[24ch]` locks the column; `truncate` ellipsises anything
|
|
||||||
longer. The full path remains in the parent's `title`. -->
|
|
||||||
<span
|
|
||||||
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
|
|
||||||
>
|
|
||||||
{basename}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
46
web/src/lib/components/layout/StatusPill.svelte
Normal file
46
web/src/lib/components/layout/StatusPill.svelte
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!--
|
||||||
|
Generic status pill used in the header for both the PhotoPrism indexer
|
||||||
|
and bulk-action progress. Renders nothing when idle so it never steals
|
||||||
|
header real estate.
|
||||||
|
|
||||||
|
`detail` is a full path or filename; only the basename is shown inline
|
||||||
|
(fixed-width slot to stop the pill from resizing on every file). The
|
||||||
|
full string is exposed via `title` for hover.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { Loader2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
active: boolean;
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
let { active, label, detail }: Props = $props();
|
||||||
|
|
||||||
|
const basename = $derived.by(() => {
|
||||||
|
if (!detail) return '';
|
||||||
|
const i = detail.lastIndexOf('/');
|
||||||
|
return i >= 0 ? detail.slice(i + 1) : detail;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if active || label}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
|
||||||
|
title={detail ?? label}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{#if active}
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin text-primary" />
|
||||||
|
{/if}
|
||||||
|
<span class="whitespace-nowrap">{label}</span>
|
||||||
|
{#if basename}
|
||||||
|
<span
|
||||||
|
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{basename}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
import { startBulk, setDetail, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
import { Layers } from 'lucide-svelte';
|
import { Layers } from 'lucide-svelte';
|
||||||
|
|
||||||
@@ -113,10 +114,27 @@
|
|||||||
setFocused(null);
|
setFocused(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
|
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
interface BulkConfig {
|
||||||
|
ids: string[];
|
||||||
|
label: string;
|
||||||
|
doneLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withBusy<T>(fn: () => Promise<T>, bulk?: BulkConfig): Promise<T> {
|
||||||
busy = true;
|
busy = true;
|
||||||
|
if (bulk) startBulk(`${bulk.label}…`, bulk.ids);
|
||||||
try {
|
try {
|
||||||
return await fn();
|
const result = await fn();
|
||||||
|
if (bulk) {
|
||||||
|
doneBulk(bulk.doneLabel, bulk.ids);
|
||||||
|
await delay(400);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
if (bulk) failBulk(bulk.ids);
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
@@ -131,7 +149,12 @@
|
|||||||
// no /unapprove route. We fan out per-photo because there's no
|
// no /unapprove route. We fan out per-photo because there's no
|
||||||
// batch endpoint either. Errors are tallied rather than aborting
|
// batch endpoint either. Errors are tallied rather than aborting
|
||||||
// the loop so a single bad UID doesn't block the rest.
|
// the loop so a single bad UID doesn't block the rest.
|
||||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
|
||||||
|
onProgress: (_done, _total, completedId) => {
|
||||||
|
const p = cachedPhoto(completedId);
|
||||||
|
setDetail(p?.FileName ?? completedId);
|
||||||
|
}
|
||||||
|
});
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
|
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
|
||||||
} else {
|
} else {
|
||||||
@@ -139,13 +162,17 @@
|
|||||||
}
|
}
|
||||||
focusAfter(ids);
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
});
|
}, { ids, label: 'Keeping', doneLabel: `Kept ${ids.length}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAcceptDateAndKeep() {
|
async function onAcceptDateAndKeep() {
|
||||||
const ids = snapshotIds();
|
const ids = snapshotIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
await withBusy(() => acceptDateAndKeep(ids));
|
await withBusy(() => acceptDateAndKeep(ids), {
|
||||||
|
ids,
|
||||||
|
label: 'Updating',
|
||||||
|
doneLabel: `Updated ${ids.length}`
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onArchive() {
|
async function onArchive() {
|
||||||
@@ -167,7 +194,7 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||||
}
|
}
|
||||||
});
|
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
@@ -187,7 +214,7 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||||
}
|
}
|
||||||
});
|
}, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRestore() {
|
async function onRestore() {
|
||||||
@@ -206,13 +233,14 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Restore failed');
|
toast.error(err instanceof Error ? err.message : 'Restore failed');
|
||||||
}
|
}
|
||||||
});
|
}, { ids, label: 'Restoring', doneLabel: `Restored ${ids.length}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAddToHeap(heap: PpAlbum) {
|
async function onAddToHeap(heap: PpAlbum) {
|
||||||
const ids = snapshotIds();
|
const ids = snapshotIds();
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
heapPickerOpen = false;
|
heapPickerOpen = false;
|
||||||
|
startBulk(`Adding to ${heap.Title}…`, ids);
|
||||||
await withBusy(async () => {
|
await withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
const { added } = await addToHeap(heap.UID, ids);
|
const { added } = await addToHeap(heap.UID, ids);
|
||||||
@@ -222,11 +250,14 @@
|
|||||||
// real delta so the user isn't fooled by a green toast over
|
// real delta so the user isn't fooled by a green toast over
|
||||||
// a no-op.
|
// a no-op.
|
||||||
if (added.length === 0) {
|
if (added.length === 0) {
|
||||||
|
failBulk(ids);
|
||||||
toast.error(`Nothing added to ${heap.Title}`, {
|
toast.error(`Nothing added to ${heap.Title}`, {
|
||||||
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
doneBulk(`Added ${added.length} → ${heap.Title}`, ids);
|
||||||
|
await delay(400);
|
||||||
if (added.length < ids.length) {
|
if (added.length < ids.length) {
|
||||||
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||||||
description: 'The rest were already in this heap.'
|
description: 'The rest were already in this heap.'
|
||||||
@@ -240,6 +271,7 @@
|
|||||||
});
|
});
|
||||||
clearSelection();
|
clearSelection();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
failBulk(ids);
|
||||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
|
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
|
||||||
import { view } from "$lib/stores/view.svelte";
|
import { view } from "$lib/stores/view.svelte";
|
||||||
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
||||||
|
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import { Loader2, Check, X } from "lucide-svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
photo: PpPhoto;
|
photo: PpPhoto;
|
||||||
@@ -51,7 +54,7 @@
|
|||||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function onMouseEnter() {
|
function onMouseEnter() {
|
||||||
if (!video || selected) return;
|
if (!video || selected || bulkState) return;
|
||||||
if (hoverTimer) clearTimeout(hoverTimer);
|
if (hoverTimer) clearTimeout(hoverTimer);
|
||||||
hoverTimer = setTimeout(() => {
|
hoverTimer = setTimeout(() => {
|
||||||
hoverPlaying = true;
|
hoverPlaying = true;
|
||||||
@@ -73,6 +76,7 @@
|
|||||||
const tilePx = $derived(view.thumbnailSize);
|
const tilePx = $derived(view.thumbnailSize);
|
||||||
const src1x = $derived(thumbSrc(hash, tilePx));
|
const src1x = $derived(thumbSrc(hash, tilePx));
|
||||||
const srcset = $derived(thumbSrcSet(hash, tilePx));
|
const srcset = $derived(thumbSrcSet(hash, tilePx));
|
||||||
|
const bulkState = $derived(bulkPhotoStates.get(photo.UID));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -150,6 +154,23 @@
|
|||||||
{#if selected}
|
{#if selected}
|
||||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if bulkState === 'pending'}
|
||||||
|
<div class="pointer-events-none absolute inset-0 bg-black/50"></div>
|
||||||
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<Loader2 class="h-5 w-5 animate-spin text-white/80 drop-shadow" />
|
||||||
|
</div>
|
||||||
|
{:else if bulkState === 'done'}
|
||||||
|
<div
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-emerald-500/70"
|
||||||
|
>
|
||||||
|
<Check class="h-7 w-7 text-white drop-shadow-md" />
|
||||||
|
</div>
|
||||||
|
{:else if bulkState === 'error'}
|
||||||
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/60">
|
||||||
|
<X class="h-7 w-7 text-white drop-shadow-md" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if isVideo(photo)}
|
{#if isVideo(photo)}
|
||||||
<span
|
<span
|
||||||
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface BatchResult<T> {
|
|||||||
|
|
||||||
export interface BatchOptions {
|
export interface BatchOptions {
|
||||||
concurrency?: number;
|
concurrency?: number;
|
||||||
onProgress?: (done: number, total: number) => void;
|
onProgress?: (done: number, total: number, completedId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function batchEdit<T>(
|
export async function batchEdit<T>(
|
||||||
@@ -38,7 +38,7 @@ export async function batchEdit<T>(
|
|||||||
errors.push({ id, message: err instanceof Error ? err.message : String(err) });
|
errors.push({ id, message: err instanceof Error ? err.message : String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
done++;
|
done++;
|
||||||
opts.onProgress?.(done, ids.length);
|
opts.onProgress?.(done, ids.length, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
web/src/lib/stores/bulkAction.svelte.ts
Normal file
60
web/src/lib/stores/bulkAction.svelte.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Bulk-action status, written by BulkActionBar and read by the header
|
||||||
|
* StatusPill and individual PhotoTile overlays.
|
||||||
|
*
|
||||||
|
* State lifecycle:
|
||||||
|
* startBulk → pill spins, all target tiles go "pending"
|
||||||
|
* setDetail → pill shows the filename currently being processed (fan-out ops)
|
||||||
|
* doneBulk → pill shows completion label, tiles flash green, auto-clears after 3 s
|
||||||
|
* failBulk → tiles flash red, auto-clears after 2 s
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface BulkActionState {
|
||||||
|
active: boolean;
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bulkAction = $state<BulkActionState>({ active: false, label: '' });
|
||||||
|
export const bulkPhotoStates = $state(new Map<string, 'pending' | 'done' | 'error'>());
|
||||||
|
|
||||||
|
let doneTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function startBulk(label: string, ids: string[]): void {
|
||||||
|
if (doneTimer !== null) {
|
||||||
|
clearTimeout(doneTimer);
|
||||||
|
doneTimer = null;
|
||||||
|
}
|
||||||
|
bulkPhotoStates.clear();
|
||||||
|
for (const id of ids) bulkPhotoStates.set(id, 'pending');
|
||||||
|
bulkAction.active = true;
|
||||||
|
bulkAction.label = label;
|
||||||
|
bulkAction.detail = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDetail(path: string): void {
|
||||||
|
bulkAction.detail = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function doneBulk(label: string, ids: string[]): void {
|
||||||
|
for (const id of ids) bulkPhotoStates.set(id, 'done');
|
||||||
|
bulkAction.active = false;
|
||||||
|
bulkAction.label = label;
|
||||||
|
bulkAction.detail = undefined;
|
||||||
|
if (doneTimer !== null) clearTimeout(doneTimer);
|
||||||
|
doneTimer = setTimeout(() => {
|
||||||
|
bulkAction.label = '';
|
||||||
|
bulkPhotoStates.clear();
|
||||||
|
doneTimer = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function failBulk(ids: string[]): void {
|
||||||
|
for (const id of ids) bulkPhotoStates.set(id, 'error');
|
||||||
|
bulkAction.active = false;
|
||||||
|
bulkAction.label = '';
|
||||||
|
bulkAction.detail = undefined;
|
||||||
|
setTimeout(() => {
|
||||||
|
for (const id of ids) bulkPhotoStates.delete(id);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
import { resizable } from '$lib/actions/resizable';
|
import { resizable } from '$lib/actions/resizable';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
|
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
|
||||||
|
import { bulkAction } from '$lib/stores/bulkAction.svelte';
|
||||||
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
|
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
|
||||||
|
import StatusPill from '$lib/components/layout/StatusPill.svelte';
|
||||||
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||||
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
||||||
@@ -72,6 +74,7 @@
|
|||||||
<div class="flex h-screen flex-col overflow-hidden">
|
<div class="flex h-screen flex-col overflow-hidden">
|
||||||
<AnimatedMule>
|
<AnimatedMule>
|
||||||
<IndexerStatusPill />
|
<IndexerStatusPill />
|
||||||
|
<StatusPill active={bulkAction.active} label={bulkAction.label} detail={bulkAction.detail} />
|
||||||
</AnimatedMule>
|
</AnimatedMule>
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
{#if !view.leftSidebarCollapsed}
|
{#if !view.leftSidebarCollapsed}
|
||||||
|
|||||||
Reference in New Issue
Block a user