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

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react";
import Avatar from "./Avatar";
import Feed from "./Feed";
import { useConfirm } from "./ConfirmProvider";
@ -68,12 +68,21 @@ export default function ChannelPage({
useEffect(() => {
if (!ch || me.is_demo) return;
if (autoTried.current === channelId) return;
if (ch.subscribed) return;
if (ch.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId;
runExplore().catch(() => {});
}, [ch, channelId, me.is_demo, runExplore]);
const block = useMutation({
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] });
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
},
});
const subscribe = useMutation({
mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => {
@ -178,10 +187,17 @@ export default function ChannelPage({
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-semibold truncate">{name}</h1>
{ch?.explored && !ch?.subscribed && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
{t("channel.exploringBadge")}
{ch?.blocked ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")}
</span>
) : (
ch?.explored &&
!ch?.subscribed && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
{t("channel.exploringBadge")}
</span>
)
)}
</div>
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
@ -204,7 +220,23 @@ export default function ChannelPage({
>
<ExternalLink className="w-4 h-4" />
</a>
{!me.is_demo && (
<button
onClick={() => block.mutate()}
disabled={block.isPending}
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
ch?.blocked
? "border-danger text-danger"
: "border-border text-muted hover:text-danger hover:border-danger"
}`}
>
<Ban className="w-4 h-4" />
</button>
)}
{!me.is_demo &&
!ch?.blocked &&
(ch?.subscribed ? (
<button
onClick={onUnsubscribe}