Skip to content

Commit 9356fd6

Browse files
thejudge22claude
andcommitted
feat: add pagination for saved videos (Issue #32)
Implements classic pagination for the Saved Videos page to handle collections larger than 100 videos. Backend changes: - Add PaginatedVideosResponse schema with videos, total, limit, offset, has_more - Update /videos/saved endpoint to return paginated response with total count Frontend changes: - Add PaginatedVideosResponse interface and update SavedVideosParams - Update API client and useSavedVideos hook to handle pagination - Add pagination controls with page numbers and prev/next buttons - Reset to page 1 when filters change - Update Settings page to handle paginated data - Bump version to 1.8.0 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 83b8f36 commit 9356fd6

9 files changed

Lines changed: 511 additions & 15 deletions

File tree

backend/app/routers/videos.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from ..database import get_db
1616
from ..models.video import Video
1717
from ..models.channel import Channel
18-
from ..schemas.video import VideoResponse, VideoFromUrl, BulkVideoAction, ChannelFilterOption
18+
from ..schemas.video import VideoResponse, VideoFromUrl, BulkVideoAction, ChannelFilterOption, PaginatedVideosResponse
1919
from pydantic import BaseModel
2020
from ..schemas.mappers import map_video_to_response
2121
from ..services.youtube_utils import extract_video_id, get_video_url, get_rss_url, get_channel_url
@@ -80,7 +80,7 @@ async def list_inbox_videos(
8080
]
8181

8282

83-
@router.get("/videos/saved", response_model=List[VideoResponse])
83+
@router.get("/videos/saved", response_model=PaginatedVideosResponse)
8484
async def list_saved_videos(
8585
channel_youtube_id: Optional[str] = Query(None, description="Filter by channel YouTube ID"),
8686
channel_id: Optional[str] = Query(None, description="Filter by channel ID (deprecated)"),
@@ -113,6 +113,13 @@ async def list_saved_videos(
113113
if channel_record:
114114
filter_channel_youtube_id = channel_record
115115

116+
# Get total count for pagination
117+
count_query = select(func.count(Video.id)).where(Video.status == 'saved')
118+
if filter_channel_youtube_id:
119+
count_query = count_query.where(Video.channel_youtube_id == filter_channel_youtube_id)
120+
count_result = await db.execute(count_query)
121+
total = count_result.scalar_one()
122+
116123
# Use VideoService to fetch saved videos
117124
# Validation is handled within VideoService.get_videos
118125
videos = await VideoService.get_videos(
@@ -125,7 +132,7 @@ async def list_saved_videos(
125132
order=order
126133
)
127134

128-
return [
135+
video_responses = [
129136
VideoResponse(
130137
id=video.id,
131138
youtube_video_id=video.youtube_video_id,
@@ -146,6 +153,14 @@ async def list_saved_videos(
146153
for video in videos
147154
]
148155

156+
return PaginatedVideosResponse(
157+
videos=video_responses,
158+
total=total,
159+
limit=limit,
160+
offset=offset,
161+
has_more=(offset + len(video_responses)) < total
162+
)
163+
149164

150165
@router.get("/videos/saved/channels", response_model=List[ChannelFilterOption])
151166
async def list_saved_video_channels(

backend/app/schemas/video.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,12 @@ class VideoFromUrl(BaseModel):
4242
class BulkVideoAction(BaseModel):
4343
"""Schema for bulk video actions."""
4444
video_ids: List[str]
45+
46+
47+
class PaginatedVideosResponse(BaseModel):
48+
"""Response schema for paginated video lists."""
49+
videos: List[VideoResponse]
50+
total: int
51+
limit: int
52+
offset: int
53+
has_more: bool

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "frontend",
33
"private": true,
4-
"version": "1.01",
4+
"version": "1.8.0",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

frontend/src/api/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import axios from 'axios';
2-
import type { Channel, Video, SavedVideosParams, ChannelFilterOption } from '../types';
2+
import type { Channel, Video, SavedVideosParams, ChannelFilterOption, PaginatedVideosResponse } from '../types';
33

44
// Default timeout for most requests
55
const DEFAULT_TIMEOUT = 30000; // 30 seconds
@@ -90,7 +90,7 @@ export const videosApi = {
9090
...(isShort !== undefined && isShort !== null ? { is_short: isShort } : {})
9191
}
9292
}),
93-
getSaved: (params?: SavedVideosParams) => api.get<Video[]>('/videos/saved', { params }),
93+
getSaved: (params?: SavedVideosParams) => api.get<PaginatedVideosResponse>('/videos/saved', { params }),
9494
getSavedChannels: () => api.get<ChannelFilterOption[]>('/videos/saved/channels'),
9595
getDiscarded: (days?: number) => api.get<Video[]>('/videos/discarded', { params: { days } }),
9696
save: (id: string) => api.post<Video>(`/videos/${id}/save`),

frontend/src/hooks/useVideos.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function useInboxVideos(channelId?: string, shortsFilter?: ShortsFilter)
1515
});
1616
}
1717

18-
export function useSavedVideos(params?: SavedVideosParams) {
18+
export function useSavedVideos(params: SavedVideosParams) {
1919
return useQuery({
2020
queryKey: ['videos', 'saved', params],
2121
queryFn: async () => {

frontend/src/pages/Saved.tsx

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useState, useMemo } from 'react';
2-
import { PlayIcon } from '@heroicons/react/24/outline';
2+
import { PlayIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
33
import { useSavedVideos, useDiscardVideo, useBulkDiscardVideos, useSavedVideoChannels } from '../hooks/useVideos';
44
import { VideoList } from '../components/video/VideoList';
55
import { RecentlyDeletedModal } from '../components/video/RecentlyDeletedModal';
@@ -28,6 +28,7 @@ export function Saved() {
2828
const [viewMode, setViewMode] = useLocalStorage<ViewMode>('saved-view-mode', 'large');
2929
const [isSelectionMode, setIsSelectionMode] = useState(false);
3030
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
31+
const [currentPage, setCurrentPage] = useState(1);
3132

3233
const params: SavedVideosParams = useMemo(() => {
3334
const p: SavedVideosParams = {};
@@ -40,10 +41,17 @@ export function Saved() {
4041
if (order) {
4142
p.order = order;
4243
}
44+
p.limit = 100;
45+
p.offset = (currentPage - 1) * 100;
4346
return p;
44-
}, [channelYoutubeId, sortBy, order]);
47+
}, [channelYoutubeId, sortBy, order, currentPage]);
4548

46-
const { data: videos, isLoading, error, refetch } = useSavedVideos(params);
49+
// Reset to page 1 when filters change
50+
const handleFilterChange = () => {
51+
setCurrentPage(1);
52+
};
53+
54+
const { data, isLoading, error, refetch } = useSavedVideos(params);
4755
const { data: channelOptions } = useSavedVideoChannels();
4856
const discardVideo = useDiscardVideo();
4957
const bulkDiscard = useBulkDiscardVideos();
@@ -107,9 +115,19 @@ export function Saved() {
107115
if (selected) {
108116
setSortBy(selected.value);
109117
setOrder(selected.order);
118+
handleFilterChange();
110119
}
111120
};
112121

122+
const handleChannelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
123+
setChannelYoutubeId(e.target.value);
124+
handleFilterChange();
125+
};
126+
127+
const videos = data?.videos ?? [];
128+
const totalCount = data?.total ?? 0;
129+
const totalPages = Math.ceil(totalCount / 100);
130+
113131
if (isLoading) {
114132
return (
115133
<div className="flex items-center justify-center min-h-[400px]">
@@ -140,9 +158,9 @@ export function Saved() {
140158
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between mb-6 gap-4">
141159
<div>
142160
<h1 className="text-2xl font-bold text-white">Saved Videos</h1>
143-
{videos && videos.length > 0 && (
161+
{totalCount > 0 && (
144162
<p className="text-gray-400 text-sm mt-1">
145-
{videos.length} video{videos.length !== 1 ? 's' : ''} saved
163+
{totalCount} video{totalCount !== 1 ? 's' : ''} saved
146164
</p>
147165
)}
148166
</div>
@@ -173,7 +191,7 @@ export function Saved() {
173191
<select
174192
id="channel-filter"
175193
value={channelYoutubeId}
176-
onChange={(e) => setChannelYoutubeId(e.target.value)}
194+
onChange={handleChannelChange}
177195
className="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
178196
>
179197
<option value="">All Channels</option>
@@ -249,7 +267,7 @@ export function Saved() {
249267
)}
250268

251269
<VideoList
252-
videos={videos || []}
270+
videos={videos}
253271
onDiscard={handleDiscard}
254272
showSaveButton={false}
255273
showDiscardButton={false}
@@ -260,6 +278,63 @@ export function Saved() {
260278
onToggleSelect={handleToggleSelect}
261279
/>
262280

281+
{/* Pagination Controls */}
282+
{totalPages > 1 && (
283+
<div className="mt-6 flex items-center justify-center gap-2">
284+
<Button
285+
variant="secondary"
286+
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
287+
disabled={currentPage === 1}
288+
className="px-3 py-2"
289+
>
290+
<ChevronLeftIcon className="w-5 h-5" />
291+
</Button>
292+
293+
<div className="flex items-center gap-1">
294+
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
295+
// Show first page, last page, current page, and pages around current
296+
let pageNum: number;
297+
if (totalPages <= 10) {
298+
pageNum = i + 1;
299+
} else if (currentPage <= 5) {
300+
pageNum = i < 7 ? i + 1 : (i === 7 ? -1 : totalPages);
301+
} else if (currentPage >= totalPages - 4) {
302+
pageNum = i < 2 ? i + 1 : (i === 2 ? -1 : totalPages - 6 + i);
303+
} else {
304+
pageNum = i < 2 ? i + 1 : (i === 2 ? -1 : (i === 7 ? -1 : currentPage - 3 + i));
305+
}
306+
307+
if (pageNum === -1) {
308+
return <span key={i} className="px-2 text-gray-500">...</span>;
309+
}
310+
311+
return (
312+
<button
313+
key={i}
314+
onClick={() => setCurrentPage(pageNum)}
315+
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
316+
currentPage === pageNum
317+
? 'bg-blue-600 text-white'
318+
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
319+
}`}
320+
>
321+
{pageNum}
322+
</button>
323+
);
324+
})}
325+
</div>
326+
327+
<Button
328+
variant="secondary"
329+
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
330+
disabled={currentPage === totalPages}
331+
className="px-3 py-2"
332+
>
333+
<ChevronRightIcon className="w-5 h-5" />
334+
</Button>
335+
</div>
336+
)}
337+
263338
<RecentlyDeletedModal
264339
isOpen={showRecentlyDeleted}
265340
onClose={() => setShowRecentlyDeleted(false)}

frontend/src/pages/Settings.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ import AppSettingsSection from '../components/settings/AppSettingsSection';
77
import { BackupSettingsSection } from '../components/settings/BackupSettingsSection';
88

99
export function Settings() {
10-
const { data: videos } = useSavedVideos();
10+
const { data: savedVideosData } = useSavedVideos({ limit: 100, offset: 0 });
1111
const { data: discardedVideos } = useDiscardedVideos();
1212
const bulkDiscard = useBulkDiscardVideos();
1313
const purgeAllDiscarded = usePurgeAllDiscarded();
1414
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
1515
const [isPurgeConfirmModalOpen, setIsPurgeConfirmModalOpen] = useState(false);
1616
const [showDangerZone, setShowDangerZone] = useState(false);
1717

18+
const videos = savedVideosData?.videos ?? [];
19+
1820
const handleRemoveAll = () => {
1921
if (videos && videos.length > 0) {
2022
const videoIds = videos.map((v) => v.id);

frontend/src/types/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ export interface SavedVideosParams {
4040
channel_youtube_id?: string;
4141
sort_by?: 'published_at' | 'saved_at';
4242
order?: 'asc' | 'desc';
43+
limit?: number;
44+
offset?: number;
45+
}
46+
47+
export interface PaginatedVideosResponse {
48+
videos: Video[];
49+
total: number;
50+
limit: number;
51+
offset: number;
52+
has_more: boolean;
4353
}
4454

4555
// Issue #8: Shorts filter type for inbox

0 commit comments

Comments
 (0)