mirror of
https://github.com/neosubhamoy/neodlp.git
synced 2026-02-04 14:12:22 +05:30
feat: added clear, copy log buttons and pagination in completed downloads
This commit is contained in:
@@ -309,7 +309,7 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSuccessFetchingDownloadStates && downloadStates) {
|
if (isSuccessFetchingDownloadStates && downloadStates) {
|
||||||
console.log("Download States fetched successfully:", downloadStates);
|
// console.log("Download States fetched successfully:", downloadStates);
|
||||||
setDownloadStates(downloadStates);
|
setDownloadStates(downloadStates);
|
||||||
}
|
}
|
||||||
}, [downloadStates, isSuccessFetchingDownloadStates, setDownloadStates]);
|
}, [downloadStates, isSuccessFetchingDownloadStates, setDownloadStates]);
|
||||||
|
|||||||
81
src/components/custom/paginationBar.tsx
Normal file
81
src/components/custom/paginationBar.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Paginated } from "@/types/download";
|
||||||
|
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||||
|
|
||||||
|
export default function PaginationBar({
|
||||||
|
paginatedData,
|
||||||
|
setPage,
|
||||||
|
}: {
|
||||||
|
paginatedData: Paginated;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pagination className="mt-4">
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => setPage(paginatedData.prev_page ?? paginatedData.first_page)}
|
||||||
|
aria-disabled={!paginatedData.prev_page}
|
||||||
|
className={!paginatedData.prev_page ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{paginatedData.pages.map((link, index, array) => {
|
||||||
|
const currentPage = paginatedData.current_page;
|
||||||
|
const pageNumber = link.page!;
|
||||||
|
|
||||||
|
// Show first page, last page, current page, and 2 pages around current
|
||||||
|
const showPage =
|
||||||
|
pageNumber === 1 ||
|
||||||
|
pageNumber === paginatedData.last_page ||
|
||||||
|
Math.abs(pageNumber - currentPage) <= 1;
|
||||||
|
|
||||||
|
// Show ellipsis if there's a gap
|
||||||
|
const prevVisiblePage = array
|
||||||
|
.slice(0, index)
|
||||||
|
.reverse()
|
||||||
|
.find((prevLink) => {
|
||||||
|
const prevPageNum = prevLink.page!;
|
||||||
|
return (
|
||||||
|
prevPageNum === 1 ||
|
||||||
|
prevPageNum === paginatedData.last_page ||
|
||||||
|
Math.abs(prevPageNum - currentPage) <= 1
|
||||||
|
);
|
||||||
|
})?.page;
|
||||||
|
|
||||||
|
const showEllipsis = showPage && prevVisiblePage && pageNumber - prevVisiblePage > 1;
|
||||||
|
|
||||||
|
if (!showPage) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={link.page} className="contents">
|
||||||
|
{showEllipsis && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
{showPage && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setPage(link.page)}
|
||||||
|
isActive={link.active}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => setPage(paginatedData.next_page ?? paginatedData.last_page)}
|
||||||
|
aria-disabled={!paginatedData.next_page}
|
||||||
|
className={!paginatedData.next_page ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { getRouteName } from "@/utils";
|
import { getRouteName } from "@/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Terminal } from "lucide-react";
|
import { BrushCleaning, Check, Copy, Terminal } from "lucide-react";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { useLogger } from "@/helpers/use-logger";
|
import { useLogger } from "@/helpers/use-logger";
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const logs = useLogger().getLogs();
|
const logger = useLogger();
|
||||||
|
const logs = logger.getLogs();
|
||||||
|
const logText = logs.map(log => `${new Date(log.timestamp).toLocaleTimeString()} [${log.level.toUpperCase()}] ${log.context}: ${log.message}`).join('\n');
|
||||||
|
|
||||||
|
const handleCopyLogs = async () => {
|
||||||
|
await writeText(logText);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex justify-between items-center py-3 px-4 sticky top-0 backdrop-blur supports-backdrop-filter:bg-background/60 border-b z-50">
|
<nav className="flex justify-between items-center py-3 px-4 sticky top-0 backdrop-blur supports-backdrop-filter:bg-background/60 border-b z-50">
|
||||||
@@ -48,6 +59,28 @@ export default function Navbar() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
onClick={() => logger.clearLogs()}
|
||||||
|
>
|
||||||
|
<BrushCleaning className="size-4" />
|
||||||
|
Clear Logs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="transition-all duration-300"
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
onClick={() => handleCopyLogs()}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4" />
|
||||||
|
)}
|
||||||
|
Copy Logs
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { ProxyImage } from "@/components/custom/proxyImage";
|
import { ProxyImage } from "@/components/custom/proxyImage";
|
||||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useCurrentVideoMetadataStore, useDownloadActionStatesStore } from "@/services/store";
|
import { useCurrentVideoMetadataStore, useDownloadActionStatesStore, useLibraryPageStatesStore } from "@/services/store";
|
||||||
import { formatBitrate, formatCodec, formatDurationString, formatFileSize } from "@/utils";
|
import { formatBitrate, formatCodec, formatDurationString, formatFileSize, paginate } from "@/utils";
|
||||||
import { ArrowUpRightIcon, AudioLines, CircleArrowDown, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Music, Play, Search, Trash2, Video } from "lucide-react";
|
import { ArrowUpRightIcon, AudioLines, CircleArrowDown, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Music, Play, Search, Trash2, Video } from "lucide-react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import * as fs from "@tauri-apps/plugin-fs";
|
import * as fs from "@tauri-apps/plugin-fs";
|
||||||
@@ -17,6 +18,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useLogger } from "@/helpers/use-logger";
|
import { useLogger } from "@/helpers/use-logger";
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||||
|
import PaginationBar from "@/components/custom/paginationBar";
|
||||||
|
|
||||||
interface CompletedDownloadProps {
|
interface CompletedDownloadProps {
|
||||||
state: DownloadState;
|
state: DownloadState;
|
||||||
@@ -250,16 +252,35 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CompletedDownloads({ downloads }: CompletedDownloadsProps) {
|
export function CompletedDownloads({ downloads }: CompletedDownloadsProps) {
|
||||||
|
const activeCompletedDownloadsPage = useLibraryPageStatesStore(state => state.activeCompletedDownloadsPage);
|
||||||
|
const setActiveCompletedDownloadsPage = useLibraryPageStatesStore(state => state.setActiveCompletedDownloadsPage);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const paginatedCompletedDownloads = paginate(downloads, activeCompletedDownloadsPage, 5);
|
||||||
|
|
||||||
|
// Ensure current page is valid when downloads change
|
||||||
|
useEffect(() => {
|
||||||
|
if (downloads.length > 0 && activeCompletedDownloadsPage > paginatedCompletedDownloads.last_page) {
|
||||||
|
setActiveCompletedDownloadsPage(paginatedCompletedDownloads.last_page);
|
||||||
|
}
|
||||||
|
}, [downloads.length, activeCompletedDownloadsPage, paginatedCompletedDownloads.last_page, setActiveCompletedDownloadsPage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="w-full flex flex-col gap-2">
|
||||||
{downloads.length > 0 ? (
|
{paginatedCompletedDownloads.data.length > 0 ? (
|
||||||
downloads.map((state) => {
|
<>
|
||||||
|
{paginatedCompletedDownloads.data.map((state) => {
|
||||||
return (
|
return (
|
||||||
<CompletedDownload key={state.download_id} state={state} />
|
<CompletedDownload key={state.download_id} state={state} />
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
{paginatedCompletedDownloads.pages.length > 1 && (
|
||||||
|
<PaginationBar
|
||||||
|
paginatedData={paginatedCompletedDownloads}
|
||||||
|
setPage={setActiveCompletedDownloadsPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Empty className="mt-10">
|
<Empty className="mt-10">
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export default function useDownloader() {
|
|||||||
|
|
||||||
const updateDownloadState = debounce((state: DownloadState) => {
|
const updateDownloadState = debounce((state: DownloadState) => {
|
||||||
downloadStateSaver.mutate(state, {
|
downloadStateSaver.mutate(state, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (_data) => {
|
||||||
console.log("Download State saved successfully:", data);
|
// console.log("Download State saved successfully:", data);
|
||||||
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ export default function LibraryPage() {
|
|||||||
const { pauseDownload } = useAppContext();
|
const { pauseDownload } = useAppContext();
|
||||||
|
|
||||||
const incompleteDownloads = downloadStates.filter(state => state.download_status !== 'completed');
|
const incompleteDownloads = downloadStates.filter(state => state.download_status !== 'completed');
|
||||||
const completedDownloads = downloadStates.filter(state => state.download_status === 'completed')
|
const completedDownloads = downloadStates.filter(state => state.download_status === 'completed').sort((a, b) => {
|
||||||
.sort((a, b) => {
|
|
||||||
// Latest updated first
|
// Latest updated first
|
||||||
const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
|
const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
|
||||||
const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
|
const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ export const useDownloaderPageStatesStore = create<DownloaderPageStatesStore>((s
|
|||||||
|
|
||||||
export const useLibraryPageStatesStore = create<LibraryPageStatesStore>((set) => ({
|
export const useLibraryPageStatesStore = create<LibraryPageStatesStore>((set) => ({
|
||||||
activeTab: 'completed',
|
activeTab: 'completed',
|
||||||
setActiveTab: (tab) => set(() => ({ activeTab: tab }))
|
activeCompletedDownloadsPage: 1,
|
||||||
|
setActiveTab: (tab) => set(() => ({ activeTab: tab })),
|
||||||
|
setActiveCompletedDownloadsPage: (page) => set(() => ({ activeCompletedDownloadsPage: page }))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useDownloadActionStatesStore = create<DownloadActionStatesStore>((set) => ({
|
export const useDownloadActionStatesStore = create<DownloadActionStatesStore>((set) => ({
|
||||||
|
|||||||
@@ -97,3 +97,21 @@ export interface DownloadProgress {
|
|||||||
total: number | null;
|
total: number | null;
|
||||||
eta: number | null;
|
eta: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Paginated<T = any> {
|
||||||
|
current_page: number;
|
||||||
|
from: number;
|
||||||
|
first_page: number;
|
||||||
|
last_page: number;
|
||||||
|
pages: Array<{
|
||||||
|
label: string;
|
||||||
|
page: number;
|
||||||
|
active: boolean;
|
||||||
|
}>;
|
||||||
|
next_page: number | null;
|
||||||
|
per_page: number;
|
||||||
|
prev_page: number | null;
|
||||||
|
to: number;
|
||||||
|
total: number;
|
||||||
|
data: T[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ export interface DownloaderPageStatesStore {
|
|||||||
|
|
||||||
export interface LibraryPageStatesStore {
|
export interface LibraryPageStatesStore {
|
||||||
activeTab: string;
|
activeTab: string;
|
||||||
|
activeCompletedDownloadsPage: number;
|
||||||
setActiveTab: (tab: string) => void;
|
setActiveTab: (tab: string) => void;
|
||||||
|
setActiveCompletedDownloadsPage: (page: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadActionStatesStore {
|
export interface DownloadActionStatesStore {
|
||||||
|
|||||||
45
src/utils.ts
45
src/utils.ts
@@ -1,6 +1,6 @@
|
|||||||
import { RoutesObj } from "@/types/route";
|
import { RoutesObj } from "@/types/route";
|
||||||
import { AllRoutes } from "@/routes";
|
import { AllRoutes } from "@/routes";
|
||||||
import { DownloadProgress } from "@/types/download";
|
import { DownloadProgress, Paginated } from "@/types/download";
|
||||||
import { VideoFormat } from "@/types/video";
|
import { VideoFormat } from "@/types/video";
|
||||||
import * as fs from "@tauri-apps/plugin-fs";
|
import * as fs from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
@@ -402,3 +402,46 @@ export const sortByBitrate = (formats: VideoFormat[] | undefined) => {
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const paginate = <T>(items: T[], currentPage: number, itemsPerPage: number): Paginated<T> => {
|
||||||
|
const total = items.length;
|
||||||
|
const lastPage = Math.max(1, Math.ceil(total / itemsPerPage));
|
||||||
|
|
||||||
|
// Clamp current page to valid range
|
||||||
|
const validCurrentPage = Math.max(1, Math.min(currentPage, lastPage));
|
||||||
|
|
||||||
|
// Calculate start and end indices
|
||||||
|
const startIndex = (validCurrentPage - 1) * itemsPerPage;
|
||||||
|
const endIndex = Math.min(startIndex + itemsPerPage, total);
|
||||||
|
|
||||||
|
// Get paginated data
|
||||||
|
const data = items.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
// Calculate from/to (1-indexed)
|
||||||
|
const from = total > 0 ? startIndex + 1 : 0;
|
||||||
|
const to = total > 0 ? endIndex : 0;
|
||||||
|
|
||||||
|
// Generate pages array
|
||||||
|
const pages: Array<{ label: string; page: number; active: boolean }> = [];
|
||||||
|
for (let i = 1; i <= lastPage; i++) {
|
||||||
|
pages.push({
|
||||||
|
label: i.toString(),
|
||||||
|
page: i,
|
||||||
|
active: i === validCurrentPage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
current_page: validCurrentPage,
|
||||||
|
from,
|
||||||
|
first_page: 1,
|
||||||
|
last_page: lastPage,
|
||||||
|
pages,
|
||||||
|
next_page: validCurrentPage < lastPage ? validCurrentPage + 1 : null,
|
||||||
|
per_page: itemsPerPage,
|
||||||
|
prev_page: validCurrentPage > 1 ? validCurrentPage - 1 : null,
|
||||||
|
to,
|
||||||
|
total,
|
||||||
|
data
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user