mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feat: add create and delete custom format functionality
- Implemented `create.ts` for creating custom formats with associated tags. - Added `delete.ts` for deleting custom formats with cascading deletes for related entities. - Updated `index.ts` to export new create and delete functions. - Enhanced the server-side logic in `+page.server.ts` for handling new custom format creation. - Created a new Svelte component `GeneralForm.svelte` for managing custom format details. - Updated the UI in `+page.svelte` for creating new custom formats and handling form submissions. - Integrated dirty state management for form inputs in `TestForm.svelte` and `GeneralForm.svelte`. - Added delete functionality in the UI for custom formats with confirmation modals.
This commit is contained in:
78
src/lib/server/pcd/queries/customFormats/create.ts
Normal file
78
src/lib/server/pcd/queries/customFormats/create.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Create a custom format operation
|
||||
*/
|
||||
|
||||
import type { PCDCache } from '../../cache.ts';
|
||||
import { writeOperation, type OperationLayer } from '../../writer.ts';
|
||||
|
||||
export interface CreateCustomFormatInput {
|
||||
name: string;
|
||||
description: string | null;
|
||||
includeInRename: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface CreateCustomFormatOptions {
|
||||
databaseId: number;
|
||||
cache: PCDCache;
|
||||
layer: OperationLayer;
|
||||
input: CreateCustomFormatInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom format by writing an operation to the specified layer
|
||||
*/
|
||||
export async function create(options: CreateCustomFormatOptions) {
|
||||
const { databaseId, cache, layer, input } = options;
|
||||
const db = cache.kb;
|
||||
|
||||
const queries = [];
|
||||
|
||||
// 1. Insert the custom format
|
||||
const insertFormat = db
|
||||
.insertInto('custom_formats')
|
||||
.values({
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
include_in_rename: input.includeInRename ? 1 : 0
|
||||
})
|
||||
.compile();
|
||||
|
||||
queries.push(insertFormat);
|
||||
|
||||
// 2. Insert tags (create if not exist, then link)
|
||||
for (const tagName of input.tags) {
|
||||
// Insert tag if not exists
|
||||
const insertTag = db
|
||||
.insertInto('tags')
|
||||
.values({ name: tagName })
|
||||
.onConflict((oc) => oc.column('name').doNothing())
|
||||
.compile();
|
||||
|
||||
queries.push(insertTag);
|
||||
|
||||
// Link tag to custom format
|
||||
const linkTag = {
|
||||
sql: `INSERT INTO custom_format_tags (custom_format_id, tag_id) VALUES ((SELECT id FROM custom_formats WHERE name = '${input.name.replace(/'/g, "''")}'), tag('${tagName.replace(/'/g, "''")}'))`,
|
||||
parameters: [],
|
||||
query: {} as never
|
||||
};
|
||||
|
||||
queries.push(linkTag);
|
||||
}
|
||||
|
||||
// Write the operation
|
||||
const result = await writeOperation({
|
||||
databaseId,
|
||||
layer,
|
||||
description: `create-custom-format-${input.name}`,
|
||||
queries,
|
||||
metadata: {
|
||||
operation: 'create',
|
||||
entity: 'custom_format',
|
||||
name: input.name
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
63
src/lib/server/pcd/queries/customFormats/delete.ts
Normal file
63
src/lib/server/pcd/queries/customFormats/delete.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Delete a custom format operation
|
||||
*/
|
||||
|
||||
import type { PCDCache } from '../../cache.ts';
|
||||
import { writeOperation, type OperationLayer } from '../../writer.ts';
|
||||
|
||||
export interface DeleteCustomFormatOptions {
|
||||
databaseId: number;
|
||||
cache: PCDCache;
|
||||
layer: OperationLayer;
|
||||
/** The custom format ID */
|
||||
formatId: number;
|
||||
/** The custom format name (for metadata and value guards) */
|
||||
formatName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for SQL
|
||||
*/
|
||||
function esc(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom format by writing an operation to the specified layer
|
||||
* Cascading deletes handle conditions, tests, and tag links
|
||||
*/
|
||||
export async function remove(options: DeleteCustomFormatOptions) {
|
||||
const { databaseId, cache, layer, formatId, formatName } = options;
|
||||
const db = cache.kb;
|
||||
|
||||
const queries = [];
|
||||
|
||||
// Delete the custom format with value guards
|
||||
// Foreign key cascades will handle:
|
||||
// - custom_format_tags
|
||||
// - custom_format_conditions (and their type-specific tables)
|
||||
// - custom_format_tests
|
||||
const deleteFormat = db
|
||||
.deleteFrom('custom_formats')
|
||||
.where('id', '=', formatId)
|
||||
// Value guard - ensure this is the format we expect
|
||||
.where('name', '=', formatName)
|
||||
.compile();
|
||||
|
||||
queries.push(deleteFormat);
|
||||
|
||||
// Write the operation
|
||||
const result = await writeOperation({
|
||||
databaseId,
|
||||
layer,
|
||||
description: `delete-custom-format-${formatName}`,
|
||||
queries,
|
||||
metadata: {
|
||||
operation: 'delete',
|
||||
entity: 'custom_format',
|
||||
name: formatName
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export type { ConditionListItem } from './listConditions.ts';
|
||||
export type { ConditionResult, EvaluationResult, ParsedInfo } from './evaluator.ts';
|
||||
export type { UpdateGeneralInput, UpdateGeneralOptions } from './updateGeneral.ts';
|
||||
export type { UpdateConditionsOptions } from './updateConditions.ts';
|
||||
export type { CreateCustomFormatInput, CreateCustomFormatOptions } from './create.ts';
|
||||
export type { DeleteCustomFormatOptions } from './delete.ts';
|
||||
|
||||
// Export query functions (reads)
|
||||
export { list } from './list.ts';
|
||||
@@ -22,6 +24,8 @@ export { listConditions } from './listConditions.ts';
|
||||
export { evaluateCustomFormat, getParsedInfo } from './evaluator.ts';
|
||||
|
||||
// Export mutation functions (writes via PCD operations)
|
||||
export { create } from './create.ts';
|
||||
export { remove } from './delete.ts';
|
||||
export { createTest } from './testCreate.ts';
|
||||
export { updateTest } from './testUpdate.ts';
|
||||
export { deleteTest } from './testDelete.ts';
|
||||
|
||||
@@ -121,5 +121,54 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
throw redirect(303, `/custom-formats/${databaseId}/${id}/general`);
|
||||
},
|
||||
|
||||
delete: 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 for value guards
|
||||
const current = await customFormatQueries.general(cache, formatId);
|
||||
if (!current) {
|
||||
return fail(404, { error: 'Custom format not found' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const layer = (formData.get('layer') as OperationLayer) || 'user';
|
||||
|
||||
// Check layer permission
|
||||
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
|
||||
return fail(403, { error: 'Cannot write to base layer without personal access token' });
|
||||
}
|
||||
|
||||
// Delete the custom format
|
||||
const result = await customFormatQueries.remove({
|
||||
databaseId: currentDatabaseId,
|
||||
cache,
|
||||
layer,
|
||||
formatId,
|
||||
formatName: current.name
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return fail(500, { error: result.error || 'Failed to delete custom format' });
|
||||
}
|
||||
|
||||
throw redirect(303, `/custom-formats/${databaseId}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { tick } from 'svelte';
|
||||
import { Check, Save, Loader2 } from 'lucide-svelte';
|
||||
import MarkdownInput from '$ui/form/MarkdownInput.svelte';
|
||||
import TagInput from '$ui/form/TagInput.svelte';
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import {
|
||||
current,
|
||||
isDirty,
|
||||
initEdit,
|
||||
update
|
||||
} from '$lib/client/stores/dirty';
|
||||
import GeneralForm from '../../components/GeneralForm.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
@@ -24,163 +11,18 @@
|
||||
description: data.format.description ?? '',
|
||||
includeInRename: data.format.include_in_rename
|
||||
};
|
||||
|
||||
// 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
|
||||
$: name = ($current.name ?? '') as string;
|
||||
$: tags = ($current.tags ?? []) as string[];
|
||||
$: description = ($current.description ?? '') as string;
|
||||
$: includeInRename = ($current.includeInRename ?? false) as boolean;
|
||||
|
||||
// Validation
|
||||
$: isValid = name.trim() !== '';
|
||||
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.format.name} - General - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<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', 'Custom format 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="tags" value={JSON.stringify(tags)} />
|
||||
<input type="hidden" name="layer" value={selectedLayer} />
|
||||
<input type="hidden" name="includeInRename" value={includeInRename} />
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
The name of this custom format
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<MarkdownInput
|
||||
id="description"
|
||||
name="description"
|
||||
label="Description"
|
||||
description="Add any notes or details about this custom format's purpose and configuration."
|
||||
value={description}
|
||||
onchange={(v) => update('description', v)}
|
||||
/>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">Tags</div>
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
Add tags to organize and categorize this custom format.
|
||||
</p>
|
||||
<TagInput
|
||||
{tags}
|
||||
onchange={(newTags) => update('tags', newTags)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Include In Rename -->
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Include In Rename
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
When enabled, this custom format's name will be included in the renamed filename.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconCheckbox
|
||||
icon={Check}
|
||||
checked={includeInRename}
|
||||
on:click={() => update('includeInRename', !includeInRename)}
|
||||
/>
|
||||
<span class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{includeInRename ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end pt-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !isValid || !$isDirty}
|
||||
onclick={handleSaveClick}
|
||||
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
|
||||
>
|
||||
{#if saving}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
Save Changes
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Save Target Modal -->
|
||||
{#if data.canWriteToBase}
|
||||
<SaveTargetModal
|
||||
open={showSaveTargetModal}
|
||||
mode="save"
|
||||
on:select={handleLayerSelect}
|
||||
on:cancel={() => (showSaveTargetModal = false)}
|
||||
<div class="mt-6">
|
||||
<GeneralForm
|
||||
mode="edit"
|
||||
databaseName={data.currentDatabase.name}
|
||||
canWriteToBase={data.canWriteToBase}
|
||||
actionUrl="?/update"
|
||||
{initialData}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import TestForm from '../components/TestForm.svelte';
|
||||
import DirtyModal from '$ui/modal/DirtyModal.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let title = data.test.title;
|
||||
let type: 'movie' | 'series' = data.test.type as 'movie' | 'series';
|
||||
let shouldMatch = data.test.should_match;
|
||||
let description = data.test.description ?? '';
|
||||
|
||||
function handleCancel() {
|
||||
goto(`/custom-formats/${$page.params.databaseId}/${$page.params.id}/testing`);
|
||||
}
|
||||
@@ -25,9 +21,13 @@
|
||||
formatName={data.format.name}
|
||||
canWriteToBase={data.canWriteToBase}
|
||||
actionUrl="?/update"
|
||||
bind:title
|
||||
bind:type
|
||||
bind:shouldMatch
|
||||
bind:description
|
||||
initialData={{
|
||||
title: data.test.title,
|
||||
type: data.test.type as 'movie' | 'series',
|
||||
shouldMatch: data.test.should_match,
|
||||
description: data.test.description ?? ''
|
||||
}}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
|
||||
<DirtyModal />
|
||||
|
||||
@@ -5,23 +5,47 @@
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import MarkdownInput from '$ui/form/MarkdownInput.svelte';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import { Save, Trash2, Loader2, Check, X } from 'lucide-svelte';
|
||||
import { Trash2, Loader2, Check, X } from 'lucide-svelte';
|
||||
import {
|
||||
current,
|
||||
isDirty,
|
||||
initEdit,
|
||||
initCreate,
|
||||
update
|
||||
} from '$lib/client/stores/dirty';
|
||||
|
||||
// Form data shape
|
||||
interface TestFormData {
|
||||
title: string;
|
||||
type: 'movie' | 'series';
|
||||
shouldMatch: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Props
|
||||
export let mode: 'create' | 'edit';
|
||||
export let formatName: string;
|
||||
export let canWriteToBase: boolean = false;
|
||||
export let actionUrl: string = '';
|
||||
|
||||
// Form data
|
||||
export let title: string = '';
|
||||
export let type: 'movie' | 'series' = 'movie';
|
||||
export let shouldMatch: boolean = true;
|
||||
export let description: string = '';
|
||||
export let initialData: TestFormData;
|
||||
|
||||
// Event handlers
|
||||
export let onCancel: () => void;
|
||||
|
||||
// Initialize dirty tracking
|
||||
const defaults: TestFormData = {
|
||||
title: '',
|
||||
type: 'movie',
|
||||
shouldMatch: true,
|
||||
description: ''
|
||||
};
|
||||
|
||||
if (mode === 'create') {
|
||||
initCreate(initialData ?? defaults);
|
||||
} else {
|
||||
initEdit(initialData);
|
||||
}
|
||||
|
||||
// Loading states
|
||||
let saving = false;
|
||||
let deleting = false;
|
||||
@@ -38,6 +62,12 @@
|
||||
let mainFormElement: HTMLFormElement;
|
||||
let deleteFormElement: HTMLFormElement;
|
||||
|
||||
// Reactive getters for current values
|
||||
$: title = ($current.title ?? '') as string;
|
||||
$: type = ($current.type ?? 'movie') as 'movie' | 'series';
|
||||
$: shouldMatch = ($current.shouldMatch ?? true) as boolean;
|
||||
$: description = ($current.description ?? '') as string;
|
||||
|
||||
// Display text based on mode
|
||||
$: pageTitle = mode === 'create' ? 'New Test Case' : 'Edit Test Case';
|
||||
$: pageDescription = mode === 'create'
|
||||
@@ -108,13 +138,15 @@
|
||||
action={actionUrl}
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return async ({ result, update }) => {
|
||||
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', mode === 'create' ? 'Test case created!' : 'Test case updated!');
|
||||
// Mark as clean so navigation guard doesn't trigger
|
||||
initEdit($current as TestFormData);
|
||||
}
|
||||
await update();
|
||||
await formUpdate();
|
||||
saving = false;
|
||||
};
|
||||
}}
|
||||
@@ -124,6 +156,7 @@
|
||||
<input type="hidden" name="shouldMatch" value={shouldMatch ? '1' : '0'} />
|
||||
<input type="hidden" name="formatName" value={formatName} />
|
||||
<input type="hidden" name="layer" value={selectedLayer} />
|
||||
<input type="hidden" name="description" value={description} />
|
||||
|
||||
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div class="space-y-6 p-4">
|
||||
@@ -136,7 +169,8 @@
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
bind:value={title}
|
||||
value={title}
|
||||
oninput={(e) => update('title', e.currentTarget.value)}
|
||||
placeholder="e.g., Movie.Name.2024.1080p.BluRay.x264-GROUP"
|
||||
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono 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"
|
||||
/>
|
||||
@@ -151,7 +185,7 @@
|
||||
{#each typeOptions as option}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (type = option.value)}
|
||||
onclick={() => update('type', option.value)}
|
||||
class="flex flex-1 cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
|
||||
>
|
||||
<IconCheckbox
|
||||
@@ -181,7 +215,7 @@
|
||||
{#each matchOptions as option}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (shouldMatch = option.value)}
|
||||
onclick={() => update('shouldMatch', option.value)}
|
||||
class="flex flex-1 cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
|
||||
>
|
||||
<IconCheckbox
|
||||
@@ -207,7 +241,8 @@
|
||||
<MarkdownInput
|
||||
label="Description"
|
||||
placeholder="Why this test exists or what edge case it covers"
|
||||
bind:value={description}
|
||||
value={description}
|
||||
onchange={(v) => update('description', v)}
|
||||
minRows={2}
|
||||
/>
|
||||
</div>
|
||||
@@ -218,7 +253,7 @@
|
||||
{#if mode === 'edit'}
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleDeleteClick}
|
||||
onclick={handleDeleteClick}
|
||||
disabled={deleting}
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 disabled:opacity-50 dark:border-red-700 dark:bg-neutral-800 dark:text-red-300 dark:hover:bg-red-900"
|
||||
>
|
||||
@@ -234,7 +269,7 @@
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
on:click={onCancel}
|
||||
onclick={onCancel}
|
||||
class="flex cursor-pointer 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"
|
||||
>
|
||||
<X size={14} />
|
||||
@@ -242,8 +277,8 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleSaveClick}
|
||||
disabled={saving || !isValid}
|
||||
onclick={handleSaveClick}
|
||||
disabled={saving || !isValid || (mode === 'edit' && !$isDirty)}
|
||||
class="flex cursor-pointer 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 dark:bg-accent-500 dark:hover:bg-accent-600"
|
||||
>
|
||||
{#if saving}
|
||||
@@ -268,13 +303,13 @@
|
||||
class="hidden"
|
||||
use:enhance={() => {
|
||||
deleting = true;
|
||||
return async ({ result, update }) => {
|
||||
return async ({ result, update: formUpdate }) => {
|
||||
if (result.type === 'failure' && result.data) {
|
||||
alertStore.add('error', (result.data as { error?: string }).error || 'Failed to delete');
|
||||
} else if (result.type === 'redirect') {
|
||||
alertStore.add('success', 'Test case deleted');
|
||||
}
|
||||
await update();
|
||||
await formUpdate();
|
||||
deleting = false;
|
||||
};
|
||||
}}
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import TestForm from '../components/TestForm.svelte';
|
||||
import DirtyModal from '$ui/modal/DirtyModal.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let title = '';
|
||||
let type: 'movie' | 'series' = 'movie';
|
||||
let shouldMatch = true;
|
||||
let description = '';
|
||||
|
||||
function handleCancel() {
|
||||
goto(`/custom-formats/${$page.params.databaseId}/${$page.params.id}/testing`);
|
||||
}
|
||||
@@ -24,9 +20,13 @@
|
||||
mode="create"
|
||||
formatName={data.format.name}
|
||||
canWriteToBase={data.canWriteToBase}
|
||||
bind:title
|
||||
bind:type
|
||||
bind:shouldMatch
|
||||
bind:description
|
||||
initialData={{
|
||||
title: '',
|
||||
type: 'movie',
|
||||
shouldMatch: true,
|
||||
description: ''
|
||||
}}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
|
||||
<DirtyModal />
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { tick } from 'svelte';
|
||||
import { Check, Save, Loader2, Trash2 } from 'lucide-svelte';
|
||||
import MarkdownInput from '$ui/form/MarkdownInput.svelte';
|
||||
import TagInput from '$ui/form/TagInput.svelte';
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import {
|
||||
current,
|
||||
isDirty,
|
||||
initEdit,
|
||||
initCreate,
|
||||
update
|
||||
} from '$lib/client/stores/dirty';
|
||||
|
||||
// Form data shape
|
||||
interface GeneralFormData {
|
||||
name: string;
|
||||
tags: string[];
|
||||
description: string;
|
||||
includeInRename: boolean;
|
||||
}
|
||||
|
||||
// Props
|
||||
export let mode: 'create' | 'edit';
|
||||
export let databaseName: string;
|
||||
export let canWriteToBase: boolean = false;
|
||||
export let actionUrl: string = '';
|
||||
export let initialData: GeneralFormData;
|
||||
|
||||
// Event handlers
|
||||
export let onCancel: (() => void) | undefined = undefined;
|
||||
|
||||
const defaults: GeneralFormData = {
|
||||
name: '',
|
||||
tags: [],
|
||||
description: '',
|
||||
includeInRename: false
|
||||
};
|
||||
|
||||
if (mode === 'create') {
|
||||
initCreate(initialData ?? defaults);
|
||||
} else {
|
||||
initEdit(initialData);
|
||||
}
|
||||
|
||||
// Loading states
|
||||
let saving = false;
|
||||
let deleting = false;
|
||||
|
||||
// Layer selection
|
||||
let selectedLayer: 'user' | 'base' = 'user';
|
||||
let deleteLayer: 'user' | 'base' = 'user';
|
||||
|
||||
// Modal state
|
||||
let showSaveTargetModal = false;
|
||||
let showDeleteTargetModal = false;
|
||||
let mainFormElement: HTMLFormElement;
|
||||
let deleteFormElement: HTMLFormElement;
|
||||
|
||||
// Display text based on mode
|
||||
$: title = mode === 'create' ? 'New Custom Format' : 'General';
|
||||
$: description_ =
|
||||
mode === 'create'
|
||||
? `After saving, you'll be able to add conditions and tests.`
|
||||
: `Update custom format settings`;
|
||||
$: submitButtonText = mode === 'create' ? 'Create' : 'Save Changes';
|
||||
|
||||
// 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 = name.trim() !== '';
|
||||
|
||||
async function handleSaveClick() {
|
||||
if (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();
|
||||
}
|
||||
|
||||
async function handleDeleteClick() {
|
||||
if (canWriteToBase) {
|
||||
showDeleteTargetModal = true;
|
||||
} else {
|
||||
deleteLayer = 'user';
|
||||
await tick();
|
||||
deleteFormElement?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteLayerSelect(event: CustomEvent<'user' | 'base'>) {
|
||||
deleteLayer = event.detail;
|
||||
showDeleteTargetModal = false;
|
||||
await tick();
|
||||
deleteFormElement?.requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header (only shown in create mode, edit mode uses layout tabs) -->
|
||||
{#if mode === 'create'}
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-50">{title}</h1>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{description_}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
bind:this={mainFormElement}
|
||||
method="POST"
|
||||
action={actionUrl}
|
||||
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', mode === 'create' ? 'Custom format created!' : 'Custom format updated!');
|
||||
// Mark as clean so navigation guard doesn't trigger
|
||||
initEdit($current as GeneralFormData);
|
||||
}
|
||||
await formUpdate();
|
||||
saving = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- Hidden fields for form data -->
|
||||
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
|
||||
<input type="hidden" name="layer" value={selectedLayer} />
|
||||
<input type="hidden" name="includeInRename" value={includeInRename} />
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
The name of this custom format
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<MarkdownInput
|
||||
id="description"
|
||||
name="description"
|
||||
label="Description"
|
||||
description="Add any notes or details about this custom format's purpose and configuration."
|
||||
value={description}
|
||||
onchange={(v) => update('description', v)}
|
||||
/>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">Tags</div>
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
Add tags to organize and categorize this custom format.
|
||||
</p>
|
||||
<TagInput
|
||||
{tags}
|
||||
onchange={(newTags) => update('tags', newTags)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Include In Rename -->
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Include In Rename
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
When enabled, this custom format's name will be included in the renamed filename.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconCheckbox
|
||||
icon={Check}
|
||||
checked={includeInRename}
|
||||
on:click={() => update('includeInRename', !includeInRename)}
|
||||
/>
|
||||
<span class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{includeInRename ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<!-- Left side: Delete (only in edit mode) -->
|
||||
<div>
|
||||
{#if mode === 'edit'}
|
||||
<button
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onclick={handleDeleteClick}
|
||||
class="flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-700 dark:bg-neutral-900 dark:text-red-300 dark:hover:bg-red-900/20"
|
||||
>
|
||||
{#if deleting}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
Deleting...
|
||||
{:else}
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right side: Cancel and Save -->
|
||||
<div class="flex gap-3">
|
||||
{#if onCancel}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !isValid || !$isDirty}
|
||||
onclick={handleSaveClick}
|
||||
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
|
||||
>
|
||||
{#if saving}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{mode === 'create' ? 'Creating...' : 'Saving...'}
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
{submitButtonText}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Hidden delete form -->
|
||||
{#if mode === 'edit'}
|
||||
<form
|
||||
bind:this={deleteFormElement}
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
class="hidden"
|
||||
use:enhance={() => {
|
||||
deleting = true;
|
||||
return async ({ result, update: formUpdate }) => {
|
||||
if (result.type === 'failure' && result.data) {
|
||||
alertStore.add('error', (result.data as { error?: string }).error || 'Failed to delete');
|
||||
} else if (result.type === 'redirect') {
|
||||
alertStore.add('success', 'Custom format deleted');
|
||||
}
|
||||
await formUpdate();
|
||||
deleting = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="layer" value={deleteLayer} />
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Save Target Modal -->
|
||||
{#if canWriteToBase}
|
||||
<SaveTargetModal
|
||||
open={showSaveTargetModal}
|
||||
mode="save"
|
||||
on:select={handleLayerSelect}
|
||||
on:cancel={() => (showSaveTargetModal = false)}
|
||||
/>
|
||||
|
||||
<!-- Delete Target Modal -->
|
||||
<SaveTargetModal
|
||||
open={showDeleteTargetModal}
|
||||
mode="delete"
|
||||
on:select={handleDeleteLayerSelect}
|
||||
on:cancel={() => (showDeleteTargetModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
118
src/routes/custom-formats/[databaseId]/new/+page.server.ts
Normal file
118
src/routes/custom-formats/[databaseId]/new/+page.server.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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 type { OperationLayer } from '$pcd/writer.ts';
|
||||
|
||||
export const load: ServerLoad = ({ params }) => {
|
||||
const { databaseId } = params;
|
||||
|
||||
if (!databaseId) {
|
||||
throw error(400, 'Missing database ID');
|
||||
}
|
||||
|
||||
const currentDatabaseId = parseInt(databaseId, 10);
|
||||
if (isNaN(currentDatabaseId)) {
|
||||
throw error(400, 'Invalid database ID');
|
||||
}
|
||||
|
||||
const currentDatabase = pcdManager.getById(currentDatabaseId);
|
||||
if (!currentDatabase) {
|
||||
throw error(404, 'Database not found');
|
||||
}
|
||||
|
||||
return {
|
||||
currentDatabase,
|
||||
canWriteToBase: canWriteToBase(currentDatabaseId)
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, params }) => {
|
||||
const { databaseId } = params;
|
||||
|
||||
if (!databaseId) {
|
||||
return fail(400, { error: 'Missing database ID' });
|
||||
}
|
||||
|
||||
const currentDatabaseId = parseInt(databaseId, 10);
|
||||
if (isNaN(currentDatabaseId)) {
|
||||
return fail(400, { error: 'Invalid database ID' });
|
||||
}
|
||||
|
||||
const cache = pcdManager.getCache(currentDatabaseId);
|
||||
if (!cache) {
|
||||
return fail(500, { error: 'Database cache not available' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
// Parse form data
|
||||
const name = formData.get('name') as string;
|
||||
const description = (formData.get('description') as string) || null;
|
||||
const tagsJson = formData.get('tags') as string;
|
||||
const includeInRename = formData.get('includeInRename') === 'true';
|
||||
const layer = (formData.get('layer') as OperationLayer) || 'user';
|
||||
|
||||
// Validate
|
||||
if (!name?.trim()) {
|
||||
return fail(400, { error: 'Name is required' });
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
const existingFormats = await customFormatQueries.list(cache);
|
||||
const duplicate = existingFormats.find(f => f.name.toLowerCase() === name.trim().toLowerCase());
|
||||
if (duplicate) {
|
||||
return fail(400, { error: `A custom format named "${name.trim()}" already exists` });
|
||||
}
|
||||
|
||||
let tags: string[] = [];
|
||||
try {
|
||||
tags = JSON.parse(tagsJson || '[]');
|
||||
} catch {
|
||||
return fail(400, { error: 'Invalid tags format' });
|
||||
}
|
||||
|
||||
// Check layer permission
|
||||
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
|
||||
return fail(403, { error: 'Cannot write to base layer without personal access token' });
|
||||
}
|
||||
|
||||
// Create the custom format
|
||||
const result = await customFormatQueries.create({
|
||||
databaseId: currentDatabaseId,
|
||||
cache,
|
||||
layer,
|
||||
input: {
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
includeInRename,
|
||||
tags
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return fail(500, { error: result.error || 'Failed to create custom format' });
|
||||
}
|
||||
|
||||
// Get fresh cache after create (compile creates a new cache instance)
|
||||
const freshCache = pcdManager.getCache(currentDatabaseId);
|
||||
if (!freshCache) {
|
||||
// Fallback to list page if cache isn't ready
|
||||
throw redirect(303, `/custom-formats/${databaseId}`);
|
||||
}
|
||||
|
||||
// Get the new format ID by looking it up by name
|
||||
const formats = await customFormatQueries.list(freshCache);
|
||||
const newFormat = formats.find(f => f.name === name.trim());
|
||||
|
||||
if (newFormat) {
|
||||
// Redirect to conditions page so user can add conditions
|
||||
throw redirect(303, `/custom-formats/${databaseId}/${newFormat.id}/conditions`);
|
||||
}
|
||||
|
||||
// Fallback to list page if we can't find the new format
|
||||
throw redirect(303, `/custom-formats/${databaseId}`);
|
||||
}
|
||||
};
|
||||
36
src/routes/custom-formats/[databaseId]/new/+page.svelte
Normal file
36
src/routes/custom-formats/[databaseId]/new/+page.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import GeneralForm from '../components/GeneralForm.svelte';
|
||||
import DirtyModal from '$ui/modal/DirtyModal.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// Default initial data for new format
|
||||
const initialData = {
|
||||
name: '',
|
||||
tags: [],
|
||||
description: '',
|
||||
includeInRename: false
|
||||
};
|
||||
|
||||
function handleCancel() {
|
||||
goto(`/custom-formats/${data.currentDatabase.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>New Custom Format - {data.currentDatabase.name} - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-8">
|
||||
<GeneralForm
|
||||
mode="create"
|
||||
databaseName={data.currentDatabase.name}
|
||||
canWriteToBase={data.canWriteToBase}
|
||||
{initialData}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DirtyModal />
|
||||
Reference in New Issue
Block a user