feat(frontend): make library sidebar drilldown-aware
This commit is contained in:
@@ -86,6 +86,24 @@ struct PagedResponse<T: Serialize> {
|
|||||||
total: u32,
|
total: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ShowSummaryResponse {
|
||||||
|
series_name: String,
|
||||||
|
episode_count: u32,
|
||||||
|
season_count: u32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
thumbnail_url: Option<String>,
|
||||||
|
genres: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct SeasonSummaryResponse {
|
||||||
|
season_number: u32,
|
||||||
|
episode_count: u32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
thumbnail_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct SyncLogResponse {
|
struct SyncLogResponse {
|
||||||
id: i64,
|
id: i64,
|
||||||
@@ -133,6 +151,20 @@ struct ItemsQuery {
|
|||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
offset: Option<u32>,
|
offset: Option<u32>,
|
||||||
provider: Option<String>,
|
provider: Option<String>,
|
||||||
|
season: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct ShowsQuery {
|
||||||
|
q: Option<String>,
|
||||||
|
provider: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
genres: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SeasonsQuery {
|
||||||
|
provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -207,6 +239,7 @@ async fn search_items(
|
|||||||
collection_id: params.collection,
|
collection_id: params.collection,
|
||||||
genres: params.genres,
|
genres: params.genres,
|
||||||
search_term: params.q,
|
search_term: params.q,
|
||||||
|
season_number: params.season,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -306,6 +339,53 @@ async fn trigger_sync(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_shows(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(_user): CurrentUser,
|
||||||
|
Query(params): Query<ShowsQuery>,
|
||||||
|
) -> Result<Json<Vec<ShowSummaryResponse>>, ApiError> {
|
||||||
|
let shows = state
|
||||||
|
.library_repo
|
||||||
|
.list_shows(
|
||||||
|
params.provider.as_deref(),
|
||||||
|
params.q.as_deref(),
|
||||||
|
¶ms.genres,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let resp = shows
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| ShowSummaryResponse {
|
||||||
|
series_name: s.series_name,
|
||||||
|
episode_count: s.episode_count,
|
||||||
|
season_count: s.season_count,
|
||||||
|
thumbnail_url: s.thumbnail_url,
|
||||||
|
genres: s.genres,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_seasons(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(_user): CurrentUser,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
Query(params): Query<SeasonsQuery>,
|
||||||
|
) -> Result<Json<Vec<SeasonSummaryResponse>>, ApiError> {
|
||||||
|
let seasons = state
|
||||||
|
.library_repo
|
||||||
|
.list_seasons(&name, params.provider.as_deref())
|
||||||
|
.await?;
|
||||||
|
let resp = seasons
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| SeasonSummaryResponse {
|
||||||
|
season_number: s.season_number,
|
||||||
|
episode_count: s.episode_count,
|
||||||
|
thumbnail_url: s.thumbnail_url,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(resp))
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_settings(
|
async fn get_settings(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
|
|||||||
@@ -5,23 +5,61 @@ import type { LibrarySearchParams } from "@/hooks/use-library-search";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filter: LibrarySearchParams;
|
filter: LibrarySearchParams;
|
||||||
onFilterChange: (next: Partial<LibrarySearchParams>) => void;
|
onFilterChange: (next: Partial<LibrarySearchParams>) => void;
|
||||||
|
viewMode: "grouped" | "flat";
|
||||||
|
drilldown: null | { series: string } | { series: string; season: number };
|
||||||
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTENT_TYPES = [
|
const ALL = "";
|
||||||
{ value: "", label: "All types" },
|
|
||||||
|
const CONTENT_TYPES_ALL = [
|
||||||
|
{ value: ALL, label: "All types" },
|
||||||
{ value: "movie", label: "Movies" },
|
{ value: "movie", label: "Movies" },
|
||||||
{ value: "episode", label: "Episodes" },
|
{ value: "episode", label: "Episodes" },
|
||||||
{ value: "short", label: "Shorts" },
|
{ value: "short", label: "Shorts" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function LibrarySidebar({ filter, onFilterChange }: Props) {
|
const CONTENT_TYPES_GROUPED = [
|
||||||
|
{ value: ALL, label: "All types" },
|
||||||
|
{ value: "movie", label: "Movies" },
|
||||||
|
{ value: "short", label: "Shorts" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LibrarySidebar({ filter, onFilterChange, viewMode, drilldown, onBack }: Props) {
|
||||||
const { data: collections } = useCollections(filter.provider);
|
const { data: collections } = useCollections(filter.provider);
|
||||||
const { data: genres } = useGenres(filter.type, { provider: filter.provider });
|
const { data: genres } = useGenres(filter.type, { provider: filter.provider });
|
||||||
|
|
||||||
|
if (drilldown !== null) {
|
||||||
|
return (
|
||||||
|
<aside className="w-56 shrink-0 border-r border-zinc-800 bg-zinc-950 p-4 flex flex-col gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-zinc-500">Search</p>
|
||||||
|
<Input
|
||||||
|
placeholder="Search…"
|
||||||
|
value={filter.q ?? ""}
|
||||||
|
onChange={e => onFilterChange({ q: e.target.value || undefined })}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypes = viewMode === "grouped" ? CONTENT_TYPES_GROUPED : CONTENT_TYPES_ALL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 shrink-0 border-r border-zinc-800 bg-zinc-950 p-4 flex flex-col gap-4">
|
<aside className="w-56 shrink-0 border-r border-zinc-800 bg-zinc-950 p-4 flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -36,10 +74,10 @@ export function LibrarySidebar({ filter, onFilterChange }: Props) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-zinc-500">Type</p>
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-zinc-500">Type</p>
|
||||||
<Select value={filter.type ?? ""} onValueChange={v => onFilterChange({ type: v || undefined })}>
|
<Select value={filter.type ?? ALL} onValueChange={v => onFilterChange({ type: v === ALL ? undefined : v })}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CONTENT_TYPES.map(ct => (
|
{contentTypes.map(ct => (
|
||||||
<SelectItem key={ct.value} value={ct.value}>{ct.label}</SelectItem>
|
<SelectItem key={ct.value} value={ct.value}>{ct.label}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -12,16 +12,18 @@ import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedItems: LibraryItemFull[];
|
selectedItems: LibraryItemFull[];
|
||||||
|
selectedShows?: ShowSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleFromLibraryDialog({ selectedItems }: Props) {
|
export function ScheduleFromLibraryDialog({ selectedItems, selectedShows }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [channelId, setChannelId] = useState("");
|
const [channelId, setChannelId] = useState("");
|
||||||
const [selectedDays, setSelectedDays] = useState<Set<Weekday>>(new Set());
|
const [selectedDays, setSelectedDays] = useState<Set<Weekday>>(new Set());
|
||||||
const [startTime, setStartTime] = useState("20:00");
|
const [startTime, setStartTime] = useState("20:00");
|
||||||
const [durationMins, setDurationMins] = useState(() =>
|
const [durationMins, setDurationMins] = useState(() => {
|
||||||
selectedItems.length === 1 ? Math.ceil(selectedItems[0].duration_secs / 60) : 60
|
if (selectedItems.length === 1) return Math.ceil(selectedItems[0].duration_secs / 60);
|
||||||
);
|
return 60;
|
||||||
|
});
|
||||||
const [strategy, setStrategy] = useState<"sequential" | "random" | "best_fit">("sequential");
|
const [strategy, setStrategy] = useState<"sequential" | "random" | "best_fit">("sequential");
|
||||||
|
|
||||||
const { data: channels } = useChannels();
|
const { data: channels } = useChannels();
|
||||||
@@ -47,32 +49,54 @@ export function ScheduleFromLibraryDialog({ selectedItems }: Props) {
|
|||||||
if (!selectedChannel || selectedDays.size === 0) return;
|
if (!selectedChannel || selectedDays.size === 0) return;
|
||||||
const startTimeFull = startTime.length === 5 ? `${startTime}:00` : startTime;
|
const startTimeFull = startTime.length === 5 ? `${startTime}:00` : startTime;
|
||||||
|
|
||||||
const newBlock: ProgrammingBlock = allSameSeries
|
const hasShows = selectedShows && selectedShows.length > 0;
|
||||||
|
|
||||||
|
const newBlock: ProgrammingBlock = hasShows
|
||||||
? {
|
? {
|
||||||
id: globalThis.crypto.randomUUID(),
|
id: globalThis.crypto.randomUUID(),
|
||||||
name: `${selectedItems[0].series_name ?? "Series"} — ${startTime}`,
|
name: selectedShows!.length === 1
|
||||||
|
? `${selectedShows![0].series_name} — ${startTime}`
|
||||||
|
: `${selectedShows!.length} shows — ${startTime}`,
|
||||||
start_time: startTimeFull,
|
start_time: startTimeFull,
|
||||||
duration_mins: durationMins,
|
duration_mins: durationMins,
|
||||||
content: {
|
content: {
|
||||||
type: "algorithmic",
|
type: "algorithmic",
|
||||||
filter: {
|
filter: {
|
||||||
content_type: "episode",
|
content_type: "episode",
|
||||||
series_names: [selectedItems[0].series_name!],
|
series_names: selectedShows!.map(s => s.series_name),
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
collections: [],
|
collections: [],
|
||||||
},
|
},
|
||||||
strategy,
|
strategy,
|
||||||
provider_id: selectedItems[0].id.split("::")[0],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: allSameSeries
|
||||||
id: globalThis.crypto.randomUUID(),
|
? {
|
||||||
name: `${selectedItems.length} items — ${startTime}`,
|
id: globalThis.crypto.randomUUID(),
|
||||||
start_time: startTimeFull,
|
name: `${selectedItems[0].series_name ?? "Series"} — ${startTime}`,
|
||||||
duration_mins: durationMins,
|
start_time: startTimeFull,
|
||||||
content: { type: "manual", items: selectedItems.map(i => i.id) },
|
duration_mins: durationMins,
|
||||||
};
|
content: {
|
||||||
|
type: "algorithmic",
|
||||||
|
filter: {
|
||||||
|
content_type: "episode",
|
||||||
|
series_names: [selectedItems[0].series_name!],
|
||||||
|
genres: [],
|
||||||
|
tags: [],
|
||||||
|
collections: [],
|
||||||
|
},
|
||||||
|
strategy,
|
||||||
|
provider_id: selectedItems[0].id.split("::")[0],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: globalThis.crypto.randomUUID(),
|
||||||
|
name: `${selectedItems.length} items — ${startTime}`,
|
||||||
|
start_time: startTimeFull,
|
||||||
|
duration_mins: durationMins,
|
||||||
|
content: { type: "manual", items: selectedItems.map(i => i.id) },
|
||||||
|
};
|
||||||
|
|
||||||
const updatedDayBlocks = { ...selectedChannel.schedule_config.day_blocks };
|
const updatedDayBlocks = { ...selectedChannel.schedule_config.day_blocks };
|
||||||
for (const day of selectedDays) {
|
for (const day of selectedDays) {
|
||||||
|
|||||||
Reference in New Issue
Block a user