feat(inbox): add navigation links to watched and scheduler notices

Extend the reload-safe meta+link pattern (introduced for "Subscribed") to
more inbox entries, so every notice that can point somewhere does:

- A "Marked watched" activity notice now offers "Find in feed" (jumping to
  the feed filtered to Watched + that channel), alongside Unwatch — matching
  what the "Hidden" notice already does. VideoWatchedMeta carries the
  channel, and locate() handles both hidden and watched.
- A scheduler job-completion notice (System) now offers "Open Scheduler",
  jumping to the dashboard.

Maintenance notices reference already-removed videos, so they stay
informational with nothing to navigate to.
This commit is contained in:
npeter83 2026-06-19 03:05:01 +02:00
parent a004fa4107
commit c2b877c14e
6 changed files with 38 additions and 7 deletions

View file

@ -174,7 +174,13 @@ export default function Feed({
? i18n.t("feed.markedWatchedNamed", { title: v.title }) ? i18n.t("feed.markedWatchedNamed", { title: v.title })
: i18n.t("feed.markedWatched"), : i18n.t("feed.markedWatched"),
action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") }, action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, meta: {
kind: "video-watched",
videoId: id,
title: v?.title ?? "this video",
channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "",
},
}); });
} else if (status === "new") { } else if (status === "new") {
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve // Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve

View file

@ -1,7 +1,7 @@
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, X } from "lucide-react"; import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, X } from "lucide-react";
import { api, type AppNotification, type FeedFilters } from "../lib/api"; import { api, type AppNotification, type FeedFilters } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { import {
@ -94,11 +94,12 @@ export default function NotificationsPanel({
clearMut.mutate(); clearMut.mutate();
} }
// "Find in feed" / undo, ported from the old bell. // "Find in feed" / undo, ported from the old bell. Works for both hidden and watched
function locateHidden(meta: VideoHiddenMeta) { // notices — jump to the feed filtered to that state and the video's channel.
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
setFilters({ setFilters({
...filters, ...filters,
show: "hidden", show: meta.kind === "video-hidden" ? "hidden" : "watched",
channelId: meta.channelId || undefined, channelId: meta.channelId || undefined,
channelName: meta.channelName || undefined, channelName: meta.channelName || undefined,
}); });
@ -168,6 +169,7 @@ export default function NotificationsPanel({
t={t} t={t}
onRead={() => readMut.mutate(n.id)} onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)} onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={() => setPage("scheduler")}
/> />
))} ))}
</div> </div>
@ -184,7 +186,7 @@ export default function NotificationsPanel({
key={n.id} key={n.id}
n={n} n={n}
t={t} t={t}
onFind={locateHidden} onFind={locate}
onRevert={revertState} onRevert={revertState}
onFocusChannel={onFocusChannel} onFocusChannel={onFocusChannel}
onRemove={() => removeClient(n.id)} onRemove={() => removeClient(n.id)}
@ -204,11 +206,13 @@ function NotificationRow({
t, t,
onRead, onRead,
onDismiss, onDismiss,
onOpenScheduler,
}: { }: {
n: AppNotification; n: AppNotification;
t: (k: string, o?: any) => string; t: (k: string, o?: any) => string;
onRead: () => void; onRead: () => void;
onDismiss: () => void; onDismiss: () => void;
onOpenScheduler: () => void;
}) { }) {
// The maintenance producer ships the affected videos in `data.videos`; list a few titles. // The maintenance producer ships the affected videos in `data.videos`; list a few titles.
const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos) const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos)
@ -261,6 +265,15 @@ function NotificationRow({
)} )}
</ul> </ul>
)} )}
{isScheduler && (
<button
onClick={onOpenScheduler}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
>
<Activity className="w-3.5 h-3.5" />
{t("inbox.openScheduler")}
</button>
)}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{!n.read && ( {!n.read && (
@ -296,7 +309,7 @@ function ClientActivityRow({
}: { }: {
n: ClientNotif; n: ClientNotif;
t: (k: string, o?: any) => string; t: (k: string, o?: any) => string;
onFind: (meta: VideoHiddenMeta) => void; onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onFocusChannel: (name: string) => void; onFocusChannel: (name: string) => void;
onRemove: () => void; onRemove: () => void;
@ -342,6 +355,13 @@ function ClientActivityRow({
</div> </div>
) : watched ? ( ) : watched ? (
<div className="flex items-center gap-3 mt-1"> <div className="flex items-center gap-3 mt-1">
<button
onClick={() => onFind(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button <button
onClick={() => onRevert(watched)} onClick={() => onRevert(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline" className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"

View file

@ -10,6 +10,7 @@
"sectionSystem": "System", "sectionSystem": "System",
"sectionActivity": "Aktivität", "sectionActivity": "Aktivität",
"andMore": "und {{count}} weitere", "andMore": "und {{count}} weitere",
"openScheduler": "Zeitplaner öffnen",
"maintenance": { "maintenance": {
"title": "Videos entfernt", "title": "Videos entfernt",
"body_one": "{{count}} gespeichertes oder in einer Playlist befindliches Video wurde entfernt, weil es auf YouTube nicht mehr verfügbar ist.", "body_one": "{{count}} gespeichertes oder in einer Playlist befindliches Video wurde entfernt, weil es auf YouTube nicht mehr verfügbar ist.",

View file

@ -10,6 +10,7 @@
"sectionSystem": "System", "sectionSystem": "System",
"sectionActivity": "Activity", "sectionActivity": "Activity",
"andMore": "and {{count}} more", "andMore": "and {{count}} more",
"openScheduler": "Open Scheduler",
"maintenance": { "maintenance": {
"title": "Videos removed", "title": "Videos removed",
"body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.", "body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.",

View file

@ -10,6 +10,7 @@
"sectionSystem": "Rendszer", "sectionSystem": "Rendszer",
"sectionActivity": "Tevékenység", "sectionActivity": "Tevékenység",
"andMore": "és még {{count}}", "andMore": "és még {{count}}",
"openScheduler": "Ütemező megnyitása",
"maintenance": { "maintenance": {
"title": "Videók eltávolítva", "title": "Videók eltávolítva",
"body_one": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhető a YouTube-on.", "body_one": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhető a YouTube-on.",

View file

@ -23,6 +23,8 @@ export type VideoWatchedMeta = {
kind: "video-watched"; kind: "video-watched";
videoId: string; videoId: string;
title: string; title: string;
channelId: string;
channelName: string;
}; };
export type ChannelSubscribedMeta = { export type ChannelSubscribedMeta = {
kind: "channel-subscribed"; kind: "channel-subscribed";