Skip to content
6 changes: 0 additions & 6 deletions apps/web/src/components/admin-settings-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,6 @@ export function AdminSettingsPanel({ settings, pending, onToggle }: Props) {
pending={pending}
onClick={() => onToggle("allowGuest")}
/>
<SettingToggle
label="Force email verification"
value={settings.forceEmailVerification}
pending={pending}
onClick={() => onToggle("forceEmailVerification")}
/>
<SettingToggle
label="Track active sessions"
value={settings.activeSessionsEnabled}
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/components/channel-podcasts-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { usePodcasts } from "../hooks/use-podcasts";
import { detectProvider } from "../lib/provider";
import { PodcastCard } from "./podcast-card";

const SKELETON_KEYS = [
"podcast-skeleton-1",
"podcast-skeleton-2",
"podcast-skeleton-3",
"podcast-skeleton-4",
];

type Props = {
channelUrl: string;
channelAvatar?: string;
};

export function ChannelPodcastsSection({ channelUrl, channelAvatar }: Props) {
const isYoutube = detectProvider(channelUrl) === "youtube";
const query = usePodcasts(channelUrl, isYoutube);
const podcasts = query.data?.pages.flatMap((page) => page.podcasts) ?? [];

if (!isYoutube || query.isError || (query.isFetched && podcasts.length === 0)) return null;

return (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-medium uppercase tracking-wider text-fg-soft">Podcasts</p>
<p className="text-xs text-fg-muted">Podcast playlists from this channel</p>
</div>
{query.hasNextPage && (
<button
type="button"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
className="rounded-full border border-border px-3 py-1.5 text-xs font-medium text-fg-muted hover:border-border-strong hover:text-fg disabled:opacity-60"
>
{query.isFetchingNextPage ? "Loading..." : "Load more"}
</button>
)}
</div>
{query.isLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{SKELETON_KEYS.map((key) => (
<div key={key} className="aspect-square rounded-2xl bg-surface-strong" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{podcasts.map((podcast) => (
<PodcastCard key={podcast.url} podcast={podcast} channelAvatar={channelAvatar} />
))}
</div>
)}
</section>
);
}
24 changes: 24 additions & 0 deletions apps/web/src/components/download-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useArtifactDownloadOnDone } from "../hooks/use-artifact-download-on-don
import { useDownloaderJob } from "../hooks/use-downloader-job";
import { useOverlayLock } from "../hooks/use-overlay-lock";
import { useSmoothDismiss } from "../hooks/use-smooth-dismiss";
import { deleteDownloaderJob } from "../lib/api-downloader";
import type { VideoStream } from "../types/stream";
import {
buildDownloaderCreatePayload,
Expand All @@ -28,13 +29,17 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) {
isQueued,
isRunning,
errorText,
isFailed,
openArtifact,
reset,
start,
canUseIosShareFlow,
cancelJob,
isCancelling,
} = downloader;
const isBusy = isQueued || isRunning;
const [artifactError, setArtifactError] = useState<string | null>(null);
const [clearPending, setClearPending] = useState(false);
const options = useMemo(() => buildDownloadOptions(stream), [stream]);
const [mode, setMode] = useState<DownloadMode>("video");
const [selectedId, setSelectedId] = useState(
Expand Down Expand Up @@ -69,6 +74,19 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) {
start(buildDownloaderCreatePayload(stream.id, selected));
}

async function clearJob() {
if (!jobId) return reset();
setClearPending(true);
try {
await deleteDownloaderJob(jobId);
reset();
setArtifactError(null);
} catch (error) {
setArtifactError(error instanceof Error ? error.message : "Failed to clear download job");
}
setClearPending(false);
}

return (
<div className="fixed inset-0 z-[90]" role="dialog" aria-modal="true">
<button
Expand Down Expand Up @@ -137,6 +155,12 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) {
errorText={artifactError ?? errorText}
immersive={showWorkingState}
forceWaiting={completion.isCompleting}
canCancel={isBusy}
cancelPending={isCancelling}
onCancel={cancelJob}
canClear={Boolean(jobId) && (isDone || isFailed)}
clearPending={clearPending}
onClear={() => void clearJob()}
/>
</div>
</div>
Expand Down
42 changes: 39 additions & 3 deletions apps/web/src/components/downloader-job-feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ type Props = {
errorText: string | null;
immersive?: boolean;
forceWaiting?: boolean;
canCancel?: boolean;
cancelPending?: boolean;
onCancel?: () => void;
canClear?: boolean;
clearPending?: boolean;
onClear?: () => void;
};

function formatResolved(resolved: DownloaderResolvedSelection | null): string | null {
Expand Down Expand Up @@ -57,6 +63,12 @@ export function DownloaderJobFeedback({
errorText,
immersive = false,
forceWaiting = false,
canCancel = false,
cancelPending = false,
onCancel,
canClear = false,
clearPending = false,
onClear,
}: Props) {
const resolvedLabel = formatResolved(resolved);
const visibleError =
Expand All @@ -69,6 +81,8 @@ export function DownloaderJobFeedback({
const message = downloaderStatusMessage(status, stage, errorCode, visibleError, forceWaiting);
const progress = downloaderProgressValue(status, stage, progressPercent, forceWaiting);
const activeStep = downloaderStageIndex(status, stage);
const showCancel = canCancel && !cancelled && !failed && typeof onCancel === "function";
const showClear = canClear && typeof onClear === "function";
const showProgress = shouldShowDownloaderProgress(status, forceWaiting) && !failed && !cancelled;
const showPercent = typeof progressPercent === "number" && status === "running";

Expand All @@ -87,9 +101,31 @@ export function DownloaderJobFeedback({
{message}
</p>
</div>
{showPercent && (
<span className="text-sm font-semibold text-fg">{Math.round(progress)}%</span>
)}
<div className="flex shrink-0 items-center gap-2">
{showPercent && (
<span className="text-sm font-semibold text-fg">{Math.round(progress)}%</span>
)}
{showCancel && (
<button
type="button"
onClick={onCancel}
disabled={cancelPending}
className="rounded-full border border-border px-2 py-1 text-[11px] font-medium text-fg-muted hover:border-border-strong hover:text-fg disabled:cursor-not-allowed disabled:opacity-60"
>
{cancelPending ? "Cancelling..." : "Cancel"}
</button>
)}
{showClear && (
<button
type="button"
onClick={onClear}
disabled={clearPending}
className="rounded-full border border-border px-2 py-1 text-[11px] font-medium text-fg-muted hover:border-border-strong hover:text-fg disabled:cursor-not-allowed disabled:opacity-60"
>
{clearPending ? "Clearing..." : "Clear"}
</button>
)}
</div>
</div>
{showProgress && (
<div className="mt-3 h-2 overflow-hidden rounded-full bg-app">
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/components/library-collection-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Link } from "@tanstack/react-router";

type Props = {
kind: "favorites" | "watch-later";
title: string;
count: number;
thumbnail?: string;
};

function EmptyLibraryIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={32}
height={32}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Collection"
className="text-fg-soft"
>
<path d="M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
);
}

export function LibraryCollectionCard({ kind, title, count, thumbnail }: Props) {
const label = `${count} video${count !== 1 ? "s" : ""}`;
const body = (
<div className="flex flex-col gap-2 group">
<div className="relative aspect-video overflow-hidden rounded-xl bg-surface-strong">
{thumbnail ? (
<img
src={thumbnail}
alt={title}
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
decoding="async"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<EmptyLibraryIcon />
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/80 px-1.5 py-0.5 text-[10px] font-medium text-white">
{label}
</span>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-fg transition-colors group-hover:text-white">
{title}
</p>
<p className="text-xs text-fg-soft">{label}</p>
</div>
</div>
);

return kind === "favorites" ? (
<Link to="/favorites">{body}</Link>
) : (
<Link to="/watch-later">{body}</Link>
);
}
Loading