Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions apps/web/src/components/channel-filter-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { FormEvent } from "react";
import { useEffect, useState } from "react";
import type { ChannelSort } from "../lib/api";
import { CHANNEL_SORT_OPTIONS, channelSortOrDefault } from "../lib/channel-sort";

type Props = {
sort: ChannelSort;
query: string;
searchAvailable: boolean;
onSearch: (query: string) => void;
onSortChange: (sort: ChannelSort) => void;
};

export function ChannelFilterBar({ sort, query, searchAvailable, onSearch, onSortChange }: Props) {
const [input, setInput] = useState(query);
const trimmedInput = input.trim();
const isSearching = query.length > 0;

useEffect(() => {
setInput(query);
}, [query]);

function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (searchAvailable) onSearch(trimmedInput);
}

function clearSearch() {
setInput("");
onSearch("");
}

return (
<section className="border-y border-border py-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{searchAvailable && (
<div className="min-w-0 flex-1 md:max-w-lg">
<form onSubmit={submitSearch} className="flex min-w-0 items-end gap-3">
<span className="hidden pb-2 text-xs uppercase tracking-wide text-fg-soft sm:inline">
Channel
</span>
<input
type="search"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Search this channel"
className="h-9 min-w-0 flex-1 border-border-strong border-b bg-transparent text-sm text-fg outline-none transition-colors placeholder:text-fg-soft focus:border-fg"
/>
{input.length > 0 && (
<button
type="button"
onClick={clearSearch}
className="h-9 text-xs font-medium text-fg-soft transition-colors hover:text-fg"
>
Clear
</button>
)}
<button
type="submit"
disabled={trimmedInput === query}
className="h-9 text-xs font-semibold uppercase tracking-wide text-fg transition-colors hover:text-white disabled:cursor-not-allowed disabled:text-fg-soft"
>
Search
</button>
</form>
</div>
)}
{!isSearching && (
<div className="flex w-fit items-center gap-2 text-sm">
{CHANNEL_SORT_OPTIONS.map((option) => {
const selected = option.value === sort;
return (
<button
key={option.value}
type="button"
onClick={() => onSortChange(channelSortOrDefault(option.value))}
className={`border-border-strong border-b py-1 font-medium transition-colors ${
selected ? "border-fg text-fg" : "border-transparent text-fg-soft hover:text-fg"
}`}
>
{option.label}
</button>
);
})}
</div>
)}
</div>
{isSearching && (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-fg-soft">
<span>
Search results for <span className="text-fg">{query}</span>, ranked by YouTube.
</span>
<button
type="button"
onClick={clearSearch}
className="font-medium text-fg-muted underline-offset-4 hover:text-fg hover:underline"
>
Back to all videos
</button>
</div>
)}
</section>
);
}
93 changes: 69 additions & 24 deletions apps/web/src/components/continue-card.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,88 @@
import { Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useWatchPrefetch } from "../hooks/use-watch-prefetch";
import { formatDuration } from "../lib/format";
import { resolveHistoryChannelMeta } from "../lib/history-enrichment";
import { proxyImage } from "../lib/proxy";
import type { HistoryItem } from "../types/user";
import { HistoryChannelAvatar } from "./history-channel-avatar";
import { VideoProgressBar } from "./video-progress-bar";
import { VerifiedBadgeIcon } from "./watch-icons";

type ContinueCardProps = {
item: HistoryItem;
};

