feat: condition improvements

- refactor cards into unified component with modes
- add placeholders to dropdown selects
- style autocomplete similar to other ui components
- add placeholders to number inputs
- show any in language conditions
- add boolean for except langauge
This commit is contained in:
Sam Chau
2026-01-22 15:17:18 +10:30
parent 4efefe63ca
commit 97c21b9572
11 changed files with 384 additions and 690 deletions

View File

@@ -16,6 +16,8 @@
export let fullWidth: boolean = false;
// Optional href - renders as anchor instead of button
export let href: string | undefined = undefined;
// Alignment for content (center or between for dropdowns)
export let justify: 'center' | 'between' = 'center';
let isSmallScreen = false;
let mediaQuery: MediaQueryList | null = null;
@@ -38,8 +40,9 @@
isSmallScreen = e.matches;
}
const baseClasses =
'inline-flex items-center justify-center font-medium transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-50';
$: justifyClass = justify === 'between' ? 'justify-between' : 'justify-center';
$: baseClasses = `inline-flex items-center ${justifyClass} font-medium transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-50`;
const sizeClasses = {
xs: 'gap-1 rounded px-2 py-1 text-xs',

View File

@@ -9,6 +9,7 @@
export let label: string | undefined = undefined;
export let value: string;
export let options: { value: string; label: string; description?: string }[];
export let placeholder: string = 'Select...';
export let minWidth: string = '8rem';
export let position: 'left' | 'right' | 'middle' = 'left';
// Separate compact controls - compact is shorthand for both
@@ -22,6 +23,8 @@
export let fullWidth: boolean = false;
// Fixed positioning to escape overflow containers (e.g. tables)
export let fixed: boolean = false;
// Custom width class (overrides fullWidth if set)
export let width: string | undefined = undefined;
const dispatch = createEventDispatcher<{ change: string }>();
@@ -48,7 +51,9 @@
isSmallScreen = e.matches;
}
$: currentLabel = options.find((o) => o.value === value)?.label ?? value;
$: matchedOption = options.find((o) => o.value === value);
$: currentLabel = matchedOption?.label || placeholder;
$: isPlaceholder = !matchedOption;
$: isCompactButton = compactButton ?? (responsiveButton ? isSmallScreen : compact);
$: isCompactDropdown =
compactDropdown !== undefined
@@ -67,7 +72,7 @@
}
</script>
<div class="flex items-center gap-2" class:w-full={fullWidth}>
<div class="flex items-center gap-2 {width ?? ''}" class:w-full={fullWidth && !width}>
{#if label}
<span class={labelClasses}>{label}</span>
{/if}
@@ -83,6 +88,8 @@
iconPosition="right"
size={buttonSize}
{fullWidth}
justify={fullWidth || width ? 'between' : 'center'}
textColor={isPlaceholder ? 'text-neutral-400 dark:text-neutral-500' : ''}
on:click={() => (open = !open)}
/>
{#if open}

View File

@@ -1,31 +1,40 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { clickOutside } from '$lib/client/utils/clickOutside';
import { ChevronDown, Search } from 'lucide-svelte';
import Button from '$ui/button/Button.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import DropdownItem from '$ui/dropdown/DropdownItem.svelte';
type Option = {
value: string;
label: string;
[key: string]: unknown;
};
export let options: Option[] = [];
export let selected: Option[] = [];
export let max: number = 1;
export let placeholder: string = 'Type to search...';
export let placeholder: string = 'Select...';
export let mono: boolean = false;
export let customItems: boolean = false;
export let width: string = 'w-full';
export let fullWidth: boolean = false;
const dispatch = createEventDispatcher<{
change: Option[];
}>();
let inputValue = '';
let searchValue = '';
let isOpen = false;
let highlightedIndex = 0;
let inputElement: HTMLInputElement;
let searchInputElement: HTMLInputElement;
let triggerEl: HTMLElement;
// Filter options based on input and exclude already selected
// Filter options based on search and exclude already selected
$: filteredOptions = options.filter(
(opt) =>
opt.label.toLowerCase().includes(inputValue.toLowerCase()) &&
opt.label.toLowerCase().includes(searchValue.toLowerCase()) &&
!selected.some((s) => s.value === opt.value)
);
@@ -34,31 +43,26 @@
highlightedIndex = Math.min(highlightedIndex, filteredOptions.length - 1);
}
function handleInput() {
isOpen = true;
highlightedIndex = 0;
// Current display label
$: currentLabel = selected.length > 0 ? selected.map((s) => s.label).join(', ') : placeholder;
function toggleOpen() {
isOpen = !isOpen;
if (isOpen) {
searchValue = '';
highlightedIndex = 0;
// Focus search input after dropdown opens
setTimeout(() => searchInputElement?.focus(), 0);
}
}
function handleFocus() {
isOpen = true;
}
function handleBlur(event: FocusEvent) {
// Delay closing to allow click on option
setTimeout(() => {
isOpen = false;
inputValue = '';
}, 150);
function close() {
isOpen = false;
searchValue = '';
}
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) {
if (event.key === 'ArrowDown' || event.key === 'Enter') {
isOpen = true;
event.preventDefault();
}
return;
}
if (!isOpen) return;
switch (event.key) {
case 'ArrowDown':
@@ -77,105 +81,93 @@
break;
case 'Escape':
event.preventDefault();
isOpen = false;
inputValue = '';
break;
case 'Backspace':
if (inputValue === '' && selected.length > 0) {
removeOption(selected[selected.length - 1]);
}
close();
break;
}
}
function selectOption(option: Option) {
if (selected.length >= max) {
// Replace the last one if at max
selected = max === 1 ? [option] : [...selected.slice(0, -1), option];
} else {
selected = [...selected, option];
}
dispatch('change', selected);
inputValue = '';
isOpen = false;
inputElement?.focus();
}
function removeOption(option: Option) {
selected = selected.filter((s) => s.value !== option.value);
dispatch('change', selected);
inputElement?.focus();
close();
}
</script>
<div class="relative">
<!-- Input container with selected tags -->
<div
class="flex min-h-[2.25rem] flex-wrap items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-2 py-1.5 dark:border-neutral-700 dark:bg-neutral-800"
>
<!-- Selected items as tags -->
{#each selected as item (item.value)}
<div
class="flex items-center gap-1 rounded bg-accent-100 px-2 py-0.5 text-sm text-accent-800 dark:bg-accent-900/30 dark:text-accent-300"
>
<span class={mono ? 'font-mono' : ''}>{item.label}</span>
<button
type="button"
on:click={() => removeOption(item)}
class="cursor-pointer hover:text-accent-900 dark:hover:text-accent-100"
aria-label="Remove {item.label}"
>
<X size={14} />
</button>
</div>
{/each}
<div
class="flex items-center gap-2 {width}"
class:w-full={fullWidth && !width}
bind:this={triggerEl}
use:clickOutside={close}
>
<div class="relative" class:flex-1={fullWidth} class:w-full={!fullWidth}>
<Button
text={currentLabel}
icon={ChevronDown}
iconPosition="right"
size="sm"
fullWidth
justify="between"
on:click={toggleOpen}
/>
{#if isOpen}
<Dropdown position="left" minWidth="100%" {triggerEl}>
<!-- Search input -->
<div class="border-b border-neutral-200 p-2 dark:border-neutral-700">
<div
class="flex items-center gap-2 rounded border border-neutral-300 bg-white px-2 py-1.5 dark:border-neutral-600 dark:bg-neutral-900"
>
<Search size={14} class="text-neutral-400 dark:text-neutral-500" />
<input
bind:this={searchInputElement}
type="text"
bind:value={searchValue}
on:keydown={handleKeydown}
placeholder="Search..."
class="w-full border-0 bg-transparent text-sm text-neutral-900 outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-500 {mono
? 'font-mono'
: ''}"
/>
</div>
</div>
<!-- Input field -->
{#if selected.length < max}
<input
bind:this={inputElement}
type="text"
bind:value={inputValue}
on:input={handleInput}
on:focus={handleFocus}
on:blur={handleBlur}
on:keydown={handleKeydown}
{placeholder}
class="min-w-[120px] flex-1 border-0 bg-transparent text-sm text-neutral-900 outline-none placeholder:text-neutral-400 focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-500 {mono
? 'font-mono'
: ''}"
/>
<!-- Options list -->
<div class="max-h-80 overflow-auto">
{#if filteredOptions.length > 0}
{#each filteredOptions as option, index (option.value)}
{#if customItems}
<button
type="button"
on:mousedown|preventDefault={() => selectOption(option)}
on:mouseenter={() => (highlightedIndex = index)}
class="flex w-full items-center border-b border-neutral-200 px-3 py-2 text-left text-sm transition-colors last:border-b-0 dark:border-neutral-700 {mono
? 'font-mono'
: ''} {highlightedIndex === index
? 'bg-neutral-100 dark:bg-neutral-700'
: 'text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700'}"
>
<slot name="item" {option} highlighted={highlightedIndex === index}>
{option.label}
</slot>
</button>
{:else}
<DropdownItem
label={option.label}
selected={false}
on:click={() => selectOption(option)}
/>
{/if}
{/each}
{:else}
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
No matches found
</div>
{/if}
</div>
</Dropdown>
{/if}
</div>
<!-- Dropdown -->
{#if isOpen && filteredOptions.length > 0}
<div
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
{#each filteredOptions as option, index (option.value)}
<button
type="button"
on:mousedown|preventDefault={() => selectOption(option)}
on:mouseenter={() => (highlightedIndex = index)}
class="w-full px-3 py-2 text-left text-sm transition-colors {mono
? 'font-mono'
: ''} {highlightedIndex === index
? 'bg-accent-100 text-accent-900 dark:bg-accent-900/30 dark:text-accent-100'
: 'text-neutral-900 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700'}"
>
{option.label}
</button>
{/each}
</div>
{/if}
<!-- No results message -->
{#if isOpen && inputValue && filteredOptions.length === 0}
<div
class="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-500 shadow-lg dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400"
>
No matches found
</div>
{/if}
</div>

View File

@@ -9,6 +9,8 @@
export let compact: boolean = false;
// Responsive: auto-switch to compact on smaller screens (< 1280px)
export let responsive: boolean = false;
// Error state - shows red border
export let error: boolean = false;
const dispatch = createEventDispatcher<{ input: string }>();
@@ -51,7 +53,7 @@
{placeholder}
{disabled}
on:input={handleInput}
class="{width} border border-neutral-300 bg-white text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-500 {sizeClasses} {disabled
? 'cursor-not-allowed opacity-50'
: ''}"
class="{width} border bg-white text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-500 {sizeClasses} {error
? 'border-red-400 bg-red-50 dark:border-red-500 dark:bg-red-900/20'
: 'border-neutral-300 dark:border-neutral-700'} {disabled ? 'cursor-not-allowed opacity-50' : ''}"
/>

View File

@@ -2,17 +2,18 @@
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { ChevronUp, ChevronDown } from 'lucide-svelte';
const dispatch = createEventDispatcher<{ change: number }>();
const dispatch = createEventDispatcher<{ change: number | undefined }>();
// Props
export let name: string;
export let id: string = name;
export let value: number;
export let value: number | undefined = undefined;
export let min: number | undefined = undefined;
export let max: number | undefined = undefined;
export let step: number = 1;
export let required: boolean = false;
export let disabled: boolean = false;
export let placeholder: string = '';
export let font: 'mono' | 'sans' | undefined = undefined;
export let compact: boolean = false;
// Responsive: auto-switch to compact on smaller screens (< 1280px)
@@ -58,28 +59,38 @@
// Increment/decrement handlers
function increment() {
if (max !== undefined && value >= max) {
const currentValue = value ?? min ?? 0;
if (max !== undefined && currentValue >= max) {
onMaxBlocked?.();
return;
}
updateValue(value + step);
updateValue(currentValue + step);
}
function decrement() {
if (min !== undefined && value <= min) {
const currentValue = value ?? min ?? 0;
if (min !== undefined && currentValue <= min) {
onMinBlocked?.();
return;
}
updateValue(value - step);
updateValue(currentValue - step);
}
// Validate on input
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
let newValue = parseInt(target.value);
// Allow clearing the input (show placeholder)
if (target.value === '') {
value = undefined;
dispatch('change', undefined);
return;
}
let newValue = parseFloat(target.value);
if (isNaN(newValue)) {
newValue = min ?? 0;
return; // Don't update for invalid input
}
if (min !== undefined && newValue < min) {
@@ -106,6 +117,7 @@
{step}
{required}
{disabled}
{placeholder}
class="block w-full [appearance:textfield] border border-neutral-300 bg-white text-neutral-900 placeholder-neutral-400 focus:outline-none disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none {inputSizeClasses} {fontClass}"
/>

View File

@@ -11,6 +11,7 @@
export let value: string = '';
export let placeholder: string = 'Select...';
export let mono: boolean = false;
export let width: string = 'w-full';
const dispatch = createEventDispatcher<{
change: string;
@@ -77,7 +78,7 @@
}
</script>
<div class="relative" bind:this={containerElement}>
<div class="relative {width}" bind:this={containerElement}>
<!-- Trigger button -->
<button
type="button"

View File

@@ -525,9 +525,6 @@ export function getLanguagesWithSupport(): LanguageWithSupport[] {
// Build result with support flags
const result: LanguageWithSupport[] = [];
for (const name of allNames) {
// Skip "Any" - it's only for quality profiles, not conditions
if (name === 'Any') continue;
result.push({
name,
radarr: radarrLangs.has(name),
@@ -535,8 +532,10 @@ export function getLanguagesWithSupport(): LanguageWithSupport[] {
});
}
// Sort: Original first, then alphabetically
// Sort: Any first, Original second, then alphabetically
return result.sort((a, b) => {
if (a.name === 'Any') return -1;
if (b.name === 'Any') return 1;
if (a.name === 'Original') return -1;
if (b.name === 'Original') return 1;
return a.name.localeCompare(b.name);

View File

@@ -4,7 +4,7 @@ import { pcdManager } from '$pcd/pcd.ts';
import { canWriteToBase } from '$pcd/writer.ts';
import * as customFormatQueries from '$pcd/queries/customFormats/index.ts';
import * as regularExpressionQueries from '$pcd/queries/regularExpressions/index.ts';
import * as languageQueries from '$pcd/queries/languages.ts';
import { getLanguagesWithSupport } from '$lib/server/sync/mappings.ts';
import type { OperationLayer } from '$pcd/writer.ts';
import type { ConditionData } from '$pcd/queries/customFormats/index.ts';
@@ -47,18 +47,20 @@ export const load: ServerLoad = async ({ params }) => {
}
// Get conditions and available options
const [conditions, patterns, languages] = await Promise.all([
const [conditions, patterns] = await Promise.all([
customFormatQueries.getConditionsForEvaluation(cache, format.name),
regularExpressionQueries.list(cache),
languageQueries.list(cache)
regularExpressionQueries.list(cache)
]);
// Get languages with arr type support from static mappings
const availableLanguages = getLanguagesWithSupport();
return {
currentDatabase,
format,
conditions,
availablePatterns: patterns.map((p) => ({ id: p.id, name: p.name, pattern: p.pattern })),
availableLanguages: languages,
availableLanguages,
canWriteToBase: canWriteToBase(currentDatabaseId)
};
};

View File

@@ -3,7 +3,6 @@
import { tick } from 'svelte';
import { Plus, Save, Loader2, AlertTriangle } from 'lucide-svelte';
import ConditionCard from './components/ConditionCard.svelte';
import DraftConditionCard from './components/DraftConditionCard.svelte';
import Badge from '$ui/badge/Badge.svelte';
import Button from '$ui/button/Button.svelte';
import StickyCard from '$ui/card/StickyCard.svelte';
@@ -240,17 +239,18 @@
</svelte:fragment>
</StickyCard>
<div class="mt-6 space-y-6 px-4 pb-12">
<div class="mt-6 space-y-6 pb-12">
<!-- Draft conditions -->
{#if draftConditions.length > 0}
<div class="space-y-2">
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 px-3">
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-400">Drafts</span>
<Badge variant="neutral" size="sm">{draftConditions.length}</Badge>
</div>
{#each draftConditions as draft (draft._key)}
<DraftConditionCard
<ConditionCard
mode="draft"
condition={draft}
availablePatterns={data.availablePatterns}
availableLanguages={data.availableLanguages}
@@ -264,9 +264,13 @@
<!-- Existing conditions sorted by status, type, then name -->
{#if conditions.length === 0 && draftConditions.length === 0}
<p class="text-sm text-neutral-500 dark:text-neutral-400">No conditions defined</p>
{:else}
<p class="px-3 text-sm text-neutral-500 dark:text-neutral-400">No conditions defined</p>
{:else if conditions.length > 0}
<div class="space-y-2">
<div class="flex items-center gap-2 px-3">
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-400">Conditions</span>
<Badge variant="neutral" size="sm">{conditions.length}</Badge>
</div>
{#each sortedConditions as condition (condition._key)}
<ConditionCard
{condition}
@@ -274,6 +278,7 @@
availableLanguages={data.availableLanguages}
invalid={!isConditionValid(condition)}
nameConflict={hasNameConflict(condition)}
{hasDrafts}
on:remove={() => handleRemove(condition._key)}
on:change={(e) => handleConditionChange(e.detail, condition._key)}
/>

View File

@@ -3,7 +3,10 @@
import { createEventDispatcher } from 'svelte';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import Autocomplete from '$ui/form/Autocomplete.svelte';
import Select from '$ui/form/Select.svelte';
import Input from '$ui/form/Input.svelte';
import NumberInput from '$ui/form/NumberInput.svelte';
import DropdownSelect from '$ui/dropdown/DropdownSelect.svelte';
import Badge from '$ui/badge/Badge.svelte';
import {
CONDITION_TYPES,
PATTERN_TYPES,
@@ -18,20 +21,25 @@
const dispatch = createEventDispatcher<{
remove: void;
confirm: ConditionData;
discard: void;
change: ConditionData;
}>();
// Mode: 'normal' for existing conditions, 'draft' for new unsaved conditions
export let mode: 'normal' | 'draft' = 'normal';
export let condition: ConditionData;
export let invalid = false;
export let nameConflict = false;
// Combined error state for styling
$: hasError = invalid || nameConflict;
export let hasDrafts = false;
// Available patterns and languages from database (passed in)
export let availablePatterns: { id: number; name: string; pattern: string }[] = [];
export let availableLanguages: { id: number; name: string }[] = [];
// Note: availablePatterns/Languages still have id from DB, but ConditionData uses name for FK stability
export let availableLanguages: { name: string; radarr: boolean; sonarr: boolean }[] = [];
// Computed states based on mode
$: isDraft = mode === 'draft';
$: rightPadding = isDraft ? 'pr-14' : hasDrafts ? 'pr-14' : 'pr-3';
// Helper to emit changes - creates new object to maintain immutability
function emitChange(updates: Partial<ConditionData>) {
@@ -48,13 +56,13 @@
if (r && s) return 'all';
if (r) return 'radarr';
if (s) return 'sonarr';
return 'all'; // Default to 'all' if neither is checked
return 'all';
}
// All condition types (no arrType filtering)
// All condition types
$: filteredConditionTypes = [...CONDITION_TYPES];
// Get value options based on current type (no arrType filtering - show all options)
// Get value options based on current type
$: valueOptions = getValueOptions(condition.type);
function getValueOptions(type: string) {
@@ -77,7 +85,7 @@
// Check if type is pattern-based
$: isPatternType = PATTERN_TYPES.includes(condition.type as (typeof PATTERN_TYPES)[number]);
// Autocomplete options for patterns (use name as value for FK stability)
// Autocomplete options for patterns
$: patternOptions = availablePatterns.map((p) => ({ value: p.name, label: p.name }));
// Currently selected pattern for Autocomplete
@@ -137,45 +145,48 @@
case 'indexer_flag':
emitChange({ indexerFlags: value ? [value] : [] });
break;
case 'language':
const lang = availableLanguages.find((l) => l.name === value);
emitChange({ languages: lang ? [{ name: lang.name, except: false }] : [] });
break;
}
}
// Language options for Autocomplete (use name as value for FK stability)
$: languageOptions = availableLanguages
.map((l) => ({ value: l.name, label: l.name }))
.sort((a, b) => {
if (a.label === 'Any') return -1;
if (b.label === 'Any') return 1;
if (a.label === 'Original') return -1;
if (b.label === 'Original') return 1;
return a.label.localeCompare(b.label);
});
// Language options for Autocomplete
$: languageOptions = availableLanguages.map((l) => ({
value: l.name,
label: l.name,
radarr: l.radarr,
sonarr: l.sonarr
}));
// Currently selected language for Autocomplete
$: selectedLanguages = condition.languages
? condition.languages.map((l) => ({ value: l.name, label: l.name }))
: [];
// Language except state
$: hasLanguage = (condition.languages?.length ?? 0) > 0;
$: languageExcept = condition.languages?.[0]?.except ?? false;
function handleLanguageChange(event: CustomEvent<{ value: string; label: string }[]>) {
const selected = event.detail;
if (selected.length > 0) {
const langName = selected[0].value;
const lang = availableLanguages.find((l) => l.name === langName);
emitChange({ languages: lang ? [{ name: lang.name, except: false }] : [] });
emitChange({ languages: [{ name: langName, except: languageExcept }] });
} else {
emitChange({ languages: [] });
}
}
function toggleLanguageExcept() {
if (hasLanguage) {
emitChange({
languages: [{ ...condition.languages![0], except: !languageExcept }]
});
}
}
// Handle type change - reset values
function handleTypeChange(newType: string) {
emitChange({
type: newType,
// Reset all value fields
patterns: undefined,
languages: undefined,
sources: undefined,
@@ -192,84 +203,79 @@
$: typeOptions = filteredConditionTypes.map((t) => ({ value: t.value, label: t.label }));
// Size helpers (convert between bytes and GB for display)
$: minSizeGB = condition.size?.minBytes ? condition.size.minBytes / 1024 / 1024 / 1024 : null;
$: maxSizeGB = condition.size?.maxBytes ? condition.size.maxBytes / 1024 / 1024 / 1024 : null;
$: minSizeGB = condition.size?.minBytes
? condition.size.minBytes / 1024 / 1024 / 1024
: undefined;
$: maxSizeGB = condition.size?.maxBytes
? condition.size.maxBytes / 1024 / 1024 / 1024
: undefined;
function handleMinSizeChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
function handleMinSizeChange(value: number | undefined) {
const currentSize = condition.size ?? { minBytes: null, maxBytes: null };
emitChange({
size: {
...currentSize,
minBytes: isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024)
minBytes: value == null ? null : Math.round(value * 1024 * 1024 * 1024)
}
});
}
function handleMaxSizeChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
function handleMaxSizeChange(value: number | undefined) {
const currentSize = condition.size ?? { minBytes: null, maxBytes: null };
emitChange({
size: {
...currentSize,
maxBytes: isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024)
maxBytes: value == null ? null : Math.round(value * 1024 * 1024 * 1024)
}
});
}
function handleMinYearChange(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value);
// Year helpers
$: minYear = condition.years?.minYear ?? undefined;
$: maxYear = condition.years?.maxYear ?? undefined;
function handleMinYearChange(value: number | undefined) {
const currentYears = condition.years ?? { minYear: null, maxYear: null };
emitChange({
years: {
...currentYears,
minYear: isNaN(value) ? null : value
minYear: value ?? null
}
});
}
function handleMaxYearChange(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value);
function handleMaxYearChange(value: number | undefined) {
const currentYears = condition.years ?? { minYear: null, maxYear: null };
emitChange({
years: {
...currentYears,
maxYear: isNaN(value) ? null : value
maxYear: value ?? null
}
});
}
function handleNameChange(event: Event) {
emitChange({ name: (event.target as HTMLInputElement).value });
}
const inputClass =
'w-full rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm font-mono text-neutral-900 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100';
</script>
<div
class="flex items-center gap-3 rounded-lg border px-3 py-2 {invalid
? 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/20'
: 'border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800'}"
>
<div class="relative flex items-center gap-3 py-2 pl-3 {rightPadding}">
<!-- Name -->
<div class="w-48 shrink-0" title={nameConflict ? 'Duplicate condition name' : ''}>
<input
type="text"
<Input
value={condition.name}
on:input={handleNameChange}
placeholder="Condition name"
class="{inputClass} {nameConflict ? 'text-red-500 dark:text-red-400' : ''}"
width="w-full"
error={invalid && !isDraft}
on:input={(e) => emitChange({ name: e.detail })}
/>
</div>
<!-- Type dropdown -->
<div class="w-44 shrink-0">
<Select
<div class="w-52 shrink-0">
<DropdownSelect
options={typeOptions}
value={condition.type}
placeholder="Select type..."
mono
fullWidth
on:change={(e) => handleTypeChange(e.detail)}
/>
</div>
@@ -281,121 +287,190 @@
options={patternOptions}
selected={selectedPatterns}
max={1}
placeholder="Search patterns..."
placeholder="Select pattern..."
mono
on:change={handlePatternChange}
/>
{:else if condition.type === 'language'}
<Autocomplete
options={languageOptions}
selected={selectedLanguages}
max={1}
placeholder="Search languages..."
mono
on:change={handleLanguageChange}
/>
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<Autocomplete
options={languageOptions}
selected={selectedLanguages}
max={1}
placeholder="Select language..."
mono
customItems
fullWidth
on:change={handleLanguageChange}
>
<svelte:fragment slot="item" let:option>
<span class="flex items-center justify-between w-full">
<span>{option.label}</span>
<span class="flex gap-1">
{#if option.radarr}
<Badge variant="warning" size="sm">Radarr</Badge>
{/if}
{#if option.sonarr}
<Badge variant="info" size="sm">Sonarr</Badge>
{/if}
</span>
</span>
</svelte:fragment>
</Autocomplete>
</div>
<button
type="button"
class="relative z-10 flex shrink-0 items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-left transition-colors dark:border-neutral-600 dark:bg-neutral-800 {hasLanguage
? 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-700'
: 'cursor-not-allowed opacity-50'}"
disabled={!hasLanguage}
on:click={toggleLanguageExcept}
>
<span class="text-sm text-neutral-700 dark:text-neutral-300">Except</span>
<IconCheckbox icon={X} checked={languageExcept} color="red" shape="rounded" />
</button>
</div>
{:else if condition.type === 'size'}
<div class="flex items-center gap-2">
<input
type="number"
step="0.1"
placeholder="Min"
value={minSizeGB ?? ''}
on:change={handleMinSizeChange}
class="{inputClass} w-20 font-mono"
/>
<div class="flex-1">
<NumberInput
name="minSize"
value={minSizeGB}
min={0}
step={1}
font="mono"
placeholder="Min GB"
on:change={(e) => handleMinSizeChange(e.detail)}
/>
</div>
<span class="text-sm text-neutral-500">-</span>
<input
type="number"
step="0.1"
placeholder="Max"
value={maxSizeGB ?? ''}
on:change={handleMaxSizeChange}
class="{inputClass} w-20 font-mono"
/>
<span class="text-sm text-neutral-500 dark:text-neutral-400">GB</span>
<div class="flex-1">
<NumberInput
name="maxSize"
value={maxSizeGB}
min={0}
step={1}
font="mono"
placeholder="Max GB"
on:change={(e) => handleMaxSizeChange(e.detail)}
/>
</div>
</div>
{:else if condition.type === 'year'}
<div class="flex items-center gap-2">
<input
type="number"
placeholder="Min"
value={condition.years?.minYear ?? ''}
on:change={handleMinYearChange}
class="{inputClass} w-20 font-mono"
/>
<div class="flex-1">
<NumberInput
name="minYear"
value={minYear}
min={1900}
max={2100}
step={1}
font="mono"
placeholder="Min Year"
on:change={(e) => handleMinYearChange(e.detail)}
/>
</div>
<span class="text-sm text-neutral-500">-</span>
<input
type="number"
placeholder="Max"
value={condition.years?.maxYear ?? ''}
on:change={handleMaxYearChange}
class="{inputClass} w-20 font-mono"
/>
<div class="flex-1">
<NumberInput
name="maxYear"
value={maxYear}
min={1900}
max={2100}
step={1}
font="mono"
placeholder="Max Year"
on:change={(e) => handleMaxYearChange(e.detail)}
/>
</div>
</div>
{:else}
<!-- Enum-based types -->
<Select
<DropdownSelect
options={valueOptions}
value={selectedValue}
placeholder="Select value..."
mono
fullWidth
on:change={(e) => handleSelectChange(e.detail)}
/>
{/if}
</div>
<!-- Negate -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={X}
checked={condition.negate}
color="red"
<!-- Controls - right aligned -->
<div class="ml-auto flex shrink-0 items-center gap-2">
<!-- Negate -->
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-left transition-colors hover:bg-neutral-50 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700"
on:click={() => emitChange({ negate: !condition.negate })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Negate</span>
</div>
>
<span class="text-sm text-neutral-700 dark:text-neutral-300">Negate</span>
<IconCheckbox icon={X} checked={condition.negate} color="red" shape="rounded" />
</button>
<!-- Required -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={Check}
checked={condition.required}
color="green"
<!-- Required -->
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-left transition-colors hover:bg-neutral-50 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700"
on:click={() => emitChange({ required: !condition.required })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Required</span>
</div>
>
<span class="text-sm text-neutral-700 dark:text-neutral-300">Required</span>
<IconCheckbox icon={Check} checked={condition.required} color="green" shape="rounded" />
</button>
<!-- Radarr -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={Check}
checked={radarrEnabled}
color={ARR_COLORS.radarr}
<!-- Radarr -->
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-left transition-colors hover:bg-neutral-50 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700"
on:click={() => emitChange({ arrType: getArrType(!radarrEnabled, sonarrEnabled) })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Radarr</span>
</div>
>
<span class="text-sm text-neutral-700 dark:text-neutral-300">Radarr</span>
<IconCheckbox icon={Check} checked={radarrEnabled} color={ARR_COLORS.radarr} shape="rounded" />
</button>
<!-- Sonarr -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={Check}
checked={sonarrEnabled}
color={ARR_COLORS.sonarr}
<!-- Sonarr -->
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-left transition-colors hover:bg-neutral-50 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700"
on:click={() => emitChange({ arrType: getArrType(radarrEnabled, !sonarrEnabled) })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Sonarr</span>
>
<span class="text-sm text-neutral-700 dark:text-neutral-300">Sonarr</span>
<IconCheckbox icon={Check} checked={sonarrEnabled} color={ARR_COLORS.sonarr} shape="rounded" />
</button>
<!-- Action buttons based on mode -->
{#if isDraft}
<!-- Discard -->
<button
type="button"
on:click={() => dispatch('discard')}
class="flex cursor-pointer items-center rounded-lg border border-neutral-300 bg-white p-2 transition-colors hover:border-red-300 hover:bg-red-50 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:border-red-500 dark:hover:bg-red-900/20"
title="Discard condition"
>
<Trash2 size={14} class="text-red-500 dark:text-red-400" />
</button>
{:else}
<!-- Remove -->
<button
type="button"
on:click={() => dispatch('remove')}
class="flex cursor-pointer items-center rounded-lg border border-neutral-300 bg-white p-2 transition-colors hover:border-red-300 hover:bg-red-50 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:border-red-500 dark:hover:bg-red-900/20"
title="Remove condition"
>
<Trash2 size={14} class="text-red-500 dark:text-red-400" />
</button>
{/if}
</div>
<!-- Remove -->
<button
type="button"
on:click={() => dispatch('remove')}
class="shrink-0 cursor-pointer p-1 text-neutral-400 transition-colors hover:text-red-500 dark:text-neutral-500 dark:hover:text-red-400"
title="Remove condition"
>
<Trash2 size={16} />
</button>
<!-- Confirm button for draft mode - positioned in right padding -->
{#if isDraft}
<button
type="button"
on:click={() => dispatch('confirm', condition)}
class="absolute right-3 top-1/2 -translate-y-1/2 flex cursor-pointer items-center rounded-lg border border-neutral-300 bg-white p-2 transition-colors hover:border-emerald-300 hover:bg-emerald-50 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:border-emerald-500 dark:hover:bg-emerald-900/20"
title="Confirm condition"
>
<Check size={14} class="text-emerald-500 dark:text-emerald-400" />
</button>
{/if}
</div>

View File

@@ -1,404 +0,0 @@
<script lang="ts">
import { Check, X, Trash2 } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import Autocomplete from '$ui/form/Autocomplete.svelte';
import Select from '$ui/form/Select.svelte';
import {
CONDITION_TYPES,
PATTERN_TYPES,
SOURCE_VALUES,
RESOLUTION_VALUES,
QUALITY_MODIFIER_VALUES,
RELEASE_TYPE_VALUES,
INDEXER_FLAG_VALUES,
type ArrType
} from '$lib/shared/conditionTypes';
import type { ConditionData } from '$pcd/queries/customFormats/index';
const dispatch = createEventDispatcher<{
confirm: ConditionData;
discard: void;
change: ConditionData;
}>();
export let condition: ConditionData;
// Available patterns and languages from database (passed in)
export let availablePatterns: { id: number; name: string; pattern: string }[] = [];
export let availableLanguages: { id: number; name: string }[] = [];
// Helper to emit changes - creates new object to maintain immutability
function emitChange(updates: Partial<ConditionData>) {
dispatch('change', { ...condition, ...updates });
}
// Arr type toggle colors and state
const ARR_COLORS = { radarr: '#FFC230', sonarr: '#00CCFF' };
$: radarrEnabled = condition.arrType === 'all' || condition.arrType === 'radarr';
$: sonarrEnabled = condition.arrType === 'all' || condition.arrType === 'sonarr';
function getArrType(r: boolean, s: boolean): 'all' | 'radarr' | 'sonarr' {
if (r && s) return 'all';
if (r) return 'radarr';
if (s) return 'sonarr';
return 'all'; // Default to 'all' if neither is checked
}
// All condition types (no arrType filtering)
$: filteredConditionTypes = [...CONDITION_TYPES];
// Get value options based on current type (no arrType filtering - show all options)
$: valueOptions = getValueOptions(condition.type);
function getValueOptions(type: string) {
switch (type) {
case 'source':
return [...SOURCE_VALUES];
case 'resolution':
return [...RESOLUTION_VALUES];
case 'quality_modifier':
return [...QUALITY_MODIFIER_VALUES];
case 'release_type':
return [...RELEASE_TYPE_VALUES];
case 'indexer_flag':
return [...INDEXER_FLAG_VALUES];
default:
return [];
}
}
// Check if type is pattern-based
$: isPatternType = PATTERN_TYPES.includes(condition.type as (typeof PATTERN_TYPES)[number]);
// Autocomplete options for patterns (use name as value for FK stability)
$: patternOptions = availablePatterns.map((p) => ({ value: p.name, label: p.name }));
// Currently selected pattern for Autocomplete
$: selectedPatterns = condition.patterns
? condition.patterns.map((p) => ({ value: p.name, label: p.name }))
: [];
function handlePatternChange(event: CustomEvent<{ value: string; label: string }[]>) {
const selected = event.detail;
if (selected.length > 0) {
const patternName = selected[0].value;
const pattern = availablePatterns.find((p) => p.name === patternName);
emitChange({ patterns: pattern ? [{ name: pattern.name, pattern: pattern.pattern }] : [] });
} else {
emitChange({ patterns: [] });
}
}
// Reactive selected value based on condition type
$: selectedValue = (() => {
if (isPatternType) {
return condition.patterns?.[0]?.name ?? '';
}
switch (condition.type) {
case 'source':
return condition.sources?.[0] ?? '';
case 'resolution':
return condition.resolutions?.[0] ?? '';
case 'quality_modifier':
return condition.qualityModifiers?.[0] ?? '';
case 'release_type':
return condition.releaseTypes?.[0] ?? '';
case 'indexer_flag':
return condition.indexerFlags?.[0] ?? '';
case 'language':
return condition.languages?.[0]?.name ?? '';
default:
return '';
}
})();
// Update value when Select changes
function handleSelectChange(value: string) {
switch (condition.type) {
case 'source':
emitChange({ sources: value ? [value] : [] });
break;
case 'resolution':
emitChange({ resolutions: value ? [value] : [] });
break;
case 'quality_modifier':
emitChange({ qualityModifiers: value ? [value] : [] });
break;
case 'release_type':
emitChange({ releaseTypes: value ? [value] : [] });
break;
case 'indexer_flag':
emitChange({ indexerFlags: value ? [value] : [] });
break;
case 'language':
const lang = availableLanguages.find((l) => l.name === value);
emitChange({ languages: lang ? [{ name: lang.name, except: false }] : [] });
break;
}
}
// Language options for Autocomplete (use name as value for FK stability)
$: languageOptions = availableLanguages
.map((l) => ({ value: l.name, label: l.name }))
.sort((a, b) => {
if (a.label === 'Any') return -1;
if (b.label === 'Any') return 1;
if (a.label === 'Original') return -1;
if (b.label === 'Original') return 1;
return a.label.localeCompare(b.label);
});
// Currently selected language for Autocomplete
$: selectedLanguages = condition.languages
? condition.languages.map((l) => ({ value: l.name, label: l.name }))
: [];
function handleLanguageChange(event: CustomEvent<{ value: string; label: string }[]>) {
const selected = event.detail;
if (selected.length > 0) {
const langName = selected[0].value;
const lang = availableLanguages.find((l) => l.name === langName);
emitChange({ languages: lang ? [{ name: lang.name, except: false }] : [] });
} else {
emitChange({ languages: [] });
}
}
// Handle type change - reset values
function handleTypeChange(newType: string) {
emitChange({
type: newType,
// Reset all value fields
patterns: undefined,
languages: undefined,
sources: undefined,
resolutions: undefined,
qualityModifiers: undefined,
releaseTypes: undefined,
indexerFlags: undefined,
size: undefined,
years: undefined
});
}
// Type options for Select
$: typeOptions = filteredConditionTypes.map((t) => ({ value: t.value, label: t.label }));
// Size helpers (convert between bytes and GB for display)
$: minSizeGB = condition.size?.minBytes ? condition.size.minBytes / 1024 / 1024 / 1024 : null;
$: maxSizeGB = condition.size?.maxBytes ? condition.size.maxBytes / 1024 / 1024 / 1024 : null;
function handleMinSizeChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
const currentSize = condition.size ?? { minBytes: null, maxBytes: null };
emitChange({
size: {
...currentSize,
minBytes: isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024)
}
});
}
function handleMaxSizeChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
const currentSize = condition.size ?? { minBytes: null, maxBytes: null };
emitChange({
size: {
...currentSize,
maxBytes: isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024)
}
});
}
function handleMinYearChange(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value);
const currentYears = condition.years ?? { minYear: null, maxYear: null };
emitChange({
years: {
...currentYears,
minYear: isNaN(value) ? null : value
}
});
}
function handleMaxYearChange(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value);
const currentYears = condition.years ?? { minYear: null, maxYear: null };
emitChange({
years: {
...currentYears,
maxYear: isNaN(value) ? null : value
}
});
}
function handleNameChange(event: Event) {
emitChange({ name: (event.target as HTMLInputElement).value });
}
const inputClass =
'w-full rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm font-mono text-neutral-900 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100';
</script>
<div
class="flex items-center gap-3 rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-800"
>
<!-- Name -->
<div class="w-48 shrink-0">
<input
type="text"
value={condition.name}
on:input={handleNameChange}
placeholder="Condition name"
class={inputClass}
/>
</div>
<!-- Type dropdown -->
<div class="w-44 shrink-0">
<Select
options={typeOptions}
value={condition.type}
placeholder="Select type..."
mono
on:change={(e) => handleTypeChange(e.detail)}
/>
</div>
<!-- Value input - changes based on type -->
<div class="min-w-0 flex-1">
{#if isPatternType}
<Autocomplete
options={patternOptions}
selected={selectedPatterns}
max={1}
placeholder="Search patterns..."
mono
on:change={handlePatternChange}
/>
{:else if condition.type === 'language'}
<Autocomplete
options={languageOptions}
selected={selectedLanguages}
max={1}
placeholder="Search languages..."
mono
on:change={handleLanguageChange}
/>
{:else if condition.type === 'size'}
<div class="flex items-center gap-2">
<input
type="number"
step="0.1"
placeholder="Min"
value={minSizeGB ?? ''}
on:change={handleMinSizeChange}
class="{inputClass} w-20 font-mono"
/>
<span class="text-sm text-neutral-500">-</span>
<input
type="number"
step="0.1"
placeholder="Max"
value={maxSizeGB ?? ''}
on:change={handleMaxSizeChange}
class="{inputClass} w-20 font-mono"
/>
<span class="text-sm text-neutral-500 dark:text-neutral-400">GB</span>
</div>
{:else if condition.type === 'year'}
<div class="flex items-center gap-2">
<input
type="number"
placeholder="Min"
value={condition.years?.minYear ?? ''}
on:change={handleMinYearChange}
class="{inputClass} w-20 font-mono"
/>
<span class="text-sm text-neutral-500">-</span>
<input
type="number"
placeholder="Max"
value={condition.years?.maxYear ?? ''}
on:change={handleMaxYearChange}
class="{inputClass} w-20 font-mono"
/>
</div>
{:else}
<!-- Enum-based types -->
<Select
options={valueOptions}
value={selectedValue}
placeholder="Select value..."
mono
on:change={(e) => handleSelectChange(e.detail)}
/>
{/if}
</div>
<!-- Negate -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={X}
checked={condition.negate}
color="red"
on:click={() => emitChange({ negate: !condition.negate })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Negate</span>
</div>
<!-- Required -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={Check}
checked={condition.required}
color="green"
on:click={() => emitChange({ required: !condition.required })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Required</span>
</div>
<!-- Radarr -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={Check}
checked={radarrEnabled}
color={ARR_COLORS.radarr}
on:click={() => emitChange({ arrType: getArrType(!radarrEnabled, sonarrEnabled) })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Radarr</span>
</div>
<!-- Sonarr -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={Check}
checked={sonarrEnabled}
color={ARR_COLORS.sonarr}
on:click={() => emitChange({ arrType: getArrType(radarrEnabled, !sonarrEnabled) })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Sonarr</span>
</div>
<!-- Confirm -->
<button
type="button"
on:click={() => dispatch('confirm', condition)}
class="shrink-0 cursor-pointer p-1 text-neutral-400 transition-colors hover:text-emerald-500 dark:text-neutral-500 dark:hover:text-emerald-400"
title="Confirm condition"
>
<Check size={16} />
</button>
<!-- Discard -->
<button
type="button"
on:click={() => dispatch('discard')}
class="shrink-0 cursor-pointer p-1 text-neutral-400 transition-colors hover:text-red-500 dark:text-neutral-500 dark:hover:text-red-400"
title="Discard condition"
>
<Trash2 size={16} />
</button>
</div>