Initial commit
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eye, EyeOff, Gem, Star, MinusCircle, ChevronDown, Tag } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Action = "watched" | "unwatched" | "vip" | "favorite" | "unmark";
|
||||
|
||||
export function MarkAsMenu({
|
||||
onAction,
|
||||
disabled,
|
||||
}: {
|
||||
onAction: (action: Action) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
function pick(action: Action) {
|
||||
setOpen(false);
|
||||
onAction(action);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass hover:text-[var(--color-fg)] disabled:opacity-40"
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
Mark As
|
||||
<ChevronDown className="w-3 h-3 opacity-70" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 bottom-[calc(100%+6px)] z-30 bg-[var(--color-bg-0)] border border-[var(--color-glass-border-strong)] rounded-xl shadow-2xl p-1 w-52"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Row icon={Eye} label="Watched" colorClass="text-[var(--color-mint)]" onClick={() => pick("watched")} />
|
||||
<Row icon={EyeOff} label="Unwatched" onClick={() => pick("unwatched")} />
|
||||
<div className="h-px bg-[var(--color-glass-border)] my-1" />
|
||||
<Row icon={Gem} label="VIP" colorClass="text-[var(--color-cyan)]" onClick={() => pick("vip")} />
|
||||
<Row icon={Star} label="Favorite" colorClass="text-amber-300" iconStyle={{ fill: "#fbbf24" }} onClick={() => pick("favorite")} />
|
||||
<Row icon={MinusCircle} label="Unmark" colorClass="text-[var(--color-fg-muted)]" onClick={() => pick("unmark")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
colorClass,
|
||||
iconStyle,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
colorClass?: string;
|
||||
iconStyle?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors hover:bg-[var(--color-glass)]",
|
||||
colorClass ?? "text-[var(--color-fg-dim)]",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" style={iconStyle} />
|
||||
<span className="flex-1">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSelection } from "./SelectionProvider";
|
||||
|
||||
export function RegisterVisible({ ids }: { ids: number[] }) {
|
||||
const pathname = usePathname();
|
||||
const { clear, setVisibleIds } = useSelection();
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleIds(ids);
|
||||
return () => setVisibleIds([]);
|
||||
}, [ids, setVisibleIds]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clear();
|
||||
}, [pathname, clear]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
import { useTransition } from "react";
|
||||
import { useSelection } from "./SelectionProvider";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2, X, ListChecks } from "lucide-react";
|
||||
import { deleteImages, bulkSetWatched, bulkSetMark } from "@/app/actions/bulk";
|
||||
import { useUndoDeleteToast } from "@/components/select/UndoDeleteToast";
|
||||
import { useSettings } from "@/components/settings/SettingsProvider";
|
||||
import { MarkAsMenu } from "./MarkAsMenu";
|
||||
import { useWatchQueue } from "@/components/queue/WatchQueueProvider";
|
||||
import { dispatchQueueRemove } from "@/components/queue/watchQueueEvents";
|
||||
import { ListVideo } from "lucide-react";
|
||||
|
||||
export function SelectionBar() {
|
||||
const { ids, clear, visibleIds, selectMany } = useSelection();
|
||||
const { settings } = useSettings();
|
||||
const { show: showUndo } = useUndoDeleteToast();
|
||||
const [pending, start] = useTransition();
|
||||
const router = useRouter();
|
||||
const queue = useWatchQueue();
|
||||
|
||||
if (ids.size === 0) return null;
|
||||
const count = ids.size;
|
||||
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => ids.has(id));
|
||||
|
||||
const onDelete = (e: React.MouseEvent) => {
|
||||
const permanent = e.shiftKey || !settings.useRecycleBin;
|
||||
if (permanent) {
|
||||
if (!confirm(`Permanently delete ${count} cover${count === 1 ? "" : "s"}? Cannot be undone.`)) return;
|
||||
}
|
||||
const targetIds = Array.from(ids);
|
||||
start(async () => {
|
||||
await deleteImages(targetIds, permanent ? { permanent: true } : undefined);
|
||||
clear();
|
||||
router.refresh();
|
||||
if (!permanent) showUndo(targetIds);
|
||||
});
|
||||
};
|
||||
|
||||
const onSelectAllToggle = () => {
|
||||
if (allVisibleSelected) clear();
|
||||
else selectMany(visibleIds);
|
||||
};
|
||||
|
||||
const onMarkAs = (action: "watched" | "unwatched" | "vip" | "favorite" | "unmark") => {
|
||||
const targetIds = Array.from(ids);
|
||||
start(async () => {
|
||||
try {
|
||||
if (action === "watched") { await bulkSetWatched(targetIds, true); dispatchQueueRemove(targetIds); }
|
||||
else if (action === "unwatched") await bulkSetWatched(targetIds, false);
|
||||
else if (action === "vip") await bulkSetMark(targetIds, "vip");
|
||||
else if (action === "favorite") await bulkSetMark(targetIds, "favorite");
|
||||
else if (action === "unmark") await bulkSetMark(targetIds, "unmarked");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(`[bulk ${action}] failed:`, err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-[80px] left-1/2 -translate-x-1/2 z-50">
|
||||
<div
|
||||
className="rounded-2xl shadow-2xl px-4 py-2.5 flex items-center gap-3 border border-[var(--color-glass-border-strong)] backdrop-blur-2xl"
|
||||
style={{ background: "color-mix(in oklch, var(--color-bg-0) 85%, transparent)" }}
|
||||
>
|
||||
<span className="text-sm font-mono tabular-nums">
|
||||
<span className="text-[var(--color-cyan)] font-semibold">{count}</span>
|
||||
<span className="text-[var(--color-fg-dim)]"> selected</span>
|
||||
</span>
|
||||
<div className="w-px h-5 bg-[var(--color-glass-border)]" />
|
||||
{visibleIds.length > 0 && (
|
||||
<button
|
||||
onClick={onSelectAllToggle}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass hover:text-[var(--color-fg)]"
|
||||
>
|
||||
<ListChecks className="w-3.5 h-3.5" />
|
||||
{allVisibleSelected ? "Deselect All" : `All (${visibleIds.length})`}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { queue.addMany(Array.from(ids)); }}
|
||||
title="Add to watch queue"
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass hover:text-[var(--color-fg)]"
|
||||
>
|
||||
<ListVideo className="w-3.5 h-3.5" /> Queue
|
||||
</button>
|
||||
<MarkAsMenu onAction={onMarkAs} disabled={pending} />
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={pending}
|
||||
title={settings.useRecycleBin ? "Send to trash · Shift-click for permanent delete" : "Delete permanently"}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-coral)]/15 text-[var(--color-coral)] border border-[var(--color-coral)]/40 hover:bg-[var(--color-coral)]/25 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> {pending ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
<button
|
||||
onClick={clear}
|
||||
className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-lg text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" /> Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
type Ctx = {
|
||||
ids: Set<number>;
|
||||
has: (id: number) => boolean;
|
||||
toggle: (id: number) => void;
|
||||
selectMany: (ids: number[]) => void;
|
||||
clear: () => void;
|
||||
visibleIds: number[];
|
||||
setVisibleIds: (ids: number[]) => void;
|
||||
};
|
||||
|
||||
const SelectCtx = createContext<Ctx | null>(null);
|
||||
|
||||
export function SelectionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [ids, setIds] = useState<Set<number>>(new Set());
|
||||
const [visibleIds, setVisibleIdsState] = useState<number[]>([]);
|
||||
// Guard against fresh-array identity churn: server-side renders pass a new
|
||||
// `number[]` reference every time, which would otherwise re-fire all consumers.
|
||||
const setVisibleIds = useCallback((next: number[]) => {
|
||||
setVisibleIdsState((cur) => {
|
||||
if (cur.length === next.length && cur.every((v, i) => v === next[i])) return cur;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback((id: number) => setIds((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
}), []);
|
||||
const selectMany = useCallback((newIds: number[]) => setIds((cur) => {
|
||||
const next = new Set(cur);
|
||||
newIds.forEach((i) => next.add(i));
|
||||
return next;
|
||||
}), []);
|
||||
const clear = useCallback(() => setIds(new Set()), []);
|
||||
|
||||
// Global route-change cleanup: pages without RegisterVisible (e.g.
|
||||
// /actress, /category, /tag, /search) would otherwise carry stale
|
||||
// selections across navigation. Clear on any pathname change.
|
||||
const pathname = usePathname();
|
||||
const lastPath = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (lastPath.current !== null && lastPath.current !== pathname) {
|
||||
setIds(new Set());
|
||||
setVisibleIdsState([]);
|
||||
}
|
||||
lastPath.current = pathname;
|
||||
}, [pathname]);
|
||||
|
||||
const value = useMemo<Ctx>(() => ({
|
||||
ids,
|
||||
has: (id) => ids.has(id),
|
||||
toggle,
|
||||
selectMany,
|
||||
clear,
|
||||
visibleIds,
|
||||
setVisibleIds,
|
||||
}), [ids, toggle, selectMany, clear, visibleIds, setVisibleIds]);
|
||||
|
||||
return <SelectCtx.Provider value={value}>{children}</SelectCtx.Provider>;
|
||||
}
|
||||
|
||||
export function useSelection() {
|
||||
const ctx = useContext(SelectCtx);
|
||||
if (!ctx) throw new Error("useSelection must be used within SelectionProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Undo2, X } from "lucide-react";
|
||||
import { restoreImages } from "@/app/actions/trash";
|
||||
|
||||
interface ToastState {
|
||||
ids: number[];
|
||||
visibleAt: number;
|
||||
failed?: { message: string };
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
show: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
const ToastCtx = createContext<Ctx | null>(null);
|
||||
const VISIBLE_MS = 8000;
|
||||
|
||||
export function UndoDeleteToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<ToastState | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const router = useRouter();
|
||||
const [pending, start] = useTransition();
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setState(null);
|
||||
if (timerRef.current != null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const show = useCallback((ids: number[]) => {
|
||||
if (ids.length === 0) return;
|
||||
setState({ ids, visibleAt: Date.now() });
|
||||
if (timerRef.current != null) clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => setState(null), VISIBLE_MS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => { if (timerRef.current != null) clearTimeout(timerRef.current); }, []);
|
||||
|
||||
const undo = () => {
|
||||
if (!state) return;
|
||||
const ids = state.ids;
|
||||
// Hide the trash-confirmation copy while the restore is in flight,
|
||||
// but DON'T dismiss the toast — if restoreImages throws, the items
|
||||
// are still in trash and the user has no signal. Re-surface in the
|
||||
// catch with a retry affordance.
|
||||
if (timerRef.current != null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
start(async () => {
|
||||
try {
|
||||
await restoreImages(ids);
|
||||
router.refresh();
|
||||
setState(null);
|
||||
} catch (e) {
|
||||
setState({
|
||||
ids,
|
||||
visibleAt: Date.now(),
|
||||
failed: { message: (e as Error).message || "Restore failed" },
|
||||
});
|
||||
timerRef.current = window.setTimeout(() => setState(null), VISIBLE_MS);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ctx = useMemo<Ctx>(() => ({ show }), [show]);
|
||||
|
||||
return (
|
||||
<ToastCtx.Provider value={ctx}>
|
||||
{children}
|
||||
{state && (
|
||||
<div className="fixed bottom-[80px] left-1/2 -translate-x-1/2 z-[60]">
|
||||
<div
|
||||
className="rounded-2xl shadow-2xl px-4 py-2.5 flex items-center gap-3 border border-[var(--color-glass-border-strong)] backdrop-blur-2xl"
|
||||
style={{ background: "color-mix(in oklch, var(--color-bg-0) 90%, transparent)" }}
|
||||
>
|
||||
<span className="text-sm">
|
||||
{state.failed ? (
|
||||
<>
|
||||
<span className="text-[var(--color-red)]">Restore failed</span>
|
||||
<span className="text-[var(--color-fg-dim)]"> — {state.failed.message}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-mono text-[var(--color-cyan)]">{state.ids.length}</span>
|
||||
<span className="text-[var(--color-fg-dim)]"> moved to trash</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<div className="w-px h-5 bg-[var(--color-glass-border)]" />
|
||||
<button
|
||||
onClick={undo}
|
||||
disabled={pending}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-50"
|
||||
>
|
||||
<Undo2 className="w-3.5 h-3.5" /> {state.failed ? "Retry" : "Undo"}
|
||||
</button>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss"
|
||||
className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ToastCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUndoDeleteToast() {
|
||||
const ctx = useContext(ToastCtx);
|
||||
if (!ctx) throw new Error("useUndoDeleteToast must be used within UndoDeleteToastProvider");
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user