2026-06-11 02:19:47 +02:00
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
|
|
import { api, HttpError, type FeedFilters } from "./lib/api";
|
|
|
|
|
import {
|
|
|
|
|
applyTheme,
|
|
|
|
|
DEFAULT_THEME,
|
|
|
|
|
loadLocalTheme,
|
|
|
|
|
saveLocalTheme,
|
|
|
|
|
type ThemePrefs,
|
|
|
|
|
} from "./lib/theme";
|
|
|
|
|
import Login from "./components/Login";
|
|
|
|
|
import Header from "./components/Header";
|
|
|
|
|
import Sidebar from "./components/Sidebar";
|
|
|
|
|
import Feed from "./components/Feed";
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
import Toaster from "./components/Toaster";
|
2026-06-11 02:19:47 +02:00
|
|
|
|
|
|
|
|
const DEFAULT_FILTERS: FeedFilters = {
|
|
|
|
|
tags: [],
|
|
|
|
|
tagMode: "or",
|
|
|
|
|
q: "",
|
|
|
|
|
sort: "newest",
|
|
|
|
|
includeShorts: false,
|
|
|
|
|
includeLive: false,
|
|
|
|
|
show: "unwatched",
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-11 03:28:45 +02:00
|
|
|
const FILTERS_KEY = "subfeed.filters";
|
|
|
|
|
|
|
|
|
|
function loadFilters(): FeedFilters {
|
|
|
|
|
try {
|
|
|
|
|
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
|
|
|
|
|
} catch {
|
|
|
|
|
return DEFAULT_FILTERS;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export default function App() {
|
|
|
|
|
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
2026-06-11 03:28:45 +02:00
|
|
|
const [filters, setFiltersState] = useState<FeedFilters>(loadFilters);
|
2026-06-11 02:19:47 +02:00
|
|
|
const [view, setView] = useState<"grid" | "list">("grid");
|
|
|
|
|
|
2026-06-11 03:28:45 +02:00
|
|
|
function setFilters(next: FeedFilters) {
|
|
|
|
|
setFiltersState(next);
|
|
|
|
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
useEffect(() => applyTheme(theme), [theme]);
|
|
|
|
|
|
|
|
|
|
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
|
|
|
|
|
|
|
|
|
// On login, adopt server-stored preferences.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const prefs = meQuery.data?.preferences;
|
|
|
|
|
if (!prefs) return;
|
|
|
|
|
if (prefs.theme) {
|
|
|
|
|
const merged = { ...DEFAULT_THEME, ...prefs.theme };
|
|
|
|
|
setThemeState(merged);
|
|
|
|
|
saveLocalTheme(merged);
|
|
|
|
|
}
|
|
|
|
|
if (prefs.view === "grid" || prefs.view === "list") setView(prefs.view);
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [meQuery.data?.id]);
|
|
|
|
|
|
|
|
|
|
function setTheme(next: ThemePrefs) {
|
|
|
|
|
setThemeState(next);
|
|
|
|
|
saveLocalTheme(next);
|
|
|
|
|
api.savePrefs({ theme: next }).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
function changeView(v: "grid" | "list") {
|
|
|
|
|
setView(v);
|
|
|
|
|
api.savePrefs({ view: v }).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (meQuery.isLoading)
|
|
|
|
|
return <div className="min-h-screen grid place-items-center text-muted">Loading…</div>;
|
|
|
|
|
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
|
|
|
|
|
return <Login />;
|
|
|
|
|
if (meQuery.error)
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen grid place-items-center text-muted">
|
|
|
|
|
Something went wrong.
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-screen flex flex-col bg-bg text-fg">
|
|
|
|
|
<Header
|
|
|
|
|
me={meQuery.data!}
|
|
|
|
|
theme={theme}
|
|
|
|
|
setTheme={setTheme}
|
|
|
|
|
filters={filters}
|
|
|
|
|
setFilters={setFilters}
|
|
|
|
|
view={view}
|
|
|
|
|
setView={changeView}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-1 min-h-0">
|
|
|
|
|
<Sidebar filters={filters} setFilters={setFilters} />
|
|
|
|
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
|
|
|
|
<Feed filters={filters} view={view} />
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
<Toaster />
|
2026-06-11 02:19:47 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|