mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-04-20 05:51:21 -04:00
Feature: Manual search option (#687)
- Adds a toggle to advanced search fields to search sources manually instead of using metadata - Hidden for users when "Request Book" or "Blocked" default policy is in effect.
This commit is contained in:
@@ -2256,6 +2256,20 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
|
||||
language=direct_book.get("language"),
|
||||
source_url=direct_book.get("source_url"),
|
||||
)
|
||||
elif provider == "manual":
|
||||
resolved_title = title_param or manual_query or "Manual Search"
|
||||
resolved_author = author_param or ""
|
||||
authors = [a.strip() for a in resolved_author.split(",") if a.strip()]
|
||||
|
||||
book = BookMetadata(
|
||||
provider="manual",
|
||||
provider_id=book_id,
|
||||
provider_display_name="Manual Search",
|
||||
title=resolved_title,
|
||||
search_title=resolved_title,
|
||||
search_author=resolved_author or None,
|
||||
authors=authors,
|
||||
)
|
||||
else:
|
||||
if not is_provider_registered(provider):
|
||||
return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400
|
||||
|
||||
@@ -384,6 +384,7 @@ function App() {
|
||||
observer.observe(el);
|
||||
headerObserverRef.current = observer;
|
||||
}, []);
|
||||
const [isManualSearch, setIsManualSearch] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [selfSettingsOpen, setSelfSettingsOpen] = useState(false);
|
||||
const [configBannerOpen, setConfigBannerOpen] = useState(false);
|
||||
@@ -749,26 +750,32 @@ function App() {
|
||||
}, [getDefaultMode, contentType]);
|
||||
|
||||
const buildReleaseDownloadPayload = useCallback(
|
||||
(book: Book, release: Release, releaseContentType: ContentType): DownloadReleasePayload => ({
|
||||
source: release.source,
|
||||
source_id: release.source_id,
|
||||
title: book.title, // Use book metadata title, not release/torrent title
|
||||
author: book.author, // Pass author from metadata
|
||||
year: book.year, // Pass year from metadata
|
||||
format: release.format,
|
||||
size: release.size,
|
||||
size_bytes: release.size_bytes,
|
||||
download_url: release.download_url,
|
||||
protocol: release.protocol,
|
||||
indexer: release.indexer,
|
||||
seeders: release.seeders,
|
||||
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,
|
||||
}),
|
||||
(book: Book, release: Release, releaseContentType: ContentType): DownloadReleasePayload => {
|
||||
const isManual = book.provider === 'manual';
|
||||
const releasePreview = typeof release.extra?.preview === 'string' ? release.extra.preview : undefined;
|
||||
const releaseAuthor = typeof release.extra?.author === 'string' ? release.extra.author : undefined;
|
||||
|
||||
return {
|
||||
source: release.source,
|
||||
source_id: release.source_id,
|
||||
title: isManual ? release.title : book.title,
|
||||
author: isManual ? (releaseAuthor || '') : book.author,
|
||||
year: book.year,
|
||||
format: release.format,
|
||||
size: release.size,
|
||||
size_bytes: release.size_bytes,
|
||||
download_url: release.download_url,
|
||||
protocol: release.protocol,
|
||||
indexer: release.indexer,
|
||||
seeders: release.seeders,
|
||||
extra: release.extra,
|
||||
preview: isManual ? (releasePreview || undefined) : book.preview,
|
||||
content_type: releaseContentType,
|
||||
series_name: book.series_name,
|
||||
series_position: book.series_position,
|
||||
subtitle: book.subtitle,
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -1289,6 +1296,50 @@ function App() {
|
||||
runSearchWithPolicyRefresh(query, { ...searchFieldValues, series: seriesName });
|
||||
}, [setSearchInput, clearTracking, searchFieldValues, advancedFilters, setAdvancedFilters, bookLanguages, defaultLanguageCodes, searchMode, runSearchWithPolicyRefresh]);
|
||||
|
||||
const handleManualSearch = useCallback(() => {
|
||||
const trimmed = searchInput.trim();
|
||||
if (!trimmed) return;
|
||||
const manualId = `manual_${Date.now()}`;
|
||||
const syntheticBook: Book = {
|
||||
id: manualId,
|
||||
title: trimmed,
|
||||
author: '',
|
||||
provider: 'manual',
|
||||
provider_id: manualId,
|
||||
search_title: trimmed,
|
||||
};
|
||||
setReleaseBook(syntheticBook);
|
||||
}, [searchInput]);
|
||||
|
||||
// Manual search is only allowed when the default policy permits browsing releases
|
||||
const universalDefaultMode = getUniversalDefaultPolicyMode();
|
||||
const manualSearchAllowed = searchMode === 'universal'
|
||||
&& (universalDefaultMode === 'download' || universalDefaultMode === 'request_release');
|
||||
|
||||
// Reset manual search if policy changes to disallow it
|
||||
useEffect(() => {
|
||||
if (!manualSearchAllowed && isManualSearch) {
|
||||
setIsManualSearch(false);
|
||||
}
|
||||
}, [manualSearchAllowed, isManualSearch]);
|
||||
|
||||
// Unified search dispatch: intercepts manual search mode, otherwise runs normal search
|
||||
const handleSearchDispatch = useCallback(() => {
|
||||
if (isManualSearch) {
|
||||
handleManualSearch();
|
||||
return;
|
||||
}
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
});
|
||||
runSearchWithPolicyRefresh(query);
|
||||
}, [isManualSearch, handleManualSearch, searchInput, showAdvanced, advancedFilters, bookLanguages, defaultLanguageCodes, searchMode, runSearchWithPolicyRefresh]);
|
||||
|
||||
const isBrowseFulfilMode = fulfillingRequest !== null;
|
||||
const activeReleaseBook = fulfillingRequest?.book ?? releaseBook;
|
||||
const activeReleaseContentType = fulfillingRequest?.contentType ?? contentType;
|
||||
@@ -1343,27 +1394,18 @@ function App() {
|
||||
actingAsUser={actingAsUser}
|
||||
onActingAsUserChange={setActingAsUser}
|
||||
statusCounts={statusCounts}
|
||||
onLogoClick={() => handleResetSearch(config)}
|
||||
onLogoClick={() => { handleResetSearch(config); setIsManualSearch(false); }}
|
||||
authRequired={authRequired}
|
||||
isAuthenticated={isAuthenticated}
|
||||
onLogout={handleLogoutWithCleanup}
|
||||
onSearch={() => {
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
});
|
||||
runSearchWithPolicyRefresh(query);
|
||||
}}
|
||||
onSearch={handleSearchDispatch}
|
||||
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
|
||||
isLoading={isSearching}
|
||||
onShowToast={showToast}
|
||||
onRemoveToast={removeToast}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
isManualSearch={isManualSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1396,17 +1438,9 @@ function App() {
|
||||
metadataSearchFields={config?.metadata_search_fields}
|
||||
searchFieldValues={searchFieldValues}
|
||||
onSearchFieldChange={updateSearchFieldValue}
|
||||
onSubmit={() => {
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
});
|
||||
runSearchWithPolicyRefresh(query);
|
||||
}}
|
||||
onSubmit={handleSearchDispatch}
|
||||
isManualSearch={isManualSearch}
|
||||
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
|
||||
/>
|
||||
|
||||
<main
|
||||
@@ -1418,7 +1452,7 @@ function App() {
|
||||
}
|
||||
>
|
||||
<SearchSection
|
||||
onSearch={(query) => runSearchWithPolicyRefresh(query)}
|
||||
onSearch={() => handleSearchDispatch()}
|
||||
isLoading={isSearching}
|
||||
isInitialState={isInitialState}
|
||||
bookLanguages={bookLanguages}
|
||||
@@ -1436,6 +1470,8 @@ function App() {
|
||||
onSearchFieldChange={updateSearchFieldValue}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
isManualSearch={isManualSearch}
|
||||
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
|
||||
/>
|
||||
|
||||
<ResultsSection
|
||||
@@ -1493,9 +1529,9 @@ function App() {
|
||||
bookLanguages={bookLanguages}
|
||||
currentStatus={statusForButtonState}
|
||||
defaultReleaseSource={config?.default_release_source}
|
||||
onSearchSeries={isBrowseFulfilMode ? undefined : handleSearchSeries}
|
||||
defaultShowManualQuery={isBrowseFulfilMode}
|
||||
isRequestMode={isBrowseFulfilMode}
|
||||
onSearchSeries={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual' ? undefined : handleSearchSeries}
|
||||
defaultShowManualQuery={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
|
||||
isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import { LanguageMultiSelect } from './LanguageMultiSelect';
|
||||
import { DropdownList } from './DropdownList';
|
||||
import { CONTENT_OPTIONS } from '../data/filterOptions';
|
||||
import { SearchFieldRenderer } from './shared';
|
||||
import { SearchFieldRenderer, ToggleSwitch } from './shared';
|
||||
|
||||
const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr', 'zip', 'rar'] as const;
|
||||
|
||||
@@ -24,6 +24,9 @@ interface AdvancedFiltersProps {
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
|
||||
// Submit handler for Enter key
|
||||
onSubmit?: () => void;
|
||||
// Manual search mode (universal only)
|
||||
isManualSearch?: boolean;
|
||||
onManualSearchToggle?: () => void;
|
||||
}
|
||||
|
||||
export const AdvancedFilters = ({
|
||||
@@ -39,6 +42,8 @@ export const AdvancedFilters = ({
|
||||
searchFieldValues = {},
|
||||
onSearchFieldChange,
|
||||
onSubmit,
|
||||
isManualSearch = false,
|
||||
onManualSearchToggle,
|
||||
}: AdvancedFiltersProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
const { isbn, author, title, lang, content, formats } = filters;
|
||||
@@ -73,10 +78,28 @@ export const AdvancedFilters = ({
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// Universal search mode: render dynamic provider fields
|
||||
// Universal search mode: render dynamic provider fields + manual search toggle
|
||||
if (searchMode === 'universal') {
|
||||
// If no fields defined for this provider, don't show the section
|
||||
if (metadataSearchFields.length === 0) return null;
|
||||
const hasProviderFields = metadataSearchFields.length > 0;
|
||||
|
||||
// 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;
|
||||
|
||||
const universalForm = (
|
||||
<form
|
||||
@@ -86,7 +109,8 @@ export const AdvancedFilters = ({
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
|
||||
}
|
||||
>
|
||||
{metadataSearchFields.map((field) => (
|
||||
{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">
|
||||
|
||||
@@ -39,6 +39,7 @@ interface HeaderProps {
|
||||
onRemoveToast?: (id: string) => void;
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
isManualSearch?: boolean;
|
||||
}
|
||||
|
||||
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
@@ -69,6 +70,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
onRemoveToast,
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
isManualSearch = false,
|
||||
}, ref) => {
|
||||
const activityBadge = getActivityBadgeState(statusCounts, isAdmin);
|
||||
const settingsEnabled = canAccessSettings ?? isAdmin;
|
||||
@@ -652,6 +654,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
isLoading={isLoading}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
isManualSearch={isManualSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1289,46 +1289,50 @@ export const ReleaseModal = ({
|
||||
{/* Header */}
|
||||
<header className="flex items-start gap-3 border-b border-[var(--border-muted)] px-5 py-4">
|
||||
{/* Animated thumbnail that appears when scrolling */}
|
||||
<div
|
||||
className="flex-shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
|
||||
style={{
|
||||
width: showHeaderThumb ? 46 : 0,
|
||||
marginRight: showHeaderThumb ? 0 : -12,
|
||||
}}
|
||||
>
|
||||
{!isRequestMode && (
|
||||
<div
|
||||
className="transition-opacity duration-300 ease-out"
|
||||
style={{ opacity: showHeaderThumb ? 1 : 0 }}
|
||||
className="flex-shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
|
||||
style={{
|
||||
width: showHeaderThumb ? 46 : 0,
|
||||
marginRight: showHeaderThumb ? 0 : -12,
|
||||
}}
|
||||
>
|
||||
{book.preview ? (
|
||||
<img
|
||||
src={book.preview}
|
||||
alt=""
|
||||
width={46}
|
||||
height={68}
|
||||
className="rounded shadow-md object-cover object-top"
|
||||
style={{ width: 46, height: 68, minWidth: 46 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[7px] text-gray-500"
|
||||
style={{ width: 46, height: 68, minWidth: 46 }}
|
||||
>
|
||||
No cover
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="transition-opacity duration-300 ease-out"
|
||||
style={{ opacity: showHeaderThumb ? 1 : 0 }}
|
||||
>
|
||||
{book.preview ? (
|
||||
<img
|
||||
src={book.preview}
|
||||
alt=""
|
||||
width={46}
|
||||
height={68}
|
||||
className="rounded shadow-md object-cover object-top"
|
||||
style={{ width: 46, height: 68, minWidth: 46 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[7px] text-gray-500"
|
||||
style={{ width: 46, height: 68, minWidth: 46 }}
|
||||
>
|
||||
No cover
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 space-y-1 min-w-0">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Find Releases
|
||||
</p>
|
||||
<h3 id={titleId} className="text-lg font-semibold leading-snug truncate">
|
||||
{book.title || 'Untitled'}
|
||||
{book.provider === 'manual' ? 'Manual Query' : (book.title || 'Untitled')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 truncate">
|
||||
{book.author || 'Unknown author'}
|
||||
</p>
|
||||
{!isRequestMode && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 truncate">
|
||||
{book.author || 'Unknown author'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
@@ -1836,15 +1840,19 @@ export const ReleaseModal = ({
|
||||
const provider = book.provider;
|
||||
const bookId = book.provider_id;
|
||||
|
||||
// Clear cache + clear visible results so user gets feedback.
|
||||
invalidateCachedReleases(provider, bookId, activeTab, contentType);
|
||||
setExpandedBySource((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[activeTab];
|
||||
return next;
|
||||
// Clear cache + results for ALL tabs so tab switches re-fetch with the new query
|
||||
for (const tab of allTabs) {
|
||||
invalidateCachedReleases(provider, bookId, tab.name, contentType);
|
||||
}
|
||||
setExpandedBySource({});
|
||||
setErrorBySource({});
|
||||
// null for active tab triggers immediate re-fetch; omitting others
|
||||
// means they'll re-fetch when switched to
|
||||
setReleasesBySource((prev) => {
|
||||
const cleared: typeof prev = {};
|
||||
cleared[activeTab] = null;
|
||||
return cleared;
|
||||
});
|
||||
setErrorBySource((prev) => ({ ...prev, [activeTab]: null }));
|
||||
setReleasesBySource((prev) => ({ ...prev, [activeTab]: null }));
|
||||
|
||||
setLoadingBySource((prev) => ({ ...prev, [activeTab]: true }));
|
||||
try {
|
||||
|
||||
@@ -24,6 +24,8 @@ interface SearchBarProps {
|
||||
// Content type selector props
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
// Manual search mode
|
||||
isManualSearch?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchBarHandle {
|
||||
@@ -51,6 +53,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
enterKeyHint = 'search',
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
isManualSearch = false,
|
||||
}, ref) => {
|
||||
const { searchMode, isUniversalMode } = useSearchMode();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -62,10 +65,12 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange;
|
||||
|
||||
// Dynamic placeholder based on content type
|
||||
const effectivePlaceholder = showContentTypeSelector
|
||||
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
|
||||
: placeholder;
|
||||
// Dynamic placeholder based on content type and manual search
|
||||
const effectivePlaceholder = isManualSearch
|
||||
? 'Search releases directly...'
|
||||
: showContentTypeSelector
|
||||
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
|
||||
: placeholder;
|
||||
|
||||
// Close dropdown on click outside or escape
|
||||
useEffect(() => {
|
||||
|
||||
@@ -24,6 +24,9 @@ interface SearchSectionProps {
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
// Manual search mode (universal only)
|
||||
isManualSearch?: boolean;
|
||||
onManualSearchToggle?: () => void;
|
||||
}
|
||||
|
||||
export const SearchSection = ({
|
||||
@@ -45,6 +48,8 @@ export const SearchSection = ({
|
||||
onSearchFieldChange,
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
isManualSearch = false,
|
||||
onManualSearchToggle,
|
||||
}: SearchSectionProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
|
||||
@@ -86,6 +91,7 @@ export const SearchSection = ({
|
||||
onAdvancedToggle={onAdvancedToggle}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
isManualSearch={isManualSearch}
|
||||
/>
|
||||
<AdvancedFilters
|
||||
visible={showAdvanced}
|
||||
@@ -100,6 +106,8 @@ export const SearchSection = ({
|
||||
searchFieldValues={searchFieldValues}
|
||||
onSearchFieldChange={onSearchFieldChange}
|
||||
onSubmit={handleSearch}
|
||||
isManualSearch={isManualSearch}
|
||||
onManualSearchToggle={onManualSearchToggle}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CheckboxFieldConfig } from '../../../types/settings';
|
||||
import { ToggleSwitch } from '../../shared';
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
field: CheckboxFieldConfig;
|
||||
@@ -8,26 +9,7 @@ interface CheckboxFieldProps {
|
||||
}
|
||||
|
||||
export const CheckboxField = ({ field: _field, value, onChange, disabled }: CheckboxFieldProps) => {
|
||||
// disabled prop is already computed by SettingsContent.getDisabledState()
|
||||
const isDisabled = disabled ?? false;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
onClick={() => !isDisabled && onChange(!value)}
|
||||
disabled={isDisabled}
|
||||
className={`relative inline-flex h-6 w-11 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
|
||||
${value ? '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
|
||||
${value ? 'translate-x-6' : 'translate-x-1'}`}
|
||||
/>
|
||||
</button>
|
||||
<ToggleSwitch checked={value} onChange={onChange} disabled={disabled} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
OrderableListItem,
|
||||
OrderableListOption,
|
||||
} from '../../../types/settings';
|
||||
import { ToggleSwitch } from '../../shared';
|
||||
|
||||
interface OrderableListFieldProps {
|
||||
field: OrderableListFieldConfig;
|
||||
@@ -307,30 +308,13 @@ export const OrderableListField = ({
|
||||
</div>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={item.enabled && !item.isLocked}
|
||||
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
|
||||
${item.enabled && !item.isLocked ? '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
|
||||
${item.enabled && !item.isLocked ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
<div onClick={(e) => e.stopPropagation()} className="flex-shrink-0">
|
||||
<ToggleSwitch
|
||||
checked={item.enabled && !item.isLocked}
|
||||
onChange={() => toggleItem(index)}
|
||||
disabled={isItemDisabled}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
40
src/frontend/src/components/shared/ToggleSwitch.tsx
Normal file
40
src/frontend/src/components/shared/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
color?: 'sky' | 'emerald';
|
||||
}
|
||||
|
||||
const colorClasses = {
|
||||
sky: { active: 'bg-sky-600', ring: 'focus:ring-sky-500/50' },
|
||||
emerald: { active: 'bg-emerald-600', ring: 'focus:ring-emerald-500/50' },
|
||||
};
|
||||
|
||||
export const ToggleSwitch = ({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
color = 'sky',
|
||||
}: ToggleSwitchProps) => {
|
||||
const { active, ring } = colorClasses[color];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
disabled={disabled}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full
|
||||
transition-colors duration-200 focus:outline-none focus:ring-2
|
||||
${ring} disabled:opacity-60 disabled:cursor-not-allowed
|
||||
${checked ? active : '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
|
||||
${checked ? 'translate-x-6' : 'translate-x-1'}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { DisplayFieldIcon, DisplayFieldBadge, DisplayFieldBadges } from './DisplayFieldIcon';
|
||||
export { CircularProgress } from './CircularProgress';
|
||||
export { SearchFieldRenderer } from './SearchFieldRenderer';
|
||||
export { ToggleSwitch } from './ToggleSwitch';
|
||||
|
||||
Reference in New Issue
Block a user