mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' : ''}"
|
||||
/>
|
||||
|
||||
@@ -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}"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user