Add Episodes panel: search a series, browse a season's episode list
New src-tauri/src/episodes.rs exposes browse_episodes, reusing the existing AnyTvClient::fetch_season machinery Fetch Data already built (via a build_tv_client helper factored out of metadata.rs) so this panel needs no credentials of its own - it reads whatever TV source (TMDB/TVmaze/OMDb) is already configured in Fetch Data Settings. New EpisodesPanel.tsx/.css: series name + season stepper, an episode list (number/title/air date), and a Copy list button that copies the season as plain text. Wired into App.tsx's sidebar routing as the second stub panel (after Subtitles) to become a real screen. 145/145 Rust tests passing (+1), npm run build clean, 15/15 Playwright checks against npm run dev (mocked Tauri bridge) covering search, the empty-season case, a not-found series, the season stepper, Copy list, and the blank-query guard.
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import Sidebar, { PANELS } from "./components/Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import RenamePanel from "./components/RenamePanel";
|
||||
import EpisodesPanel from "./components/EpisodesPanel";
|
||||
import SubtitlesPanel from "./components/SubtitlesPanel";
|
||||
import PanelStub from "./components/PanelStub";
|
||||
|
||||
@@ -17,6 +18,8 @@ export default function App() {
|
||||
<div className="main-content">
|
||||
{selected === "rename" ? (
|
||||
<RenamePanel />
|
||||
) : selected === "episodes" ? (
|
||||
<EpisodesPanel />
|
||||
) : selected === "subtitles" ? (
|
||||
<SubtitlesPanel />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
.episodes-panel {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.episodes-search-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.episodes-search-row-inline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.episodes-search-row-inline > label {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.episodes-series-field,
|
||||
.episodes-season-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.episodes-series-field {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.episodes-series-field label,
|
||||
.episodes-season-field label {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.episodes-season-field {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.episodes-season-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.episodes-season-input {
|
||||
width: 56px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.episodes-hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.episodes-results {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.episodes-results-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 2px 4px;
|
||||
}
|
||||
|
||||
.episodes-series-name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.episodes-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 10px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.episodes-row-number {
|
||||
flex: 0 0 28px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--aurora-cyan);
|
||||
}
|
||||
|
||||
.episodes-row-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.episodes-row-date {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.episodes-empty-note {
|
||||
color: var(--muted-text);
|
||||
font-size: 12.5px;
|
||||
padding: 8px 2px;
|
||||
}
|
||||
|
||||
.episodes-status {
|
||||
font-size: 12px;
|
||||
color: var(--aurora-cyan);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface EpisodeRow {
|
||||
episodeNumber: number;
|
||||
title: string;
|
||||
airDate: string | null;
|
||||
}
|
||||
|
||||
type EpisodeListOutcome =
|
||||
| { status: "found"; seriesName: string; episodes: EpisodeRow[] }
|
||||
| { status: "failed"; error: string };
|
||||
|
||||
export default function EpisodesPanel() {
|
||||
const [seriesQuery, setSeriesQuery] = useState("");
|
||||
const [season, setSeason] = useState(1);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [seriesName, setSeriesName] = useState<string | null>(null);
|
||||
const [episodes, setEpisodes] = useState<EpisodeRow[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
async function runBrowse(seasonToUse: number) {
|
||||
const trimmed = seriesQuery.trim();
|
||||
if (!trimmed) {
|
||||
setStatus("Enter a series name to search for.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
const outcome = await invoke<EpisodeListOutcome>("browse_episodes", {
|
||||
seriesQuery: trimmed,
|
||||
season: seasonToUse,
|
||||
});
|
||||
setHasSearched(true);
|
||||
if (outcome.status === "found") {
|
||||
setSeriesName(outcome.seriesName);
|
||||
setEpisodes(outcome.episodes);
|
||||
if (outcome.episodes.length === 0) {
|
||||
setStatus(`${outcome.seriesName} — no episodes found for season ${seasonToUse}.`);
|
||||
}
|
||||
} else {
|
||||
setSeriesName(null);
|
||||
setEpisodes([]);
|
||||
setStatus(outcome.error);
|
||||
}
|
||||
} catch (err) {
|
||||
setSeriesName(null);
|
||||
setEpisodes([]);
|
||||
setStatus(String(err));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function changeSeason(delta: number) {
|
||||
const next = Math.max(1, season + delta);
|
||||
setSeason(next);
|
||||
if (hasSearched) {
|
||||
runBrowse(next);
|
||||
}
|
||||
}
|
||||
|
||||
function copyList() {
|
||||
if (!seriesName || episodes.length === 0) return;
|
||||
const lines = episodes.map(
|
||||
(e) => `${seriesName} - ${season}x${String(e.episodeNumber).padStart(2, "0")} - ${e.title}`
|
||||
);
|
||||
navigator.clipboard
|
||||
.writeText(lines.join("\n"))
|
||||
.then(() => setStatus(`Copied ${lines.length} episode names to the clipboard.`))
|
||||
.catch(() => setStatus("Couldn't copy to the clipboard."));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="episodes-panel">
|
||||
<div className="episodes-search-form">
|
||||
<div className="episodes-search-row-inline">
|
||||
<div className="episodes-series-field">
|
||||
<label htmlFor="episodes-series">Series name</label>
|
||||
<input
|
||||
id="episodes-series"
|
||||
className="modal-text-input"
|
||||
type="text"
|
||||
placeholder="e.g. Breaking Bad"
|
||||
value={seriesQuery}
|
||||
onChange={(e) => setSeriesQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") runBrowse(season);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="episodes-season-field">
|
||||
<label htmlFor="episodes-season">Season</label>
|
||||
<div className="episodes-season-stepper">
|
||||
<button className="action-button" onClick={() => changeSeason(-1)} disabled={searching || season <= 1}>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
id="episodes-season"
|
||||
className="modal-text-input episodes-season-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={season}
|
||||
onChange={(e) => setSeason(Math.max(1, Number(e.target.value) || 1))}
|
||||
/>
|
||||
<button className="action-button" onClick={() => changeSeason(1)} disabled={searching}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="action-button primary" onClick={() => runBrowse(season)} disabled={searching}>
|
||||
{searching ? "Searching…" : "Browse"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="episodes-hint">
|
||||
Searches whichever TV source (TMDB, TVmaze, or OMDb) is set in the Rename panel's Fetch Data Settings — no
|
||||
separate credentials needed here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="episodes-results">
|
||||
{seriesName && episodes.length > 0 && (
|
||||
<div className="episodes-results-header">
|
||||
<span className="episodes-series-name">
|
||||
{seriesName} — Season {season}
|
||||
</span>
|
||||
<button className="action-button" onClick={copyList}>
|
||||
Copy list
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{episodes.length === 0 && !status && !searching && (
|
||||
<div className="episodes-empty-note">
|
||||
{hasSearched ? "No episodes to show." : "Search a series and season to browse its episode list."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{episodes.map((ep) => (
|
||||
<div className="episodes-row" key={ep.episodeNumber}>
|
||||
<span className="episodes-row-number">{String(ep.episodeNumber).padStart(2, "0")}</span>
|
||||
<span className="episodes-row-title">{ep.title}</span>
|
||||
{ep.airDate && <span className="episodes-row-date">{ep.airDate}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{status && <div className="episodes-status">{status}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import "./App.css";
|
||||
import "./components/Sidebar.css";
|
||||
import "./components/Header.css";
|
||||
import "./components/RenamePanel.css";
|
||||
import "./components/EpisodesPanel.css";
|
||||
import "./components/SubtitlesPanel.css";
|
||||
import "./components/PanelStub.css";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user