Refine UI and adjust content type settings (#705)

- Tweak manual search toggle position
- Refinements to the Hardcover list dropdown behavior
- Hide the content type dropdown when a content type is blocked for a
user
- Fixes to Hardcover author parsing to strip out initialed names
- Remove `env_supported=false` for security config options.
This commit is contained in:
Alex
2026-03-05 16:24:03 +00:00
committed by GitHub
parent cce2c10704
commit ba92ad90bc
13 changed files with 408 additions and 169 deletions
+50 -2
View File
@@ -227,6 +227,27 @@ function App() {
const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false;
// Compute which content types this user is allowed to search for.
// If a content type's default policy mode is 'blocked', hide it from the dropdown.
const allowedContentTypes = useMemo((): ContentType[] => {
// If policy not loaded yet or user is admin, allow everything
if (!requestPolicy || requestRoleIsAdmin || !requestsPolicyEnabled) {
return ['ebook', 'audiobook'];
}
const types: ContentType[] = [];
if (getDefaultMode('ebook') !== 'blocked') types.push('ebook');
if (getDefaultMode('audiobook') !== 'blocked') types.push('audiobook');
// If both are blocked, still show both (user can see results, just can't download)
return types.length > 0 ? types : ['ebook', 'audiobook'];
}, [requestPolicy, requestRoleIsAdmin, requestsPolicyEnabled, getDefaultMode]);
// Auto-switch content type if the current selection is blocked
useEffect(() => {
if (allowedContentTypes.length > 0 && !allowedContentTypes.includes(contentType)) {
setContentType(allowedContentTypes[0]);
}
}, [allowedContentTypes, contentType]);
const {
isLoading: isRequestsLoading,
cancelRequest: cancelUserRequest,
@@ -356,6 +377,7 @@ function App() {
handleSortChange,
searchFieldValues,
updateSearchFieldValue,
searchFieldLabels,
// Pagination (universal mode)
hasMore,
isLoadingMore,
@@ -1385,6 +1407,13 @@ function App() {
});
}, [config?.metadata_search_fields, searchFieldValues]);
const activeListLabel = useMemo(() => {
if (!isListBrowsing) return '';
const field = (config?.metadata_search_fields ?? [])
.find((f) => f.type === 'DynamicSelectSearchField' && searchFieldValues[f.key]);
return field ? (searchFieldLabels[field.key] || '') : '';
}, [isListBrowsing, config?.metadata_search_fields, searchFieldValues, searchFieldLabels]);
// Reset manual search if policy changes to disallow it
useEffect(() => {
if (!manualSearchAllowed && isManualSearch) {
@@ -1392,6 +1421,21 @@ function App() {
}
}, [manualSearchAllowed, isManualSearch]);
const handleManualSearchToggle = useCallback(() => {
setIsManualSearch(prev => {
if (!prev) {
// Turning on: clear any dynamic select field values (e.g. list selection)
const dynamicKeys = (config?.metadata_search_fields ?? [])
.filter((f) => f.type === 'DynamicSelectSearchField')
.map((f) => f.key);
for (const key of dynamicKeys) {
updateSearchFieldValue(key, '');
}
}
return !prev;
});
}, [config?.metadata_search_fields, updateSearchFieldValue]);
// Unified search dispatch: intercepts manual search mode, otherwise runs normal search
const handleSearchDispatch = useCallback(() => {
if (isManualSearch) {
@@ -1474,8 +1518,10 @@ function App() {
onRemoveToast={removeToast}
contentType={contentType}
onContentTypeChange={setContentType}
allowedContentTypes={allowedContentTypes}
isManualSearch={isManualSearch}
searchDisabled={isListBrowsing}
activeListLabel={activeListLabel}
/>
</div>
@@ -1510,7 +1556,7 @@ function App() {
onSearchFieldChange={updateSearchFieldValue}
onSubmit={handleSearchDispatch}
isManualSearch={isManualSearch}
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined}
/>
<main
@@ -1540,9 +1586,11 @@ function App() {
onSearchFieldChange={updateSearchFieldValue}
contentType={contentType}
onContentTypeChange={setContentType}
allowedContentTypes={allowedContentTypes}
isManualSearch={isManualSearch}
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined}
searchDisabled={isListBrowsing}
activeListLabel={activeListLabel}
/>
<ResultsSection
+65 -43
View File
@@ -21,7 +21,7 @@ interface AdvancedFiltersProps {
// Universal mode props
metadataSearchFields?: MetadataSearchField[];
searchFieldValues?: Record<string, string | number | boolean>;
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void;
// Submit handler for Enter key
onSubmit?: () => void;
// Manual search mode (universal only)
@@ -85,50 +85,72 @@ export const AdvancedFilters = ({
// If no fields and no toggle available, don't show the section
if (!hasProviderFields && !onManualSearchToggle) return null;
const manualToggle = onManualSearchToggle ? (
<div className="space-y-1.5">
<div className="flex items-start justify-between gap-2">
<label className="text-sm font-medium">Manual search</label>
</div>
<div>
<ToggleSwitch
checked={isManualSearch}
onChange={() => onManualSearchToggle()}
color="emerald"
/>
</div>
<p className="text-xs"><span className="opacity-60">Search release sources directly</span></p>
</div>
) : null;
// When formClassName is provided (initial state), the form carries its own padding;
// otherwise use the default positioning classes for the header-bar state.
const wrapperClassName = formClassName
? 'px-2'
: 'px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]';
const universalForm = (
<form
id="search-filters"
className={
formClassName ??
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
}
>
{manualToggle && <div className="col-span-full">{manualToggle}</div>}
{!isManualSearch && metadataSearchFields.map((field) => (
<div key={field.key}>
{field.type !== 'CheckboxSearchField' && (
<label htmlFor={`${field.key}-input`} className="block text-sm mb-1 opacity-80">
{field.label}
</label>
)}
<SearchFieldRenderer
field={field}
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
onChange={(value) => onSearchFieldChange?.(field.key, value)}
onSubmit={onSubmit}
/>
{field.description && (
<p className="text-xs mt-1 opacity-60">{field.description}</p>
)}
<div className={wrapperClassName}>
{onManualSearchToggle && (
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium opacity-70">Search Options</span>
<label className="flex items-center gap-2 cursor-pointer">
<span className="text-xs opacity-60">Manual search</span>
<ToggleSwitch
checked={isManualSearch}
onChange={() => onManualSearchToggle()}
color="emerald"
/>
</label>
</div>
))}
</form>
)}
{isManualSearch && (
<p className="text-xs opacity-50 mb-3">
Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates.
</p>
)}
{!isManualSearch && metadataSearchFields.length > 0 && (
<form
id="search-filters"
className={
formClassName ??
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
}
>
{metadataSearchFields.map((field) => (
<div key={field.key}>
{field.type !== 'CheckboxSearchField' && (
<div className="flex items-center justify-between mb-1">
<label htmlFor={`${field.key}-input`} className="text-sm opacity-80">
{field.label}
</label>
{field.type === 'DynamicSelectSearchField' && searchFieldValues[field.key] && (
<button
type="button"
onClick={() => onSearchFieldChange?.(field.key, '')}
className="text-xs font-medium text-sky-500 hover:text-sky-400 transition-colors"
>
Clear
</button>
)}
</div>
)}
<SearchFieldRenderer
field={field}
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
onChange={(value, label) => onSearchFieldChange?.(field.key, value, label)}
onSubmit={onSubmit}
/>
{field.description && (
<p className="text-xs mt-1 opacity-60">{field.description}</p>
)}
</div>
))}
</form>
)}
</div>
);
const wrappedUniversalForm = renderWrapper ? (
@@ -148,7 +170,7 @@ export const AdvancedFilters = ({
id="search-filters"
className={
formClassName ??
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]'
}
>
<div>
+6
View File
@@ -39,8 +39,10 @@ interface HeaderProps {
onRemoveToast?: (id: string) => void;
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
isManualSearch?: boolean;
searchDisabled?: boolean;
activeListLabel?: string;
}
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
@@ -71,8 +73,10 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
onRemoveToast,
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
isManualSearch = false,
searchDisabled = false,
activeListLabel,
}, ref) => {
const activityBadge = getActivityBadgeState(statusCounts, isAdmin);
const settingsEnabled = canAccessSettings ?? isAdmin;
@@ -656,8 +660,10 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
isLoading={isLoading}
contentType={contentType}
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
isManualSearch={isManualSearch}
disabled={searchDisabled}
activeListLabel={activeListLabel}
/>
</div>
</div>
+16 -8
View File
@@ -24,9 +24,11 @@ interface SearchBarProps {
// Content type selector props
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
// Manual search mode
isManualSearch?: boolean;
disabled?: boolean;
activeListLabel?: string;
}
export interface SearchBarHandle {
@@ -54,8 +56,10 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
enterKeyHint = 'search',
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
isManualSearch = false,
disabled = false,
activeListLabel,
}, ref) => {
const { searchMode, isUniversalMode } = useSearchMode();
const inputRef = useRef<HTMLInputElement>(null);
@@ -65,14 +69,18 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
// Content type dropdown state
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange;
const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1;
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange && hasMultipleContentTypes;
// Dynamic placeholder based on content type and manual search
const effectivePlaceholder = isManualSearch
? 'Search releases directly...'
: showContentTypeSelector
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
: placeholder;
// Dynamic placeholder based on content type, manual search, and list browsing
const isContentTypeAware = isUniversalMode && !!onContentTypeChange;
const effectivePlaceholder = activeListLabel
? `${activeListLabel} selected`
: isManualSearch
? 'Search releases directly...'
: isContentTypeAware
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
: placeholder;
// Close dropdown on click outside or escape
useEffect(() => {
@@ -162,7 +170,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
<div
className="flex items-stretch rounded-full border"
style={{
background: 'var(--bg-soft)',
background: disabled ? 'var(--bg)' : 'var(--bg-soft)',
borderColor: 'var(--border-muted)',
}}
>
@@ -21,13 +21,15 @@ interface SearchSectionProps {
// Universal mode props
metadataSearchFields?: MetadataSearchField[];
searchFieldValues?: Record<string, string | number | boolean>;
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void;
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
// Manual search mode (universal only)
isManualSearch?: boolean;
onManualSearchToggle?: () => void;
searchDisabled?: boolean;
activeListLabel?: string;
}
export const SearchSection = ({
@@ -49,9 +51,11 @@ export const SearchSection = ({
onSearchFieldChange,
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
isManualSearch = false,
onManualSearchToggle,
searchDisabled = false,
activeListLabel,
}: SearchSectionProps) => {
const { searchMode } = useSearchMode();
@@ -93,8 +97,10 @@ export const SearchSection = ({
onAdvancedToggle={onAdvancedToggle}
contentType={contentType}
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
isManualSearch={isManualSearch}
disabled={searchDisabled}
activeListLabel={activeListLabel}
/>
<AdvancedFilters
visible={showAdvanced}
@@ -103,7 +109,7 @@ export const SearchSection = ({
supportedFormats={supportedFormats}
filters={advancedFilters}
onFiltersChange={onAdvancedFiltersChange}
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2"
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
renderWrapper={form => form}
metadataSearchFields={metadataSearchFields}
searchFieldValues={searchFieldValues}
@@ -2,26 +2,30 @@ import { useEffect, useState } from 'react';
import { getAdminUsers } from '../../../services/api';
import { CustomSettingsFieldRendererProps } from './types';
const ADMIN_CHECK_KEYS = new Set(['builtin_admin_requirement', 'oidc_admin_requirement']);
export const OidcAdminHint = ({ field }: CustomSettingsFieldRendererProps) => {
const [needsAdmin, setNeedsAdmin] = useState<boolean | null>(null);
const needsAdminCheck = ADMIN_CHECK_KEYS.has(field.key);
const [visible, setVisible] = useState(!needsAdminCheck);
useEffect(() => {
if (!needsAdminCheck) return;
let cancelled = false;
getAdminUsers()
.then((users) => {
if (!cancelled) {
setNeedsAdmin(!users.some(u => u.role === 'admin' && u.auth_source === 'builtin'));
setVisible(!users.some(u => u.role === 'admin' && u.auth_source === 'builtin'));
}
})
.catch(() => {
if (!cancelled) {
setNeedsAdmin(true);
setVisible(true);
}
});
return () => { cancelled = true; };
}, []);
}, [needsAdminCheck]);
if (!needsAdmin) return null;
if (!visible) return null;
return (
<div className="text-sm px-3 py-2 rounded-lg bg-amber-500/15 text-amber-700 dark:text-amber-300">
@@ -5,48 +5,25 @@ import { DynamicFieldOption, fetchFieldOptions } from '../../services/api';
interface DynamicDropdownProps {
endpoint: string;
value: string;
onChange: (value: string) => void;
onChange: (value: string, label?: string) => void;
placeholder?: string;
allLabel?: string;
}
const GROUP_HEADER_PREFIX = '__group__';
const buildOptions = (
options: DynamicFieldOption[],
allLabel: string
): DropdownListOption[] => {
const built: DropdownListOption[] = [{ value: '', label: allLabel }];
let previousGroup: string | null = null;
options.forEach((option, index) => {
if (option.group && option.group !== previousGroup) {
previousGroup = option.group;
built.push({
value: `${GROUP_HEADER_PREFIX}${option.group}:${index}`,
label: option.group,
disabled: true,
});
} else if (!option.group) {
previousGroup = null;
}
built.push({
value: option.value,
label: option.label,
description: option.description,
});
});
return built;
return options.map((option) => ({
value: option.value,
label: option.label,
description: option.description,
}));
};
export const DynamicDropdown = ({
endpoint,
value,
onChange,
placeholder = 'Select an option',
allLabel = 'All',
placeholder = 'Select...',
}: DynamicDropdownProps) => {
const [options, setOptions] = useState<DynamicFieldOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -87,28 +64,20 @@ export const DynamicDropdown = ({
const dropdownOptions = useMemo(() => {
if (isLoading) {
return [
{ value: '', label: allLabel },
{ value: '__loading', label: 'Loading...', disabled: true },
];
return [{ value: '__loading', label: 'Loading...', disabled: true }];
}
if (loadError) {
return [
{ value: '', label: allLabel },
{ value: '__error', label: loadError, disabled: true },
];
return [{ value: '__error', label: loadError, disabled: true }];
}
return buildOptions(options, allLabel);
}, [allLabel, isLoading, loadError, options]);
return buildOptions(options);
}, [isLoading, loadError, options]);
const handleChange = (nextValue: string[] | string) => {
const normalized = Array.isArray(nextValue) ? nextValue[0] ?? '' : nextValue;
if (normalized.startsWith(GROUP_HEADER_PREFIX)) {
return;
}
onChange(normalized);
const match = options.find((opt) => opt.value === normalized);
onChange(normalized, match?.label);
};
return (
@@ -6,7 +6,7 @@ import { DynamicDropdown } from './DynamicDropdown';
interface SearchFieldRendererProps {
field: MetadataSearchField;
value: string | number | boolean;
onChange: (value: string | number | boolean) => void;
onChange: (value: string | number | boolean, label?: string) => void;
onSubmit?: () => void;
}
@@ -104,9 +104,8 @@ export const SearchFieldRenderer = ({ field, value, onChange, onSubmit }: Search
<DynamicDropdown
endpoint={field.options_endpoint}
value={(value as string) ?? ''}
onChange={(v) => onChange(v)}
placeholder={field.placeholder || 'Select an option'}
allLabel="All"
onChange={(v, label) => onChange(v, label)}
placeholder={field.placeholder || 'Select...'}
/>
);
+11 -21
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Book, AppConfig, AdvancedFilterState, ContentType } from '../types';
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
@@ -41,7 +41,8 @@ interface UseSearchReturn {
resetSortFilter: () => void;
// Universal mode search field values
searchFieldValues: SearchFieldValues;
updateSearchFieldValue: (key: string, value: string | number | boolean) => void;
updateSearchFieldValue: (key: string, value: string | number | boolean, label?: string) => void;
searchFieldLabels: Record<string, string>;
// Pagination (universal mode only)
hasMore: boolean;
isLoadingMore: boolean;
@@ -70,6 +71,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
// Universal mode: provider-specific search field values
const [searchFieldValues, setSearchFieldValues] = useState<SearchFieldValues>({});
const [searchFieldLabels, setSearchFieldLabels] = useState<Record<string, string>>({});
// Pagination state (universal mode only)
const [currentPage, setCurrentPage] = useState(1);
@@ -83,31 +85,18 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
sort: string;
fieldValues: SearchFieldValues;
} | null>(null);
const previousContentTypeRef = useRef<ContentType>(contentType);
// Switching media type starts a new search session.
useEffect(() => {
if (previousContentTypeRef.current === contentType) {
return;
}
previousContentTypeRef.current = contentType;
setBooks([]);
setLastSearchQuery('');
setSearchFieldValues({});
setHasMore(false);
setTotalFound(0);
setCurrentPage(1);
lastSearchParamsRef.current = null;
onSearchReset?.();
}, [contentType, onSearchReset]);
const updateAdvancedFilters = useCallback((updates: Partial<AdvancedFilterState>) => {
setAdvancedFilters(prev => ({ ...prev, ...updates }));
}, []);
const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean) => {
const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean, label?: string) => {
setSearchFieldValues(prev => ({ ...prev, [key]: value }));
if (label !== undefined) {
setSearchFieldLabels(prev => ({ ...prev, [key]: label }));
} else if (!value) {
setSearchFieldLabels(prev => { const next = { ...prev }; delete next[key]; return next; });
}
}, []);
const resetSortFilter = useCallback(() => {
@@ -319,6 +308,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
// Universal mode search field values
searchFieldValues,
updateSearchFieldValue,
searchFieldLabels,
// Pagination (universal mode only)
hasMore,
isLoadingMore,