export function ContinueCard({ item }: ContinueCardProps) {
const prefetch = useWatchPrefetch(item.url);
const [uploaderVerified, setUploaderVerified] = useState(item.uploaderVerified ?? false);

useEffect(() => {
let active = true;
setUploaderVerified(item.uploaderVerified ?? false);
if (item.uploaderVerified !== undefined) {
return () => {
active = false;
};
}
resolveHistoryChannelMeta(item).then((meta) => {
if (active) setUploaderVerified(meta.uploaderVerified);
});
return () => {
active = false;
};
}, [item]);

return (
<Link
to="/watch"
search={{ v: item.url }}
className="flex-shrink-0 w-44 flex flex-col gap-2 group"
onMouseEnter={prefetch.onMouseEnter}
onMouseLeave={prefetch.onMouseLeave}
>
<div className="relative aspect-video rounded-lg overflow-hidden bg-surface-strong">
<img
src={item.thumbnail}
alt={item.title}
className="w-full h-full object-cover"
loading="lazy"
decoding="async"
/>
<span className="absolute bottom-1 right-1 bg-black/80 text-white text-[10px] font-medium px-1 py-0.5 rounded">
{formatDuration(item.duration)}
</span>
<VideoProgressBar progress={item.progress} duration={item.duration} />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-fg line-clamp-2 leading-snug group-hover:text-white">
<div className="w-44 flex-shrink-0">
<Link
to="/watch"
search={{ v: item.url }}
className="group flex flex-col gap-2"
onMouseEnter={prefetch.onMouseEnter}
onMouseLeave={prefetch.onMouseLeave}
>
<div className="relative aspect-video overflow-hidden rounded-lg bg-surface-strong">
<img
src={proxyImage(item.thumbnail)}
alt={item.title}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
<span className="absolute right-1 bottom-1 rounded bg-black/80 px-1 py-0.5 text-[10px] font-medium text-white">
{formatDuration(item.duration)}
</span>
<VideoProgressBar progress={item.progress} duration={item.duration} />
</div>
<span className="line-clamp-2 text-fg text-xs leading-snug group-hover:text-white">
{item.title}
</span>
<span className="text-[10px] text-fg-soft truncate">{item.channelName}</span>
</Link>
<div className="mt-1.5 flex min-w-0 items-center gap-1.5">
{item.channelUrl ? (
<Link to="/channel" search={{ url: item.channelUrl }} className="flex-shrink-0">
<HistoryChannelAvatar item={item} className="h-5 w-5" />
</Link>
) : (
<HistoryChannelAvatar item={item} className="h-5 w-5" />
)}
{item.channelUrl ? (
<Link
to="/channel"
search={{ url: item.channelUrl }}
className="flex min-w-0 items-center gap-1 text-[10px] text-fg-soft transition-colors hover:text-fg"
>
<span className="min-w-0 truncate">{item.channelName}</span>
{uploaderVerified && <VerifiedBadgeIcon />}
</Link>
) : (
<span className="flex min-w-0 items-center gap-1 text-[10px] text-fg-soft">
<span className="min-w-0 truncate">{item.channelName}</span>
{uploaderVerified && <VerifiedBadgeIcon />}
</span>
)}
</div>
</Link>
</div>
);
}
19 changes: 4 additions & 15 deletions apps/web/src/components/home-fallback-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,18 @@ import { useBlockedFilter } from "../hooks/use-blocked-filter";
import { useSubscriptionFeed } from "../hooks/use-subscription-feed";
import { useSubscriptions } from "../hooks/use-subscriptions";
import { ScrollSentinel } from "./scroll-sentinel";
import { VideoCardSkeleton } from "./video-card-skeleton";
import { VideoGrid } from "./video-grid";

const SKELETON_KEYS = Array.from({ length: 12 }, (_, i) => `hfs-${i}`);

function SkeletonGrid() {
return (
<div className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 sm:gap-y-8 md:grid-cols-3 lg:grid-cols-4">
{SKELETON_KEYS.map((k) => (
<VideoCardSkeleton key={k} />
))}
</div>
);
}
import { VideoGridSkeleton } from "./video-grid-skeleton";

