mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 21:51:08 +01:00
Settings UI enhancements - Source priority controls, default sort, caching controls (#353)
Also: Adjusted Welib/Zlib/Libgen URLs to be dynamically generated via hash. Fixed Zlib downloads and user agent flow. AA URLS are now fetched lazily if another source is prioritised.
This commit is contained in:
@@ -89,7 +89,6 @@ function App() {
|
||||
handleSearch,
|
||||
handleResetSearch,
|
||||
handleSortChange,
|
||||
resetSortFilter,
|
||||
searchFieldValues,
|
||||
updateSearchFieldValue,
|
||||
} = useSearch({
|
||||
@@ -157,6 +156,10 @@ function App() {
|
||||
if (!prevQueued[bookId]) {
|
||||
const book = currQueued[bookId];
|
||||
showToast(`${book.title || 'Book'} added to queue`, 'info');
|
||||
// Auto-open downloads sidebar if enabled
|
||||
if (config?.auto_open_downloads_sidebar !== false) {
|
||||
setDownloadsSidebarOpen(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -181,6 +184,16 @@ function App() {
|
||||
const book = currComplete[bookId];
|
||||
showToast(`${book.title || 'Book'} completed`, 'success');
|
||||
|
||||
// Auto-download to browser if enabled
|
||||
if (config?.download_to_browser && book.download_path) {
|
||||
const link = document.createElement('a');
|
||||
link.href = `/api/localdownload?id=${encodeURIComponent(bookId)}`;
|
||||
link.download = '';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
// Track completed release IDs in session state for universal mode
|
||||
Object.entries(bookToReleaseMap).forEach(([metadataBookId, releaseIds]) => {
|
||||
if (releaseIds.includes(bookId)) {
|
||||
@@ -199,7 +212,7 @@ function App() {
|
||||
showToast(`${book.title || 'Book'}: ${errorMsg}`, 'error');
|
||||
}
|
||||
});
|
||||
}, [showToast, bookToReleaseMap, markBookCompleted]);
|
||||
}, [showToast, bookToReleaseMap, markBookCompleted, config]);
|
||||
|
||||
// Detect status changes when currentStatus updates
|
||||
useEffect(() => {
|
||||
@@ -218,30 +231,37 @@ function App() {
|
||||
if (mode === 'settings-saved' && prevSearchModeRef.current !== cfg.search_mode) {
|
||||
setBooks([]);
|
||||
setSelectedBook(null);
|
||||
resetSortFilter();
|
||||
clearTracking();
|
||||
}
|
||||
|
||||
prevSearchModeRef.current = cfg.search_mode;
|
||||
setConfig(cfg);
|
||||
|
||||
// Determine the default sort based on search mode
|
||||
const defaultSort = cfg.search_mode === 'universal'
|
||||
? (cfg.metadata_default_sort || 'relevance')
|
||||
: (cfg.default_sort || 'relevance');
|
||||
|
||||
if (cfg?.supported_formats) {
|
||||
if (mode === 'initial') {
|
||||
setAdvancedFilters(prev => ({
|
||||
...prev,
|
||||
formats: cfg.supported_formats,
|
||||
sort: defaultSort,
|
||||
}));
|
||||
} else if (mode === 'settings-saved') {
|
||||
// On settings save, update formats and reset sort to new default
|
||||
setAdvancedFilters(prev => ({
|
||||
...prev,
|
||||
formats: prev.formats.filter(f => cfg.supported_formats.includes(f)),
|
||||
sort: defaultSort,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error);
|
||||
}
|
||||
}, [setBooks, setAdvancedFilters, resetSortFilter, clearTracking]);
|
||||
}, [setBooks, setAdvancedFilters, clearTracking]);
|
||||
|
||||
// Fetch config when authenticated
|
||||
useEffect(() => {
|
||||
|
||||
@@ -92,6 +92,10 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
|
||||
: getButtonState(book.id);
|
||||
const isLoadingDetails = detailsLoadingId === book.id;
|
||||
|
||||
// Compute color styles for direct mode badges
|
||||
const languageColor = getLanguageColor(book.language);
|
||||
const formatColor = getFormatColor(book.format);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={book.id}
|
||||
@@ -173,7 +177,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
|
||||
{searchMode !== 'universal' && (
|
||||
<div className="hidden sm:flex justify-center">
|
||||
<span
|
||||
className={`${getLanguageColor(book.language)} text-white text-[11px] font-semibold px-2 py-0.5 rounded uppercase tracking-wide`}
|
||||
className={`${languageColor.bg} ${languageColor.text} text-[11px] font-semibold px-2 py-0.5 rounded-lg uppercase tracking-wide`}
|
||||
title={book.language || 'Unknown'}
|
||||
>
|
||||
{book.language || '-'}
|
||||
@@ -185,7 +189,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
|
||||
{searchMode !== 'universal' && (
|
||||
<div className="hidden sm:flex justify-center">
|
||||
<span
|
||||
className={`${getFormatColor(book.format)} text-white text-[11px] font-semibold px-2 py-0.5 rounded uppercase tracking-wide`}
|
||||
className={`${formatColor.bg} ${formatColor.text} text-[11px] font-semibold px-2 py-0.5 rounded-lg uppercase tracking-wide`}
|
||||
title={book.format || 'Unknown'}
|
||||
>
|
||||
{book.format || '-'}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
CheckboxFieldConfig,
|
||||
SelectFieldConfig,
|
||||
MultiSelectFieldConfig,
|
||||
OrderableListFieldConfig,
|
||||
OrderableListItem,
|
||||
ActionButtonConfig,
|
||||
HeadingFieldConfig,
|
||||
} from '../../types/settings';
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
MultiSelectField,
|
||||
OrderableListField,
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
} from './fields';
|
||||
@@ -39,11 +42,6 @@ function isFieldVisible(
|
||||
field: SettingsField,
|
||||
values: Record<string, unknown>
|
||||
): boolean {
|
||||
// HeadingField doesn't have showWhen
|
||||
if (field.type === 'HeadingField') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const showWhen = field.showWhen;
|
||||
if (!showWhen) return true;
|
||||
|
||||
@@ -161,6 +159,15 @@ const renderField = (
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'OrderableListField':
|
||||
return (
|
||||
<OrderableListField
|
||||
field={field as OrderableListFieldConfig}
|
||||
value={(value as OrderableListItem[]) ?? []}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'ActionButton':
|
||||
return <ActionButton field={field as ActionButtonConfig} onAction={onAction} disabled={isDisabled} />;
|
||||
case 'HeadingField':
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import { SettingsHeader } from './SettingsHeader';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
@@ -32,6 +32,9 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
|
||||
const [showMobileDetail, setShowMobileDetail] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
// Track previous isOpen state to detect modal open transition
|
||||
const prevIsOpenRef = useRef(false);
|
||||
|
||||
// Check for mobile viewport
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
@@ -87,12 +90,16 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// On desktop, select first tab when modal first opens (only if no tab selected)
|
||||
// Reset to first tab when modal transitions from closed to open
|
||||
useEffect(() => {
|
||||
if (isOpen && !isMobile && tabs.length > 0 && !selectedTab) {
|
||||
const justOpened = isOpen && !prevIsOpenRef.current;
|
||||
prevIsOpenRef.current = isOpen;
|
||||
|
||||
// On desktop, select first tab when modal opens (reset on each open)
|
||||
if (justOpened && !isMobile && tabs.length > 0) {
|
||||
setSelectedTab(tabs[0].name);
|
||||
}
|
||||
}, [isOpen, isMobile, tabs, selectedTab, setSelectedTab]);
|
||||
}, [isOpen, isMobile, tabs, setSelectedTab]);
|
||||
|
||||
const handleSelectTab = useCallback(
|
||||
(tabName: string) => {
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
OrderableListFieldConfig,
|
||||
OrderableListItem,
|
||||
OrderableListOption,
|
||||
} from '../../../types/settings';
|
||||
|
||||
interface OrderableListFieldProps {
|
||||
field: OrderableListFieldConfig;
|
||||
value: OrderableListItem[];
|
||||
onChange: (value: OrderableListItem[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Represents where the drop indicator should appear
|
||||
type DropPosition = { index: number; position: 'before' | 'after' } | null;
|
||||
|
||||
/**
|
||||
* Merge current value with options to get full item info.
|
||||
* Items in value take precedence; any options not in value are appended.
|
||||
*/
|
||||
const mergeValueWithOptions = (
|
||||
value: OrderableListItem[],
|
||||
options: OrderableListOption[]
|
||||
): Array<OrderableListItem & OrderableListOption> => {
|
||||
const optionsMap = new Map(options.map((opt) => [opt.id, opt]));
|
||||
const result: Array<OrderableListItem & OrderableListOption> = [];
|
||||
|
||||
// Add items from value (preserves order)
|
||||
for (const item of value) {
|
||||
const option = optionsMap.get(item.id);
|
||||
if (option) {
|
||||
result.push({ ...option, ...item });
|
||||
optionsMap.delete(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining options not in value (shouldn't happen normally)
|
||||
for (const option of optionsMap.values()) {
|
||||
result.push({ ...option, id: option.id, enabled: false });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const OrderableListField = ({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: OrderableListFieldProps) => {
|
||||
const isDisabled = disabled ?? false;
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dropPosition, setDropPosition] = useState<DropPosition>(null);
|
||||
const dragNodeRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const items = mergeValueWithOptions(value ?? [], field.options);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
if (isDisabled) return;
|
||||
setDraggedIndex(index);
|
||||
dragNodeRef.current = e.currentTarget as HTMLDivElement;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(index));
|
||||
// Add a slight delay before adding the dragging class for better visual feedback
|
||||
requestAnimationFrame(() => {
|
||||
if (dragNodeRef.current) {
|
||||
dragNodeRef.current.classList.add('opacity-50');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (dragNodeRef.current) {
|
||||
dragNodeRef.current.classList.remove('opacity-50');
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
setDropPosition(null);
|
||||
dragNodeRef.current = null;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) {
|
||||
setDropPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if we're in the top or bottom half of the target
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const position = e.clientY < midpoint ? 'before' : 'after';
|
||||
|
||||
setDropPosition({ index, position });
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
// Only clear if we're leaving the item entirely (not entering a child)
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (!e.currentTarget.contains(relatedTarget)) {
|
||||
setDropPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || dropPosition === null) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the actual target index based on drop position
|
||||
let targetIndex = dropPosition.index;
|
||||
if (dropPosition.position === 'after') {
|
||||
targetIndex += 1;
|
||||
}
|
||||
// Adjust if dragging from before the target
|
||||
if (draggedIndex < targetIndex) {
|
||||
targetIndex -= 1;
|
||||
}
|
||||
|
||||
if (draggedIndex === targetIndex) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reorder the items
|
||||
const newItems = [...items];
|
||||
const [removed] = newItems.splice(draggedIndex, 1);
|
||||
newItems.splice(targetIndex, 0, removed);
|
||||
|
||||
// Convert back to value format
|
||||
const newValue: OrderableListItem[] = newItems.map((item) => ({
|
||||
id: item.id,
|
||||
enabled: item.enabled,
|
||||
}));
|
||||
|
||||
onChange(newValue);
|
||||
handleDragEnd();
|
||||
};
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
if (isDisabled) return;
|
||||
const item = items[index];
|
||||
if (item.isLocked) return;
|
||||
|
||||
const newValue: OrderableListItem[] = items.map((it, i) => ({
|
||||
id: it.id,
|
||||
enabled: i === index ? !it.enabled : it.enabled,
|
||||
}));
|
||||
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const moveItem = (fromIndex: number, direction: 'up' | 'down') => {
|
||||
if (isDisabled) return;
|
||||
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1;
|
||||
if (toIndex < 0 || toIndex >= items.length) return;
|
||||
|
||||
const newItems = [...items];
|
||||
[newItems[fromIndex], newItems[toIndex]] = [newItems[toIndex], newItems[fromIndex]];
|
||||
|
||||
const newValue: OrderableListItem[] = newItems.map((item) => ({
|
||||
id: item.id,
|
||||
enabled: item.enabled,
|
||||
}));
|
||||
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
// Calculate which gap index to show the indicator at (0 = before first item, N = after last item)
|
||||
const getDropGapIndex = (): number | null => {
|
||||
if (!dropPosition) return null;
|
||||
if (dropPosition.position === 'before') {
|
||||
return dropPosition.index;
|
||||
} else {
|
||||
return dropPosition.index + 1;
|
||||
}
|
||||
};
|
||||
|
||||
const dropGapIndex = getDropGapIndex();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((item, index) => {
|
||||
const isDragging = draggedIndex === index;
|
||||
const isItemDisabled = isDisabled || item.isLocked;
|
||||
// Show indicator before this item if the gap index matches
|
||||
const showIndicatorBefore = dropGapIndex === index;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="relative">
|
||||
{/* Drop indicator - absolutely positioned so it doesn't affect layout */}
|
||||
{showIndicatorBefore && (
|
||||
<div className="absolute left-1 right-1 h-1 bg-sky-500 rounded-full z-10 -top-1 -translate-y-1/2" />
|
||||
)}
|
||||
|
||||
<div
|
||||
draggable={!isDisabled}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-lg border
|
||||
transition-all duration-150
|
||||
${isDragging ? 'opacity-50' : ''}
|
||||
border-[var(--border-muted)]
|
||||
${isDisabled ? 'opacity-60' : 'hover:bg-[var(--hover-surface)]'}
|
||||
`}
|
||||
>
|
||||
{/* Reorder Controls */}
|
||||
<div className="flex flex-col flex-shrink-0 -my-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveItem(index, 'up');
|
||||
}}
|
||||
disabled={isDisabled || index === 0}
|
||||
className={`
|
||||
p-1.5 sm:p-0.5 rounded transition-colors
|
||||
${isDisabled || index === 0
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 sm:hover:bg-gray-100 sm:dark:hover:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveItem(index, 'down');
|
||||
}}
|
||||
disabled={isDisabled || index === items.length - 1}
|
||||
className={`
|
||||
p-1.5 sm:p-0.5 rounded transition-colors
|
||||
${isDisabled || index === items.length - 1
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 sm:hover:bg-gray-100 sm:dark:hover:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Label and Description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{item.label}</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
{item.isLocked && item.disabledReason && (
|
||||
<div className="text-xs text-amber-500 mt-0.5 flex items-center gap-1">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{item.disabledReason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
{(() => {
|
||||
// Locked items always show as "off" regardless of enabled state
|
||||
const showAsEnabled = item.enabled && !item.isLocked;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={showAsEnabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleItem(index);
|
||||
}}
|
||||
disabled={isItemDisabled}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full
|
||||
transition-colors duration-200 focus:outline-none focus:ring-2
|
||||
focus:ring-sky-500/50 disabled:opacity-60 disabled:cursor-not-allowed
|
||||
${showAsEnabled ? 'bg-sky-600' : 'bg-gray-300 dark:bg-gray-600'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-white
|
||||
shadow-sm transition-transform duration-200
|
||||
${showAsEnabled ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Drop indicator after last item - use relative container with absolute indicator */}
|
||||
{dropGapIndex === items.length && (
|
||||
<div className="relative h-0">
|
||||
<div className="absolute left-1 right-1 h-1 bg-sky-500 rounded-full z-10 -top-0.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,5 +4,6 @@ export { NumberField } from './NumberField';
|
||||
export { CheckboxField } from './CheckboxField';
|
||||
export { SelectField } from './SelectField';
|
||||
export { MultiSelectField } from './MultiSelectField';
|
||||
export { OrderableListField } from './OrderableListField';
|
||||
export { ActionButton } from './ActionButton';
|
||||
export { HeadingField } from './HeadingField';
|
||||
|
||||
@@ -150,7 +150,11 @@ export interface AppConfig {
|
||||
metadata_sort_options: SortOption[];
|
||||
metadata_search_fields: MetadataSearchField[];
|
||||
default_release_source?: string; // Default tab in ReleaseModal (e.g., 'direct_download')
|
||||
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
|
||||
download_to_browser: boolean; // Auto-download completed files to browser
|
||||
settings_enabled: boolean; // Whether config directory is mounted and writable
|
||||
default_sort: string; // Default sort for direct mode (Anna's Archive)
|
||||
metadata_default_sort: string; // Default sort for universal mode (from metadata provider)
|
||||
}
|
||||
|
||||
// Authentication types
|
||||
|
||||
@@ -7,6 +7,7 @@ export type FieldType =
|
||||
| 'CheckboxField'
|
||||
| 'SelectField'
|
||||
| 'MultiSelectField'
|
||||
| 'OrderableListField'
|
||||
| 'ActionButton'
|
||||
| 'HeadingField';
|
||||
|
||||
@@ -82,6 +83,26 @@ export interface MultiSelectFieldConfig extends BaseField {
|
||||
options: SelectOption[];
|
||||
}
|
||||
|
||||
// OrderableListField types - generic drag-and-drop reorderable list
|
||||
export interface OrderableListItem {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface OrderableListOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabledReason?: string; // Explanation when item cannot be enabled
|
||||
isLocked?: boolean; // Item cannot be toggled (e.g., missing dependency)
|
||||
}
|
||||
|
||||
export interface OrderableListFieldConfig extends BaseField {
|
||||
type: 'OrderableListField';
|
||||
value: OrderableListItem[];
|
||||
options: OrderableListOption[];
|
||||
}
|
||||
|
||||
export interface ActionButtonConfig extends BaseField {
|
||||
type: 'ActionButton';
|
||||
style: 'default' | 'primary' | 'danger';
|
||||
@@ -94,6 +115,7 @@ export interface HeadingFieldConfig {
|
||||
description?: string;
|
||||
linkUrl?: string;
|
||||
linkText?: string;
|
||||
showWhen?: ShowWhenCondition; // Conditional visibility based on another field's value
|
||||
}
|
||||
|
||||
// Union type for all fields
|
||||
@@ -104,6 +126,7 @@ export type SettingsField =
|
||||
| CheckboxFieldConfig
|
||||
| SelectFieldConfig
|
||||
| MultiSelectFieldConfig
|
||||
| OrderableListFieldConfig
|
||||
| ActionButtonConfig
|
||||
| HeadingFieldConfig;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user