feat: M4 (part 2) — React reader UI with theming
- Vite + React + TS + Tailwind SPA served by FastAPI (multi-stage Docker build) - Four color schemes (Midnight default, Forest, Slate, YouTube) x dark/light, adjustable text size; persisted to user preferences and localStorage - Header search, grid/list toggle, theme menu; sidebar filters (show state, sort, include Shorts/live, language + topic tag chips with any/all matching) - Infinite-scroll feed of video cards; click opens youtube.com in a new tab; per-video watched / saved / hidden actions with optimistic updates - SPA fallback routing; login screen for unauthenticated users
This commit is contained in:
parent
9a377b7e7e
commit
e56502789f
20 changed files with 1194 additions and 4 deletions
232
frontend/src/components/Sidebar.tsx
Normal file
232
frontend/src/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
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: "shuffle", label: "Surprise me" },
|
||||
];
|
||||
|
||||
const SHOWS = [
|
||||
{ id: "unwatched", label: "Unwatched" },
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "watched", label: "Watched" },
|
||||
{ id: "saved", label: "Saved" },
|
||||
];
|
||||
|
||||
function TagChip({
|
||||
tag,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
tag: Tag;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`text-xs px-2.5 py-1 rounded-full border transition ${
|
||||
active
|
||||
? "bg-accent text-accent-fg border-accent"
|
||||
: "bg-card border-border text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
<span className={active ? "opacity-80 ml-1" : "text-muted ml-1"}>
|
||||
{tag.channel_count}
|
||||
</span>
|
||||
</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.includeShorts ||
|
||||
filters.includeLive ||
|
||||
filters.show !== "unwatched" ||
|
||||
filters.sort !== "newest";
|
||||
|
||||
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">
|
||||
<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 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="Content">
|
||||
<Toggle
|
||||
label="Include Shorts"
|
||||
checked={filters.includeShorts}
|
||||
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Include 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",
|
||||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue