mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 19:31:05 +01:00
Template based file naming and torrent hardlinking (#385)
- Added alternative file processing mode. Save files directly into a
library folder and set up file names / directories based on user
preference.
- Uses template based naming and directory creation. E.g. {Author} /
{Series} {Title} {Part} etc. Works for saving correctly to libraries
such as Audiobookshelf.
- Use torrent hardlinking directly into library directories.
This commit is contained in:
@@ -475,6 +475,9 @@ function App() {
|
||||
extra: release.extra,
|
||||
preview: book.preview, // Pass book cover from metadata
|
||||
content_type: releaseContentType, // For audiobook directory routing
|
||||
series_name: book.series_name,
|
||||
series_position: book.series_position,
|
||||
subtitle: book.subtitle,
|
||||
});
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
@@ -554,7 +557,6 @@ function App() {
|
||||
isLoading={isSearching}
|
||||
onShowToast={showToast}
|
||||
onRemoveToast={removeToast}
|
||||
searchMode={searchMode}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
/>
|
||||
@@ -601,6 +603,7 @@ function App() {
|
||||
searchFieldValues={searchFieldValues}
|
||||
onSearchFieldChange={updateSearchFieldValue}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
/>
|
||||
|
||||
{isInitialState && !featureNoticeDismissed && (
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
// Simple throttle function to limit how often a function can be called
|
||||
function throttle<T extends (...args: unknown[]) => void>(fn: T, delay: number): T {
|
||||
let lastCall = 0;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return ((...args: unknown[]) => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastCall = now - lastCall;
|
||||
|
||||
if (timeSinceLastCall >= delay) {
|
||||
lastCall = now;
|
||||
fn(...args);
|
||||
} else if (!timeoutId) {
|
||||
// Schedule a trailing call
|
||||
timeoutId = setTimeout(() => {
|
||||
lastCall = Date.now();
|
||||
timeoutId = null;
|
||||
fn(...args);
|
||||
}, delay - timeSinceLastCall);
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
label?: string;
|
||||
@@ -62,32 +85,36 @@ export const Dropdown = ({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Memoize the panel direction calculation
|
||||
const updatePanelDirection = useCallback(() => {
|
||||
if (!containerRef.current || !panelRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const panelHeight = panelRef.current.offsetHeight || panelRef.current.scrollHeight;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
||||
const spaceAbove = rect.top - 8;
|
||||
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
|
||||
|
||||
setPanelDirection(shouldOpenUp ? 'up' : 'down');
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const updatePanelDirection = () => {
|
||||
if (!containerRef.current || !panelRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const panelHeight = panelRef.current.offsetHeight || panelRef.current.scrollHeight;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
||||
const spaceAbove = rect.top - 8;
|
||||
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
|
||||
|
||||
setPanelDirection(shouldOpenUp ? 'up' : 'down');
|
||||
};
|
||||
// Throttle scroll/resize handlers to reduce layout thrashing
|
||||
const throttledUpdate = throttle(updatePanelDirection, 100);
|
||||
|
||||
updatePanelDirection();
|
||||
window.addEventListener('resize', updatePanelDirection);
|
||||
window.addEventListener('scroll', updatePanelDirection, true);
|
||||
window.addEventListener('resize', throttledUpdate);
|
||||
window.addEventListener('scroll', throttledUpdate, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePanelDirection);
|
||||
window.removeEventListener('scroll', updatePanelDirection, true);
|
||||
window.removeEventListener('resize', throttledUpdate);
|
||||
window.removeEventListener('scroll', throttledUpdate, true);
|
||||
};
|
||||
}, [isOpen]);
|
||||
}, [isOpen, updatePanelDirection]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${widthClassName}`} ref={containerRef}>
|
||||
|
||||
@@ -31,7 +31,6 @@ interface HeaderProps {
|
||||
onLogout?: () => void;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info', persistent?: boolean) => string;
|
||||
onRemoveToast?: (id: string) => void;
|
||||
searchMode?: string;
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
}
|
||||
@@ -55,7 +54,6 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
onLogout,
|
||||
onShowToast,
|
||||
onRemoveToast,
|
||||
searchMode = 'direct',
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
}, ref) => {
|
||||
@@ -261,46 +259,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
}}
|
||||
>
|
||||
<div className="py-1">
|
||||
{/* Content Type Toggle - only shown in Universal search mode */}
|
||||
{onContentTypeChange && searchMode === 'universal' && (
|
||||
<>
|
||||
<div className="px-4 py-2">
|
||||
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">Search for</div>
|
||||
<div className="flex flex-col rounded-lg overflow-hidden border border-[var(--border-muted)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onContentTypeChange('ebook')}
|
||||
className={`w-full px-3 py-2 text-sm font-medium flex items-center gap-2 transition-colors ${
|
||||
contentType === 'ebook'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-transparent hover:bg-[var(--hover-surface)]'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
Books
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onContentTypeChange('audiobook')}
|
||||
className={`w-full px-3 py-2 text-sm font-medium flex items-center gap-2 transition-colors border-t border-[var(--border-muted)] ${
|
||||
contentType === 'audiobook'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-transparent hover:bg-[var(--hover-surface)]'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
</svg>
|
||||
Audiobooks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-[var(--border-muted)] my-1"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<a
|
||||
href="https://github.com/calibrain/calibre-web-automated-book-downloader/issues"
|
||||
target="_blank"
|
||||
@@ -493,6 +452,8 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
onSubmit={handleHeaderSearch}
|
||||
onAdvancedToggle={onAdvancedToggle}
|
||||
isLoading={isLoading}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { KeyboardEvent, InputHTMLAttributes, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { KeyboardEvent, InputHTMLAttributes, useRef, forwardRef, useImperativeHandle, useState, useEffect } from 'react';
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import { ContentType } from '../types';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
@@ -20,6 +21,9 @@ interface SearchBarProps {
|
||||
searchButtonTitle?: string;
|
||||
autoComplete?: string;
|
||||
enterKeyHint?: InputHTMLAttributes<HTMLInputElement>['enterKeyHint'];
|
||||
// Content type selector props
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
}
|
||||
|
||||
export interface SearchBarHandle {
|
||||
@@ -45,12 +49,54 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
searchButtonTitle = 'Search',
|
||||
autoComplete = 'off',
|
||||
enterKeyHint = 'search',
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
}, ref) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
const { searchMode, isUniversalMode } = useSearchMode();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const hasSearchQuery = value.trim().length > 0;
|
||||
|
||||
// Content type dropdown state
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange;
|
||||
|
||||
// Dynamic placeholder based on content type
|
||||
const effectivePlaceholder = showContentTypeSelector
|
||||
? (contentType === 'ebook' ? 'Search books by title, author, ISBN...' : 'Search audiobooks by title, author...')
|
||||
: placeholder;
|
||||
|
||||
// Close dropdown on click outside or escape
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape as unknown as EventListener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape as unknown as EventListener);
|
||||
};
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
const handleContentTypeSelect = (type: ContentType) => {
|
||||
onContentTypeChange?.(type);
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
submit: () => {
|
||||
buttonRef.current?.click();
|
||||
@@ -71,7 +117,8 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
|
||||
const wrapperClasses = ['relative', className].filter(Boolean).join(' ').trim();
|
||||
const inputClasses = [
|
||||
'w-full pl-4 pr-40 py-3 rounded-full border outline-none search-input',
|
||||
'w-full pr-40 py-3 border outline-none search-input',
|
||||
showContentTypeSelector ? 'pl-3 rounded-r-full' : 'pl-4 rounded-full',
|
||||
inputClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -85,25 +132,138 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
// Content type icons
|
||||
const BookIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AudiobookIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={wrapperClasses}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
aria-label={inputAriaLabel}
|
||||
autoComplete={autoComplete}
|
||||
enterKeyHint={enterKeyHint}
|
||||
className={inputClasses}
|
||||
<div
|
||||
className="flex items-stretch rounded-full border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={inputRef}
|
||||
/>
|
||||
>
|
||||
{/* Content Type Selector */}
|
||||
{showContentTypeSelector && (
|
||||
<div className="relative flex-shrink-0 flex" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="flex items-center gap-1.5 pl-5 pr-2 rounded-l-full transition-colors hover-action"
|
||||
style={{ color: 'var(--text)' }}
|
||||
aria-label={`Searching ${contentType === 'ebook' ? 'books' : 'audiobooks'}. Click to change.`}
|
||||
aria-expanded={isDropdownOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
{contentType === 'ebook' ? <BookIcon /> : <AudiobookIcon />}
|
||||
<svg
|
||||
className={`w-3 h-3 opacity-50 transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2.5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 w-px h-6"
|
||||
style={{ background: 'var(--border-muted)' }}
|
||||
/>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
className="absolute left-0 top-full mt-2 w-40 rounded-lg border shadow-lg z-50 overflow-hidden animate-fade-in-down"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="Content type options"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('ebook')}
|
||||
className={`w-full px-3 py-2.5 text-sm font-medium flex items-center gap-2.5 transition-colors ${
|
||||
contentType === 'ebook'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={contentType !== 'ebook' ? { color: 'var(--text)' } : undefined}
|
||||
role="option"
|
||||
aria-selected={contentType === 'ebook'}
|
||||
>
|
||||
<BookIcon />
|
||||
Books
|
||||
{contentType === 'ebook' && (
|
||||
<svg className="w-4 h-4 ml-auto" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('audiobook')}
|
||||
className={`w-full px-3 py-2.5 text-sm font-medium flex items-center gap-2.5 transition-colors border-t ${
|
||||
contentType === 'audiobook'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
...(contentType !== 'audiobook' ? { color: 'var(--text)' } : {}),
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={contentType === 'audiobook'}
|
||||
>
|
||||
<AudiobookIcon />
|
||||
Audiobooks
|
||||
{contentType === 'audiobook' && (
|
||||
<svg className="w-4 h-4 ml-auto" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Input */}
|
||||
<input
|
||||
type="search"
|
||||
placeholder={effectivePlaceholder}
|
||||
aria-label={inputAriaLabel}
|
||||
autoComplete={autoComplete}
|
||||
enterKeyHint={enterKeyHint}
|
||||
className={inputClasses}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: 'var(--text)',
|
||||
border: 'none',
|
||||
}}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right-side controls */}
|
||||
<div className={controlsClasses}>
|
||||
{hasSearchQuery && (
|
||||
<button
|
||||
|
||||
@@ -23,6 +23,7 @@ interface SearchSectionProps {
|
||||
searchFieldValues?: Record<string, string | number | boolean>;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
}
|
||||
|
||||
export const SearchSection = ({
|
||||
@@ -43,6 +44,7 @@ export const SearchSection = ({
|
||||
searchFieldValues,
|
||||
onSearchFieldChange,
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
}: SearchSectionProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
|
||||
@@ -71,9 +73,7 @@ export const SearchSection = ({
|
||||
isInitialState ? 'opacity-100 mb-6 sm:mb-8' : 'opacity-0 h-0 mb-0 overflow-hidden'
|
||||
}`}>
|
||||
<img src={logoUrl} alt="Logo" className="h-8 w-8" />
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{contentType === 'audiobook' ? 'Audiobook Search & Download' : 'Book Search & Download'}
|
||||
</h1>
|
||||
<h1 className="text-2xl font-semibold">Book Search & Download</h1>
|
||||
</div>
|
||||
<div className={`flex flex-col gap-3 search-wrapper transition-all duration-500 ${
|
||||
isInitialState ? '' : 'hidden'
|
||||
@@ -84,6 +84,8 @@ export const SearchSection = ({
|
||||
onSubmit={handleSearch}
|
||||
isLoading={isLoading}
|
||||
onAdvancedToggle={onAdvancedToggle}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
/>
|
||||
<AdvancedFilters
|
||||
visible={showAdvanced}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
SettingsTab,
|
||||
SettingsField,
|
||||
@@ -35,13 +35,20 @@ interface SettingsContentProps {
|
||||
onAction: (key: string) => Promise<ActionResult>;
|
||||
isSaving: boolean;
|
||||
hasChanges: boolean;
|
||||
isUniversalMode?: boolean; // Whether app is in Universal search mode
|
||||
}
|
||||
|
||||
// Check if a field should be visible based on showWhen condition
|
||||
// Check if a field should be visible based on showWhen condition and search mode
|
||||
function isFieldVisible(
|
||||
field: SettingsField,
|
||||
values: Record<string, unknown>
|
||||
values: Record<string, unknown>,
|
||||
isUniversalMode: boolean
|
||||
): boolean {
|
||||
// Check universalOnly - hide these fields in Direct mode
|
||||
if ('universalOnly' in field && field.universalOnly && !isUniversalMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const showWhen = field.showWhen;
|
||||
if (!showWhen) return true;
|
||||
|
||||
@@ -193,6 +200,7 @@ export const SettingsContent = ({
|
||||
onAction,
|
||||
isSaving,
|
||||
hasChanges,
|
||||
isUniversalMode = true,
|
||||
}: SettingsContentProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -203,6 +211,12 @@ export const SettingsContent = ({
|
||||
}
|
||||
}, [tab.name]);
|
||||
|
||||
// Memoize the visible fields to avoid recalculating on every render
|
||||
const visibleFields = useMemo(
|
||||
() => tab.fields.filter((field) => isFieldVisible(field, values, isUniversalMode)),
|
||||
[tab.fields, values, isUniversalMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Scrollable content area */}
|
||||
@@ -212,9 +226,7 @@ export const SettingsContent = ({
|
||||
style={{ paddingBottom: hasChanges ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem' }}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{tab.fields
|
||||
.filter((field) => isFieldVisible(field, values))
|
||||
.map((field) => {
|
||||
{visibleFields.map((field) => {
|
||||
const disabledState = getDisabledState(field, values);
|
||||
return (
|
||||
<FieldWrapper
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import { useSearchMode } from '../../contexts/SearchModeContext';
|
||||
import { SettingsHeader } from './SettingsHeader';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
import { SettingsContent } from './SettingsContent';
|
||||
@@ -27,6 +28,8 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
|
||||
isSaving,
|
||||
} = useSettings();
|
||||
|
||||
const { isUniversalMode } = useSearchMode();
|
||||
|
||||
// Track if we're showing detail view on mobile
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [showMobileDetail, setShowMobileDetail] = useState(false);
|
||||
@@ -143,6 +146,23 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
|
||||
[selectedTab, executeAction]
|
||||
);
|
||||
|
||||
// Memoize the field change handler to prevent creating new functions on every render
|
||||
const handleFieldChange = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
if (selectedTab) {
|
||||
updateValue(selectedTab, key, value);
|
||||
}
|
||||
},
|
||||
[selectedTab, updateValue]
|
||||
);
|
||||
|
||||
// Memoize hasChanges to avoid expensive JSON.stringify comparisons on every render
|
||||
// Must be before early returns to satisfy React's rules of hooks
|
||||
const currentTabHasChanges = useMemo(
|
||||
() => (selectedTab ? hasChanges(selectedTab) : false),
|
||||
[selectedTab, hasChanges, values]
|
||||
);
|
||||
|
||||
if (!isOpen && !isClosing) return null;
|
||||
|
||||
const currentTab = tabs.find((t) => t.name === selectedTab);
|
||||
@@ -153,7 +173,8 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
|
||||
style={{ willChange: 'opacity', contain: 'strict' }}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div
|
||||
@@ -189,7 +210,8 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
|
||||
style={{ willChange: 'opacity', contain: 'strict' }}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div
|
||||
@@ -261,11 +283,12 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
|
||||
<SettingsContent
|
||||
tab={currentTab}
|
||||
values={values[currentTab.name] || {}}
|
||||
onChange={(key, value) => updateValue(currentTab.name, key, value)}
|
||||
onChange={handleFieldChange}
|
||||
onSave={handleSave}
|
||||
onAction={handleAction}
|
||||
isSaving={isSaving}
|
||||
hasChanges={hasChanges(currentTab.name)}
|
||||
hasChanges={currentTabHasChanges}
|
||||
isUniversalMode={isUniversalMode}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -279,8 +302,9 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
|
||||
className={`absolute inset-0 bg-black/50 backdrop-blur-[2px] transition-opacity duration-150
|
||||
${isClosing ? 'opacity-0' : 'opacity-100'}`}
|
||||
style={{ willChange: 'opacity', contain: 'strict' }}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
@@ -310,11 +334,12 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
|
||||
<SettingsContent
|
||||
tab={currentTab}
|
||||
values={values[currentTab.name] || {}}
|
||||
onChange={(key, value) => updateValue(currentTab.name, key, value)}
|
||||
onChange={handleFieldChange}
|
||||
onSave={handleSave}
|
||||
onAction={handleAction}
|
||||
isSaving={isSaving}
|
||||
hasChanges={hasChanges(currentTab.name)}
|
||||
hasChanges={currentTabHasChanges}
|
||||
isUniversalMode={isUniversalMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-sm opacity-60">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { SettingsTab, SettingsGroup } from '../../types/settings';
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
@@ -129,47 +129,52 @@ export const SettingsSidebar = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Build grouped tabs map
|
||||
const groupedTabs = new Map<string, SettingsTab[]>();
|
||||
tabs.forEach((tab) => {
|
||||
if (tab.group) {
|
||||
const existing = groupedTabs.get(tab.group) || [];
|
||||
existing.push(tab);
|
||||
groupedTabs.set(tab.group, existing);
|
||||
}
|
||||
});
|
||||
|
||||
// Build a unified sorted list of tabs and groups
|
||||
const sidebarItems: SidebarItem[] = [];
|
||||
|
||||
// Add ungrouped tabs (with section headers where needed)
|
||||
tabs.forEach((tab) => {
|
||||
if (!tab.group) {
|
||||
// Check if this tab needs a section header before it
|
||||
const sectionHeader = SECTION_HEADERS.find((s) => s.beforeTab === tab.name);
|
||||
if (sectionHeader) {
|
||||
sidebarItems.push({ type: 'section', label: sectionHeader.label, order: tab.order - 0.5 });
|
||||
// Memoize the sidebar items to avoid rebuilding on every render
|
||||
const sidebarItems = useMemo(() => {
|
||||
// Build grouped tabs map
|
||||
const groupedTabs = new Map<string, SettingsTab[]>();
|
||||
tabs.forEach((tab) => {
|
||||
if (tab.group) {
|
||||
const existing = groupedTabs.get(tab.group) || [];
|
||||
existing.push(tab);
|
||||
groupedTabs.set(tab.group, existing);
|
||||
}
|
||||
sidebarItems.push({ type: 'tab', tab, order: tab.order });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add groups (with their tabs) and section headers
|
||||
groups.forEach((group) => {
|
||||
const groupTabList = groupedTabs.get(group.name) || [];
|
||||
if (groupTabList.length > 0) {
|
||||
// Check if this group needs a section header before it
|
||||
const sectionHeader = SECTION_HEADERS.find((s) => s.beforeGroup === group.name);
|
||||
if (sectionHeader) {
|
||||
// Insert section header just before this group (order - 0.5 to sort before)
|
||||
sidebarItems.push({ type: 'section', label: sectionHeader.label, order: group.order - 0.5 });
|
||||
// Build a unified sorted list of tabs and groups
|
||||
const items: SidebarItem[] = [];
|
||||
|
||||
// Add ungrouped tabs (with section headers where needed)
|
||||
tabs.forEach((tab) => {
|
||||
if (!tab.group) {
|
||||
// Check if this tab needs a section header before it
|
||||
const sectionHeader = SECTION_HEADERS.find((s) => s.beforeTab === tab.name);
|
||||
if (sectionHeader) {
|
||||
items.push({ type: 'section', label: sectionHeader.label, order: tab.order - 0.5 });
|
||||
}
|
||||
items.push({ type: 'tab', tab, order: tab.order });
|
||||
}
|
||||
sidebarItems.push({ type: 'group', group, tabs: groupTabList, order: group.order });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by order
|
||||
sidebarItems.sort((a, b) => a.order - b.order);
|
||||
// Add groups (with their tabs) and section headers
|
||||
groups.forEach((group) => {
|
||||
const groupTabList = groupedTabs.get(group.name) || [];
|
||||
if (groupTabList.length > 0) {
|
||||
// Check if this group needs a section header before it
|
||||
const sectionHeader = SECTION_HEADERS.find((s) => s.beforeGroup === group.name);
|
||||
if (sectionHeader) {
|
||||
// Insert section header just before this group (order - 0.5 to sort before)
|
||||
items.push({ type: 'section', label: sectionHeader.label, order: group.order - 0.5 });
|
||||
}
|
||||
items.push({ type: 'group', group, tabs: groupTabList, order: group.order });
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by order
|
||||
items.sort((a, b) => a.order - b.order);
|
||||
|
||||
return items;
|
||||
}, [tabs, groups]);
|
||||
|
||||
if (mode === 'list') {
|
||||
// Mobile: Clean list style with inset dividers
|
||||
|
||||
@@ -166,6 +166,9 @@ export const downloadRelease = async (release: {
|
||||
extra?: Record<string, unknown>;
|
||||
preview?: string; // Book cover from metadata provider
|
||||
content_type?: string; // "ebook" or "audiobook" - for directory routing
|
||||
series_name?: string;
|
||||
series_position?: number;
|
||||
subtitle?: string;
|
||||
}): Promise<void> => {
|
||||
await fetchJSON(`${API_BASE}/releases/download`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface Book {
|
||||
series_name?: string; // Name of the series
|
||||
series_position?: number; // This book's position (e.g., 3, 1.5 for novellas)
|
||||
series_count?: number; // Total books in the series
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
// Status response types
|
||||
@@ -266,6 +267,7 @@ export interface ReleasesResponse {
|
||||
provider: string;
|
||||
provider_id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
authors?: string[];
|
||||
isbn_10?: string;
|
||||
isbn_13?: string;
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface BaseField {
|
||||
showWhen?: ShowWhenCondition; // Conditional visibility based on another field's value
|
||||
disabledWhen?: DisabledWhenCondition; // Conditional disable based on another field's value
|
||||
requiresRestart?: boolean; // True if changing this setting requires a container restart
|
||||
universalOnly?: boolean; // Only show in Universal search mode (hide in Direct mode)
|
||||
}
|
||||
|
||||
// Specific field interfaces
|
||||
@@ -120,6 +121,7 @@ export interface HeadingFieldConfig {
|
||||
linkUrl?: string;
|
||||
linkText?: string;
|
||||
showWhen?: ShowWhenCondition; // Conditional visibility based on another field's value
|
||||
universalOnly?: boolean; // Only show in Universal search mode (hide in Direct mode)
|
||||
}
|
||||
|
||||
// Union type for all fields
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface MetadataBookData {
|
||||
series_name?: string;
|
||||
series_position?: number;
|
||||
series_count?: number;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,7 @@ export function transformMetadataToBook(data: MetadataBookData): Book {
|
||||
series_name: data.series_name,
|
||||
series_position: data.series_position,
|
||||
series_count: data.series_count,
|
||||
subtitle: data.subtitle,
|
||||
info: {
|
||||
...(data.isbn_13 && { ISBN: data.isbn_13 }),
|
||||
...(data.isbn_10 && !data.isbn_13 && { ISBN: data.isbn_10 }),
|
||||
|
||||
@@ -45,9 +45,16 @@ const DOWNLOAD_TYPE_COLORS: Record<string, ColorStyle> = {
|
||||
direct: { bg: 'bg-emerald-500/20', text: 'text-emerald-700 dark:text-emerald-300' },
|
||||
};
|
||||
|
||||
const CONTENT_TYPE_COLORS: Record<string, ColorStyle> = {
|
||||
book: { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' },
|
||||
ebook: { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' }, // Alias for backwards compatibility
|
||||
audiobook: { bg: 'bg-violet-500/20', text: 'text-violet-700 dark:text-violet-300' },
|
||||
};
|
||||
|
||||
const DEFAULT_FORMAT_COLOR: ColorStyle = { bg: 'bg-cyan-500/20', text: 'text-cyan-700 dark:text-cyan-300' };
|
||||
const DEFAULT_LANGUAGE_COLOR: ColorStyle = { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300' };
|
||||
const DEFAULT_DOWNLOAD_TYPE_COLOR: ColorStyle = { bg: 'bg-violet-500/20', text: 'text-violet-700 dark:text-violet-300' };
|
||||
const DEFAULT_CONTENT_TYPE_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
|
||||
const FALLBACK_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
|
||||
|
||||
export function getFormatColor(format?: string): ColorStyle {
|
||||
@@ -65,6 +72,11 @@ export function getDownloadTypeColor(downloadType?: string): ColorStyle {
|
||||
return DOWNLOAD_TYPE_COLORS[downloadType.toLowerCase()] || DEFAULT_DOWNLOAD_TYPE_COLOR;
|
||||
}
|
||||
|
||||
export function getContentTypeColor(contentType?: string): ColorStyle {
|
||||
if (!contentType || contentType === '-') return FALLBACK_COLOR;
|
||||
return CONTENT_TYPE_COLORS[contentType.toLowerCase()] || DEFAULT_CONTENT_TYPE_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color hint type for dynamic column coloring.
|
||||
*/
|
||||
@@ -92,6 +104,8 @@ export function getColorStyleFromHint(value: string, colorHint?: ColumnColorHint
|
||||
return getLanguageColor(value);
|
||||
case 'download_type':
|
||||
return getDownloadTypeColor(value);
|
||||
case 'content_type':
|
||||
return getContentTypeColor(value);
|
||||
default:
|
||||
return FALLBACK_COLOR;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user