mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
23881eb670
Implements configurable indexer flag bonuses/penalties for torrent ranking, including UI for admin settings and support in ranking-algorithm. Adds an option to disable SSL certificate verification for qBittorrent connections (for self-signed certs), with UI in both setup and admin settings, and persists the setting. Updates documentation, API routes, and ranking logic to support these features. Also includes minor UI improvements and bug fixes.
171 lines
5.4 KiB
TypeScript
171 lines
5.4 KiB
TypeScript
/**
|
|
* Component: Sticky Pagination with Progress Bar
|
|
* Documentation: documentation/frontend/components.md
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
|
|
|
interface StickyPaginationProps {
|
|
currentPage: number;
|
|
totalPages: number;
|
|
onPageChange: (page: number) => void;
|
|
sectionRef: React.RefObject<HTMLElement | null>;
|
|
label: string; // e.g., "Popular Audiobooks"
|
|
footerRef?: React.RefObject<HTMLElement | null>; // Optional footer ref to avoid overlap
|
|
}
|
|
|
|
export function StickyPagination({
|
|
currentPage,
|
|
totalPages,
|
|
onPageChange,
|
|
sectionRef,
|
|
label,
|
|
footerRef,
|
|
}: StickyPaginationProps) {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const [isFooterVisible, setIsFooterVisible] = useState(false);
|
|
const [jumpPage, setJumpPage] = useState(currentPage.toString());
|
|
|
|
// Update jump page input when current page changes externally
|
|
useEffect(() => {
|
|
setJumpPage(currentPage.toString());
|
|
}, [currentPage]);
|
|
|
|
// Intersection Observer to show/hide pagination based on section visibility
|
|
useEffect(() => {
|
|
if (!sectionRef.current) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
// Show pagination when section is in viewport
|
|
setIsVisible(entry.isIntersecting && entry.intersectionRatio > 0.1);
|
|
},
|
|
{
|
|
threshold: [0, 0.1, 0.5, 1],
|
|
rootMargin: '-60px 0px -60px 0px', // Account for header/footer
|
|
}
|
|
);
|
|
|
|
observer.observe(sectionRef.current);
|
|
|
|
return () => observer.disconnect();
|
|
}, [sectionRef]);
|
|
|
|
// Footer observer to hide pagination when footer is visible
|
|
useEffect(() => {
|
|
if (!footerRef?.current) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
// Hide pagination when footer is in viewport
|
|
setIsFooterVisible(entry.isIntersecting);
|
|
},
|
|
{
|
|
threshold: [0, 0.1],
|
|
rootMargin: '0px',
|
|
}
|
|
);
|
|
|
|
observer.observe(footerRef.current);
|
|
|
|
return () => observer.disconnect();
|
|
}, [footerRef]);
|
|
|
|
if (totalPages <= 1) {
|
|
return null;
|
|
}
|
|
|
|
const handlePrevious = () => {
|
|
if (currentPage > 1) {
|
|
onPageChange(currentPage - 1);
|
|
}
|
|
};
|
|
|
|
const handleNext = () => {
|
|
if (currentPage < totalPages) {
|
|
onPageChange(currentPage + 1);
|
|
}
|
|
};
|
|
|
|
const handleJumpSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const page = parseInt(jumpPage, 10);
|
|
if (!isNaN(page) && page >= 1 && page <= totalPages) {
|
|
onPageChange(page);
|
|
} else {
|
|
// Reset to current page if invalid
|
|
setJumpPage(currentPage.toString());
|
|
}
|
|
};
|
|
|
|
// Final visibility: show when section is visible AND footer is not visible
|
|
const shouldShow = isVisible && !isFooterVisible;
|
|
|
|
return (
|
|
<div
|
|
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-40 transition-all duration-300 ${
|
|
shouldShow ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0'
|
|
}`}
|
|
>
|
|
<div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg rounded-full shadow-lg border border-gray-200 dark:border-gray-700 px-4 py-2.5">
|
|
<div className="flex items-center gap-3">
|
|
{/* Section Label - Hidden on small screens */}
|
|
<div className="hidden md:block text-xs font-medium text-gray-600 dark:text-gray-400 pr-2 border-r border-gray-300 dark:border-gray-600">
|
|
{label}
|
|
</div>
|
|
|
|
{/* Previous Button */}
|
|
<button
|
|
onClick={handlePrevious}
|
|
disabled={currentPage === 1}
|
|
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
|
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
|
transition-colors"
|
|
aria-label="Previous page"
|
|
>
|
|
<ChevronLeftIcon className="w-4 h-4" />
|
|
</button>
|
|
|
|
{/* Page Info & Jump */}
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
|
Page
|
|
</span>
|
|
<form onSubmit={handleJumpSubmit} className="inline-flex">
|
|
<input
|
|
type="text"
|
|
value={jumpPage}
|
|
onChange={(e) => setJumpPage(e.target.value)}
|
|
onBlur={handleJumpSubmit}
|
|
className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded
|
|
bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
|
border border-gray-300 dark:border-gray-600
|
|
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
|
aria-label="Current page"
|
|
/>
|
|
</form>
|
|
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
|
of {totalPages}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Next Button */}
|
|
<button
|
|
onClick={handleNext}
|
|
disabled={currentPage === totalPages}
|
|
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
|
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
|
transition-colors"
|
|
aria-label="Next page"
|
|
>
|
|
<ChevronRightIcon className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|