siftlode/frontend/src/components/Sidebar.tsx
npeter83 f73cbdb490 feat: unhide action, date-range filter, more sort options
- Hidden view shows an Unhide action (eye icon) instead of Hide
- Upload-date filter: From/To date range (inclusive); feed shows only videos
  published in that window (backend published_after / published_before)
- New sort options: Name (A-Z) and Channel subscribers, alongside date/views/
  duration/shuffle
- Native controls follow the theme via color-scheme (dark date picker)
2026-06-11 04:00:17 +02:00

291 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api";
const SORTS = [
{ id: "newest", label: "Newest" },
{ id: "oldest", label: "Oldest" },
{ id: "views", label: "Most viewed" },
{ id: "duration_desc", label: "Longest" },
{ id: "duration_asc", label: "Shortest" },
{ id: "title", label: "Name (AZ)" },
{ id: "subscribers", label: "Channel subscribers" },
{ id: "shuffle", label: "Surprise me" },
];
const SHOWS = [
{ id: "unwatched", label: "Unwatched" },
{ id: "all", label: "All" },
{ id: "watched", label: "Watched" },
{ id: "saved", label: "Saved" },
{ id: "hidden", label: "Hidden" },
];
function TagChip({
tag,
active,
onClick,
}: {
tag: Tag;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
title={`${tag.channel_count} channel${tag.channel_count === 1 ? "" : "s"}`}
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
active
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-fg hover:border-accent"
}`}
>
{tag.name}
</button>
);
}
export default function Sidebar({
filters,
setFilters,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) {
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tags = tagsQuery.data ?? [];
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
function toggleTag(id: number) {
const has = filters.tags.includes(id);
setFilters({
...filters,
tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id],
});
}
const active =
filters.tags.length > 0 ||
!filters.includeNormal ||
filters.includeShorts ||
filters.includeLive ||
filters.show !== "unwatched" ||
filters.sort !== "newest" ||
!!filters.channelId ||
!!filters.dateFrom ||
!!filters.dateTo;
return (
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-5 hidden md:block">
{filters.channelId && (
<Section title="Channel">
<button
onClick={() => setFilters({ ...filters, channelId: undefined, channelName: undefined })}
className="w-full flex items-center justify-between gap-2 text-sm px-3 py-2 rounded-lg bg-accent text-accent-fg shadow-sm hover:opacity-90 transition"
>
<span className="truncate">{filters.channelName ?? "This channel"}</span>
<X className="w-4 h-4 shrink-0" />
</button>
</Section>
)}
<Section title="Show">
<div className="grid grid-cols-2 gap-1.5">
{SHOWS.map((s) => (
<button
key={s.id}
onClick={() => setFilters({ ...filters, show: s.id })}
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
filters.show === s.id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent"
}`}
>
{s.label}
</button>
))}
</div>
</Section>
<Section title="Sort">
<select
value={filters.sort}
onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
>
{SORTS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</Section>
<Section title="Upload date">
<div className="flex flex-col gap-1.5">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">From</span>
<input
type="date"
value={filters.dateFrom ?? ""}
onChange={(e) =>
setFilters({ ...filters, dateFrom: e.target.value || undefined })
}
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">To</span>
<input
type="date"
value={filters.dateTo ?? ""}
onChange={(e) =>
setFilters({ ...filters, dateTo: e.target.value || undefined })
}
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</label>
{(filters.dateFrom || filters.dateTo) && (
<button
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
className="text-[11px] text-muted hover:text-accent self-end"
>
clear dates
</button>
)}
</div>
</Section>
<Section title="Content type">
<Toggle
label="Normal"
checked={filters.includeNormal}
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
/>
<Toggle
label="Shorts"
checked={filters.includeShorts}
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
/>
<Toggle
label="Live / Upcoming"
checked={filters.includeLive}
onChange={(v) => setFilters({ ...filters, includeLive: v })}
/>
</Section>
{languages.length > 0 && (
<Section title="Language">
<div className="flex flex-wrap gap-1.5">
{languages.map((t) => (
<TagChip
key={t.id}
tag={t}
active={filters.tags.includes(t.id)}
onClick={() => toggleTag(t.id)}
/>
))}
</div>
</Section>
)}
{topics.length > 0 && (
<Section
title="Topic"
right={
<button
onClick={() =>
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
}
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
title="Match any vs all selected tags"
>
{filters.tagMode === "or" ? "Any" : "All"}
</button>
}
>
<div className="flex flex-wrap gap-1.5">
{topics.map((t) => (
<TagChip
key={t.id}
tag={t}
active={filters.tags.includes(t.id)}
onClick={() => toggleTag(t.id)}
/>
))}
</div>
</Section>
)}
{active && (
<button
onClick={() =>
setFilters({
tags: [],
tagMode: "or",
q: filters.q,
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
})
}
className="w-full text-xs py-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
>
Clear filters
</button>
)}
</aside>
);
}
function Section({
title,
right,
children,
}: {
title: string;
right?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-xs uppercase tracking-wide text-muted">{title}</div>
{right}
</div>
{children}
</div>
);
}
function Toggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex items-center justify-between py-1 cursor-pointer text-sm">
<span>{label}</span>
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative ${
checked ? "bg-accent" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
</label>
);
}