Add Filter panel: narrow a loaded batch by extension, kind, filename, and season
Fully local, and adds no new Rust/Tauri commands at all - reuses pick_files plus the same guess_episode/guess_movie/guess_release_info local matchers "Match" already calls in the Rename panel, just applied to a second, independently-loaded file list and rendered as toggleable filter criteria instead of a rename preview. New src/components/FilterPanel.tsx/.css: Load Files scans the batch immediately, toggleable chips appear for every extension and media kind actually present, a filename-contains text field and a season number field (TV only) narrow further, and Copy File Paths exports whatever currently matches to the clipboard. Verified with a 21-check Playwright script against npm run dev (chip toggling, the filename/season filters, the no-match empty state, and the clipboard export).
This commit is contained in:
@@ -5,6 +5,7 @@ import RenamePanel from "./components/RenamePanel";
|
||||
import EpisodesPanel from "./components/EpisodesPanel";
|
||||
import SubtitlesPanel from "./components/SubtitlesPanel";
|
||||
import SfvPanel from "./components/SfvPanel";
|
||||
import FilterPanel from "./components/FilterPanel";
|
||||
import PanelStub from "./components/PanelStub";
|
||||
|
||||
export default function App() {
|
||||
@@ -25,6 +26,8 @@ export default function App() {
|
||||
<SubtitlesPanel />
|
||||
) : selected === "sfv" ? (
|
||||
<SfvPanel />
|
||||
) : selected === "filter" ? (
|
||||
<FilterPanel />
|
||||
) : (
|
||||
<PanelStub name={current.name} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
.filter-panel {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filter-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.filter-loaded-count {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.filter-criteria {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.filter-criteria-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-criteria-label {
|
||||
flex: 0 0 130px;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.filter-chip-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--surface-deep);
|
||||
color: var(--muted-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-chip.selected {
|
||||
background: rgba(82, 199, 219, 0.16);
|
||||
border-color: var(--aurora-cyan);
|
||||
color: var(--aurora-cyan);
|
||||
}
|
||||
|
||||
.filter-name-input {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.filter-season-input {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.filter-results {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-results-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 2px 4px;
|
||||
}
|
||||
|
||||
.filter-match-count {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 10px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.filter-row-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-row-subtitle {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.filter-empty-note {
|
||||
color: var(--muted-text);
|
||||
font-size: 12.5px;
|
||||
padding: 8px 2px;
|
||||
}
|
||||
|
||||
.filter-status {
|
||||
font-size: 12px;
|
||||
color: var(--aurora-cyan);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
name: string; // filename without extension
|
||||
ext: string;
|
||||
}
|
||||
|
||||
// Same local, filename-only guessers "Match" uses in the Rename panel (aurora-curator-core's
|
||||
// `matching` module) - no network calls, so Filter can scan a freshly-loaded batch
|
||||
// instantly and narrow it down before deciding what to do with it elsewhere.
|
||||
interface EpisodeGuess {
|
||||
seriesName: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
}
|
||||
|
||||
interface MovieGuess {
|
||||
title: string;
|
||||
year: number | null;
|
||||
}
|
||||
|
||||
interface ReleaseInfo {
|
||||
resolution: string | null;
|
||||
source: string | null;
|
||||
videoCodec: string | null;
|
||||
audioCodec: string | null;
|
||||
group: string | null;
|
||||
}
|
||||
|
||||
type MediaKind = "tv" | "movie" | "unmatched";
|
||||
|
||||
interface FileInfo {
|
||||
kind: MediaKind;
|
||||
seriesName?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
movieName?: string;
|
||||
year?: number | null;
|
||||
release: ReleaseInfo | null;
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<MediaKind, string> = {
|
||||
tv: "TV",
|
||||
movie: "Movies",
|
||||
unmatched: "Unmatched",
|
||||
};
|
||||
|
||||
function extLabel(ext: string): string {
|
||||
return ext ? ext.toUpperCase() : "(none)";
|
||||
}
|
||||
|
||||
function displayName(file: FileEntry): string {
|
||||
return file.ext ? `${file.name}.${file.ext}` : file.name;
|
||||
}
|
||||
|
||||
function subtitleFor(info: FileInfo | undefined): string {
|
||||
if (!info) return "";
|
||||
if (info.kind === "tv") {
|
||||
const se = `S${String(info.season ?? 0).padStart(2, "0")}E${String(info.episode ?? 0).padStart(2, "0")}`;
|
||||
return info.seriesName ? `${info.seriesName} · ${se}` : se;
|
||||
}
|
||||
if (info.kind === "movie") {
|
||||
return info.year ? `${info.movieName} (${info.year})` : info.movieName ?? "";
|
||||
}
|
||||
return "Unmatched";
|
||||
}
|
||||
|
||||
export default function FilterPanel() {
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [info, setInfo] = useState<Record<string, FileInfo>>({});
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [extensions, setExtensions] = useState<Set<string>>(new Set());
|
||||
const [kinds, setKinds] = useState<Set<MediaKind>>(new Set());
|
||||
const [nameContains, setNameContains] = useState("");
|
||||
const [season, setSeason] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
async function loadFiles() {
|
||||
const picked = await invoke<FileEntry[]>("pick_files");
|
||||
if (picked.length === 0) return;
|
||||
setFiles(picked);
|
||||
setInfo({});
|
||||
setNameContains("");
|
||||
setSeason("");
|
||||
setStatus(null);
|
||||
await scan(picked);
|
||||
}
|
||||
|
||||
async function scan(targets: FileEntry[]) {
|
||||
setScanning(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
targets.map(async (file): Promise<readonly [string, FileInfo]> => {
|
||||
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
|
||||
filename: displayName(file),
|
||||
}).catch(() => null);
|
||||
|
||||
let fileInfo: FileInfo | null = null;
|
||||
try {
|
||||
const episode = await invoke<EpisodeGuess | null>("guess_episode", { filename: displayName(file) });
|
||||
if (episode) {
|
||||
fileInfo = {
|
||||
kind: "tv",
|
||||
seriesName: episode.seriesName,
|
||||
season: episode.season,
|
||||
episode: episode.episode,
|
||||
release: null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through and try a movie guess instead.
|
||||
}
|
||||
if (!fileInfo) {
|
||||
try {
|
||||
const movie = await invoke<MovieGuess | null>("guess_movie", { filename: displayName(file) });
|
||||
if (movie) {
|
||||
fileInfo = { kind: "movie", movieName: movie.title, year: movie.year, release: null };
|
||||
}
|
||||
} catch {
|
||||
// Neither guess worked.
|
||||
}
|
||||
}
|
||||
if (!fileInfo) fileInfo = { kind: "unmatched", release: null };
|
||||
fileInfo.release = await releasePromise;
|
||||
return [file.path, fileInfo] as const;
|
||||
})
|
||||
);
|
||||
|
||||
const nextInfo: Record<string, FileInfo> = {};
|
||||
const seenExt = new Set<string>();
|
||||
const seenKinds = new Set<MediaKind>();
|
||||
for (const [path, fileInfo] of results) {
|
||||
nextInfo[path] = fileInfo;
|
||||
seenKinds.add(fileInfo.kind);
|
||||
}
|
||||
for (const file of targets) seenExt.add(file.ext);
|
||||
|
||||
setInfo(nextInfo);
|
||||
setExtensions(seenExt);
|
||||
setKinds(seenKinds);
|
||||
setStatus(`Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (no network calls).`);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExtension(ext: string) {
|
||||
setExtensions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ext)) next.delete(ext);
|
||||
else next.add(ext);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleKind(kind: MediaKind) {
|
||||
setKinds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(kind)) {
|
||||
next.delete(kind);
|
||||
if (kind === "tv") setSeason("");
|
||||
} else {
|
||||
next.add(kind);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const allExtensions = Array.from(new Set(files.map((f) => f.ext))).sort();
|
||||
const allKinds = (["tv", "movie", "unmatched"] as MediaKind[]).filter((k) =>
|
||||
Object.values(info).some((i) => i.kind === k)
|
||||
);
|
||||
|
||||
const seasonFilter = kinds.has("tv") && season.trim() !== "" ? Number(season) : null;
|
||||
|
||||
const filtered = files.filter((file) => {
|
||||
const fileInfo = info[file.path];
|
||||
if (!fileInfo) return false;
|
||||
if (!extensions.has(file.ext)) return false;
|
||||
if (!kinds.has(fileInfo.kind)) return false;
|
||||
if (nameContains.trim() && !displayName(file).toLowerCase().includes(nameContains.trim().toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (seasonFilter !== null && !(fileInfo.kind === "tv" && fileInfo.season === seasonFilter)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function copyPaths() {
|
||||
if (filtered.length === 0) return;
|
||||
navigator.clipboard
|
||||
.writeText(filtered.map((f) => f.path).join("\n"))
|
||||
.then(() => setStatus(`Copied ${filtered.length} file path${filtered.length === 1 ? "" : "s"} to the clipboard.`))
|
||||
.catch(() => setStatus("Couldn't copy to the clipboard."));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="filter-panel">
|
||||
<div className="filter-toolbar">
|
||||
<button className="action-button" onClick={loadFiles}>
|
||||
Load Files…
|
||||
</button>
|
||||
<span className="filter-loaded-count">
|
||||
{files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="filter-criteria">
|
||||
{allExtensions.length > 0 && (
|
||||
<div className="filter-criteria-row">
|
||||
<span className="filter-criteria-label">Extension</span>
|
||||
<div className="filter-chip-row" role="group" aria-label="Filter by extension">
|
||||
{allExtensions.map((ext) => (
|
||||
<button
|
||||
key={ext || "(none)"}
|
||||
className={`filter-chip${extensions.has(ext) ? " selected" : ""}`}
|
||||
onClick={() => toggleExtension(ext)}
|
||||
>
|
||||
{extLabel(ext)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allKinds.length > 0 && (
|
||||
<div className="filter-criteria-row">
|
||||
<span className="filter-criteria-label">Kind</span>
|
||||
<div className="filter-chip-row" role="group" aria-label="Filter by media kind">
|
||||
{allKinds.map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
className={`filter-chip${kinds.has(kind) ? " selected" : ""}`}
|
||||
onClick={() => toggleKind(kind)}
|
||||
>
|
||||
{KIND_LABELS[kind]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="filter-criteria-row">
|
||||
<span className="filter-criteria-label">Filename contains</span>
|
||||
<input
|
||||
className="modal-text-input filter-name-input"
|
||||
type="text"
|
||||
placeholder="e.g. director's cut"
|
||||
value={nameContains}
|
||||
onChange={(e) => setNameContains(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{kinds.has("tv") && (
|
||||
<div className="filter-criteria-row">
|
||||
<span className="filter-criteria-label">Season</span>
|
||||
<input
|
||||
className="modal-text-input filter-season-input"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="any"
|
||||
value={season}
|
||||
onChange={(e) => setSeason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="filter-results">
|
||||
{files.length === 0 && <div className="filter-empty-note">Load files to filter them by extension, kind, name, or season.</div>}
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="filter-results-header">
|
||||
<span className="filter-match-count">
|
||||
{scanning ? "Scanning…" : `${filtered.length} of ${files.length} files match`}
|
||||
</span>
|
||||
<button className="action-button" onClick={copyPaths} disabled={filtered.length === 0}>
|
||||
Copy File Paths
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length > 0 && !scanning && filtered.length === 0 && (
|
||||
<div className="filter-empty-note">No files match the current filters.</div>
|
||||
)}
|
||||
|
||||
{filtered.map((file) => (
|
||||
<div className="filter-row" key={file.path}>
|
||||
<span className="filter-row-name">{displayName(file)}</span>
|
||||
<span className="filter-row-subtitle">{subtitleFor(info[file.path])}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{status && <div className="filter-status">{status}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import "./components/RenamePanel.css";
|
||||
import "./components/EpisodesPanel.css";
|
||||
import "./components/SubtitlesPanel.css";
|
||||
import "./components/SfvPanel.css";
|
||||
import "./components/FilterPanel.css";
|
||||
import "./components/PanelStub.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
|
||||
Reference in New Issue
Block a user