mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Implement file hash-based library matching and remove fuzzy ASIN matching
Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
This commit is contained in:
@@ -24,15 +24,18 @@ interface IndexerConfigModalProps {
|
||||
};
|
||||
initialConfig?: {
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
seedingTimeMinutes?: number;
|
||||
removeAfterProcessing?: boolean;
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
};
|
||||
onSave: (config: {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: string;
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
seedingTimeMinutes?: number;
|
||||
removeAfterProcessing?: boolean;
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
}) => void;
|
||||
@@ -47,9 +50,11 @@ export function IndexerConfigModal({
|
||||
onSave,
|
||||
}: IndexerConfigModalProps) {
|
||||
// Default values for Add mode
|
||||
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
|
||||
const defaults = {
|
||||
priority: 10,
|
||||
seedingTimeMinutes: 0,
|
||||
removeAfterProcessing: true, // Default to true for Usenet
|
||||
rssEnabled: indexer.supportsRss,
|
||||
categories: DEFAULT_CATEGORIES, // Default to Audio/Audiobook [3030]
|
||||
};
|
||||
@@ -61,6 +66,9 @@ export function IndexerConfigModal({
|
||||
const [seedingTimeMinutes, setSeedingTimeMinutes] = useState(
|
||||
initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes
|
||||
);
|
||||
const [removeAfterProcessing, setRemoveAfterProcessing] = useState(
|
||||
initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing
|
||||
);
|
||||
const [rssEnabled, setRssEnabled] = useState(
|
||||
initialConfig?.rssEnabled ?? defaults.rssEnabled
|
||||
);
|
||||
@@ -81,11 +89,13 @@ export function IndexerConfigModal({
|
||||
if (mode === 'add') {
|
||||
setPriority(defaults.priority);
|
||||
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
|
||||
setRemoveAfterProcessing(defaults.removeAfterProcessing);
|
||||
setRssEnabled(defaults.rssEnabled);
|
||||
setSelectedCategories(defaults.categories);
|
||||
} else {
|
||||
setPriority(initialConfig?.priority ?? defaults.priority);
|
||||
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
|
||||
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
|
||||
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
|
||||
setSelectedCategories(initialConfig?.categories ?? defaults.categories);
|
||||
}
|
||||
@@ -100,7 +110,7 @@ export function IndexerConfigModal({
|
||||
newErrors.priority = 'Priority must be between 1 and 25';
|
||||
}
|
||||
|
||||
if (seedingTimeMinutes < 0) {
|
||||
if (isTorrent && seedingTimeMinutes < 0) {
|
||||
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
|
||||
}
|
||||
|
||||
@@ -117,15 +127,23 @@ export function IndexerConfigModal({
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
const config: any = {
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
protocol: indexer.protocol,
|
||||
priority,
|
||||
seedingTimeMinutes,
|
||||
rssEnabled: indexer.supportsRss ? rssEnabled : false,
|
||||
categories: selectedCategories,
|
||||
});
|
||||
};
|
||||
|
||||
// Add protocol-specific fields
|
||||
if (isTorrent) {
|
||||
config.seedingTimeMinutes = seedingTimeMinutes;
|
||||
} else {
|
||||
config.removeAfterProcessing = removeAfterProcessing;
|
||||
}
|
||||
|
||||
onSave(config);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -196,29 +214,54 @@ export function IndexerConfigModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Seeding Time */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Seeding Time (minutes)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={seedingTimeMinutes}
|
||||
onChange={(e) => handleSeedingTimeChange(e.target.value)}
|
||||
placeholder="0"
|
||||
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
0 = unlimited seeding (files remain seeded indefinitely)
|
||||
</p>
|
||||
{errors.seedingTimeMinutes && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{errors.seedingTimeMinutes}
|
||||
{/* Seeding Time (Torrents only) */}
|
||||
{isTorrent && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Seeding Time (minutes)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={seedingTimeMinutes}
|
||||
onChange={(e) => handleSeedingTimeChange(e.target.value)}
|
||||
placeholder="0"
|
||||
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
0 = unlimited seeding (files remain seeded indefinitely)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{errors.seedingTimeMinutes && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{errors.seedingTimeMinutes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remove After Processing (Usenet only) */}
|
||||
{!isTorrent && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Post-Processing Cleanup
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeAfterProcessing}
|
||||
onChange={(e) => setRemoveAfterProcessing(e.target.checked)}
|
||||
className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Remove download from SABnzbd after files are organized
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Recommended: Automatically deletes completed NZB downloads to save disk space
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RSS Monitoring */}
|
||||
<div>
|
||||
|
||||
@@ -23,8 +23,10 @@ interface ProwlarrIndexer {
|
||||
interface SavedIndexerConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: string;
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
seedingTimeMinutes?: number; // Torrents only
|
||||
removeAfterProcessing?: boolean; // Usenet only
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
}
|
||||
@@ -134,7 +136,7 @@ export function IndexerManagement({
|
||||
indexer: indexer || {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
protocol: 'torrent', // Default fallback
|
||||
protocol: config.protocol,
|
||||
supportsRss: config.rssEnabled,
|
||||
},
|
||||
currentConfig: config,
|
||||
@@ -251,7 +253,7 @@ export function IndexerManagement({
|
||||
indexer={{
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
protocol: 'torrent', // Will be populated correctly from fetched data
|
||||
protocol: config.protocol,
|
||||
}}
|
||||
onEdit={() => openEditModal(config)}
|
||||
onDelete={() => handleDelete(config.id)}
|
||||
|
||||
@@ -336,6 +336,33 @@ export function AudiobookDetailsModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Audible Link */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">View Details</p>
|
||||
<a
|
||||
href={`https://www.audible.com/pd/${asin}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-orange-600 dark:text-orange-400 hover:text-orange-700 dark:hover:text-orange-300 hover:underline transition-colors font-medium"
|
||||
title="View on Audible"
|
||||
>
|
||||
<span>Audible.com</span>
|
||||
<svg
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Availability Status */}
|
||||
{isAvailable && (
|
||||
<div>
|
||||
|
||||
@@ -13,6 +13,7 @@ interface CardStackProps {
|
||||
currentIndex: number;
|
||||
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
|
||||
onSwipeComplete: () => void;
|
||||
onShowDetails?: () => void; // Callback to show details modal
|
||||
}
|
||||
|
||||
export function CardStack({
|
||||
@@ -20,6 +21,7 @@ export function CardStack({
|
||||
currentIndex,
|
||||
onSwipe,
|
||||
onSwipeComplete,
|
||||
onShowDetails,
|
||||
}: CardStackProps) {
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const [exitDirection, setExitDirection] = useState<'left' | 'right' | 'up' | null>(null);
|
||||
@@ -139,6 +141,7 @@ export function CardStack({
|
||||
<RecommendationCard
|
||||
recommendation={card.recommendation}
|
||||
onSwipe={handleSwipeStart}
|
||||
onShowDetails={isTopCard ? onShowDetails : undefined}
|
||||
stackPosition={card.stackPosition}
|
||||
isAnimating={isExiting || isAdvancing}
|
||||
isDraggable={isTopCard && !isExiting && !isAdvancing}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useSwipeable } from 'react-swipeable';
|
||||
interface RecommendationCardProps {
|
||||
recommendation: any;
|
||||
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
|
||||
onShowDetails?: () => void; // Callback to show details modal
|
||||
stackPosition?: number; // 0 = top, 1 = middle, 2 = bottom
|
||||
isAnimating?: boolean; // True during exit/advance animations
|
||||
isDraggable?: boolean; // False for cards behind the top card
|
||||
@@ -20,12 +21,14 @@ interface RecommendationCardProps {
|
||||
export function RecommendationCard({
|
||||
recommendation,
|
||||
onSwipe,
|
||||
onShowDetails,
|
||||
stackPosition = 0,
|
||||
isAnimating = false,
|
||||
isDraggable = true,
|
||||
}: RecommendationCardProps) {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleSwipeRight = () => {
|
||||
setShowToast(true);
|
||||
@@ -41,13 +44,21 @@ export function RecommendationCard({
|
||||
};
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipeStart: () => {
|
||||
if (isDraggable && !isAnimating) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
},
|
||||
onSwiping: (eventData) => {
|
||||
// Only update drag offset if card is draggable and not animating
|
||||
if (isDraggable && !isAnimating) {
|
||||
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
|
||||
setIsDragging(true); // Ensure dragging state is set
|
||||
}
|
||||
},
|
||||
onSwiped: (eventData) => {
|
||||
setIsDragging(false);
|
||||
|
||||
// Only process swipe if card is draggable and not animating
|
||||
if (!isDraggable || isAnimating) {
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
@@ -77,12 +88,22 @@ export function RecommendationCard({
|
||||
// Reset drag offset
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
},
|
||||
// Enable mouse tracking for desktop
|
||||
trackMouse: true,
|
||||
preventScrollOnSwipe: true,
|
||||
// Don't use built-in delta threshold - we'll check manually in onSwiped
|
||||
delta: 0,
|
||||
});
|
||||
|
||||
// Escape hatch: reset drag state if user clicks elsewhere
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if (isDragging && !isAnimating) {
|
||||
// If we're stuck dragging, reset everything
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getOverlayOpacity = (threshold: number, value: number) => {
|
||||
return Math.min(Math.abs(value) / threshold, 1);
|
||||
};
|
||||
@@ -107,12 +128,68 @@ export function RecommendationCard({
|
||||
<>
|
||||
<div
|
||||
{...swipeHandlers}
|
||||
onClick={handleCardClick}
|
||||
className="relative w-full max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden select-none max-h-[80vh] md:max-h-[85vh] flex flex-col"
|
||||
style={{
|
||||
transform: `translate(${dragOffset.x}px, ${dragOffset.y}px) rotate(${dragOffset.x * 0.05}deg)`,
|
||||
transition: dragOffset.x === 0 && dragOffset.y === 0 ? 'transform 0.3s ease-out' : 'none',
|
||||
cursor: isDraggable ? 'grab' : 'default',
|
||||
}}
|
||||
>
|
||||
{/* Details button - only show for top card */}
|
||||
{stackPosition === 0 && onShowDetails && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isAnimating) {
|
||||
// Reset any stuck drag state when clicking the button
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
setIsDragging(false);
|
||||
onShowDetails();
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isAnimating) {
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
setIsDragging(false);
|
||||
onShowDetails();
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
className="absolute top-4 right-4 z-30 p-2.5 bg-white dark:bg-gray-800 backdrop-blur-sm rounded-full shadow-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-all border-2 border-gray-300 dark:border-gray-600 hover:border-blue-500 dark:hover:border-blue-500 active:scale-95"
|
||||
title="View details"
|
||||
aria-label="View details"
|
||||
style={{ touchAction: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-700 dark:text-gray-300 pointer-events-none"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Drag overlay indicators - show only dominant direction */}
|
||||
{dominantDirection === 'right' && (
|
||||
<div
|
||||
@@ -206,21 +283,39 @@ export function RecommendationCard({
|
||||
{stackPosition === 0 && (
|
||||
<div className="hidden md:flex justify-center gap-4 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => !isAnimating && onSwipe('left')}
|
||||
onClick={() => {
|
||||
if (!isAnimating) {
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
setIsDragging(false);
|
||||
onSwipe('left');
|
||||
}
|
||||
}}
|
||||
disabled={isAnimating}
|
||||
className="px-6 py-3 bg-red-500 hover:bg-red-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
❌ Not Interested
|
||||
</button>
|
||||
<button
|
||||
onClick={() => !isAnimating && onSwipe('up')}
|
||||
onClick={() => {
|
||||
if (!isAnimating) {
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
setIsDragging(false);
|
||||
onSwipe('up');
|
||||
}
|
||||
}}
|
||||
disabled={isAnimating}
|
||||
className="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
⬆️ Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={() => !isAnimating && handleSwipeRight()}
|
||||
onClick={() => {
|
||||
if (!isAnimating) {
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
setIsDragging(false);
|
||||
handleSwipeRight();
|
||||
}
|
||||
}}
|
||||
disabled={isAnimating}
|
||||
className="px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
|
||||
@@ -187,7 +187,7 @@ export function InteractiveTorrentSearchModal({
|
||||
{/* No results */}
|
||||
{!isSearching && results.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">No torrents found</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">No torrents/nzbs found</p>
|
||||
<Button onClick={performSearch} variant="outline" className="mt-4">
|
||||
Try Again
|
||||
</Button>
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* Component: Pagination Component
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Pagination({ currentPage, totalPages, onPageChange, className = '' }: PaginationProps) {
|
||||
if (totalPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const generatePageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxVisible = 7; // Show max 7 page buttons
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
// Show all pages if total is less than max
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
if (currentPage > 3) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Show pages around current page
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const pageNumbers = generatePageNumbers();
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center gap-2 ${className}`}>
|
||||
{/* Previous Button */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
<div className="flex items-center gap-1">
|
||||
{pageNumbers.map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className="px-3 py-2 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const pageNum = page as number;
|
||||
const isActive = pageNum === currentPage;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors
|
||||
${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
aria-label={`Page ${pageNum}`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -192,14 +192,3 @@ function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) =
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation Dialog Hook
|
||||
*/
|
||||
export function useConfirm() {
|
||||
return useCallback((message: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const result = window.confirm(message);
|
||||
resolve(result);
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user