feat(files): download from anywhere — a link box, a pasted link, the palette; the running downloads in a tray
- Files toolbar link box (paste button, Enter); a lone http(s) link pasted onto any page opens the one app-wide download dialog, which now names the link; the palette's "Download from a link…" focuses the box on its history entry. - The download tray, bottom right on every page: what waits, runs, is paused or failed (reason + Retry, the queue's right-click / "…" menu), and what finished in this tab as "Done · Open" until closed; folds to a pill (per-account pref); closed, it stays shut — across a reload — until the work changes or something finishes. Back to top steps up over it, the page end keeps clear. - A finish is announced once: "Downloaded: …", whether a phone plays it, Copy link + Open (toasts and notices may carry a second action). - The inbox's ready notice opens the file in its folder: download_done carries job_id + folder_id. - GET /api/downloads is the queue only (in flight, failed, finished in the last 10 minutes), with each row's source key.
This commit is contained in:
@@ -8,9 +8,9 @@ import {
|
||||
Bell,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
@@ -45,6 +45,7 @@ import { PageToolbar } from "./ui/PageShell";
|
||||
import { PageMenu } from "./ui/PageMenu";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { useFilesActions } from "./filesContext";
|
||||
import { useChannelsActions } from "./channelsContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
@@ -71,6 +72,7 @@ const byId = (n: { id: number }) => n.id;
|
||||
export default function NotificationsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { setPage } = useNavigationActions();
|
||||
const { openFile } = useFilesActions();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// "Focus this channel in the manager" (from a new-subscription notification) lives in
|
||||
@@ -184,7 +186,12 @@ export default function NotificationsPanel() {
|
||||
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
|
||||
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
|
||||
const openScheduler = () => setPage("scheduler");
|
||||
const openDownloads = () => setPage("downloads");
|
||||
// A finished download opens on its file, in its folder; a notice from before it named one, on Files.
|
||||
const openDone = (n: AppNotification) => {
|
||||
const { job_id: id, folder_id: folder } = n.data ?? {};
|
||||
if (typeof id === "number") openFile(id, typeof folder === "number" ? folder : null);
|
||||
else setPage("files");
|
||||
};
|
||||
const reviewAccess = () => {
|
||||
focusAccessRequestsTab();
|
||||
setPage("users");
|
||||
@@ -213,9 +220,9 @@ export default function NotificationsPanel() {
|
||||
? [
|
||||
{
|
||||
id: "open",
|
||||
label: t("inbox.openDownloads"),
|
||||
icon: icon(Download),
|
||||
onSelect: openDownloads,
|
||||
label: t("inbox.openFile"),
|
||||
icon: icon(FolderOpen),
|
||||
onSelect: () => openDone(n),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -280,17 +287,15 @@ export default function NotificationsPanel() {
|
||||
onSelect: reviewAccess,
|
||||
},
|
||||
]
|
||||
: n.action && !n.dismissed
|
||||
? [
|
||||
{
|
||||
id: "action",
|
||||
label: n.action.label,
|
||||
onSelect: () => {
|
||||
n.action!.onClick();
|
||||
dismissClient(n.id);
|
||||
},
|
||||
: !n.dismissed
|
||||
? liveActions(n).map((action, i) => ({
|
||||
id: `action${i}`,
|
||||
label: action.label,
|
||||
onSelect: () => {
|
||||
action.onClick();
|
||||
dismissClient(n.id);
|
||||
},
|
||||
]
|
||||
}))
|
||||
: [];
|
||||
return menuGroups(own, [dismissRow(() => removeClient(n.id))]);
|
||||
}
|
||||
@@ -391,7 +396,7 @@ export default function NotificationsPanel() {
|
||||
onRead={() => readMut.mutate(n.id)}
|
||||
onDismiss={() => dismissMut.mutate(n.id)}
|
||||
onOpenScheduler={openScheduler}
|
||||
onOpenDownloads={openDownloads}
|
||||
onOpenFile={() => openDone(n)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -438,6 +443,11 @@ export default function NotificationsPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
/** A client notice's live actions (they do not survive a reload): its action, then the lesser one. */
|
||||
function liveActions(n: ClientNotif): { label: string; onClick: () => void }[] {
|
||||
return [n.action, n.secondaryAction].filter((a) => a !== undefined);
|
||||
}
|
||||
|
||||
/** A server notification's link, from its type and typed payload (the row's button and its menu). */
|
||||
// The download-lifecycle notification types → their `inbox.<key>` copy.
|
||||
const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = {
|
||||
@@ -500,7 +510,7 @@ function NotificationRow({
|
||||
onRead,
|
||||
onDismiss,
|
||||
onOpenScheduler,
|
||||
onOpenDownloads,
|
||||
onOpenFile,
|
||||
}: {
|
||||
n: AppNotification;
|
||||
t: TFunction;
|
||||
@@ -510,7 +520,7 @@ function NotificationRow({
|
||||
onRead: () => void;
|
||||
onDismiss: () => void;
|
||||
onOpenScheduler: () => void;
|
||||
onOpenDownloads: () => void;
|
||||
onOpenFile: () => void;
|
||||
}) {
|
||||
const lang = useTranslation().i18n.language;
|
||||
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
|
||||
@@ -609,10 +619,10 @@ function NotificationRow({
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
data-nav-open=""
|
||||
onClick={onOpenDownloads}
|
||||
icon={<Download className="w-3.5 h-3.5" />}
|
||||
onClick={onOpenFile}
|
||||
icon={<FolderOpen className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t("inbox.openDownloads")}
|
||||
{t("inbox.openFile")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -706,19 +716,25 @@ function ClientActivityRow({
|
||||
{t("common.accessRequestsReview")}
|
||||
</Button>
|
||||
) : (
|
||||
n.action &&
|
||||
!n.dismissed && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="mt-1 text-sm"
|
||||
data-nav-open=""
|
||||
onClick={() => {
|
||||
n.action!.onClick();
|
||||
dismissClient(n.id);
|
||||
}}
|
||||
>
|
||||
{n.action.label}
|
||||
</Button>
|
||||
!n.dismissed &&
|
||||
liveActions(n).length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-x-4">
|
||||
{liveActions(n).map((action, i) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
// The first is the row's own: Enter on the row runs it.
|
||||
data-nav-open={i === 0 ? "" : undefined}
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
dismissClient(n.id);
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user