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:
Alex
2026-01-01 12:35:22 +00:00
committed by GitHub
parent 06e468d043
commit b2887eb4b0
34 changed files with 5422 additions and 578 deletions
+4 -1
View File
@@ -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 && (
+46 -19
View File
@@ -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}>
+3 -42
View File
@@ -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>
+176 -16
View File
@@ -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
+3
View File
@@ -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',
+2
View File
@@ -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;
+2
View File
@@ -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 }),
+14
View File
@@ -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;
}