mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
@@ -6,22 +6,30 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
interface DownloadClientCardProps {
|
||||
client: {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
type: DownloadClientType;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
customPath?: string;
|
||||
};
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientCardProps) {
|
||||
const typeName = client.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd';
|
||||
const typeColor = client.type === 'qbittorrent' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300';
|
||||
const typeName = getClientDisplayName(client.type);
|
||||
const typeColorMap: Record<string, string> = {
|
||||
qbittorrent: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
|
||||
transmission: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
|
||||
sabnzbd: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
|
||||
nzbget: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300',
|
||||
};
|
||||
const typeColor = typeColorMap[client.type] || typeColorMap.qbittorrent;
|
||||
|
||||
// Truncate URL for display
|
||||
const displayUrl = client.url.length > 40 ? `${client.url.substring(0, 40)}...` : client.url;
|
||||
@@ -49,6 +57,11 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate" title={client.url}>
|
||||
{displayUrl}
|
||||
</p>
|
||||
{client.customPath && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 truncate" title={`Custom path: ${client.customPath}`}>
|
||||
Path: {client.customPath}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ import { Button } from '@/components/ui/Button';
|
||||
import { DownloadClientCard } from './DownloadClientCard';
|
||||
import { DownloadClientModal } from './DownloadClientModal';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { DownloadClientType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
interface DownloadClient {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
type: DownloadClientType;
|
||||
name: string;
|
||||
url: string;
|
||||
username?: string;
|
||||
@@ -24,24 +25,27 @@ interface DownloadClient {
|
||||
remotePath?: string;
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
customPath?: string;
|
||||
}
|
||||
|
||||
interface DownloadClientManagementProps {
|
||||
mode: 'wizard' | 'settings';
|
||||
initialClients?: DownloadClient[];
|
||||
onClientsChange?: (clients: DownloadClient[]) => void;
|
||||
downloadDir?: string;
|
||||
}
|
||||
|
||||
export function DownloadClientManagement({
|
||||
mode,
|
||||
initialClients = [],
|
||||
onClientsChange,
|
||||
downloadDir: downloadDirProp,
|
||||
}: DownloadClientManagementProps) {
|
||||
const [clients, setClients] = useState<DownloadClient[]>(initialClients);
|
||||
const [modalState, setModalState] = useState<{
|
||||
isOpen: boolean;
|
||||
mode: 'add' | 'edit';
|
||||
clientType?: 'qbittorrent' | 'sabnzbd';
|
||||
clientType?: DownloadClientType;
|
||||
currentClient?: DownloadClient;
|
||||
}>({ isOpen: false, mode: 'add' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -51,14 +55,23 @@ export function DownloadClientManagement({
|
||||
clientId?: string;
|
||||
clientName?: string;
|
||||
}>({ isOpen: false });
|
||||
const [resolvedDownloadDir, setResolvedDownloadDir] = useState(downloadDirProp || '/downloads');
|
||||
|
||||
// Fetch clients when in settings mode
|
||||
// Fetch clients and download dir when in settings mode
|
||||
useEffect(() => {
|
||||
if (mode === 'settings') {
|
||||
fetchClients();
|
||||
fetchDownloadDir();
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
// Sync downloadDir prop (wizard mode)
|
||||
useEffect(() => {
|
||||
if (downloadDirProp) {
|
||||
setResolvedDownloadDir(downloadDirProp);
|
||||
}
|
||||
}, [downloadDirProp]);
|
||||
|
||||
// Sync with parent when clients change
|
||||
useEffect(() => {
|
||||
if (onClientsChange) {
|
||||
@@ -93,11 +106,26 @@ export function DownloadClientManagement({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddClient = (type: 'qbittorrent' | 'sabnzbd') => {
|
||||
// Check if this type already exists
|
||||
const existingClient = clients.find(c => c.type === type && c.enabled);
|
||||
const fetchDownloadDir = async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.paths?.downloadDir) {
|
||||
setResolvedDownloadDir(data.paths.downloadDir);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: fall back to default
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddClient = (type: DownloadClientType) => {
|
||||
// Check if the protocol is already taken (regardless of enabled status)
|
||||
const protocol = CLIENT_PROTOCOL_MAP[type];
|
||||
const existingClient = clients.find(c => CLIENT_PROTOCOL_MAP[c.type] === protocol);
|
||||
if (existingClient) {
|
||||
setError(`A ${type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} client is already configured.`);
|
||||
setError(`A ${protocol} client (${getClientDisplayName(existingClient.type)}) is already configured. Remove it first to add a different ${protocol} client.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -210,8 +238,8 @@ export function DownloadClientManagement({
|
||||
}
|
||||
};
|
||||
|
||||
const hasQBittorrent = clients.some(c => c.type === 'qbittorrent' && c.enabled);
|
||||
const hasSABnzbd = clients.some(c => c.type === 'sabnzbd' && c.enabled);
|
||||
const hasTorrentClient = clients.some(c => CLIENT_PROTOCOL_MAP[c.type] === 'torrent');
|
||||
const hasUsenetClient = clients.some(c => CLIENT_PROTOCOL_MAP[c.type] === 'usenet');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -233,9 +261,9 @@ export function DownloadClientManagement({
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Add Download Client
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* qBittorrent Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
@@ -249,9 +277,9 @@ export function DownloadClientManagement({
|
||||
Torrent
|
||||
</span>
|
||||
</div>
|
||||
{hasQBittorrent ? (
|
||||
{hasTorrentClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Already configured
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
@@ -265,8 +293,39 @@ export function DownloadClientManagement({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transmission Card */}
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Transmission
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Torrent downloads
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-block text-xs px-2 py-1 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 font-medium">
|
||||
Torrent
|
||||
</span>
|
||||
</div>
|
||||
{hasTorrentClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleAddClient('transmission')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
Add Transmission
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SABnzbd Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
@@ -280,9 +339,9 @@ export function DownloadClientManagement({
|
||||
Usenet
|
||||
</span>
|
||||
</div>
|
||||
{hasSABnzbd ? (
|
||||
{hasUsenetClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Already configured
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
@@ -295,6 +354,37 @@ export function DownloadClientManagement({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NZBGet Card */}
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
NZBGet
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Usenet/NZB downloads
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-block text-xs px-2 py-1 rounded bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium">
|
||||
Usenet
|
||||
</span>
|
||||
</div>
|
||||
{hasUsenetClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleAddClient('nzbget')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
Add NZBGet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -338,6 +428,7 @@ export function DownloadClientManagement({
|
||||
initialClient={modalState.currentClient}
|
||||
onSave={handleSaveClient}
|
||||
apiMode={mode}
|
||||
downloadDir={resolvedDownloadDir}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
|
||||
@@ -10,15 +10,16 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
interface DownloadClientModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
mode: 'add' | 'edit';
|
||||
clientType?: 'qbittorrent' | 'sabnzbd';
|
||||
clientType?: DownloadClientType;
|
||||
initialClient?: {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
type: DownloadClientType;
|
||||
name: string;
|
||||
url: string;
|
||||
username?: string;
|
||||
@@ -29,9 +30,11 @@ interface DownloadClientModalProps {
|
||||
remotePath?: string;
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
customPath?: string;
|
||||
};
|
||||
onSave: (client: any) => Promise<void>;
|
||||
apiMode: 'wizard' | 'settings';
|
||||
downloadDir?: string;
|
||||
}
|
||||
|
||||
export function DownloadClientModal({
|
||||
@@ -42,9 +45,10 @@ export function DownloadClientModal({
|
||||
initialClient,
|
||||
onSave,
|
||||
apiMode,
|
||||
downloadDir = '/downloads',
|
||||
}: DownloadClientModalProps) {
|
||||
const type = mode === 'edit' ? initialClient?.type : clientType;
|
||||
const typeName = type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd';
|
||||
const typeName = type ? getClientDisplayName(type) : '';
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
@@ -57,6 +61,7 @@ export function DownloadClientModal({
|
||||
const [remotePath, setRemotePath] = useState('');
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
const [category, setCategory] = useState('readmeabook');
|
||||
const [customPath, setCustomPath] = useState('');
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -79,6 +84,7 @@ export function DownloadClientModal({
|
||||
setRemotePath(initialClient.remotePath || '');
|
||||
setLocalPath(initialClient.localPath || '');
|
||||
setCategory(initialClient.category || 'readmeabook');
|
||||
setCustomPath(initialClient.customPath || '');
|
||||
} else {
|
||||
// Add mode defaults
|
||||
setName(typeName);
|
||||
@@ -91,6 +97,7 @@ export function DownloadClientModal({
|
||||
setRemotePath('');
|
||||
setLocalPath('');
|
||||
setCategory('readmeabook');
|
||||
setCustomPath('');
|
||||
}
|
||||
setTestResult(null);
|
||||
setErrors({});
|
||||
@@ -113,6 +120,10 @@ export function DownloadClientModal({
|
||||
newErrors.password = 'API key is required';
|
||||
}
|
||||
|
||||
if (customPath.includes('..')) {
|
||||
newErrors.customPath = 'Path cannot contain ".."';
|
||||
}
|
||||
|
||||
if (remotePathMappingEnabled) {
|
||||
if (!remotePath.trim()) {
|
||||
newErrors.remotePath = 'Remote path is required when path mapping is enabled';
|
||||
@@ -140,8 +151,9 @@ export function DownloadClientModal({
|
||||
|
||||
const testData = {
|
||||
type,
|
||||
name,
|
||||
url,
|
||||
username: type === 'qbittorrent' ? username : undefined,
|
||||
username: username || undefined,
|
||||
password: isPasswordMasked ? undefined : password,
|
||||
// Include clientId when editing so server can use stored password
|
||||
...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}),
|
||||
@@ -202,11 +214,14 @@ export function DownloadClientModal({
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Strip leading/trailing slashes from customPath
|
||||
const sanitizedCustomPath = customPath.replace(/^\/+|\/+$/g, '').trim();
|
||||
|
||||
const clientData: any = {
|
||||
type,
|
||||
name,
|
||||
url,
|
||||
username: type === 'qbittorrent' ? username : undefined,
|
||||
username: type !== 'sabnzbd' ? username : undefined,
|
||||
password: password === '********' ? undefined : password, // Don't send masked password on edit
|
||||
enabled,
|
||||
disableSSLVerify,
|
||||
@@ -214,6 +229,7 @@ export function DownloadClientModal({
|
||||
remotePath: remotePathMappingEnabled ? remotePath : undefined,
|
||||
localPath: remotePathMappingEnabled ? localPath : undefined,
|
||||
category,
|
||||
customPath: sanitizedCustomPath || undefined,
|
||||
};
|
||||
|
||||
if (mode === 'edit' && initialClient) {
|
||||
@@ -264,7 +280,7 @@ export function DownloadClientModal({
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={type === 'qbittorrent' ? 'http://localhost:8080' : 'http://localhost:8081'}
|
||||
placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'}
|
||||
error={errors.url}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -272,8 +288,8 @@ export function DownloadClientModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Username (qBittorrent only) */}
|
||||
{type === 'qbittorrent' && (
|
||||
{/* Username (qBittorrent and Transmission) */}
|
||||
{type !== 'sabnzbd' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
@@ -290,13 +306,13 @@ export function DownloadClientModal({
|
||||
{/* Password / API Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{type === 'qbittorrent' ? 'Password' : 'API Key'}
|
||||
{type === 'sabnzbd' ? 'API Key' : 'Password'}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={type === 'qbittorrent' ? 'Password' : 'API Key from SABnzbd Config > General'}
|
||||
placeholder={type === 'sabnzbd' ? 'API Key from SABnzbd Config > General' : 'Password'}
|
||||
error={errors.password}
|
||||
/>
|
||||
{type === 'sabnzbd' && (
|
||||
@@ -304,6 +320,11 @@ export function DownloadClientModal({
|
||||
Found in SABnzbd under Config → General → API Key
|
||||
</p>
|
||||
)}
|
||||
{type === 'nzbget' && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Configured in NZBGet under Settings → Security → ControlPassword
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SSL Verification */}
|
||||
@@ -342,6 +363,27 @@ export function DownloadClientModal({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Custom Download Path */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Custom Download Path
|
||||
</label>
|
||||
<Input
|
||||
value={customPath}
|
||||
onChange={(e) => setCustomPath(e.target.value)}
|
||||
placeholder="e.g. torrents or usenet/books"
|
||||
error={errors.customPath}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Optional relative sub-path appended to the base download directory
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
Downloads to: {customPath.replace(/^\/+|\/+$/g, '').trim()
|
||||
? `${downloadDir}/${customPath.replace(/^\/+|\/+$/g, '').trim()}`
|
||||
: downloadDir}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-start mb-3">
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
TORRENT_CATEGORIES,
|
||||
getChildIds,
|
||||
areAllChildrenSelected,
|
||||
isParentCategory,
|
||||
getAllStandardCategoryIds,
|
||||
} from '@/lib/utils/torrent-categories';
|
||||
|
||||
interface CategoryTreeViewProps {
|
||||
@@ -24,7 +25,19 @@ export function CategoryTreeView({
|
||||
onChange,
|
||||
defaultCategories = [3030], // Default to audiobook category for backwards compatibility
|
||||
}: CategoryTreeViewProps) {
|
||||
const [customInput, setCustomInput] = useState('');
|
||||
const [customError, setCustomError] = useState('');
|
||||
|
||||
const standardIds = useMemo(() => getAllStandardCategoryIds(), []);
|
||||
|
||||
// Derive custom categories from selected categories that aren't in the standard tree
|
||||
const customCategories = useMemo(
|
||||
() => selectedCategories.filter((id) => !standardIds.has(id)).sort((a, b) => a - b),
|
||||
[selectedCategories, standardIds]
|
||||
);
|
||||
|
||||
const isDefaultCategory = (categoryId: number) => defaultCategories.includes(categoryId);
|
||||
|
||||
const handleParentToggle = (parentId: number) => {
|
||||
const childIds = getChildIds(parentId);
|
||||
const allChildrenSelected = areAllChildrenSelected(parentId, selectedCategories);
|
||||
@@ -57,6 +70,52 @@ export function CategoryTreeView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCustom = (categoryId: number) => {
|
||||
onChange(selectedCategories.filter((id) => id !== categoryId));
|
||||
};
|
||||
|
||||
const handleAddCustom = () => {
|
||||
setCustomError('');
|
||||
const trimmed = customInput.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
setCustomError('Enter a category ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
|
||||
if (isNaN(parsed) || !Number.isInteger(Number(trimmed)) || String(parsed) !== trimmed) {
|
||||
setCustomError('Must be a whole number');
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed <= 0) {
|
||||
setCustomError('Must be a positive number');
|
||||
return;
|
||||
}
|
||||
|
||||
if (standardIds.has(parsed)) {
|
||||
setCustomError('This is a standard category — use the toggles above');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCategories.includes(parsed)) {
|
||||
setCustomError('Already added');
|
||||
return;
|
||||
}
|
||||
|
||||
onChange([...selectedCategories, parsed]);
|
||||
setCustomInput('');
|
||||
};
|
||||
|
||||
const handleCustomKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCustom();
|
||||
}
|
||||
};
|
||||
|
||||
const isParentSelected = (parentId: number) => {
|
||||
return areAllChildrenSelected(parentId, selectedCategories);
|
||||
};
|
||||
@@ -67,6 +126,7 @@ export function CategoryTreeView({
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Standard Categories */}
|
||||
{TORRENT_CATEGORIES.map((category) => (
|
||||
<div key={category.id} className="space-y-2">
|
||||
{/* Parent Category Header */}
|
||||
@@ -129,6 +189,85 @@ export function CategoryTreeView({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Custom Categories Section */}
|
||||
<div className="space-y-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 px-2 py-1">
|
||||
<span className="text-base font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wide">
|
||||
Custom
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Add custom Newznab/Torznab category IDs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Existing custom categories */}
|
||||
{customCategories.length > 0 && (
|
||||
<div className="ml-4 space-y-2">
|
||||
{customCategories.map((catId) => (
|
||||
<div
|
||||
key={catId}
|
||||
className="flex items-center justify-between p-2.5 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Custom
|
||||
</span>
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
|
||||
[{catId}]
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveCustom(catId)}
|
||||
className="text-xs px-2.5 py-1 rounded-md text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 border border-red-200 dark:border-red-800 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add custom category input */}
|
||||
<div className="ml-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={customInput}
|
||||
onChange={(e) => {
|
||||
setCustomInput(e.target.value);
|
||||
setCustomError('');
|
||||
}}
|
||||
onKeyDown={handleCustomKeyDown}
|
||||
placeholder="Category ID"
|
||||
className={`
|
||||
w-32 px-3 py-1.5 text-sm rounded-lg border bg-white dark:bg-gray-800
|
||||
text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900
|
||||
${customError
|
||||
? 'border-red-300 dark:border-red-700'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustom}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-blue-600 dark:bg-blue-500 text-white hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{customError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1.5">
|
||||
{customError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,8 +96,6 @@ export function IndexerConfigModal({
|
||||
const [errors, setErrors] = useState<{
|
||||
priority?: string;
|
||||
seedingTimeMinutes?: string;
|
||||
audiobookCategories?: string;
|
||||
ebookCategories?: string;
|
||||
}>({});
|
||||
|
||||
// Reset form when modal opens or indexer changes
|
||||
@@ -134,26 +132,12 @@ export function IndexerConfigModal({
|
||||
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
|
||||
}
|
||||
|
||||
if (audiobookCategories.length === 0) {
|
||||
newErrors.audiobookCategories = 'At least one audiobook category must be selected';
|
||||
}
|
||||
|
||||
if (ebookCategories.length === 0) {
|
||||
newErrors.ebookCategories = 'At least one ebook category must be selected';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!validate()) {
|
||||
// If there's a category error, switch to the relevant tab
|
||||
if (errors.audiobookCategories && activeTab !== 'audiobook') {
|
||||
setActiveTab('audiobook');
|
||||
} else if (errors.ebookCategories && activeTab !== 'ebook') {
|
||||
setActiveTab('ebook');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -202,9 +186,12 @@ export function IndexerConfigModal({
|
||||
// Get the current categories based on active tab
|
||||
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
|
||||
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
|
||||
const currentError = activeTab === 'audiobook' ? errors.audiobookCategories : errors.ebookCategories;
|
||||
const defaultForTab = activeTab === 'audiobook' ? DEFAULT_AUDIOBOOK_CATEGORIES : DEFAULT_EBOOK_CATEGORIES;
|
||||
|
||||
// Warning state: no categories means this indexer is effectively disabled for that type
|
||||
const audiobookDisabled = audiobookCategories.length === 0;
|
||||
const ebookDisabled = ebookCategories.length === 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -342,8 +329,8 @@ export function IndexerConfigModal({
|
||||
}`}
|
||||
>
|
||||
AudioBook
|
||||
{errors.audiobookCategories && (
|
||||
<span className="ml-2 text-red-500">!</span>
|
||||
{audiobookDisabled && (
|
||||
<span className="ml-2 text-amber-500" title="No categories — disabled for audiobooks">!</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
@@ -356,8 +343,8 @@ export function IndexerConfigModal({
|
||||
}`}
|
||||
>
|
||||
EBook
|
||||
{errors.ebookCategories && (
|
||||
<span className="ml-2 text-red-500">!</span>
|
||||
{ebookDisabled && (
|
||||
<span className="ml-2 text-amber-500" title="No categories — disabled for ebooks">!</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -372,15 +359,23 @@ export function IndexerConfigModal({
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
{activeTab === 'audiobook'
|
||||
? 'Categories to search for audiobooks. Default: Audio/Audiobook [3030]'
|
||||
: 'Categories to search for e-books. Default: Books/EBook [7020]'}
|
||||
{currentCategories.length > 0
|
||||
? `Will search categories: [${currentCategories.join(', ')}]`
|
||||
: activeTab === 'audiobook'
|
||||
? 'Default: Audio/Audiobook [3030]'
|
||||
: 'Default: Books/EBook [7020]'}
|
||||
</p>
|
||||
|
||||
{currentError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{currentError}
|
||||
</p>
|
||||
{/* Warning when all categories are deselected for the active tab */}
|
||||
{currentCategories.length === 0 && (
|
||||
<div className="flex items-start gap-2 mt-2 p-2.5 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<svg className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
No categories selected. This indexer will not be searched for {activeTab === 'audiobook' ? 'audiobooks' : 'ebooks'}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Component: Global User Settings Modal
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
interface GlobalUserSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
globalAutoApprove: boolean;
|
||||
onToggleAutoApprove: (newValue: boolean) => void;
|
||||
globalInteractiveSearch: boolean;
|
||||
onToggleInteractiveSearch: (newValue: boolean) => void;
|
||||
}
|
||||
|
||||
export function GlobalUserSettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
globalAutoApprove,
|
||||
onToggleAutoApprove,
|
||||
globalInteractiveSearch,
|
||||
onToggleInteractiveSearch,
|
||||
}: GlobalUserSettingsModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Global User Settings" size="sm">
|
||||
<div className="space-y-6">
|
||||
{/* Auto-Approve Setting */}
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
onClick={() => onToggleAutoApprove(!globalAutoApprove)}
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||
style={{ backgroundColor: globalAutoApprove ? '#3b82f6' : '#d1d5db' }}
|
||||
role="switch"
|
||||
aria-checked={globalAutoApprove}
|
||||
aria-label="Auto-Approve All Requests"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
globalAutoApprove ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
onClick={() => onToggleAutoApprove(!globalAutoApprove)}
|
||||
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Auto-Approve All Requests
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
When enabled, all user requests are automatically processed. When disabled, you can set per-user approval settings from the users table.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Search Access Setting */}
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
onClick={() => onToggleInteractiveSearch(!globalInteractiveSearch)}
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||
style={{ backgroundColor: globalInteractiveSearch ? '#3b82f6' : '#d1d5db' }}
|
||||
role="switch"
|
||||
aria-checked={globalInteractiveSearch}
|
||||
aria-label="Interactive Search Access"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
globalInteractiveSearch ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
onClick={() => onToggleInteractiveSearch(!globalInteractiveSearch)}
|
||||
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Interactive Search Access
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
When enabled, all users can manually search and select torrents/ebooks. When disabled, you can grant access per-user from the users table.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Component: User Permissions Modal
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
interface UserPermissionsUser {
|
||||
id: string;
|
||||
plexUsername: string;
|
||||
plexEmail: string;
|
||||
avatarUrl: string | null;
|
||||
role: 'user' | 'admin';
|
||||
autoApproveRequests: boolean | null;
|
||||
interactiveSearchAccess: boolean | null;
|
||||
}
|
||||
|
||||
interface UserPermissionsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: UserPermissionsUser | null;
|
||||
globalAutoApprove: boolean;
|
||||
globalInteractiveSearch: boolean;
|
||||
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
}
|
||||
|
||||
interface PermissionToggleProps {
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
value: boolean;
|
||||
disabled: boolean;
|
||||
disabledMessage?: string;
|
||||
description: string;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage, description, onToggle }: PermissionToggleProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!disabled) onToggle();
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5 ${
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
|
||||
disabled={disabled}
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||
value ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</div>
|
||||
{disabledMessage ? (
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
{disabledMessage}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserPermissionsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
globalAutoApprove,
|
||||
globalInteractiveSearch,
|
||||
onToggleAutoApprove,
|
||||
onToggleInteractiveSearch,
|
||||
}: UserPermissionsModalProps) {
|
||||
if (!user) return null;
|
||||
|
||||
const isAdmin = user.role === 'admin';
|
||||
|
||||
// Auto-Approve resolution
|
||||
const isAutoApproveGlobalOverride = !isAdmin && globalAutoApprove;
|
||||
const isAutoApproveDisabled = isAdmin || isAutoApproveGlobalOverride;
|
||||
const autoApproveValue = isAdmin ? true : isAutoApproveGlobalOverride ? true : (user.autoApproveRequests ?? false);
|
||||
|
||||
// Interactive Search resolution
|
||||
const isSearchGlobalOverride = !isAdmin && globalInteractiveSearch;
|
||||
const isSearchDisabled = isAdmin || isSearchGlobalOverride;
|
||||
const searchValue = isAdmin ? true : isSearchGlobalOverride ? true : (user.interactiveSearchAccess ?? false);
|
||||
|
||||
const getDisabledMessage = (isAdminUser: boolean, isGlobalOverride: boolean, adminMessage: string, globalMessage: string): string | undefined => {
|
||||
if (isAdminUser) return adminMessage;
|
||||
if (isGlobalOverride) return globalMessage;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="User Permissions" size="sm">
|
||||
<div className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
{user.avatarUrl && (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.plexUsername}
|
||||
className="h-10 w-10 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{user.plexUsername}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{user.plexEmail || 'No email'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`ml-auto px-2 py-0.5 text-xs font-semibold rounded-full ${
|
||||
isAdmin
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{user.role.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Permissions Section */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Permissions
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Auto-Approve Permission */}
|
||||
<PermissionToggle
|
||||
label="Auto-Approve Requests"
|
||||
ariaLabel="Auto-Approve Requests"
|
||||
value={autoApproveValue}
|
||||
disabled={isAutoApproveDisabled}
|
||||
disabledMessage={getDisabledMessage(
|
||||
isAdmin, isAutoApproveGlobalOverride,
|
||||
'Admin requests are always auto-approved',
|
||||
'Controlled by global auto-approve setting'
|
||||
)}
|
||||
description="When enabled, this user's requests are automatically processed without admin approval"
|
||||
onToggle={() => onToggleAutoApprove(user, !autoApproveValue)}
|
||||
/>
|
||||
|
||||
{/* Interactive Search Access Permission */}
|
||||
<PermissionToggle
|
||||
label="Interactive Search Access"
|
||||
ariaLabel="Interactive Search Access"
|
||||
value={searchValue}
|
||||
disabled={isSearchDisabled}
|
||||
disabledMessage={getDisabledMessage(
|
||||
isAdmin, isSearchGlobalOverride,
|
||||
'Admins always have interactive search access',
|
||||
'Controlled by global interactive search setting'
|
||||
)}
|
||||
description="When enabled, this user can manually search and select torrents and ebooks"
|
||||
onToggle={() => onToggleInteractiveSearch(user, !searchValue)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -479,8 +479,8 @@ export function AudiobookDetailsModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Search - only if not available */}
|
||||
{status.type !== 'available' && (
|
||||
{/* Interactive Search - only if not available and user has permission */}
|
||||
{status.type !== 'available' && (user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && (
|
||||
<button
|
||||
onClick={handleInteractiveSearch}
|
||||
disabled={!user}
|
||||
@@ -513,15 +513,17 @@ export function AudiobookDetailsModal({
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowInteractiveSearchEbook(true)}
|
||||
className="p-3 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
|
||||
title="Search Ebook Sources"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
{(user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && (
|
||||
<button
|
||||
onClick={() => setShowInteractiveSearchEbook(true)}
|
||||
className="p-3 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
|
||||
title="Search Ebook Sources"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,10 @@ import { StatusBadge } from './StatusBadge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCancelRequest, useManualSearch } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
|
||||
interface RequestCardProps {
|
||||
request: {
|
||||
@@ -25,6 +28,7 @@ interface RequestCardProps {
|
||||
completedAt?: string;
|
||||
audiobook: {
|
||||
id: string;
|
||||
audibleAsin?: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl?: string;
|
||||
@@ -36,8 +40,11 @@ interface RequestCardProps {
|
||||
export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const { cancelRequest, isLoading } = useCancelRequest();
|
||||
const { triggerManualSearch, isLoading: isManualSearching } = useManualSearch();
|
||||
const { squareCovers } = usePreferences();
|
||||
const { user } = useAuth();
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
||||
|
||||
const requestType = request.type || 'audiobook';
|
||||
const isEbook = requestType === 'ebook';
|
||||
@@ -46,7 +53,9 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||
const isFailed = request.status === 'failed';
|
||||
// Ebook requests don't support interactive search (Anna's Archive only)
|
||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
// Interactive search also requires the interactiveSearch permission
|
||||
const hasInteractiveSearchAccess = user?.role === 'admin' || user?.permissions?.interactiveSearch !== false;
|
||||
const canSearch = hasInteractiveSearchAccess && !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (window.confirm('Are you sure you want to cancel this request?')) {
|
||||
@@ -94,7 +103,19 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
<div className="flex gap-3 sm:gap-4 p-3 sm:p-4">
|
||||
{/* Cover Art */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative w-16 h-24 sm:w-24 sm:h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded overflow-hidden bg-gray-200 dark:bg-gray-700',
|
||||
squareCovers
|
||||
? 'w-16 sm:w-24 aspect-square'
|
||||
: 'w-16 sm:w-24 aspect-[2/3]',
|
||||
request.audiobook.audibleAsin && 'cursor-pointer hover:opacity-90 transition-opacity'
|
||||
)}
|
||||
onClick={() => request.audiobook.audibleAsin && setShowDetailsModal(true)}
|
||||
role={request.audiobook.audibleAsin ? 'button' : undefined}
|
||||
tabIndex={request.audiobook.audibleAsin ? 0 : undefined}
|
||||
onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)}
|
||||
>
|
||||
{request.audiobook.coverArtUrl ? (
|
||||
<Image
|
||||
src={request.audiobook.coverArtUrl}
|
||||
@@ -277,6 +298,18 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
author: request.audiobook.author,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Audiobook Details Modal */}
|
||||
{request.audiobook.audibleAsin && (
|
||||
<AudiobookDetailsModal
|
||||
asin={request.audiobook.audibleAsin}
|
||||
isOpen={showDetailsModal}
|
||||
onClose={() => setShowDetailsModal(false)}
|
||||
requestStatus={request.status}
|
||||
isAvailable={['available', 'downloaded'].includes(request.status)}
|
||||
hideRequestActions
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,29 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const GITHUB_REPO = 'kikootwo/ReadMeABook';
|
||||
const REMOTE_PACKAGE_URL = `https://raw.githubusercontent.com/${GITHUB_REPO}/refs/heads/main/package.json`;
|
||||
const UPDATE_CHECK_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
|
||||
|
||||
function compareVersions(current: string, latest: string): number {
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
|
||||
const a = parse(current);
|
||||
const b = parse(latest);
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const diff = (b[i] || 0) - (a[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function VersionBadge() {
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [rawVersion, setRawVersion] = useState<string | null>(null);
|
||||
const [commit, setCommit] = useState<string | null>(null);
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Try to get version from build-time env var first (instant, no API call)
|
||||
@@ -17,6 +35,7 @@ export function VersionBadge() {
|
||||
|
||||
if (buildTimeVersion && buildTimeVersion !== 'unknown') {
|
||||
setVersion(`v${buildTimeVersion}`);
|
||||
setRawVersion(buildTimeVersion);
|
||||
// Also get commit for tooltip if available
|
||||
const buildTimeCommit = process.env.NEXT_PUBLIC_GIT_COMMIT;
|
||||
if (buildTimeCommit && buildTimeCommit !== 'unknown') {
|
||||
@@ -31,6 +50,7 @@ export function VersionBadge() {
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setVersion(data.version);
|
||||
setRawVersion(data.fullVersion);
|
||||
if (data.commit && data.commit !== 'unknown') {
|
||||
setCommit(data.commit.substring(0, 7));
|
||||
}
|
||||
@@ -42,20 +62,66 @@ export function VersionBadge() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkForUpdates = useCallback(() => {
|
||||
if (!rawVersion || rawVersion === 'unknown') return;
|
||||
|
||||
fetch(REMOTE_PACKAGE_URL)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
setLatestVersion(data.version);
|
||||
setUpdateAvailable(compareVersions(rawVersion, data.version) > 0);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - update check is non-critical
|
||||
});
|
||||
}, [rawVersion]);
|
||||
|
||||
// Check for updates on mount and periodically (every 6 hours)
|
||||
useEffect(() => {
|
||||
if (!rawVersion || rawVersion === 'unknown') return;
|
||||
|
||||
checkForUpdates();
|
||||
const interval = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [rawVersion, checkForUpdates]);
|
||||
|
||||
if (!version) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tooltipText = commit ? `${version} (${commit})` : version;
|
||||
const releaseUrl = rawVersion && rawVersion !== 'unknown'
|
||||
? `https://github.com/${GITHUB_REPO}/releases/tag/v${rawVersion}`
|
||||
: `https://github.com/${GITHUB_REPO}/releases`;
|
||||
|
||||
const tooltipText = updateAvailable && latestVersion
|
||||
? `${version}${commit ? ` (${commit})` : ''} — Update available: v${latestVersion}`
|
||||
: commit ? `${version} (${commit})` : version;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-md bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 border border-gray-300 dark:border-gray-600 shadow-sm"
|
||||
<a
|
||||
href={updateAvailable && latestVersion
|
||||
? `https://github.com/${GITHUB_REPO}/releases/tag/v${latestVersion}`
|
||||
: releaseUrl
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 border border-gray-300 dark:border-gray-600 shadow-sm hover:shadow-md transition-shadow no-underline"
|
||||
title={tooltipText}
|
||||
>
|
||||
<span className="text-xs font-mono font-medium text-gray-700 dark:text-gray-300">
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
{updateAvailable && latestVersion && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-mono font-medium text-amber-600 dark:text-amber-400">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-500 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500" />
|
||||
</span>
|
||||
v{latestVersion}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user