function FeedSection() {
const { streams, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
useSubscriptionFeed();
const { filter } = useBlockedFilter();
if (isLoading) return <SkeletonGrid />;
if (isLoading) return <VideoGridSkeleton idPrefix="home-feed" />;
return (
<>
<VideoGrid streams={filter(streams)} />
{isFetchingNextPage && <VideoGridSkeleton idPrefix="home-feed-next" />}
<ScrollSentinel onIntersect={fetchNextPage} enabled={hasNextPage && !isFetchingNextPage} />
</>
);
Expand All @@ -33,7 +22,7 @@ function FeedSection() {
export function HomeFallbackSection() {
const { query } = useSubscriptions();
const hasSubs = (query.data ?? []).length > 0;
if (query.isLoading) return <SkeletonGrid />;
if (query.isLoading) return <VideoGridSkeleton idPrefix="home-fallback" />;
if (hasSubs) return <FeedSection />;
return (
<section className="rounded-xl border border-border bg-surface/70 p-6 text-center">
Expand Down
17 changes: 3 additions & 14 deletions apps/web/src/components/home-recommendations-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,21 @@ import { useBlockedFilter } from "../hooks/use-blocked-filter";
import { useHomeRecommendations } from "../hooks/use-home-recommendations";
import { HomeFallbackSection } from "./home-fallback-section";
import { ScrollSentinel } from "./scroll-sentinel";
import { VideoCardSkeleton } from "./video-card-skeleton";
import { VideoGrid } from "./video-grid";

const SKELETON_KEYS = Array.from({ length: 12 }, (_, i) => `hrs-${i}`);

function SkeletonGrid() {
return (
<div className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 sm:gap-y-8 md:grid-cols-3 lg:grid-cols-4">
{SKELETON_KEYS.map((k) => (
<VideoCardSkeleton key={k} />
))}
</div>
);
}
import { VideoGridSkeleton } from "./video-grid-skeleton";

export function HomeRecommendationsSection() {
const { streams, isLoading, isError, hasNextPage, isFetchingNextPage, fetchNextPage } =
useHomeRecommendations();
const { filter } = useBlockedFilter();
const filtered = filter(streams);

if (isLoading) return <SkeletonGrid />;
if (isLoading) return <VideoGridSkeleton idPrefix="home-recommendations" />;
if (isError || filtered.length === 0) return <HomeFallbackSection />;
return (
<>
<VideoGrid streams={filtered} />
{isFetchingNextPage && <VideoGridSkeleton idPrefix="home-recommendations-next" />}
<ScrollSentinel onIntersect={fetchNextPage} enabled={hasNextPage && !isFetchingNextPage} />
</>
);
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/components/video-grid-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { VideoCardSkeleton } from "./video-card-skeleton";

const DEFAULT_COUNT = 12;

type Props = {
count?: number;
idPrefix?: string;
};

export function VideoGridSkeleton({ count = DEFAULT_COUNT, idPrefix = "video-grid" }: Props) {
const keys = Array.from({ length: count }, (_, index) => `${idPrefix}-${index}`);
return (
<div className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 sm:gap-y-8 md:grid-cols-3 lg:grid-cols-4">
{keys.map((key) => (
<VideoCardSkeleton key={key} />
))}
</div>
);
}
30 changes: 22 additions & 8 deletions apps/web/src/hooks/use-channel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import type { ChannelSort } from "../lib/api";
import { fetchChannel } from "../lib/api";
import { buildChannelRequestUrl } from "../lib/channel-search-url";
import { mapVideoItem } from "../lib/mappers";
import { proxyImage } from "../lib/proxy";
import type { VideoStream } from "../types/stream";
Expand All @@ -20,11 +22,18 @@ type ChannelPage = {
nextpage: string | null;
};

export function useChannel(channelUrl: string, sort?: ChannelSort) {
const query = useInfiniteQuery({
queryKey: ["channel", channelUrl, sort],
type CachedChannelMeta = {
channelUrl: string;
meta: ChannelMeta;
};

export function useChannel(channelUrl: string, sort: ChannelSort, searchQuery: string) {
const lastMeta = useRef<CachedChannelMeta | null>(null);
const requestUrl = buildChannelRequestUrl(channelUrl, searchQuery);
const channelQuery = useInfiniteQuery({
queryKey: ["channel", channelUrl, sort, searchQuery],
queryFn: async ({ pageParam }): Promise<ChannelPage> => {
const res = await fetchChannel(channelUrl, pageParam as string | undefined, sort);
const res = await fetchChannel(requestUrl, pageParam as string | undefined, sort);
const isFirstPage = pageParam === undefined;
return {
meta: isFirstPage
Expand All @@ -44,15 +53,20 @@ export function useChannel(channelUrl: string, sort?: ChannelSort) {
initialPageParam: undefined as string | undefined,
getNextPageParam: (last: ChannelPage | undefined) => last?.nextpage ?? undefined,
enabled: channelUrl.length > 0,
placeholderData: (previousData) => previousData,
});

const pages = query.data?.pages ?? [];
const meta = pages.find((p) => p.meta !== null)?.meta ?? null;
const pages = channelQuery.data?.pages ?? [];
const currentMeta = pages.find((p) => p.meta !== null)?.meta ?? null;
const cachedMeta = lastMeta.current?.channelUrl === channelUrl ? lastMeta.current.meta : null;
const meta = currentMeta ?? cachedMeta;
const avatarUrl = meta?.avatarUrl ?? "";
const videos = pages.flatMap((p) =>
p.videos.map((v) => (v.channelAvatar || !avatarUrl ? v : { ...v, channelAvatar: avatarUrl })),
);

return { ...query, meta, videos };
useEffect(() => {
if (currentMeta) lastMeta.current = { channelUrl, meta: currentMeta };
}, [channelUrl, currentMeta]);

return { ...channelQuery, meta, videos };
}
Loading