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:
Sam Chau
2026-01-03 04:07:08 +10:30
parent 08710ffcb4
commit 8deef25c9e
11 changed files with 734 additions and 204 deletions

View 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;
}

View 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;
}

View File

@@ -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';

View File

@@ -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}`);
}
};

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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;
};
}}

View File

@@ -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 />

View File

@@ -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}

View 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}`);
}
};

View 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 />