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:
@@ -48,7 +48,10 @@ whichever TV source (TMDB/TVmaze/OMDb) is already configured in Fetch Data Setti
|
|||||||
separate credentials needed; see "Episodes" below. A new SFV panel computes and verifies
|
separate credentials needed; see "Episodes" below. A new SFV panel computes and verifies
|
||||||
CRC32/MD5/SHA1 checksums for a loaded set of files, and reads and writes the classic
|
CRC32/MD5/SHA1 checksums for a loaded set of files, and reads and writes the classic
|
||||||
`.sfv`/`.md5`/`.sha1` sidecar-file formats — entirely local, no network access or
|
`.sfv`/`.md5`/`.sha1` sidecar-file formats — entirely local, no network access or
|
||||||
credentials of any kind; see "SFV" below. See "Porting roadmap" below.
|
credentials of any kind; see "SFV" below. A new Filter panel loads a batch of files and
|
||||||
|
narrows it down by extension, TV/Movie/Unmatched kind, filename text, and (for TV) season
|
||||||
|
— all computed from the same local, filename-only matching "Match" uses, with a Copy File
|
||||||
|
Paths export for whatever's left; see "Filter" below. See "Porting roadmap" below.
|
||||||
|
|
||||||
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
|
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
|
||||||
to the New Names list) — the default is `{show name} - {season number}x{episode
|
to the New Names list) — the default is `{show name} - {season number}x{episode
|
||||||
@@ -244,7 +247,14 @@ src/ React + TypeScript UI (Vite). Faithful recreation of the
|
|||||||
checksum file and verify the files it lists against what's
|
checksum file and verify the files it lists against what's
|
||||||
actually on disk (OK/mismatch/missing per entry) — no settings
|
actually on disk (OK/mismatch/missing per entry) — no settings
|
||||||
modal at all, since the feature needs no credentials or network
|
modal at all, since the feature needs no credentials or network
|
||||||
access.
|
access. `FilterPanel.tsx` is the fourth: load a set of files and
|
||||||
|
narrow them down with toggleable extension/kind chips, a
|
||||||
|
filename-contains text field, and a season number field (TV
|
||||||
|
only) — adds no new Rust/Tauri commands at all, reusing
|
||||||
|
`pick_files`/`guess_episode`/`guess_movie`/`guess_release_info`
|
||||||
|
exactly as the Rename panel's own "Match" does, just applied to
|
||||||
|
a second, independently-loaded file list and rendered as
|
||||||
|
criteria instead of a rename preview.
|
||||||
scripts/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark
|
scripts/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark
|
||||||
(`scripts/logo/icon-source.png`, cropped from the supplied
|
(`scripts/logo/icon-source.png`, cropped from the supplied
|
||||||
lockup) — replace that source image to change the look.
|
lockup) — replace that source image to change the look.
|
||||||
@@ -441,6 +451,26 @@ resolved against the real files sitting next to it on disk and reported as **OK*
|
|||||||
referenced file isn't there). Checksum comparison is case-insensitive, since some tools
|
referenced file isn't there). Checksum comparison is case-insensitive, since some tools
|
||||||
write hex digests in uppercase and others in lowercase.
|
write hex digests in uppercase and others in lowercase.
|
||||||
|
|
||||||
|
## Filter
|
||||||
|
|
||||||
|
The Filter sidebar panel loads a batch of files (independent of whatever's loaded in the
|
||||||
|
Rename panel) and narrows it down before you decide what to do with it elsewhere — copy
|
||||||
|
the matching paths out, or just get a sense of what's actually in a folder. Unlike SFV,
|
||||||
|
it adds no new Rust/Tauri code at all: it scans every loaded file with the exact same
|
||||||
|
local, filename-only matchers "Match" uses (`guess_episode`/`guess_movie`/
|
||||||
|
`guess_release_info`) and turns the results into filter criteria instead of a rename
|
||||||
|
preview — no network calls, so scanning even a large batch is effectively instant.
|
||||||
|
|
||||||
|
**Load Files…** loads and scans a batch in one step. Toggleable chips then appear for
|
||||||
|
every **extension** actually present (e.g. MKV/MP4/TXT) and every **kind** Aurora Curator
|
||||||
|
locally recognized (TV/Movies/Unmatched) — click a chip to exclude that group, click it
|
||||||
|
again to bring it back. A **filename contains** field does a live, case-insensitive
|
||||||
|
substring search, and a **season** field (shown only while the TV chip is enabled) narrows
|
||||||
|
to a single season number, automatically excluding anything that isn't a TV episode in
|
||||||
|
that season. The results list updates live with an "N of M files match" count, and
|
||||||
|
**Copy File Paths** writes every currently-matching file's full path, one per line, to the
|
||||||
|
clipboard.
|
||||||
|
|
||||||
## Porting roadmap (not yet done)
|
## Porting roadmap (not yet done)
|
||||||
|
|
||||||
Roughly in priority order — each references the original FileBot source file it's
|
Roughly in priority order — each references the original FileBot source file it's
|
||||||
@@ -542,11 +572,13 @@ ported from, for anyone picking this up:
|
|||||||
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built, and so
|
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built, and so
|
||||||
are the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above),
|
are the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above),
|
||||||
the Episodes panel (series search + season browse with a Copy list export, no
|
the Episodes panel (series search + season browse with a Copy list export, no
|
||||||
credentials of its own, see "Episodes" above), and the SFV panel (compute/verify
|
credentials of its own, see "Episodes" above), the SFV panel (compute/verify
|
||||||
CRC32/MD5/SHA1 checksums, read/write `.sfv`/`.md5`/`.sha1` sidecar files, entirely
|
CRC32/MD5/SHA1 checksums, read/write `.sfv`/`.md5`/`.sha1` sidecar files, entirely
|
||||||
local with no credentials at all, see "SFV" above) — the first three of the other
|
local with no credentials at all, see "SFV" above), and the Filter panel (extension/
|
||||||
five sidebar panels to become real screens. The remaining two (Filter/List) are
|
kind/filename/season narrowing over a locally-scanned batch, no new Rust/Tauri
|
||||||
still stub screens. Align Up/Down is button-based
|
surface at all, see "Filter" above) — the first four of the other five sidebar
|
||||||
|
panels to become real screens. The remaining one (List) is still a stub screen.
|
||||||
|
Align Up/Down is button-based
|
||||||
(swaps the selected file with its neighbor), not the original's
|
(swaps the selected file with its neighbor), not the original's
|
||||||
drag-and-drop-within-a-list interaction.
|
drag-and-drop-within-a-list interaction.
|
||||||
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
|
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import RenamePanel from "./components/RenamePanel";
|
|||||||
import EpisodesPanel from "./components/EpisodesPanel";
|
import EpisodesPanel from "./components/EpisodesPanel";
|
||||||
import SubtitlesPanel from "./components/SubtitlesPanel";
|
import SubtitlesPanel from "./components/SubtitlesPanel";
|
||||||
import SfvPanel from "./components/SfvPanel";
|
import SfvPanel from "./components/SfvPanel";
|
||||||
|
import FilterPanel from "./components/FilterPanel";
|
||||||
import PanelStub from "./components/PanelStub";
|
import PanelStub from "./components/PanelStub";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -25,6 +26,8 @@ export default function App() {
|
|||||||
<SubtitlesPanel />
|
<SubtitlesPanel />
|
||||||
) : selected === "sfv" ? (
|
) : selected === "sfv" ? (
|
||||||
<SfvPanel />
|
<SfvPanel />
|
||||||
|
) : selected === "filter" ? (
|
||||||
|
<FilterPanel />
|
||||||
) : (
|
) : (
|
||||||
<PanelStub name={current.name} />
|
<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/EpisodesPanel.css";
|
||||||
import "./components/SubtitlesPanel.css";
|
import "./components/SubtitlesPanel.css";
|
||||||
import "./components/SfvPanel.css";
|
import "./components/SfvPanel.css";
|
||||||
|
import "./components/FilterPanel.css";
|
||||||
import "./components/PanelStub.css";
|
import "./components/PanelStub.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
|||||||
Reference in New Issue
Block a user