Merge: promote dev to prod
This commit is contained in:
commit
1b80c23e98
10 changed files with 222 additions and 39 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.1.0
|
0.2.0
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ def _filtered_query(
|
||||||
include_live: bool,
|
include_live: bool,
|
||||||
show: str,
|
show: str,
|
||||||
scope: str = "my",
|
scope: str = "my",
|
||||||
|
exclude_tag_category: str | None = None,
|
||||||
) -> tuple[Select, object]:
|
) -> tuple[Select, object]:
|
||||||
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
|
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
|
||||||
Returns the column-bearing select plus the watch-status expression for sorting.
|
Returns the column-bearing select plus the watch-status expression for sorting.
|
||||||
|
|
@ -153,6 +154,11 @@ def _filtered_query(
|
||||||
by_category: dict[str, list[int]] = {}
|
by_category: dict[str, list[int]] = {}
|
||||||
for tag_id, category in cat_rows:
|
for tag_id, category in cat_rows:
|
||||||
by_category.setdefault(category, []).append(tag_id)
|
by_category.setdefault(category, []).append(tag_id)
|
||||||
|
# Facet counting drops the category being counted so its own chips don't zero each
|
||||||
|
# other out (standard drill-down faceting: a category's count ignores its own
|
||||||
|
# selections but still honours the other categories' filters).
|
||||||
|
if exclude_tag_category:
|
||||||
|
by_category.pop(exclude_tag_category, None)
|
||||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
for category, ids in by_category.items():
|
for category, ids in by_category.items():
|
||||||
if category == "topic" and tag_mode == "and" and len(ids) > 1:
|
if category == "topic" and tag_mode == "and" and len(ids) > 1:
|
||||||
|
|
@ -294,6 +300,48 @@ def get_feed_count(
|
||||||
return {"count": total or 0}
|
return {"count": total or 0}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/facets")
|
||||||
|
def get_facets(
|
||||||
|
params: dict = Depends(_feed_params),
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Per-tag channel counts for the *current* filter context, so the sidebar can show
|
||||||
|
live counts and drop chips that no longer match anything. Each category is counted with
|
||||||
|
every other filter applied (scope, channel, date, content type, search, watch state,
|
||||||
|
and the other category's tags) but its own selections ignored — standard drill-down
|
||||||
|
faceting, so selecting one topic doesn't zero out the rest of the topics.
|
||||||
|
|
||||||
|
The count is the number of distinct channels that have at least one video in the current
|
||||||
|
view, matching the existing channel-count chip semantics. JSON object keys are strings
|
||||||
|
(tag ids)."""
|
||||||
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
|
counts: dict[int, int] = {}
|
||||||
|
for category in ("language", "topic"):
|
||||||
|
# Disjunctive (OR) facets drop the category's own selections so its chips keep
|
||||||
|
# independent counts (you can OR more of them in). Conjunctive (AND, topics only)
|
||||||
|
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
|
||||||
|
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
|
||||||
|
conjunctive = category == "topic" and params.get("tag_mode") == "and"
|
||||||
|
base, _status = _filtered_query(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
**{**params, "exclude_tag_category": None if conjunctive else category},
|
||||||
|
)
|
||||||
|
channels = base.with_only_columns(Video.channel_id).distinct().subquery()
|
||||||
|
rows = db.execute(
|
||||||
|
select(ChannelTag.tag_id, func.count(func.distinct(ChannelTag.channel_id)))
|
||||||
|
.select_from(channels)
|
||||||
|
.join(ChannelTag, ChannelTag.channel_id == channels.c.channel_id)
|
||||||
|
.join(Tag, and_(Tag.id == ChannelTag.tag_id, Tag.category == category))
|
||||||
|
.where(visible)
|
||||||
|
.group_by(ChannelTag.tag_id)
|
||||||
|
).all()
|
||||||
|
for tag_id, count in rows:
|
||||||
|
counts[tag_id] = count
|
||||||
|
return {"counts": counts}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/{video_id}/state")
|
@router.post("/videos/{video_id}/state")
|
||||||
def set_video_state(
|
def set_video_state(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
saveLocalTheme,
|
saveLocalTheme,
|
||||||
type ThemePrefs,
|
type ThemePrefs,
|
||||||
} from "./lib/theme";
|
} from "./lib/theme";
|
||||||
import { hasFilterParams, paramsToFilters, readPage, syncUrl, type Page } from "./lib/urlState";
|
import { hasFilterParams, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
|
||||||
import {
|
import {
|
||||||
loadLayout,
|
loadLayout,
|
||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
|
|
@ -85,12 +85,10 @@ export default function App() {
|
||||||
function setFilters(next: FeedFilters) {
|
function setFilters(next: FeedFilters) {
|
||||||
setFiltersState(next);
|
setFiltersState(next);
|
||||||
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
||||||
syncUrl(next, page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPage(next: Page) {
|
function setPage(next: Page) {
|
||||||
setPageState(next);
|
setPageState(next);
|
||||||
syncUrl(filters, next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSidebarLayout(next: SidebarLayout) {
|
function setSidebarLayout(next: SidebarLayout) {
|
||||||
|
|
@ -101,8 +99,16 @@ export default function App() {
|
||||||
|
|
||||||
useEffect(() => applyTheme(theme), [theme]);
|
useEffect(() => applyTheme(theme), [theme]);
|
||||||
|
|
||||||
// Reflect the initial filters + page into the address bar so the URL is always shareable.
|
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
|
||||||
useEffect(() => syncUrl(filters, page), []); // eslint-disable-line react-hooks/exhaustive-deps
|
// link, its params have already hydrated the initial state — persist them and strip the
|
||||||
|
// query so the URL stays clean from here on.
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (hasFilterParams(params) || params.has("page")) {
|
||||||
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
|
||||||
|
stripUrlParams();
|
||||||
|
}
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Share2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -33,6 +34,8 @@ import {
|
||||||
type SidebarLayout,
|
type SidebarLayout,
|
||||||
type WidgetId,
|
type WidgetId,
|
||||||
} from "../lib/sidebarLayout";
|
} from "../lib/sidebarLayout";
|
||||||
|
import { shareUrl } from "../lib/urlState";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
|
||||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||||
const SORT_IDS = [
|
const SORT_IDS = [
|
||||||
|
|
@ -74,10 +77,12 @@ const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
||||||
|
|
||||||
function TagChip({
|
function TagChip({
|
||||||
tag,
|
tag,
|
||||||
|
count,
|
||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
tag: Tag;
|
tag: Tag;
|
||||||
|
count: number;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -85,7 +90,7 @@ function TagChip({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={t("sidebar.channelCount", { count: tag.channel_count })}
|
title={t("sidebar.channelCount", { count })}
|
||||||
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
|
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
|
||||||
active
|
active
|
||||||
? "bg-accent text-accent-fg border-accent"
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
|
@ -98,7 +103,7 @@ function TagChip({
|
||||||
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
|
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tag.channel_count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
@ -119,6 +124,31 @@ export default function Sidebar({
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
const tags = tagsQuery.data ?? [];
|
const tags = tagsQuery.data ?? [];
|
||||||
|
|
||||||
|
// Live per-tag channel counts for the current filter context (scope, channel, date,
|
||||||
|
// search, watch state, other category's tags). Lets us show contextual counts and hide
|
||||||
|
// chips that no longer match anything. Keyed on filters so it refetches as they change.
|
||||||
|
const facetsQuery = useQuery({
|
||||||
|
queryKey: ["facets", filters],
|
||||||
|
queryFn: () => api.facets(filters),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
const facetsReady = !!facetsQuery.data;
|
||||||
|
const facetCounts = facetsQuery.data?.counts ?? {};
|
||||||
|
// Before facets load, fall back to the static global count so chips don't flash empty.
|
||||||
|
const chipCount = (tag: Tag): number =>
|
||||||
|
facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count;
|
||||||
|
// Visible chips: hide zero-count ones once facets are in, but always keep selected ones
|
||||||
|
// so an active filter can still be cleared. Sorted by count (largest first), then name —
|
||||||
|
// so scanning top→bottom the smallest counts end up at the very bottom.
|
||||||
|
const visibleChips = (list: Tag[]): Tag[] => {
|
||||||
|
const shown = facetsReady
|
||||||
|
? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id))
|
||||||
|
: list;
|
||||||
|
return [...shown].sort(
|
||||||
|
(a, b) => chipCount(b) - chipCount(a) || a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// After a page refresh the channel name isn't in the URL (only the id is), so
|
// After a page refresh the channel name isn't in the URL (only the id is), so
|
||||||
// filters.channelName is undefined and the chip would fall back to "This channel".
|
// filters.channelName is undefined and the chip would fall back to "This channel".
|
||||||
// Resolve the title from the (cached) channel list keyed by id. Only fetch when a
|
// Resolve the title from the (cached) channel list keyed by id. Only fetch when a
|
||||||
|
|
@ -169,6 +199,15 @@ export default function Sidebar({
|
||||||
(dateActive ? 1 : 0);
|
(dateActive ? 1 : 0);
|
||||||
const active = activeCount > 0;
|
const active = activeCount > 0;
|
||||||
|
|
||||||
|
async function shareView() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl(filters));
|
||||||
|
notify({ message: t("sidebar.shareCopied") });
|
||||||
|
} catch {
|
||||||
|
notify({ level: "warning", message: t("sidebar.shareFailed") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
setCustomDates(false);
|
setCustomDates(false);
|
||||||
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope });
|
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope });
|
||||||
|
|
@ -362,45 +401,74 @@ export default function Sidebar({
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "language":
|
case "language": {
|
||||||
|
const chips = visibleChips(languages);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{languages.map((t) => (
|
{chips.map((tg) => (
|
||||||
<TagChip
|
<TagChip
|
||||||
key={t.id}
|
key={tg.id}
|
||||||
tag={t}
|
tag={tg}
|
||||||
active={filters.tags.includes(t.id)}
|
count={chipCount(tg)}
|
||||||
onClick={() => toggleTag(t.id)}
|
active={filters.tags.includes(tg.id)}
|
||||||
|
onClick={() => toggleTag(tg.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{facetsReady && chips.length === 0 && (
|
||||||
|
<span className="text-[11px] text-muted">{t("sidebar.noMatchingTags")}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "topic":
|
}
|
||||||
|
case "topic": {
|
||||||
|
const chips = visibleChips(topics);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-end mb-1.5">
|
<div
|
||||||
<button
|
className="flex items-center justify-between mb-1.5"
|
||||||
onClick={() =>
|
title={t("sidebar.tagModeTooltip")}
|
||||||
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
|
>
|
||||||
}
|
<span className="text-[10px] uppercase tracking-wide text-muted">
|
||||||
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
|
{t("sidebar.match")}
|
||||||
title={t("sidebar.tagModeTooltip")}
|
</span>
|
||||||
|
<div
|
||||||
|
className="flex items-center rounded-full border border-border bg-card p-0.5 text-[10px]"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("sidebar.match")}
|
||||||
>
|
>
|
||||||
{filters.tagMode === "or" ? t("sidebar.any") : t("sidebar.all")}
|
{(["or", "and"] as const).map((m) => (
|
||||||
</button>
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setFilters({ ...filters, tagMode: m })}
|
||||||
|
aria-pressed={filters.tagMode === m}
|
||||||
|
className={`px-2 py-0.5 rounded-full transition ${
|
||||||
|
filters.tagMode === m
|
||||||
|
? "bg-accent text-accent-fg"
|
||||||
|
: "text-muted hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === "or" ? t("sidebar.any") : t("sidebar.all")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{topics.map((t) => (
|
{chips.map((tg) => (
|
||||||
<TagChip
|
<TagChip
|
||||||
key={t.id}
|
key={tg.id}
|
||||||
tag={t}
|
tag={tg}
|
||||||
active={filters.tags.includes(t.id)}
|
count={chipCount(tg)}
|
||||||
onClick={() => toggleTag(t.id)}
|
active={filters.tags.includes(tg.id)}
|
||||||
|
onClick={() => toggleTag(tg.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{facetsReady && chips.length === 0 && (
|
||||||
|
<span className="text-[11px] text-muted">{t("sidebar.noMatchingTags")}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -430,6 +498,16 @@ export default function Sidebar({
|
||||||
{t("sidebar.clearAll")}
|
{t("sidebar.clearAll")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!editing && (
|
||||||
|
<button
|
||||||
|
onClick={shareView}
|
||||||
|
title={t("sidebar.shareView")}
|
||||||
|
aria-label={t("sidebar.shareView")}
|
||||||
|
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
|
>
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{editing && (
|
{editing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
|
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,16 @@
|
||||||
"any": "Beliebig",
|
"any": "Beliebig",
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen",
|
"tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen",
|
||||||
|
"match": "Treffer",
|
||||||
"custom": "Benutzerdefiniert",
|
"custom": "Benutzerdefiniert",
|
||||||
"from": "Von",
|
"from": "Von",
|
||||||
"to": "Bis",
|
"to": "Bis",
|
||||||
"clearDates": "Daten löschen",
|
"clearDates": "Daten löschen",
|
||||||
"reshuffle": "Neu mischen",
|
"reshuffle": "Neu mischen",
|
||||||
|
"noMatchingTags": "Keine passenden Tags",
|
||||||
|
"shareView": "Link zu dieser Ansicht kopieren",
|
||||||
|
"shareCopied": "Ansichts-Link in die Zwischenablage kopiert",
|
||||||
|
"shareFailed": "Link konnte nicht kopiert werden",
|
||||||
"widget": {
|
"widget": {
|
||||||
"show": "Anzeigen",
|
"show": "Anzeigen",
|
||||||
"sort": "Sortierung",
|
"sort": "Sortierung",
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,16 @@
|
||||||
"any": "Any",
|
"any": "Any",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"tagModeTooltip": "Match any vs all selected tags",
|
"tagModeTooltip": "Match any vs all selected tags",
|
||||||
|
"match": "Match",
|
||||||
"custom": "Custom",
|
"custom": "Custom",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
"clearDates": "clear dates",
|
"clearDates": "clear dates",
|
||||||
"reshuffle": "Reshuffle",
|
"reshuffle": "Reshuffle",
|
||||||
|
"noMatchingTags": "No matching tags here",
|
||||||
|
"shareView": "Copy a link to this view",
|
||||||
|
"shareCopied": "View link copied to clipboard",
|
||||||
|
"shareFailed": "Couldn't copy the link",
|
||||||
"widget": {
|
"widget": {
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,16 @@
|
||||||
"any": "Bármelyik",
|
"any": "Bármelyik",
|
||||||
"all": "Összes",
|
"all": "Összes",
|
||||||
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
||||||
|
"match": "Egyezés",
|
||||||
"custom": "Egyéni",
|
"custom": "Egyéni",
|
||||||
"from": "Ettől",
|
"from": "Ettől",
|
||||||
"to": "Eddig",
|
"to": "Eddig",
|
||||||
"clearDates": "dátumok törlése",
|
"clearDates": "dátumok törlése",
|
||||||
"reshuffle": "Újrakeverés",
|
"reshuffle": "Újrakeverés",
|
||||||
|
"noMatchingTags": "Nincs ide illő címke",
|
||||||
|
"shareView": "Hivatkozás másolása erre a nézetre",
|
||||||
|
"shareCopied": "Nézet-hivatkozás a vágólapra másolva",
|
||||||
|
"shareFailed": "Nem sikerült a hivatkozás másolása",
|
||||||
"widget": {
|
"widget": {
|
||||||
"show": "Megjelenítés",
|
"show": "Megjelenítés",
|
||||||
"sort": "Rendezés",
|
"sort": "Rendezés",
|
||||||
|
|
|
||||||
|
|
@ -152,13 +152,13 @@ async function req(url: string, opts: RequestInit = {}): Promise<any> {
|
||||||
return r.status === 204 ? null : r.json();
|
return r.status === 204 ? null : r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function feedQuery(f: FeedFilters, offset: number, limit: number): string {
|
// The filter half of the query (everything except paging/sort), shared by the feed and
|
||||||
|
// the facet-count endpoint so both see exactly the same filter context.
|
||||||
|
function filterParams(f: FeedFilters): URLSearchParams {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
f.tags.forEach((t) => p.append("tags", String(t)));
|
f.tags.forEach((t) => p.append("tags", String(t)));
|
||||||
p.set("tag_mode", f.tagMode);
|
p.set("tag_mode", f.tagMode);
|
||||||
if (f.q) p.set("q", f.q);
|
if (f.q) p.set("q", f.q);
|
||||||
p.set("sort", f.sort);
|
|
||||||
if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
|
|
||||||
p.set("scope", f.scope);
|
p.set("scope", f.scope);
|
||||||
p.set("show_normal", String(f.includeNormal));
|
p.set("show_normal", String(f.includeNormal));
|
||||||
p.set("include_shorts", String(f.includeShorts));
|
p.set("include_shorts", String(f.includeShorts));
|
||||||
|
|
@ -170,6 +170,13 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string {
|
||||||
if (f.dateTo) p.set("published_before", f.dateTo);
|
if (f.dateTo) p.set("published_before", f.dateTo);
|
||||||
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
|
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
|
||||||
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
|
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedQuery(f: FeedFilters, offset: number, limit: number): string {
|
||||||
|
const p = filterParams(f);
|
||||||
|
p.set("sort", f.sort);
|
||||||
|
if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
|
||||||
p.set("offset", String(offset));
|
p.set("offset", String(offset));
|
||||||
p.set("limit", String(limit));
|
p.set("limit", String(limit));
|
||||||
return p.toString();
|
return p.toString();
|
||||||
|
|
@ -252,6 +259,8 @@ export const api = {
|
||||||
req(`/api/feed?${feedQuery(f, offset, limit)}`),
|
req(`/api/feed?${feedQuery(f, offset, limit)}`),
|
||||||
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
||||||
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
|
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
|
||||||
|
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
||||||
|
req(`/api/facets?${filterParams(f).toString()}`),
|
||||||
setState: (id: string, status: string) =>
|
setState: (id: string, status: string) =>
|
||||||
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
|
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
|
||||||
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
|
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,21 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.2.0",
|
||||||
|
date: "2026-06-15",
|
||||||
|
summary:
|
||||||
|
"Browse beyond your own subscriptions, and a much smarter filter sidebar.",
|
||||||
|
features: [
|
||||||
|
"Shared library: a new Mine / Library switch in the header lets you browse every channel the community has added — not just your own subscriptions — and even works without granting YouTube access. Your watch, save and hide state always stays private to you.",
|
||||||
|
"Smarter filter chips: topic and language chips now show live counts for your current view and hide the ones that no longer match anything, so narrowing down is quicker. Chips are ordered by size, and topics have a clear Any / All match toggle (All = channels that have all the selected topics).",
|
||||||
|
"“Surprise me” ordering now has a reshuffle button to reroll the order, and the topic/language chips show how many channels fall under each.",
|
||||||
|
"Share a view: copy a link that reproduces your current filters. Filters are otherwise no longer kept in the address bar.",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"The channel manager now tells apart a full-history backfill you requested from one another member already queued, and the header shows “fetching history” while the back-catalogue is still downloading instead of claiming everything is synced.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
date: "2026-06-15",
|
date: "2026-06-15",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
// Serialize feed filters to/from a compact, human-readable URL query string, so a
|
// Serialize feed filters to/from a compact, human-readable URL query string. Filters are
|
||||||
// pasted URL reproduces exactly what's on screen (handy for "here's the URL, I see
|
// NOT kept in the address bar during normal use (localStorage is the single source of
|
||||||
// this bug"). Only non-default values are emitted to keep URLs clean.
|
// truth); this serializer powers the explicit "Share view" link and the one-time hydration
|
||||||
|
// when someone opens such a link. Only non-default values are emitted to keep URLs clean.
|
||||||
import type { FeedFilters } from "./api";
|
import type { FeedFilters } from "./api";
|
||||||
|
|
||||||
const KEYS = [
|
const KEYS = [
|
||||||
"q",
|
"q",
|
||||||
"show",
|
"show",
|
||||||
"sort",
|
"sort",
|
||||||
|
"scope",
|
||||||
"normal",
|
"normal",
|
||||||
"shorts",
|
"shorts",
|
||||||
"live",
|
"live",
|
||||||
|
|
@ -25,6 +27,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
|
||||||
if (f.q) p.set("q", f.q);
|
if (f.q) p.set("q", f.q);
|
||||||
if (f.show && f.show !== "unwatched") p.set("show", f.show);
|
if (f.show && f.show !== "unwatched") p.set("show", f.show);
|
||||||
if (f.sort && f.sort !== "newest") p.set("sort", f.sort);
|
if (f.sort && f.sort !== "newest") p.set("sort", f.sort);
|
||||||
|
if (f.scope === "all") p.set("scope", "all");
|
||||||
if (!f.includeNormal) p.set("normal", "0");
|
if (!f.includeNormal) p.set("normal", "0");
|
||||||
if (f.includeShorts) p.set("shorts", "1");
|
if (f.includeShorts) p.set("shorts", "1");
|
||||||
if (f.includeLive) p.set("live", "1");
|
if (f.includeLive) p.set("live", "1");
|
||||||
|
|
@ -51,6 +54,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
|
||||||
if (params.has("q")) f.q = params.get("q") ?? "";
|
if (params.has("q")) f.q = params.get("q") ?? "";
|
||||||
if (params.has("show")) f.show = params.get("show") || "unwatched";
|
if (params.has("show")) f.show = params.get("show") || "unwatched";
|
||||||
if (params.has("sort")) f.sort = params.get("sort") || "newest";
|
if (params.has("sort")) f.sort = params.get("sort") || "newest";
|
||||||
|
if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
|
||||||
if (params.has("normal")) f.includeNormal = params.get("normal") !== "0";
|
if (params.has("normal")) f.includeNormal = params.get("normal") !== "0";
|
||||||
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";
|
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";
|
||||||
if (params.has("live")) f.includeLive = params.get("live") === "1";
|
if (params.has("live")) f.includeLive = params.get("live") === "1";
|
||||||
|
|
@ -81,11 +85,19 @@ export function readPage(): Page {
|
||||||
return p === "channels" || p === "stats" ? p : "feed";
|
return p === "channels" || p === "stats" ? p : "feed";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reflect the current filters + page into the address bar without adding history entries. */
|
/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the
|
||||||
export function syncUrl(f: FeedFilters, page: Page = "feed"): void {
|
* opt-in "Share view" action — filters are not otherwise written to the address bar. */
|
||||||
|
export function shareUrl(f: FeedFilters, page: Page = "feed"): string {
|
||||||
const params = filtersToParams(f);
|
const params = filtersToParams(f);
|
||||||
if (page !== "feed") params.set("page", page);
|
if (page !== "feed") params.set("page", page);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
return `${window.location.origin}${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
||||||
window.history.replaceState(null, "", url);
|
}
|
||||||
|
|
||||||
|
/** Strip any filter/page query params from the address bar (after hydrating from a share
|
||||||
|
* link), leaving a clean URL without touching history. */
|
||||||
|
export function stripUrlParams(): void {
|
||||||
|
if (window.location.search) {
|
||||||
|
window.history.replaceState(null, "", window.location.pathname);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue