Merge: Channels search + clickable tag chips, contextual Your-tags, card polish
Channel manager gets a prominent channel-name search box and clickable 'Your tags' chips that filter the table (replacing the hidden per-column popovers); the per-row tag picker flips up when it would clip below. Feed 'Your tags' sidebar is now contextual (counts the 'other' tag category in the facet endpoint, hides zero-count chips). Video card titles show a full-title tooltip only when clamped.
This commit is contained in:
commit
d8f6f9f117
7 changed files with 127 additions and 32 deletions
|
|
@ -535,7 +535,7 @@ def get_facets(
|
||||||
(tag ids)."""
|
(tag ids)."""
|
||||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
counts: dict[int, int] = {}
|
counts: dict[int, int] = {}
|
||||||
for category in ("language", "topic"):
|
for category in ("language", "topic", "other"):
|
||||||
# Disjunctive (OR) facets drop the category's own selections so its chips keep
|
# 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)
|
# 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
|
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,7 +11,9 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Search,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
|
|
@ -181,9 +183,26 @@ export default function Channels({
|
||||||
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
|
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Channel-name search + tag-chip filtering, applied client-side over the status-filtered list
|
||||||
|
// (prominent controls instead of the DataTable's hidden per-column popovers).
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [tagFilter, setTagFilter] = useState<number[]>([]);
|
||||||
|
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusChannelName) setSearch(focusChannelName);
|
||||||
|
}, [focusChannelName]);
|
||||||
|
// The header's reset intent clears the search + tag chips (skip the initial mount).
|
||||||
|
const resetRef = useRef(filtersResetToken);
|
||||||
|
useEffect(() => {
|
||||||
|
if (filtersResetToken === resetRef.current) return;
|
||||||
|
resetRef.current = filtersResetToken;
|
||||||
|
setSearch("");
|
||||||
|
setTagFilter([]);
|
||||||
|
}, [filtersResetToken]);
|
||||||
|
|
||||||
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
||||||
// header's "go to full history" deep-link drives it; the DataTable then handles name
|
// header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
|
||||||
// search, tag filtering, sort and pagination over whatever the status chip leaves.
|
// run over whatever the status chip leaves.
|
||||||
const channels = (channelsQuery.data ?? []).filter((c) =>
|
const channels = (channelsQuery.data ?? []).filter((c) =>
|
||||||
statusFilter === "needs_full"
|
statusFilter === "needs_full"
|
||||||
? !c.backfill_done
|
? !c.backfill_done
|
||||||
|
|
@ -193,6 +212,12 @@ export default function Channels({
|
||||||
? c.hidden
|
? c.hidden
|
||||||
: true
|
: true
|
||||||
);
|
);
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const visibleChannels = channels.filter((c) => {
|
||||||
|
if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
|
||||||
|
if (tagFilter.length && !tagFilter.some((id) => c.tag_ids.includes(id))) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
const s = statusQuery.data;
|
const s = statusQuery.data;
|
||||||
|
|
||||||
const onView = (c: ManagedChannel) =>
|
const onView = (c: ManagedChannel) =>
|
||||||
|
|
@ -231,7 +256,6 @@ export default function Channels({
|
||||||
header: t("channels.cols.channel"),
|
header: t("channels.cols.channel"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
|
||||||
cardPrimary: true,
|
cardPrimary: true,
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<ChannelLink
|
<ChannelLink
|
||||||
|
|
@ -305,12 +329,6 @@ export default function Channels({
|
||||||
key: "tags",
|
key: "tags",
|
||||||
header: t("channels.cols.tags"),
|
header: t("channels.cols.tags"),
|
||||||
cardLabel: false,
|
cardLabel: false,
|
||||||
filter: {
|
|
||||||
kind: "multi",
|
|
||||||
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
|
||||||
// OR semantics: show channels carrying any of the selected tags.
|
|
||||||
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
|
|
||||||
},
|
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<TagsCell
|
<TagsCell
|
||||||
c={c}
|
c={c}
|
||||||
|
|
@ -468,21 +486,60 @@ export default function Channels({
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Your tags — read-only overview; add/rename/delete live in the manager dialog. */}
|
{/* Channel-name search */}
|
||||||
|
<div className="relative mb-3 max-w-sm">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted pointer-events-none" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t("channels.searchPlaceholder")}
|
||||||
|
className="w-full bg-card border border-border rounded-lg pl-8 pr-8 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label={t("common.cancel")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-fg"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
||||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||||
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
|
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
|
||||||
{t("channels.tags.yourTags")}
|
{t("channels.tags.yourTags")}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{userTags.map((tg) => (
|
{userTags.map((tg) => {
|
||||||
<span
|
const active = tagFilter.includes(tg.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
key={tg.id}
|
key={tg.id}
|
||||||
className="text-xs px-2 py-1 rounded-full bg-card border border-border"
|
onClick={() =>
|
||||||
|
setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
|
||||||
|
}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={`text-xs px-2 py-1 rounded-full border transition ${
|
||||||
|
active
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border text-muted hover:text-fg hover:border-accent"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{tg.name}
|
{tg.name}
|
||||||
</span>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
{tagFilter.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setTagFilter([])}
|
||||||
|
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
|
||||||
|
>
|
||||||
|
{t("datatable.clear")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTagManagerOpen(true)}
|
onClick={() => setTagManagerOpen(true)}
|
||||||
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||||
|
|
@ -500,14 +557,12 @@ export default function Channels({
|
||||||
<div className="text-muted py-8">{t("channels.loading")}</div>
|
<div className="text-muted py-8">{t("channels.loading")}</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={channels}
|
rows={visibleChannels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey="siftlode.channelsTable"
|
persistKey="siftlode.channelsTable"
|
||||||
controlsPosition="top"
|
controlsPosition="top"
|
||||||
controlsLeading={statusChips}
|
controlsLeading={statusChips}
|
||||||
externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null}
|
|
||||||
resetFiltersToken={filtersResetToken}
|
|
||||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
/>
|
/>
|
||||||
|
|
@ -609,8 +664,20 @@ function TagsCell({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
// Open the picker upward when there isn't room below (rows near the viewport bottom would
|
||||||
|
// otherwise clip it against the scroll container), as long as there's room above.
|
||||||
|
const [openUp, setOpenUp] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
useDismiss(open, () => setOpen(false), ref);
|
useDismiss(open, () => setOpen(false), ref);
|
||||||
|
const toggle = () => {
|
||||||
|
if (!open) {
|
||||||
|
const r = btnRef.current?.getBoundingClientRect();
|
||||||
|
const est = Math.min(userTags.length * 30 + 12, 264); // matches the max-height cap below
|
||||||
|
setOpenUp(!!r && r.bottom + est > window.innerHeight - 8 && r.top - est > 8);
|
||||||
|
}
|
||||||
|
setOpen((o) => !o);
|
||||||
|
};
|
||||||
if (userTags.length === 0) return null;
|
if (userTags.length === 0) return null;
|
||||||
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
||||||
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
||||||
|
|
@ -629,7 +696,8 @@ function TagsCell({
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((o) => !o)}
|
ref={btnRef}
|
||||||
|
onClick={toggle}
|
||||||
title={t("channels.row.editTags")}
|
title={t("channels.row.editTags")}
|
||||||
aria-label={t("channels.row.editTags")}
|
aria-label={t("channels.row.editTags")}
|
||||||
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||||
|
|
@ -637,7 +705,11 @@ function TagsCell({
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="glass-menu absolute left-0 top-full mt-1 z-30 w-44 rounded-xl p-1.5 animate-[popIn_0.16s_ease]">
|
<div
|
||||||
|
className={`glass-menu absolute left-0 z-30 w-44 max-h-[264px] overflow-y-auto rounded-xl p-1.5 animate-[popIn_0.16s_ease] ${
|
||||||
|
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{userTags.map((tg) => {
|
{userTags.map((tg) => {
|
||||||
const on = c.tag_ids.includes(tg.id);
|
const on = c.tag_ids.includes(tg.id);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,8 @@ export default function Sidebar({
|
||||||
const languages = tags.filter((t) => t.category === "language");
|
const languages = tags.filter((t) => t.category === "language");
|
||||||
const topics = tags.filter((t) => t.category === "topic");
|
const topics = tags.filter((t) => t.category === "topic");
|
||||||
// The user's own (non-system) tags — those assigned to channels in the Channel manager.
|
// The user's own (non-system) tags — those assigned to channels in the Channel manager.
|
||||||
// Facets only cover language/topic, so these show their static channel_count.
|
// Facets cover them too now (the "other" category), so they're contextual like the rest:
|
||||||
|
// zero-count chips hide once facets load, counts reflect the current filter.
|
||||||
const userTags = tags.filter((t) => !t.system);
|
const userTags = tags.filter((t) => !t.system);
|
||||||
const [customDates, setCustomDates] = useState(false);
|
const [customDates, setCustomDates] = useState(false);
|
||||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||||
|
|
@ -407,18 +408,22 @@ export default function Sidebar({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "tags":
|
case "tags": {
|
||||||
|
const tagChips = visibleChips(userTags);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{userTags.map((tg) => (
|
{tagChips.map((tg) => (
|
||||||
<TagChip
|
<TagChip
|
||||||
key={tg.id}
|
key={tg.id}
|
||||||
tag={tg}
|
tag={tg}
|
||||||
count={tg.channel_count}
|
count={chipCount(tg)}
|
||||||
active={filters.tags.includes(tg.id)}
|
active={filters.tags.includes(tg.id)}
|
||||||
onClick={() => toggleTag(tg.id)}
|
onClick={() => toggleTag(tg.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{facetsReady && tagChips.length === 0 && (
|
||||||
|
<span className="text-[11px] text-muted">{t("sidebar.noMatchingTags")}</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTagManagerOpen(true)}
|
onClick={() => setTagManagerOpen(true)}
|
||||||
title={t("sidebar.manageTags")}
|
title={t("sidebar.manageTags")}
|
||||||
|
|
@ -431,6 +436,7 @@ export default function Sidebar({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const orderedAvailable = layout.order.filter((id) => available[id]);
|
const orderedAvailable = layout.order.filter((id) => available[id]);
|
||||||
const renderedIds = editing
|
const renderedIds = editing
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { memo } from "react";
|
import { memo, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
|
@ -264,12 +264,26 @@ function VideoCard({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
// Show the full title in a native tooltip only when it's clamped (overflows its 2 lines).
|
||||||
|
const titleRef = useRef<HTMLAnchorElement>(null);
|
||||||
|
const [titleClamped, setTitleClamped] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = titleRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const check = () => setTitleClamped(el.scrollHeight > el.clientHeight + 1);
|
||||||
|
check();
|
||||||
|
const ro = new ResizeObserver(check);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [video.title]);
|
||||||
const title = (
|
const title = (
|
||||||
<a
|
<a
|
||||||
|
ref={titleRef}
|
||||||
href={video.watch_url}
|
href={video.watch_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
onClick={(e) => openInApp(e, video, onOpen)}
|
onClick={(e) => openInApp(e, video, onOpen)}
|
||||||
|
title={titleClamped ? video.title ?? undefined : undefined}
|
||||||
className="font-medium leading-snug line-clamp-2 hover:text-accent"
|
className="font-medium leading-snug line-clamp-2 hover:text-accent"
|
||||||
>
|
>
|
||||||
{video.title}
|
{video.title}
|
||||||
|
|
|
||||||
|
|
@ -122,5 +122,6 @@
|
||||||
"resetFailed": "Kanal konnte nicht zurückgesetzt werden"
|
"resetFailed": "Kanal konnte nicht zurückgesetzt werden"
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",
|
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",
|
||||||
"confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent."
|
"confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent.",
|
||||||
|
"searchPlaceholder": "Kanäle suchen…"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,5 +122,6 @@
|
||||||
"resetFailed": "Couldn't reset this channel"
|
"resetFailed": "Couldn't reset this channel"
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
|
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
|
||||||
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota."
|
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.",
|
||||||
|
"searchPlaceholder": "Search channels…"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,5 +122,6 @@
|
||||||
"resetFailed": "Nem sikerült resetelni a csatornát"
|
"resetFailed": "Nem sikerült resetelni a csatornát"
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",
|
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",
|
||||||
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt."
|
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.",
|
||||||
|
"searchPlaceholder": "Csatornák keresése…"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue