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
4 changes: 3 additions & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,13 @@ import React from 'react';
import { Avatar, Box, Card, CardActionArea, Chip, Typography } from '../../ui';
import { ChannelSearchResult } from '../types';
import OpenInYouTubeLink from '../../shared/OpenInYouTubeLink';
import { formatCompactCount as formatCount } from '../../../utils/formatters';

interface ChannelCardProps {
result: ChannelSearchResult;
onClick: () => void;
}

function formatCount(count: number): string {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
return count.toLocaleString();
}

export default function ChannelCard({ result, onClick }: ChannelCardProps) {
return (
<Card className="overflow-hidden hover:shadow-md transition-shadow flex flex-col">
Expand Down
390 changes: 220 additions & 170 deletions client/src/components/FindVideos/__tests__/FindVideos.test.tsx

Large diffs are not rendered by default.

76 changes: 76 additions & 0 deletions client/src/components/FindVideos/__tests__/sortResults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { SearchResult } from '../types';
import { DEFAULT_SORT, SORT_OPTIONS, defaultDirectionFor, sortResults } from '../sortResults';

function makeResult(overrides: Partial<SearchResult> & { youtubeId: string }): SearchResult {
return {
title: 'Title',
channelName: 'Channel',
channelId: null,
duration: null,
thumbnailUrl: null,
publishedAt: null,
viewCount: null,
status: 'never_downloaded',
...overrides,
};
}

const ids = (results: SearchResult[]) => results.map((r) => r.youtubeId);

describe('sortResults', () => {
const results = [
makeResult({ youtubeId: 'b', title: 'Bravo', channelName: 'Zed', duration: 300, publishedAt: '2024-01-01T00:00:00Z', viewCount: 10 }),
makeResult({ youtubeId: 'n', title: 'Null', channelName: 'Mid' }),
makeResult({ youtubeId: 'a', title: 'alpha', channelName: 'Alpha', duration: 30, publishedAt: '2026-01-01T00:00:00Z', viewCount: 1000 }),
];

test('relevance keeps the input order in both directions', () => {
expect(ids(sortResults(results, 'relevance', 'desc'))).toEqual(['b', 'n', 'a']);
expect(ids(sortResults(results, 'relevance', 'asc'))).toEqual(['b', 'n', 'a']);
});

test('does not mutate the input', () => {
sortResults(results, 'title', 'asc');
expect(ids(results)).toEqual(['b', 'n', 'a']);
});

test('sorts titles case-insensitively', () => {
expect(ids(sortResults(results, 'title', 'asc'))).toEqual(['a', 'b', 'n']);
expect(ids(sortResults(results, 'title', 'desc'))).toEqual(['n', 'b', 'a']);
});

test('sorts by channel name', () => {
expect(ids(sortResults(results, 'channel', 'asc'))).toEqual(['a', 'n', 'b']);
});

test('puts a missing date, duration, or view count last in either direction', () => {
expect(ids(sortResults(results, 'published', 'desc'))).toEqual(['a', 'b', 'n']);
expect(ids(sortResults(results, 'published', 'asc'))).toEqual(['b', 'a', 'n']);
expect(ids(sortResults(results, 'duration', 'desc'))).toEqual(['b', 'a', 'n']);
expect(ids(sortResults(results, 'views', 'asc'))).toEqual(['b', 'a', 'n']);
});

test('is stable for equal keys', () => {
const tied = [
makeResult({ youtubeId: 'one', viewCount: 5 }),
makeResult({ youtubeId: 'two', viewCount: 5 }),
makeResult({ youtubeId: 'three', viewCount: 5 }),
];
expect(ids(sortResults(tied, 'views', 'desc'))).toEqual(['one', 'two', 'three']);
});
});

describe('sort defaults', () => {
test('text columns start ascending, numeric and date columns descending', () => {
expect(defaultDirectionFor('title')).toBe('asc');
expect(defaultDirectionFor('channel')).toBe('asc');
expect(defaultDirectionFor('published')).toBe('desc');
expect(defaultDirectionFor('duration')).toBe('desc');
expect(defaultDirectionFor('views')).toBe('desc');
});

test('relevance is the default and the only undirected option', () => {
expect(DEFAULT_SORT).toEqual({ key: 'relevance', direction: 'desc' });
expect(SORT_OPTIONS.filter((o) => o.undirected).map((o) => o.key)).toEqual(['relevance']);
});
});
86 changes: 40 additions & 46 deletions client/src/components/FindVideos/components/ResultCard.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,41 @@
import React from 'react';
import { Box, Card, CardActionArea, Checkbox, Typography, Chip } from '../../ui';
import { CalendarToday as CalendarTodayIcon } from '../../../lib/icons';
import {
getStatusColor,
getStatusIcon,
getStatusLabel,
getStatusChipVariant,
getStatusChipStyle,
VideoStatus,
} from '../../../utils/videoStatus';
import { SHARED_THEMED_CHIP_SMALL_STYLE } from '../../shared/chipStyles';
import { formatDurationClock } from '../../../utils';
import { isSelectableForDownload, ResultSelection, SearchResult } from '../types';
import {
AddChannelHandler,
DownloadButton,
PublishedDate,
ResultChannel,
ResultStatusChip,
ShortBadge,
ViewCount,
} from './resultParts';

interface ResultCardProps {
result: SearchResult;
onClick: () => void;
selection?: ResultSelection;
onAddChannel?: AddChannelHandler;
onDownloadOne?: (result: SearchResult) => void;
}

export default function ResultCard({ result, onClick, selection }: ResultCardProps) {
const status: VideoStatus = result.status;
const THUMB_OVERLAY_STYLE: React.CSSProperties = {
position: 'absolute',
top: 4,
padding: 4,
borderRadius: 'var(--radius-ui)',
backgroundColor: 'var(--media-overlay-background)',
zIndex: 3,
};

export default function ResultCard({ result, onClick, selection, onAddChannel, onDownloadOne }: ResultCardProps) {
const durationLabel = formatDurationClock(result.duration);
const selectable = Boolean(selection) && isSelectableForDownload(result.status);
const checked = selectable && selection!.isChecked(result.youtubeId);

return (
<Card className="cursor-pointer overflow-hidden hover:shadow-md transition-shadow">
<Card className="relative cursor-pointer overflow-hidden hover:shadow-md transition-shadow">
<CardActionArea
onClick={onClick}
aria-label={`Open ${result.title}`}
Expand All @@ -43,15 +52,7 @@ export default function ResultCard({ result, onClick, selection }: ResultCardPro
{selectable && (
<Box
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
top: 4,
left: 4,
padding: 4,
borderRadius: 'var(--radius-ui)',
backgroundColor: 'var(--media-overlay-background)',
zIndex: 3,
}}
style={{ ...THUMB_OVERLAY_STYLE, left: 4 }}
>
<Checkbox
checked={checked}
Expand Down Expand Up @@ -81,33 +82,26 @@ export default function ResultCard({ result, onClick, selection }: ResultCardPro
</Box>
<Box className="p-2 flex flex-col gap-2">
<Typography variant="body2" className="font-semibold line-clamp-2">{result.title}</Typography>
<Typography variant="caption" className="text-muted-foreground">{result.channelName}</Typography>
<Box className="flex items-center gap-2 flex-wrap">
{result.publishedAt && (
<Typography
variant="caption"
className="text-muted-foreground inline-flex items-center gap-1"
>
<CalendarTodayIcon size={12} />
{new Date(result.publishedAt).toLocaleDateString()}
</Typography>
)}
<Chip
icon={getStatusIcon(status)}
label={getStatusLabel(status)}
size="small"
color={getStatusColor(status)}
variant={getStatusChipVariant(status)}
style={{
flex: '0 0 auto',
minWidth: 'fit-content',
...SHARED_THEMED_CHIP_SMALL_STYLE,
...getStatusChipStyle(status),
}}
/>
<ResultChannel
result={result}
variant="caption"
className="text-muted-foreground truncate"
onAddChannel={onAddChannel}
/>
<Box className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<PublishedDate publishedAt={result.publishedAt} />
<ViewCount viewCount={result.viewCount} />
<ShortBadge duration={result.duration} />
<ResultStatusChip status={result.status} />
</Box>
</Box>
</CardActionArea>
{/* Outside the action area so the button is not nested in the card's own button role. */}
{onDownloadOne && isSelectableForDownload(result.status) && (
<Box style={{ ...THUMB_OVERLAY_STYLE, right: 4 }}>
<DownloadButton result={result} onDownload={onDownloadOne} className="text-[var(--media-overlay-foreground)]" />
</Box>
)}
</Card>
);
}
92 changes: 77 additions & 15 deletions client/src/components/FindVideos/components/ResultsGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import React from 'react';
import { Box, Typography, Alert, Button, Skeleton } from '../../ui';
import { useMediaQuery } from '../../../hooks/useMediaQuery';
import { ResultSelection, SearchResult, PageSize, ViewMode } from '../types';
import { ResultSort, SortKey } from '../sortResults';
import { ResultSelection, SearchResult, PageSize, ViewMode, THUMB_WIDTH, THUMB_HEIGHT } from '../types';
import ResultCard from './ResultCard';
import ResultsTable from './ResultsTable';
import ResultsListMobile from './ResultsListMobile';
import { AddChannelHandler } from './resultParts';

// Placeholder rows beyond the first screen are never seen; the grid keeps
// its one-per-result shape because cards are short enough to fill it.
const ROW_SKELETON_MAX = 10;

interface ResultsGridProps {
results: SearchResult[];
Expand All @@ -17,25 +23,61 @@ interface ResultsGridProps {
onResultClick: (result: SearchResult) => void;
onRetry: () => void;
selection?: ResultSelection;
sort?: ResultSort;
onSortChange?: (key: SortKey) => void;
onAddChannel?: AddChannelHandler;
onDownloadOne?: (result: SearchResult) => void;
}

function GridSkeleton({ count }: { count: number }) {
return (
<Box className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{Array.from({ length: count }).map((_, i) => (
<Box key={i} className="space-y-2">
<Skeleton className="aspect-video w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</Box>
))}
</Box>
);
}

function RowSkeleton({ count, wide, testId }: { count: number; wide: boolean; testId: string }) {
return (
<Box data-testid={testId} className="flex flex-col">
{Array.from({ length: count }).map((_, i) => (
<Box key={i} className="flex items-center gap-3 py-2 px-1 border-b border-border">
<Skeleton className="shrink-0 rounded-[var(--radius-thumb)]" style={{ width: THUMB_WIDTH, height: THUMB_HEIGHT }} />
<Box className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/3" />
</Box>
{wide && (
<>
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-12" />
<Skeleton className="h-3 w-12" />
</>
)}
</Box>
))}
</Box>
);
}

export default function ResultsGrid({
results, loading, error, hasSearched, lastQuery, pageSize, viewMode, onResultClick, onRetry, selection,
results, loading, error, hasSearched, lastQuery, pageSize, viewMode,
onResultClick, onRetry, selection, sort, onSortChange, onAddChannel, onDownloadOne,
}: ResultsGridProps) {
const isMobile = useMediaQuery('(max-width: 767px)');

if (loading) {
return (
<Box className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{Array.from({ length: pageSize }).map((_, i) => (
<Box key={i} className="space-y-2">
<Skeleton className="aspect-video w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</Box>
))}
</Box>
);
if (viewMode === 'grid') return <GridSkeleton count={pageSize} />;
const rows = Math.min(pageSize, ROW_SKELETON_MAX);
return isMobile
? <RowSkeleton count={rows} wide={false} testId="results-skeleton-list" />
: <RowSkeleton count={rows} wide testId="results-skeleton-table" />;
}

if (error) {
Expand Down Expand Up @@ -67,8 +109,26 @@ export default function ResultsGrid({

if (viewMode === 'table') {
return isMobile
? <ResultsListMobile results={results} onResultClick={onResultClick} selection={selection} />
: <ResultsTable results={results} onResultClick={onResultClick} selection={selection} />;
? (
<ResultsListMobile
results={results}
onResultClick={onResultClick}
selection={selection}
onAddChannel={onAddChannel}
onDownloadOne={onDownloadOne}
/>
)
: (
<ResultsTable
results={results}
onResultClick={onResultClick}
selection={selection}
sort={sort}
onSortChange={onSortChange}
onAddChannel={onAddChannel}
onDownloadOne={onDownloadOne}
/>
);
}

return (
Expand All @@ -79,6 +139,8 @@ export default function ResultsGrid({
result={r}
onClick={() => onResultClick(r)}
selection={selection}
onAddChannel={onAddChannel}
onDownloadOne={onDownloadOne}
/>
))}
</Box>
Expand Down
Loading
Loading