feat(search): ephemeral results UX, count selector, channel blocklist (frontend)

- Live-search view: a results-count selector (20/40/60/100) replaces manual load-more (the free
  scrape source pages until that many are gathered); an 'these results are temporary' banner with
  a 'Clear now' button that discards them 'as if never added' (api.clearSearch) and returns to
  the feed.
- Channel blocklist: a Block/Unblock toggle + 'Blocked' badge on the channel page (blocked
  channels don't auto-explore and their videos are hidden), and a 'Blocked channels' section in
  the Channel manager with one-click unblock. ChannelDetail.blocked from the backend.
- Admin: a 'Purge discovery' button on the Scheduler page (immediate un-kept search/explore
  cleanup). EN/HU/DE throughout.
This commit is contained in:
npeter83 2026-07-01 01:00:32 +02:00
parent f27f31b6db
commit 0c18845c34
21 changed files with 263 additions and 65 deletions

View file

@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowDown,
ArrowUp,
Ban,
Check,
Eye,
EyeOff,
@ -87,7 +88,15 @@ export default function Channels({
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels });
const [tagManagerOpen, setTagManagerOpen] = useState(false);
const unblock = useMutation({
mutationFn: (id: string) => api.unblockChannel(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
qc.invalidateQueries({ queryKey: ["feed"] });
},
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["channels"] });
@ -567,6 +576,37 @@ export default function Channels({
emptyText={t("channels.empty")}
/>
)}
{/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */}
{(blockedQuery.data?.length ?? 0) > 0 && (
<div className="mt-6 border-t border-border pt-4">
<div className="flex items-center gap-2 mb-2">
<Ban className="w-4 h-4 text-muted" />
<span className="text-xs uppercase tracking-wide text-muted">
{t("channels.blocked.title")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{blockedQuery.data!.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
>
{c.title ?? c.id}
<button
onClick={() => unblock.mutate(c.id)}
title={t("channel.unblock")}
aria-label={t("channel.unblock")}
className="rounded-full p-0.5 text-muted hover:text-danger transition"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
</div>
)}
</div>
</>
);