mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-25 12:22:24 +01:00
feat: implement condition management with draft support and layer permissions
- Added server-side actions for updating conditions with layer permissions. - Enhanced the conditions page to handle draft conditions and validation. - Introduced a modal for selecting save targets based on user permissions. - Refactored condition and draft condition components to emit changes immutably. - Updated general page to manage form data more reactively and validate inputs.
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { error, redirect, fail } from '@sveltejs/kit';
|
||||
import type { ServerLoad, Actions } from '@sveltejs/kit';
|
||||
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 type { OperationLayer } from '$pcd/writer.ts';
|
||||
import type { ConditionData } from '$pcd/queries/customFormats/index.ts';
|
||||
|
||||
export const load: ServerLoad = async ({ params }) => {
|
||||
const { databaseId, id } = params;
|
||||
@@ -25,6 +28,12 @@ export const load: ServerLoad = async ({ params }) => {
|
||||
throw error(400, 'Invalid format ID');
|
||||
}
|
||||
|
||||
// Get current database
|
||||
const currentDatabase = pcdManager.getById(currentDatabaseId);
|
||||
if (!currentDatabase) {
|
||||
throw error(404, 'Database not found');
|
||||
}
|
||||
|
||||
// Get the cache for the database
|
||||
const cache = pcdManager.getCache(currentDatabaseId);
|
||||
if (!cache) {
|
||||
@@ -44,9 +53,76 @@ export const load: ServerLoad = async ({ params }) => {
|
||||
}
|
||||
|
||||
return {
|
||||
currentDatabase,
|
||||
format,
|
||||
conditions,
|
||||
availablePatterns: patterns.map((p) => ({ id: p.id, name: p.name, pattern: p.pattern })),
|
||||
availableLanguages: languages
|
||||
availableLanguages: languages,
|
||||
canWriteToBase: canWriteToBase(currentDatabaseId)
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, params }) => {
|
||||
const { databaseId, id } = params;
|
||||
|
||||
if (!databaseId || !id) {
|
||||
return fail(400, { error: 'Missing parameters' });
|
||||
}
|
||||
|
||||
const currentDatabaseId = parseInt(databaseId, 10);
|
||||
const formatId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(currentDatabaseId) || isNaN(formatId)) {
|
||||
return fail(400, { error: 'Invalid parameters' });
|
||||
}
|
||||
|
||||
const cache = pcdManager.getCache(currentDatabaseId);
|
||||
if (!cache) {
|
||||
return fail(500, { error: 'Database cache not available' });
|
||||
}
|
||||
|
||||
// Get current format and conditions
|
||||
const format = await customFormatQueries.getById(cache, formatId);
|
||||
if (!format) {
|
||||
return fail(404, { error: 'Custom format not found' });
|
||||
}
|
||||
|
||||
const originalConditions = await customFormatQueries.getConditionsForEvaluation(cache, formatId);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
// Parse form data
|
||||
const conditionsJson = formData.get('conditions') as string;
|
||||
const layer = (formData.get('layer') as OperationLayer) || 'user';
|
||||
|
||||
let conditions: ConditionData[] = [];
|
||||
try {
|
||||
conditions = JSON.parse(conditionsJson || '[]');
|
||||
} catch {
|
||||
return fail(400, { error: 'Invalid conditions format' });
|
||||
}
|
||||
|
||||
// Check layer permission
|
||||
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
|
||||
return fail(403, { error: 'Cannot write to base layer without personal access token' });
|
||||
}
|
||||
|
||||
// Update conditions
|
||||
const result = await customFormatQueries.updateConditions({
|
||||
databaseId: currentDatabaseId,
|
||||
cache,
|
||||
layer,
|
||||
formatId,
|
||||
formatName: format.name,
|
||||
originalConditions,
|
||||
conditions
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return fail(500, { error: result.error || 'Failed to update conditions' });
|
||||
}
|
||||
|
||||
throw redirect(303, `/custom-formats/${databaseId}/${id}/conditions`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,27 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { Plus } from 'lucide-svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { tick } from 'svelte';
|
||||
import { Plus, Save, Loader2 } from 'lucide-svelte';
|
||||
import ConditionCard from './components/ConditionCard.svelte';
|
||||
import DraftConditionCard from './components/DraftConditionCard.svelte';
|
||||
import Badge from '$ui/badge/Badge.svelte';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import { CONDITION_TYPES } from '$lib/shared/conditionTypes';
|
||||
import {
|
||||
current,
|
||||
isDirty,
|
||||
initEdit,
|
||||
update
|
||||
} from '$lib/client/stores/dirty';
|
||||
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
|
||||
// Track next draft ID locally (negative IDs for drafts)
|
||||
let nextDraftId = -1;
|
||||
|
||||
// Build initial data from server
|
||||
$: initialData = {
|
||||
conditions: structuredClone(data.conditions),
|
||||
draftConditions: [] as ConditionData[]
|
||||
};
|
||||
|
||||
// Initialize dirty tracking
|
||||
$: initEdit(initialData);
|
||||
|
||||
// Loading state
|
||||
let saving = false;
|
||||
|
||||
// Layer selection
|
||||
let selectedLayer: 'user' | 'base' = 'user';
|
||||
|
||||
// Modal state
|
||||
let showSaveTargetModal = false;
|
||||
let mainFormElement: HTMLFormElement;
|
||||
|
||||
// Reactive getters for current values
|
||||
$: conditions = ($current.conditions ?? []) as ConditionData[];
|
||||
$: draftConditions = ($current.draftConditions ?? []) as ConditionData[];
|
||||
|
||||
// Check if there are draft conditions (blocks saving)
|
||||
$: hasDrafts = draftConditions.length > 0;
|
||||
|
||||
// Validation - check all conditions have required values
|
||||
$: invalidConditions = conditions.filter((c) => !isConditionValid(c));
|
||||
$: hasInvalidConditions = invalidConditions.length > 0;
|
||||
|
||||
function isConditionValid(condition: ConditionData): boolean {
|
||||
switch (condition.type) {
|
||||
case 'release_title':
|
||||
case 'release_group':
|
||||
case 'edition':
|
||||
return (condition.patterns?.length ?? 0) > 0;
|
||||
case 'language':
|
||||
return (condition.languages?.length ?? 0) > 0;
|
||||
case 'source':
|
||||
return (condition.sources?.length ?? 0) > 0;
|
||||
case 'resolution':
|
||||
return (condition.resolutions?.length ?? 0) > 0;
|
||||
case 'quality_modifier':
|
||||
return (condition.qualityModifiers?.length ?? 0) > 0;
|
||||
case 'release_type':
|
||||
return (condition.releaseTypes?.length ?? 0) > 0;
|
||||
case 'indexer_flag':
|
||||
return (condition.indexerFlags?.length ?? 0) > 0;
|
||||
case 'size':
|
||||
// At least one of min or max must be set
|
||||
return condition.size?.minBytes != null || condition.size?.maxBytes != null;
|
||||
case 'year':
|
||||
// At least one of min or max must be set
|
||||
return condition.years?.minYear != null || condition.years?.maxYear != null;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Group conditions by type
|
||||
$: groupedConditions = data.conditions.reduce(
|
||||
$: groupedConditions = 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>
|
||||
{} as Record<string, ConditionData[]>
|
||||
);
|
||||
|
||||
// Get ordered types (only those that have conditions)
|
||||
@@ -32,7 +100,11 @@
|
||||
}));
|
||||
|
||||
function handleRemove(conditionId: number) {
|
||||
data.conditions = data.conditions.filter((c) => c.id !== conditionId);
|
||||
update('conditions', conditions.filter((c) => c.id !== conditionId));
|
||||
}
|
||||
|
||||
function handleConditionChange(updatedCondition: ConditionData) {
|
||||
update('conditions', conditions.map((c) => c.id === updatedCondition.id ? updatedCondition : c));
|
||||
}
|
||||
|
||||
function addDraftCondition() {
|
||||
@@ -43,19 +115,39 @@
|
||||
negate: false,
|
||||
required: false
|
||||
};
|
||||
draftConditions = [...draftConditions, draft];
|
||||
update('draftConditions', [...draftConditions, draft]);
|
||||
}
|
||||
|
||||
function handleDraftChange(updatedDraft: ConditionData) {
|
||||
update('draftConditions', draftConditions.map((d) => d.id === updatedDraft.id ? updatedDraft : d));
|
||||
}
|
||||
|
||||
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 }];
|
||||
// Remove from drafts and add to main conditions
|
||||
// Keep the negative ID - the server will handle assigning real IDs
|
||||
update('draftConditions', draftConditions.filter((d) => d.id !== draft.id));
|
||||
update('conditions', [...conditions, draft]);
|
||||
}
|
||||
|
||||
function discardDraft(draftId: number) {
|
||||
draftConditions = draftConditions.filter((d) => d.id !== draftId);
|
||||
update('draftConditions', draftConditions.filter((d) => d.id !== draftId));
|
||||
}
|
||||
|
||||
async function handleSaveClick() {
|
||||
if (data.canWriteToBase) {
|
||||
showSaveTargetModal = true;
|
||||
} else {
|
||||
selectedLayer = 'user';
|
||||
await tick();
|
||||
mainFormElement?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
|
||||
selectedLayer = event.detail;
|
||||
showSaveTargetModal = false;
|
||||
await tick();
|
||||
mainFormElement?.requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,69 +155,127 @@
|
||||
<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>
|
||||
<form
|
||||
bind:this={mainFormElement}
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return async ({ result, update: formUpdate }) => {
|
||||
if (result.type === 'failure' && result.data) {
|
||||
alertStore.add('error', (result.data as { error?: string }).error || 'Operation failed');
|
||||
} else if (result.type === 'redirect') {
|
||||
alertStore.add('success', 'Conditions updated!');
|
||||
// Mark as clean so navigation guard doesn't trigger
|
||||
initEdit(initialData);
|
||||
}
|
||||
await formUpdate();
|
||||
saving = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- Hidden fields for form data -->
|
||||
<input type="hidden" name="conditions" value={JSON.stringify(conditions)} />
|
||||
<input type="hidden" name="layer" value={selectedLayer} />
|
||||
|
||||
<!-- 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 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>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={addDraftCondition}
|
||||
class="flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Condition
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !$isDirty || hasDrafts || hasInvalidConditions}
|
||||
onclick={handleSaveClick}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={hasDrafts
|
||||
? 'Confirm or discard all draft conditions before saving'
|
||||
: hasInvalidConditions
|
||||
? 'Some conditions are missing required values'
|
||||
: ''}
|
||||
>
|
||||
{#if saving}
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Save size={16} />
|
||||
Save Changes
|
||||
{/if}
|
||||
</button>
|
||||
</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)}
|
||||
<!-- Draft conditions -->
|
||||
{#if draftConditions.length > 0}
|
||||
<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>
|
||||
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-400">Drafts</span>
|
||||
<Badge variant="neutral" size="sm">{draftConditions.length}</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Conditions -->
|
||||
{#each group.conditions as condition (condition.id)}
|
||||
<ConditionCard
|
||||
{condition}
|
||||
{#each draftConditions as draft (draft.id)}
|
||||
<DraftConditionCard
|
||||
condition={draft}
|
||||
availablePatterns={data.availablePatterns}
|
||||
availableLanguages={data.availableLanguages}
|
||||
on:remove={() => handleRemove(condition.id)}
|
||||
on:confirm={(e) => confirmDraft(e.detail)}
|
||||
on:discard={() => discardDraft(draft.id)}
|
||||
on:change={(e) => handleDraftChange(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Existing conditions grouped by type -->
|
||||
{#if 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}
|
||||
invalid={!isConditionValid(condition)}
|
||||
on:remove={() => handleRemove(condition.id)}
|
||||
on:change={(e) => handleConditionChange(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Save Target Modal -->
|
||||
{#if data.canWriteToBase}
|
||||
<SaveTargetModal
|
||||
open={showSaveTargetModal}
|
||||
mode="save"
|
||||
on:select={handleLayerSelect}
|
||||
on:cancel={() => (showSaveTargetModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
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,
|
||||
@@ -18,13 +16,27 @@
|
||||
} from '$lib/shared/conditionTypes';
|
||||
import type { ConditionData } from '$pcd/queries/customFormats/index';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
remove: void;
|
||||
change: ConditionData;
|
||||
}>();
|
||||
|
||||
export let condition: ConditionData;
|
||||
export let arrType: ArrType = 'all';
|
||||
export let invalid = false;
|
||||
|
||||
// Reference invalid to avoid unused export warning (used in template)
|
||||
$: isInvalid = invalid;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// Filter condition types based on arrType
|
||||
$: filteredConditionTypes = CONDITION_TYPES.filter(
|
||||
(t) => t.arrType === 'all' || t.arrType === arrType
|
||||
@@ -66,9 +78,9 @@
|
||||
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 }] : [];
|
||||
emitChange({ patterns: pattern ? [{ id: pattern.id, pattern: pattern.pattern }] : [] });
|
||||
} else {
|
||||
condition.patterns = [];
|
||||
emitChange({ patterns: [] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,24 +111,24 @@
|
||||
function handleSelectChange(value: string) {
|
||||
switch (condition.type) {
|
||||
case 'source':
|
||||
condition.sources = value ? [value] : [];
|
||||
emitChange({ sources: value ? [value] : [] });
|
||||
break;
|
||||
case 'resolution':
|
||||
condition.resolutions = value ? [value] : [];
|
||||
emitChange({ resolutions: value ? [value] : [] });
|
||||
break;
|
||||
case 'quality_modifier':
|
||||
condition.qualityModifiers = value ? [value] : [];
|
||||
emitChange({ qualityModifiers: value ? [value] : [] });
|
||||
break;
|
||||
case 'release_type':
|
||||
condition.releaseTypes = value ? [value] : [];
|
||||
emitChange({ releaseTypes: value ? [value] : [] });
|
||||
break;
|
||||
case 'indexer_flag':
|
||||
condition.indexerFlags = value ? [value] : [];
|
||||
emitChange({ 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 }] : [];
|
||||
emitChange({ languages: lang ? [{ id: lang.id, name: lang.name, except: false }] : [] });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -142,25 +154,27 @@
|
||||
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 }] : [];
|
||||
emitChange({ languages: lang ? [{ id: lang.id, name: lang.name, except: false }] : [] });
|
||||
} else {
|
||||
condition.languages = [];
|
||||
emitChange({ 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;
|
||||
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
|
||||
@@ -172,26 +186,50 @@
|
||||
|
||||
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);
|
||||
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);
|
||||
if (!condition.size) condition.size = { minBytes: null, maxBytes: null };
|
||||
condition.size.maxBytes = isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024);
|
||||
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);
|
||||
if (!condition.years) condition.years = { minYear: null, maxYear: null };
|
||||
condition.years.minYear = isNaN(value) ? null : 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);
|
||||
if (!condition.years) condition.years = { minYear: null, maxYear: null };
|
||||
condition.years.maxYear = isNaN(value) ? null : 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 =
|
||||
@@ -199,13 +237,16 @@
|
||||
</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"
|
||||
class="flex items-center gap-3 rounded-lg border px-3 py-2 {isInvalid
|
||||
? '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'}"
|
||||
>
|
||||
<!-- Name -->
|
||||
<div class="w-48 shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={condition.name}
|
||||
value={condition.name}
|
||||
on:input={handleNameChange}
|
||||
placeholder="Condition name"
|
||||
class={inputClass}
|
||||
/>
|
||||
@@ -299,7 +340,7 @@
|
||||
icon={X}
|
||||
checked={condition.negate}
|
||||
color="red"
|
||||
on:click={() => (condition.negate = !condition.negate)}
|
||||
on:click={() => emitChange({ negate: !condition.negate })}
|
||||
/>
|
||||
<span class="text-xs text-neutral-500 dark:text-neutral-400">Negate</span>
|
||||
</div>
|
||||
@@ -310,7 +351,7 @@
|
||||
icon={Check}
|
||||
checked={condition.required}
|
||||
color="green"
|
||||
on:click={() => (condition.required = !condition.required)}
|
||||
on:click={() => emitChange({ required: !condition.required })}
|
||||
/>
|
||||
<span class="text-xs text-neutral-500 dark:text-neutral-400">Required</span>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
} from '$lib/shared/conditionTypes';
|
||||
import type { ConditionData } from '$pcd/queries/customFormats/index';
|
||||
|
||||
const dispatch = createEventDispatcher<{ confirm: void; discard: void }>();
|
||||
const dispatch = createEventDispatcher<{
|
||||
confirm: ConditionData;
|
||||
discard: void;
|
||||
change: ConditionData;
|
||||
}>();
|
||||
|
||||
export let condition: ConditionData;
|
||||
export let arrType: ArrType = 'all';
|
||||
@@ -25,6 +29,11 @@
|
||||
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 });
|
||||
}
|
||||
|
||||
// Filter condition types based on arrType
|
||||
$: filteredConditionTypes = CONDITION_TYPES.filter(
|
||||
(t) => t.arrType === 'all' || t.arrType === arrType
|
||||
@@ -69,9 +78,9 @@
|
||||
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 }] : [];
|
||||
emitChange({ patterns: pattern ? [{ id: pattern.id, pattern: pattern.pattern }] : [] });
|
||||
} else {
|
||||
condition.patterns = [];
|
||||
emitChange({ patterns: [] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,24 +111,24 @@
|
||||
function handleSelectChange(value: string) {
|
||||
switch (condition.type) {
|
||||
case 'source':
|
||||
condition.sources = value ? [value] : [];
|
||||
emitChange({ sources: value ? [value] : [] });
|
||||
break;
|
||||
case 'resolution':
|
||||
condition.resolutions = value ? [value] : [];
|
||||
emitChange({ resolutions: value ? [value] : [] });
|
||||
break;
|
||||
case 'quality_modifier':
|
||||
condition.qualityModifiers = value ? [value] : [];
|
||||
emitChange({ qualityModifiers: value ? [value] : [] });
|
||||
break;
|
||||
case 'release_type':
|
||||
condition.releaseTypes = value ? [value] : [];
|
||||
emitChange({ releaseTypes: value ? [value] : [] });
|
||||
break;
|
||||
case 'indexer_flag':
|
||||
condition.indexerFlags = value ? [value] : [];
|
||||
emitChange({ 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 }] : [];
|
||||
emitChange({ languages: lang ? [{ id: lang.id, name: lang.name, except: false }] : [] });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -145,25 +154,27 @@
|
||||
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 }] : [];
|
||||
emitChange({ languages: lang ? [{ id: lang.id, name: lang.name, except: false }] : [] });
|
||||
} else {
|
||||
condition.languages = [];
|
||||
emitChange({ 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;
|
||||
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
|
||||
@@ -175,26 +186,50 @@
|
||||
|
||||
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);
|
||||
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);
|
||||
if (!condition.size) condition.size = { minBytes: null, maxBytes: null };
|
||||
condition.size.maxBytes = isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024);
|
||||
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);
|
||||
if (!condition.years) condition.years = { minYear: null, maxYear: null };
|
||||
condition.years.minYear = isNaN(value) ? null : 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);
|
||||
if (!condition.years) condition.years = { minYear: null, maxYear: null };
|
||||
condition.years.maxYear = isNaN(value) ? null : 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 =
|
||||
@@ -208,7 +243,8 @@
|
||||
<div class="w-48 shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={condition.name}
|
||||
value={condition.name}
|
||||
on:input={handleNameChange}
|
||||
placeholder="Condition name"
|
||||
class={inputClass}
|
||||
/>
|
||||
@@ -302,7 +338,7 @@
|
||||
icon={X}
|
||||
checked={condition.negate}
|
||||
color="red"
|
||||
on:click={() => (condition.negate = !condition.negate)}
|
||||
on:click={() => emitChange({ negate: !condition.negate })}
|
||||
/>
|
||||
<span class="text-xs text-neutral-500 dark:text-neutral-400">Negate</span>
|
||||
</div>
|
||||
@@ -313,7 +349,7 @@
|
||||
icon={Check}
|
||||
checked={condition.required}
|
||||
color="green"
|
||||
on:click={() => (condition.required = !condition.required)}
|
||||
on:click={() => emitChange({ required: !condition.required })}
|
||||
/>
|
||||
<span class="text-xs text-neutral-500 dark:text-neutral-400">Required</span>
|
||||
</div>
|
||||
@@ -321,7 +357,7 @@
|
||||
<!-- Confirm -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => dispatch('confirm')}
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -17,14 +17,6 @@
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// Form data shape
|
||||
interface GeneralFormData {
|
||||
name: string;
|
||||
tags: string[];
|
||||
description: string;
|
||||
includeInRename: boolean;
|
||||
}
|
||||
|
||||
// Build initial data from server
|
||||
$: initialData = {
|
||||
name: data.format.name,
|
||||
@@ -46,8 +38,14 @@
|
||||
let showSaveTargetModal = false;
|
||||
let mainFormElement: HTMLFormElement;
|
||||
|
||||
// Reactive getters for current values
|
||||
$: name = ($current.name ?? '') as string;
|
||||
$: tags = ($current.tags ?? []) as string[];
|
||||
$: description = ($current.description ?? '') as string;
|
||||
$: includeInRename = ($current.includeInRename ?? false) as boolean;
|
||||
|
||||
// Validation
|
||||
$: isValid = ($current as GeneralFormData).name?.trim() !== '';
|
||||
$: isValid = name.trim() !== '';
|
||||
|
||||
async function handleSaveClick() {
|
||||
if (data.canWriteToBase) {
|
||||
@@ -83,7 +81,7 @@
|
||||
} else if (result.type === 'redirect') {
|
||||
alertStore.add('success', 'Custom format updated!');
|
||||
// Mark as clean so navigation guard doesn't trigger
|
||||
initEdit($current as GeneralFormData);
|
||||
initEdit(initialData);
|
||||
}
|
||||
await formUpdate();
|
||||
saving = false;
|
||||
@@ -91,9 +89,9 @@
|
||||
}}
|
||||
>
|
||||
<!-- Hidden fields for form data -->
|
||||
<input type="hidden" name="tags" value={JSON.stringify(($current as GeneralFormData).tags)} />
|
||||
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
|
||||
<input type="hidden" name="layer" value={selectedLayer} />
|
||||
<input type="hidden" name="includeInRename" value={($current as GeneralFormData).includeInRename} />
|
||||
<input type="hidden" name="includeInRename" value={includeInRename} />
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- Name -->
|
||||
@@ -108,7 +106,7 @@
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={($current as GeneralFormData).name}
|
||||
value={name}
|
||||
oninput={(e) => update('name', e.currentTarget.value)}
|
||||
placeholder="Enter custom format name"
|
||||
class="mt-2 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
|
||||
@@ -121,7 +119,7 @@
|
||||
name="description"
|
||||
label="Description"
|
||||
description="Add any notes or details about this custom format's purpose and configuration."
|
||||
value={($current as GeneralFormData).description}
|
||||
value={description}
|
||||
onchange={(v) => update('description', v)}
|
||||
/>
|
||||
|
||||
@@ -132,7 +130,7 @@
|
||||
Add tags to organize and categorize this custom format.
|
||||
</p>
|
||||
<TagInput
|
||||
tags={($current as GeneralFormData).tags}
|
||||
{tags}
|
||||
onchange={(newTags) => update('tags', newTags)}
|
||||
/>
|
||||
</div>
|
||||
@@ -148,11 +146,11 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<IconCheckbox
|
||||
icon={Check}
|
||||
checked={($current as GeneralFormData).includeInRename}
|
||||
on:click={() => update('includeInRename', !($current as GeneralFormData).includeInRename)}
|
||||
checked={includeInRename}
|
||||
on:click={() => update('includeInRename', !includeInRename)}
|
||||
/>
|
||||
<span class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{($current as GeneralFormData).includeInRename ? 'Enabled' : 'Disabled'}
|
||||
{includeInRename ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user