mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add indexer flag bonuses and SSL verify toggle
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.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Component: Flag Configuration Row
|
||||
* Documentation: documentation/phase3/ranking-algorithm.md
|
||||
*
|
||||
* Allows configuration of indexer flag bonuses/penalties with visual slider feedback
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm';
|
||||
import { TrashIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface FlagConfigRowProps {
|
||||
config: IndexerFlagConfig;
|
||||
onChange: (config: IndexerFlagConfig) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export function FlagConfigRow({ config, onChange, onRemove }: FlagConfigRowProps) {
|
||||
const exampleBase = 85;
|
||||
const bonusPoints = exampleBase * (config.modifier / 100);
|
||||
const finalScore = exampleBase + bonusPoints;
|
||||
|
||||
// Get color for modifier percentage display
|
||||
const getModifierColor = (modifier: number): string => {
|
||||
if (modifier < -50) return 'text-red-700 dark:text-red-400';
|
||||
if (modifier < 0) return 'text-red-600 dark:text-red-500';
|
||||
if (modifier === 0) return 'text-gray-600 dark:text-gray-400';
|
||||
if (modifier > 50) return 'text-green-700 dark:text-green-400';
|
||||
return 'text-green-600 dark:text-green-500';
|
||||
};
|
||||
|
||||
// Get slider gradient based on current value
|
||||
const getSliderBackground = (modifier: number): string => {
|
||||
const normalizedPosition = ((modifier + 100) / 200) * 100; // -100 to 100 → 0% to 100%
|
||||
|
||||
// Create gradient that fills from left up to current position
|
||||
// Red on left, yellow in middle, green on right
|
||||
return `linear-gradient(to right,
|
||||
#ef4444 0%,
|
||||
#ef4444 ${Math.max(0, normalizedPosition - 5)}%,
|
||||
#fbbf24 50%,
|
||||
#10b981 ${Math.min(100, normalizedPosition + 5)}%,
|
||||
#10b981 100%)`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Flag Name Input */}
|
||||
<div className="flex-shrink-0 w-48">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Flag Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.name}
|
||||
onChange={(e) => onChange({ ...config, name: e.target.value })}
|
||||
placeholder="e.g. Freeleech"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Score Modifier Slider */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Score Modifier
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 w-12 text-right">-100%</span>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="range"
|
||||
min="-100"
|
||||
max="100"
|
||||
step="5"
|
||||
value={config.modifier}
|
||||
onChange={(e) => onChange({ ...config, modifier: parseInt(e.target.value) })}
|
||||
className="w-full h-2 rounded-lg appearance-none cursor-pointer slider-custom"
|
||||
style={{
|
||||
background: getSliderBackground(config.modifier),
|
||||
}}
|
||||
/>
|
||||
<style jsx>{`
|
||||
.slider-custom::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid #3b82f6;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.slider-custom::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid #3b82f6;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 w-12">+100%</span>
|
||||
|
||||
<span className={`text-sm font-bold min-w-[60px] text-right ${getModifierColor(config.modifier)}`}>
|
||||
{config.modifier > 0 ? '+' : ''}{config.modifier}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Help Text */}
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Example: Base score of {exampleBase} with "{config.name || 'this flag'}"
|
||||
{' → '}
|
||||
<span className={bonusPoints >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
||||
{bonusPoints >= 0 ? '+' : ''}{bonusPoints.toFixed(1)} bonus points
|
||||
</span>
|
||||
{bonusPoints < 0 && finalScore < 50 && (
|
||||
<span className="text-red-600 dark:text-red-400 font-medium">
|
||||
{' '}⚠️ Would disqualify (final: {finalScore.toFixed(1)} < 50)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="flex-shrink-0 mt-7 p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
title="Remove flag rule"
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,9 +134,10 @@ export function AudiobookCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata Row */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{audiobook.rating && (
|
||||
{/* Metadata Row - Fixed height for alignment */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 h-5">
|
||||
{/* Rating - Only show if > 0 (0 means no rating) */}
|
||||
{audiobook.rating && audiobook.rating > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
@@ -144,9 +145,6 @@ export function AudiobookCard({
|
||||
<span>{audiobook.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
{audiobook.durationMinutes && (
|
||||
<span>{formatDuration(audiobook.durationMinutes)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status or Action */}
|
||||
|
||||
@@ -242,10 +242,10 @@ export function AudiobookDetailsModal({
|
||||
|
||||
{/* Metadata Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
{/* Rating */}
|
||||
{audiobook.rating && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Rating</p>
|
||||
{/* Rating - Always show header, display 'Not Found' if no rating */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Rating</p>
|
||||
{audiobook.rating && audiobook.rating > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
@@ -266,8 +266,10 @@ export function AudiobookDetailsModal({
|
||||
{Number(audiobook.rating).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 italic">Not Found</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
{audiobook.durationMinutes && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import React, { useState } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
|
||||
import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent } from '@/lib/hooks/useRequests';
|
||||
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
|
||||
@@ -41,7 +41,7 @@ export function InteractiveTorrentSearchModal({
|
||||
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
|
||||
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
|
||||
|
||||
const [results, setResults] = useState<(TorrentResult & { rank: number; qualityScore?: number })[]>([]);
|
||||
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number })[]>([]);
|
||||
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
|
||||
const [searchTitle, setSearchTitle] = useState(audiobook.title);
|
||||
|
||||
@@ -200,25 +200,28 @@ export function InteractiveTorrentSearchModal({
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-12">
|
||||
#
|
||||
</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden sm:table-cell">
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden sm:table-cell w-24">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Base quality score (0-100): Title/Author match (50) + Format (25) + Seeders (15) + Size (10)">
|
||||
Score
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell">
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Bonus points from indexer priority and other modifiers">
|
||||
Bonus
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell w-20">
|
||||
Seeds
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell">
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell w-32">
|
||||
Indexer
|
||||
</th>
|
||||
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-24">
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
@@ -230,7 +233,7 @@ export function InteractiveTorrentSearchModal({
|
||||
{result.rank}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div className="max-w-xs lg:max-w-md truncate">
|
||||
<div className="truncate">
|
||||
<a
|
||||
href={result.guid}
|
||||
target="_blank"
|
||||
@@ -259,10 +262,13 @@ export function InteractiveTorrentSearchModal({
|
||||
{formatSize(result.size)}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm">
|
||||
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(result.qualityScore || 0)}`}>
|
||||
{result.qualityScore || 0}
|
||||
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(Math.round(result.score))}`}>
|
||||
{Math.round(result.score)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden md:table-cell">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
|
||||
@@ -14,6 +14,7 @@ interface StickyPaginationProps {
|
||||
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({
|
||||
@@ -22,8 +23,10 @@ export function StickyPagination({
|
||||
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
|
||||
@@ -51,6 +54,26 @@ export function StickyPagination({
|
||||
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;
|
||||
}
|
||||
@@ -78,10 +101,13 @@ export function StickyPagination({
|
||||
}
|
||||
};
|
||||
|
||||
// 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 ${
|
||||
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0'
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user