mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-29 05:50:51 +01:00
feat: add conditions management for custom formats
- Introduced a new `listConditions` query to fetch conditions for custom formats. - Created a new `ConditionListItem` type for better type safety. - Added a new route for managing conditions under custom formats. - Implemented UI components for displaying and managing conditions, including `ConditionCard` and `DraftConditionCard`. - Enhanced the layout to include a new tab for conditions. - Added support for various condition types and their respective options.
This commit is contained in:
177
src/lib/client/ui/form/Autocomplete.svelte
Normal file
177
src/lib/client/ui/form/Autocomplete.svelte
Normal file
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export let options: Option[] = [];
|
||||
export let selected: Option[] = [];
|
||||
export let max: number = 1;
|
||||
export let placeholder: string = 'Type to search...';
|
||||
export let mono: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: Option[];
|
||||
}>();
|
||||
|
||||
let inputValue = '';
|
||||
let isOpen = false;
|
||||
let highlightedIndex = 0;
|
||||
let inputElement: HTMLInputElement;
|
||||
|
||||
// Filter options based on input and exclude already selected
|
||||
$: filteredOptions = options.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(inputValue.toLowerCase()) &&
|
||||
!selected.some((s) => s.value === opt.value)
|
||||
);
|
||||
|
||||
// Reset highlight when filtered options change
|
||||
$: if (filteredOptions.length > 0) {
|
||||
highlightedIndex = Math.min(highlightedIndex, filteredOptions.length - 1);
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
isOpen = true;
|
||||
highlightedIndex = 0;
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
function handleBlur(event: FocusEvent) {
|
||||
// Delay closing to allow click on option
|
||||
setTimeout(() => {
|
||||
isOpen = false;
|
||||
inputValue = '';
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isOpen) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Enter') {
|
||||
isOpen = true;
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
highlightedIndex = Math.min(highlightedIndex + 1, filteredOptions.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
highlightedIndex = Math.max(highlightedIndex - 1, 0);
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (filteredOptions[highlightedIndex]) {
|
||||
selectOption(filteredOptions[highlightedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
isOpen = false;
|
||||
inputValue = '';
|
||||
break;
|
||||
case 'Backspace':
|
||||
if (inputValue === '' && selected.length > 0) {
|
||||
removeOption(selected[selected.length - 1]);
|
||||
}
|
||||
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();
|
||||
}
|
||||
</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}
|
||||
|
||||
<!-- 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' : ''}"
|
||||
/>
|
||||
{/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>
|
||||
117
src/lib/client/ui/form/Select.svelte
Normal file
117
src/lib/client/ui/form/Select.svelte
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from 'lucide-svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export let options: Option[] = [];
|
||||
export let value: string = '';
|
||||
export let placeholder: string = 'Select...';
|
||||
export let mono: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: string;
|
||||
}>();
|
||||
|
||||
let isOpen = false;
|
||||
let highlightedIndex = -1;
|
||||
let containerElement: HTMLDivElement;
|
||||
|
||||
$: selectedOption = options.find((opt) => opt.value === value);
|
||||
$: if (isOpen) {
|
||||
highlightedIndex = options.findIndex((opt) => opt.value === value);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
function handleBlur(event: FocusEvent) {
|
||||
// Check if focus moved outside the container
|
||||
setTimeout(() => {
|
||||
if (!containerElement?.contains(document.activeElement)) {
|
||||
isOpen = false;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isOpen) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
isOpen = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
highlightedIndex = Math.min(highlightedIndex + 1, options.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
highlightedIndex = Math.max(highlightedIndex - 1, 0);
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
if (options[highlightedIndex]) {
|
||||
selectOption(options[highlightedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
isOpen = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(option: Option) {
|
||||
value = option.value;
|
||||
dispatch('change', value);
|
||||
isOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" bind:this={containerElement}>
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={toggle}
|
||||
on:blur={handleBlur}
|
||||
on:keydown={handleKeydown}
|
||||
class="flex min-h-[2.25rem] w-full items-center justify-between gap-2 rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-left text-sm dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<span class="{mono ? 'font-mono' : ''} {selectedOption ? 'text-neutral-900 dark:text-neutral-100' : 'text-neutral-400 dark:text-neutral-500'}">
|
||||
{selectedOption?.label ?? placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
class="text-neutral-400 transition-transform dark:text-neutral-500 {isOpen ? 'rotate-180' : ''}"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if isOpen}
|
||||
<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 options 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}
|
||||
</div>
|
||||
@@ -8,6 +8,7 @@ export type { CreateTestInput, CreateTestOptions } from './testCreate.ts';
|
||||
export type { UpdateTestInput, UpdateTestOptions } from './testUpdate.ts';
|
||||
export type { DeleteTestOptions } from './testDelete.ts';
|
||||
export type { ConditionData } from './conditions.ts';
|
||||
export type { ConditionListItem } from './listConditions.ts';
|
||||
export type { ConditionResult, EvaluationResult, ParsedInfo } from './evaluator.ts';
|
||||
|
||||
// Export query functions (reads)
|
||||
@@ -15,6 +16,7 @@ export { list } from './list.ts';
|
||||
export { general } from './general.ts';
|
||||
export { getById, listTests, getTestById } from './tests.ts';
|
||||
export { getConditionsForEvaluation } from './conditions.ts';
|
||||
export { listConditions } from './listConditions.ts';
|
||||
export { evaluateCustomFormat, getParsedInfo } from './evaluator.ts';
|
||||
|
||||
// Export mutation functions (writes via PCD operations)
|
||||
|
||||
39
src/lib/server/pcd/queries/customFormats/listConditions.ts
Normal file
39
src/lib/server/pcd/queries/customFormats/listConditions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Custom format condition list query
|
||||
*/
|
||||
|
||||
import type { PCDCache } from '../../cache.ts';
|
||||
|
||||
/** Condition item for list display */
|
||||
export interface ConditionListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
negate: boolean;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all conditions for a custom format (basic info for list display)
|
||||
*/
|
||||
export async function listConditions(
|
||||
cache: PCDCache,
|
||||
formatId: number
|
||||
): Promise<ConditionListItem[]> {
|
||||
const db = cache.kb;
|
||||
|
||||
const conditions = await db
|
||||
.selectFrom('custom_format_conditions')
|
||||
.select(['id', 'name', 'type', 'negate', 'required'])
|
||||
.where('custom_format_id', '=', formatId)
|
||||
.orderBy('id')
|
||||
.execute();
|
||||
|
||||
return conditions.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
negate: c.negate === 1,
|
||||
required: c.required === 1
|
||||
}));
|
||||
}
|
||||
84
src/lib/shared/conditionTypes.ts
Normal file
84
src/lib/shared/conditionTypes.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Condition types and their valid values for custom formats
|
||||
*/
|
||||
|
||||
// Arr type for filtering
|
||||
export type ArrType = 'all' | 'radarr' | 'sonarr';
|
||||
|
||||
// Condition type definitions
|
||||
export const CONDITION_TYPES = [
|
||||
{ value: 'release_title', label: 'Release Title', arrType: 'all' as ArrType },
|
||||
{ value: 'release_group', label: 'Release Group', arrType: 'all' as ArrType },
|
||||
{ value: 'edition', label: 'Edition', arrType: 'radarr' as ArrType },
|
||||
{ value: 'language', label: 'Language', arrType: 'all' as ArrType },
|
||||
{ value: 'source', label: 'Source', arrType: 'all' as ArrType },
|
||||
{ value: 'resolution', label: 'Resolution', arrType: 'all' as ArrType },
|
||||
{ value: 'quality_modifier', label: 'Quality Modifier', arrType: 'radarr' as ArrType },
|
||||
{ value: 'release_type', label: 'Release Type', arrType: 'sonarr' as ArrType },
|
||||
{ value: 'indexer_flag', label: 'Indexer Flag', arrType: 'all' as ArrType },
|
||||
{ value: 'size', label: 'Size', arrType: 'all' as ArrType },
|
||||
{ value: 'year', label: 'Year', arrType: 'all' as ArrType }
|
||||
] as const;
|
||||
|
||||
// Pattern-based types (use regex patterns as values)
|
||||
export const PATTERN_TYPES = ['release_title', 'release_group', 'edition'] as const;
|
||||
|
||||
export type ConditionType = (typeof CONDITION_TYPES)[number]['value'];
|
||||
|
||||
// Source values
|
||||
export const SOURCE_VALUES = [
|
||||
{ value: 'unknown', label: 'Unknown', arrType: 'all' as ArrType },
|
||||
{ value: 'television', label: 'Television', arrType: 'all' as ArrType },
|
||||
{ value: 'television_raw', label: 'Television Raw', arrType: 'sonarr' as ArrType },
|
||||
{ value: 'webdl', label: 'WEB-DL', arrType: 'all' as ArrType },
|
||||
{ value: 'webrip', label: 'WEBRip', arrType: 'all' as ArrType },
|
||||
{ value: 'dvd', label: 'DVD', arrType: 'all' as ArrType },
|
||||
{ value: 'bluray', label: 'Bluray', arrType: 'all' as ArrType },
|
||||
{ value: 'bluray_raw', label: 'Bluray Raw', arrType: 'sonarr' as ArrType },
|
||||
{ value: 'cam', label: 'CAM', arrType: 'all' as ArrType },
|
||||
{ value: 'telesync', label: 'Telesync', arrType: 'all' as ArrType },
|
||||
{ value: 'telecine', label: 'Telecine', arrType: 'all' as ArrType },
|
||||
{ value: 'workprint', label: 'Workprint', arrType: 'all' as ArrType }
|
||||
] as const;
|
||||
|
||||
// Resolution values
|
||||
export const RESOLUTION_VALUES = [
|
||||
{ value: '360p', label: '360p', arrType: 'all' as ArrType },
|
||||
{ value: '480p', label: '480p', arrType: 'all' as ArrType },
|
||||
{ value: '540p', label: '540p', arrType: 'all' as ArrType },
|
||||
{ value: '576p', label: '576p', arrType: 'all' as ArrType },
|
||||
{ value: '720p', label: '720p', arrType: 'all' as ArrType },
|
||||
{ value: '1080p', label: '1080p', arrType: 'all' as ArrType },
|
||||
{ value: '2160p', label: '2160p', arrType: 'all' as ArrType }
|
||||
] as const;
|
||||
|
||||
// Quality modifier values (Radarr only)
|
||||
export const QUALITY_MODIFIER_VALUES = [
|
||||
{ value: 'none', label: 'None', arrType: 'radarr' as ArrType },
|
||||
{ value: 'regional', label: 'Regional', arrType: 'radarr' as ArrType },
|
||||
{ value: 'screener', label: 'Screener', arrType: 'radarr' as ArrType },
|
||||
{ value: 'rawhd', label: 'RawHD', arrType: 'radarr' as ArrType },
|
||||
{ value: 'brdisk', label: 'BRDISK', arrType: 'radarr' as ArrType },
|
||||
{ value: 'remux', label: 'REMUX', arrType: 'radarr' as ArrType }
|
||||
] as const;
|
||||
|
||||
// Release type values (Sonarr only)
|
||||
export const RELEASE_TYPE_VALUES = [
|
||||
{ value: 'single_episode', label: 'Single Episode', arrType: 'sonarr' as ArrType },
|
||||
{ value: 'multi_episode', label: 'Multi Episode', arrType: 'sonarr' as ArrType },
|
||||
{ value: 'season_pack', label: 'Season Pack', arrType: 'sonarr' as ArrType }
|
||||
] as const;
|
||||
|
||||
// Indexer flag values
|
||||
export const INDEXER_FLAG_VALUES = [
|
||||
{ value: 'freeleech', label: 'Freeleech', arrType: 'all' as ArrType },
|
||||
{ value: 'halfleech', label: 'Halfleech', arrType: 'all' as ArrType },
|
||||
{ value: 'double_upload', label: 'Double Upload', arrType: 'all' as ArrType },
|
||||
{ value: 'internal', label: 'Internal', arrType: 'all' as ArrType },
|
||||
{ value: 'scene', label: 'Scene', arrType: 'all' as ArrType },
|
||||
{ value: 'freeleech_75', label: 'Freeleech 75%', arrType: 'all' as ArrType },
|
||||
{ value: 'freeleech_25', label: 'Freeleech 25%', arrType: 'all' as ArrType },
|
||||
{ value: 'nuked', label: 'Nuked', arrType: 'all' as ArrType },
|
||||
{ value: 'ptp_golden', label: 'PTP Golden', arrType: 'radarr' as ArrType },
|
||||
{ value: 'ptp_approved', label: 'PTP Approved', arrType: 'radarr' as ArrType }
|
||||
] as const;
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { FileText, FlaskConical } from 'lucide-svelte';
|
||||
import { FileText, Filter, FlaskConical } from 'lucide-svelte';
|
||||
|
||||
$: databaseId = $page.params.databaseId;
|
||||
$: formatId = $page.params.id;
|
||||
@@ -14,6 +14,12 @@
|
||||
active: currentPath.includes('/general'),
|
||||
icon: FileText
|
||||
},
|
||||
{
|
||||
label: 'Conditions',
|
||||
href: `/custom-formats/${databaseId}/${formatId}/conditions`,
|
||||
active: currentPath.includes('/conditions'),
|
||||
icon: Filter
|
||||
},
|
||||
{
|
||||
label: 'Testing',
|
||||
href: `/custom-formats/${databaseId}/${formatId}/testing`,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { pcdManager } from '$pcd/pcd.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';
|
||||
|
||||
export const load: ServerLoad = async ({ params }) => {
|
||||
const { databaseId, id } = params;
|
||||
|
||||
// Validate params exist
|
||||
if (!databaseId || !id) {
|
||||
throw error(400, 'Missing required parameters');
|
||||
}
|
||||
|
||||
// Parse and validate the database ID
|
||||
const currentDatabaseId = parseInt(databaseId, 10);
|
||||
if (isNaN(currentDatabaseId)) {
|
||||
throw error(400, 'Invalid database ID');
|
||||
}
|
||||
|
||||
// Parse and validate the format ID
|
||||
const formatId = parseInt(id, 10);
|
||||
if (isNaN(formatId)) {
|
||||
throw error(400, 'Invalid format ID');
|
||||
}
|
||||
|
||||
// Get the cache for the database
|
||||
const cache = pcdManager.getCache(currentDatabaseId);
|
||||
if (!cache) {
|
||||
throw error(500, 'Database cache not available');
|
||||
}
|
||||
|
||||
// Get custom format basic info, conditions, and available options
|
||||
const [format, conditions, patterns, languages] = await Promise.all([
|
||||
customFormatQueries.getById(cache, formatId),
|
||||
customFormatQueries.getConditionsForEvaluation(cache, formatId),
|
||||
regularExpressionQueries.list(cache),
|
||||
languageQueries.list(cache)
|
||||
]);
|
||||
|
||||
if (!format) {
|
||||
throw error(404, 'Custom format not found');
|
||||
}
|
||||
|
||||
return {
|
||||
format,
|
||||
conditions,
|
||||
availablePatterns: patterns.map((p) => ({ id: p.id, name: p.name, pattern: p.pattern })),
|
||||
availableLanguages: languages
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { Plus } from 'lucide-svelte';
|
||||
import ConditionCard from './components/ConditionCard.svelte';
|
||||
import DraftConditionCard from './components/DraftConditionCard.svelte';
|
||||
import Badge from '$ui/badge/Badge.svelte';
|
||||
import { CONDITION_TYPES } from '$lib/shared/conditionTypes';
|
||||
import type { PageData } from './$types';
|
||||
import type { ConditionData } from '$pcd/queries/customFormats/index';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// Draft conditions (not yet confirmed)
|
||||
let draftConditions: ConditionData[] = [];
|
||||
let nextDraftId = -1; // Use negative IDs for drafts
|
||||
|
||||
// Group conditions by type
|
||||
$: groupedConditions = data.conditions.reduce(
|
||||
(acc, condition) => {
|
||||
const type = condition.type;
|
||||
if (!acc[type]) acc[type] = [];
|
||||
acc[type].push(condition);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof data.conditions>
|
||||
);
|
||||
|
||||
// Get ordered types (only those that have conditions)
|
||||
$: orderedTypes = CONDITION_TYPES.filter((t) => groupedConditions[t.value]).map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
conditions: groupedConditions[t.value]
|
||||
}));
|
||||
|
||||
function handleRemove(conditionId: number) {
|
||||
data.conditions = data.conditions.filter((c) => c.id !== conditionId);
|
||||
}
|
||||
|
||||
function addDraftCondition() {
|
||||
const draft: ConditionData = {
|
||||
id: nextDraftId--,
|
||||
name: 'New Condition',
|
||||
type: 'release_title',
|
||||
negate: false,
|
||||
required: false
|
||||
};
|
||||
draftConditions = [...draftConditions, draft];
|
||||
}
|
||||
|
||||
function confirmDraft(draft: ConditionData) {
|
||||
// Remove from drafts
|
||||
draftConditions = draftConditions.filter((d) => d.id !== draft.id);
|
||||
// Add to main conditions with a new positive ID
|
||||
const newId = Math.max(0, ...data.conditions.map((c) => c.id)) + 1;
|
||||
data.conditions = [...data.conditions, { ...draft, id: newId }];
|
||||
}
|
||||
|
||||
function discardDraft(draftId: number) {
|
||||
draftConditions = draftConditions.filter((d) => d.id !== draftId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.format.name} - Conditions - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">Conditions</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Define the conditions that must be met for this custom format to match a release.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
on:click={addDraftCondition}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Condition
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Draft conditions -->
|
||||
{#if draftConditions.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<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.id)}
|
||||
<DraftConditionCard
|
||||
condition={draft}
|
||||
availablePatterns={data.availablePatterns}
|
||||
availableLanguages={data.availableLanguages}
|
||||
on:confirm={() => confirmDraft(draft)}
|
||||
on:discard={() => discardDraft(draft.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Existing conditions grouped by type -->
|
||||
{#if data.conditions.length === 0 && draftConditions.length === 0}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">No conditions defined</p>
|
||||
{:else}
|
||||
{#each orderedTypes as group (group.value)}
|
||||
<div class="space-y-2">
|
||||
<!-- Group header -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{group.label}
|
||||
</span>
|
||||
<Badge variant="neutral" size="sm">{group.conditions.length}</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Conditions -->
|
||||
{#each group.conditions as condition (condition.id)}
|
||||
<ConditionCard
|
||||
{condition}
|
||||
availablePatterns={data.availablePatterns}
|
||||
availableLanguages={data.availableLanguages}
|
||||
on:remove={() => handleRemove(condition.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,327 @@
|
||||
<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';
|
||||
|
||||
const dispatch = createEventDispatcher<{ remove: void }>();
|
||||
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';
|
||||
|
||||
export let condition: ConditionData;
|
||||
export let arrType: ArrType = 'all';
|
||||
|
||||
// Available patterns and languages from database (passed in)
|
||||
export let availablePatterns: { id: number; name: string; pattern: string }[] = [];
|
||||
export let availableLanguages: { id: number; name: string }[] = [];
|
||||
|
||||
// Filter condition types based on arrType
|
||||
$: filteredConditionTypes = CONDITION_TYPES.filter(
|
||||
(t) => t.arrType === 'all' || t.arrType === arrType
|
||||
);
|
||||
|
||||
// Get value options based on current type
|
||||
$: valueOptions = getValueOptions(condition.type);
|
||||
|
||||
function getValueOptions(type: string) {
|
||||
switch (type) {
|
||||
case 'source':
|
||||
return SOURCE_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
|
||||
case 'resolution':
|
||||
return RESOLUTION_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
|
||||
case 'quality_modifier':
|
||||
return QUALITY_MODIFIER_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
|
||||
case 'release_type':
|
||||
return RELEASE_TYPE_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
|
||||
case 'indexer_flag':
|
||||
return INDEXER_FLAG_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if type is pattern-based
|
||||
$: isPatternType = PATTERN_TYPES.includes(condition.type as typeof PATTERN_TYPES[number]);
|
||||
|
||||
// Autocomplete options for patterns
|
||||
$: patternOptions = availablePatterns.map((p) => ({ value: p.id.toString(), label: p.name }));
|
||||
|
||||
// Currently selected pattern for Autocomplete
|
||||
$: selectedPatterns = condition.patterns
|
||||
? condition.patterns.map((p) => ({ value: p.id.toString(), label: availablePatterns.find((ap) => ap.id === p.id)?.name ?? p.pattern }))
|
||||
: [];
|
||||
|
||||
function handlePatternChange(event: CustomEvent<{ value: string; label: string }[]>) {
|
||||
const selected = event.detail;
|
||||
if (selected.length > 0) {
|
||||
const patternId = parseInt(selected[0].value);
|
||||
const pattern = availablePatterns.find((p) => p.id === patternId);
|
||||
condition.patterns = pattern ? [{ id: pattern.id, pattern: pattern.pattern }] : [];
|
||||
} else {
|
||||
condition.patterns = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive selected value based on condition type
|
||||
$: selectedValue = (() => {
|
||||
if (isPatternType) {
|
||||
return condition.patterns?.[0]?.id?.toString() ?? '';
|
||||
}
|
||||
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]?.id?.toString() ?? '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
// Update value when Select changes
|
||||
function handleSelectChange(value: string) {
|
||||
switch (condition.type) {
|
||||
case 'source':
|
||||
condition.sources = value ? [value] : [];
|
||||
break;
|
||||
case 'resolution':
|
||||
condition.resolutions = value ? [value] : [];
|
||||
break;
|
||||
case 'quality_modifier':
|
||||
condition.qualityModifiers = value ? [value] : [];
|
||||
break;
|
||||
case 'release_type':
|
||||
condition.releaseTypes = value ? [value] : [];
|
||||
break;
|
||||
case 'indexer_flag':
|
||||
condition.indexerFlags = value ? [value] : [];
|
||||
break;
|
||||
case 'language':
|
||||
const langId = parseInt(value);
|
||||
const lang = availableLanguages.find((l) => l.id === langId);
|
||||
condition.languages = lang ? [{ id: lang.id, name: lang.name, except: false }] : [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Language options for Autocomplete (Any first, Original second, then alphabetical)
|
||||
$: languageOptions = availableLanguages
|
||||
.map((l) => ({ value: l.id.toString(), 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.id.toString(), label: l.name }))
|
||||
: [];
|
||||
|
||||
function handleLanguageChange(event: CustomEvent<{ value: string; label: string }[]>) {
|
||||
const selected = event.detail;
|
||||
if (selected.length > 0) {
|
||||
const langId = parseInt(selected[0].value);
|
||||
const lang = availableLanguages.find((l) => l.id === langId);
|
||||
condition.languages = lang ? [{ id: lang.id, name: lang.name, except: false }] : [];
|
||||
} else {
|
||||
condition.languages = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle type change - reset values
|
||||
function handleTypeChange(newType: string) {
|
||||
condition.type = newType;
|
||||
// Reset all value fields
|
||||
condition.patterns = undefined;
|
||||
condition.languages = undefined;
|
||||
condition.sources = undefined;
|
||||
condition.resolutions = undefined;
|
||||
condition.qualityModifiers = undefined;
|
||||
condition.releaseTypes = undefined;
|
||||
condition.indexerFlags = undefined;
|
||||
condition.size = undefined;
|
||||
condition.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);
|
||||
if (!condition.size) condition.size = { minBytes: null, maxBytes: null };
|
||||
condition.size.minBytes = isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
function handleMaxSizeChange(event: Event) {
|
||||
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||
if (!condition.size) condition.size = { minBytes: null, maxBytes: null };
|
||||
condition.size.maxBytes = isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
function handleMinYearChange(event: Event) {
|
||||
const value = parseInt((event.target as HTMLInputElement).value);
|
||||
if (!condition.years) condition.years = { minYear: null, maxYear: null };
|
||||
condition.years.minYear = isNaN(value) ? null : value;
|
||||
}
|
||||
|
||||
function handleMaxYearChange(event: Event) {
|
||||
const value = parseInt((event.target as HTMLInputElement).value);
|
||||
if (!condition.years) condition.years = { minYear: null, maxYear: null };
|
||||
condition.years.maxYear = isNaN(value) ? null : 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 border-neutral-200 bg-neutral-50 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<!-- Name -->
|
||||
<div class="w-48 shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={condition.name}
|
||||
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={() => (condition.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={() => (condition.required = !condition.required)}
|
||||
/>
|
||||
<span class="text-xs text-neutral-500 dark:text-neutral-400">Required</span>
|
||||
</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>
|
||||
</div>
|
||||
@@ -0,0 +1,340 @@
|
||||
<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: void; discard: void }>();
|
||||
|
||||
export let condition: ConditionData;
|
||||
export let arrType: ArrType = 'all';
|
||||
|
||||
// Available patterns and languages from database (passed in)
|
||||
export let availablePatterns: { id: number; name: string; pattern: string }[] = [];
|
||||
export let availableLanguages: { id: number; name: string }[] = [];
|
||||
|
||||
// Filter condition types based on arrType
|
||||
$: filteredConditionTypes = CONDITION_TYPES.filter(
|
||||
(t) => t.arrType === 'all' || t.arrType === arrType
|
||||
);
|
||||
|
||||
// Get value options based on current type
|
||||
$: valueOptions = getValueOptions(condition.type);
|
||||
|
||||
function getValueOptions(type: string) {
|
||||
switch (type) {
|
||||
case 'source':
|
||||
return SOURCE_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
|
||||
case 'resolution':
|
||||
return RESOLUTION_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
|
||||
case 'quality_modifier':
|
||||
return QUALITY_MODIFIER_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
|
||||
case 'release_type':
|
||||
return RELEASE_TYPE_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
|
||||
case 'indexer_flag':
|
||||
return INDEXER_FLAG_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if type is pattern-based
|
||||
$: isPatternType = PATTERN_TYPES.includes(condition.type as (typeof PATTERN_TYPES)[number]);
|
||||
|
||||
// Autocomplete options for patterns
|
||||
$: patternOptions = availablePatterns.map((p) => ({ value: p.id.toString(), label: p.name }));
|
||||
|
||||
// Currently selected pattern for Autocomplete
|
||||
$: selectedPatterns = condition.patterns
|
||||
? condition.patterns.map((p) => ({
|
||||
value: p.id.toString(),
|
||||
label: availablePatterns.find((ap) => ap.id === p.id)?.name ?? p.pattern
|
||||
}))
|
||||
: [];
|
||||
|
||||
function handlePatternChange(event: CustomEvent<{ value: string; label: string }[]>) {
|
||||
const selected = event.detail;
|
||||
if (selected.length > 0) {
|
||||
const patternId = parseInt(selected[0].value);
|
||||
const pattern = availablePatterns.find((p) => p.id === patternId);
|
||||
condition.patterns = pattern ? [{ id: pattern.id, pattern: pattern.pattern }] : [];
|
||||
} else {
|
||||
condition.patterns = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive selected value based on condition type
|
||||
$: selectedValue = (() => {
|
||||
if (isPatternType) {
|
||||
return condition.patterns?.[0]?.id?.toString() ?? '';
|
||||
}
|
||||
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]?.id?.toString() ?? '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
// Update value when Select changes
|
||||
function handleSelectChange(value: string) {
|
||||
switch (condition.type) {
|
||||
case 'source':
|
||||
condition.sources = value ? [value] : [];
|
||||
break;
|
||||
case 'resolution':
|
||||
condition.resolutions = value ? [value] : [];
|
||||
break;
|
||||
case 'quality_modifier':
|
||||
condition.qualityModifiers = value ? [value] : [];
|
||||
break;
|
||||
case 'release_type':
|
||||
condition.releaseTypes = value ? [value] : [];
|
||||
break;
|
||||
case 'indexer_flag':
|
||||
condition.indexerFlags = value ? [value] : [];
|
||||
break;
|
||||
case 'language':
|
||||
const langId = parseInt(value);
|
||||
const lang = availableLanguages.find((l) => l.id === langId);
|
||||
condition.languages = lang ? [{ id: lang.id, name: lang.name, except: false }] : [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Language options for Autocomplete (Any first, Original second, then alphabetical)
|
||||
$: languageOptions = availableLanguages
|
||||
.map((l) => ({ value: l.id.toString(), 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.id.toString(), label: l.name }))
|
||||
: [];
|
||||
|
||||
function handleLanguageChange(event: CustomEvent<{ value: string; label: string }[]>) {
|
||||
const selected = event.detail;
|
||||
if (selected.length > 0) {
|
||||
const langId = parseInt(selected[0].value);
|
||||
const lang = availableLanguages.find((l) => l.id === langId);
|
||||
condition.languages = lang ? [{ id: lang.id, name: lang.name, except: false }] : [];
|
||||
} else {
|
||||
condition.languages = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle type change - reset values
|
||||
function handleTypeChange(newType: string) {
|
||||
condition.type = newType;
|
||||
// Reset all value fields
|
||||
condition.patterns = undefined;
|
||||
condition.languages = undefined;
|
||||
condition.sources = undefined;
|
||||
condition.resolutions = undefined;
|
||||
condition.qualityModifiers = undefined;
|
||||
condition.releaseTypes = undefined;
|
||||
condition.indexerFlags = undefined;
|
||||
condition.size = undefined;
|
||||
condition.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);
|
||||
if (!condition.size) condition.size = { minBytes: null, maxBytes: null };
|
||||
condition.size.minBytes = isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
function handleMaxSizeChange(event: Event) {
|
||||
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||
if (!condition.size) condition.size = { minBytes: null, maxBytes: null };
|
||||
condition.size.maxBytes = isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
function handleMinYearChange(event: Event) {
|
||||
const value = parseInt((event.target as HTMLInputElement).value);
|
||||
if (!condition.years) condition.years = { minYear: null, maxYear: null };
|
||||
condition.years.minYear = isNaN(value) ? null : value;
|
||||
}
|
||||
|
||||
function handleMaxYearChange(event: Event) {
|
||||
const value = parseInt((event.target as HTMLInputElement).value);
|
||||
if (!condition.years) condition.years = { minYear: null, maxYear: null };
|
||||
condition.years.maxYear = isNaN(value) ? null : 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"
|
||||
bind:value={condition.name}
|
||||
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={() => (condition.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={() => (condition.required = !condition.required)}
|
||||
/>
|
||||
<span class="text-xs text-neutral-500 dark:text-neutral-400">Required</span>
|
||||
</div>
|
||||
|
||||
<!-- Confirm -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => dispatch('confirm')}
|
||||
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