refactor: move arr settings page into tabbed layout

This commit is contained in:
Sam Chau
2026-01-26 01:57:38 +10:30
parent 568681ad14
commit f89ba67899
18 changed files with 472 additions and 492 deletions

View File

@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
export let position: 'top' | 'bottom' = 'top';
export let variant: 'default' | 'transparent' | 'blur' = 'default';
let isStuck = false;
let sentinel: HTMLDivElement;
@@ -18,6 +19,13 @@
return () => observer.disconnect();
});
$: bgClass =
variant === 'default'
? 'bg-neutral-50 dark:bg-neutral-900'
: variant === 'blur'
? 'backdrop-blur-sm bg-neutral-50/50 dark:bg-neutral-900/50'
: '';
</script>
<div
@@ -26,7 +34,7 @@
></div>
<div
class="sticky z-10 -mx-8 bg-neutral-50 dark:bg-neutral-900
class="sticky z-10 -mx-8 {bgClass}
{position === 'top' ? 'top-0' : 'bottom-0'}"
>
<div class="px-12 py-4">
@@ -35,17 +43,19 @@
<slot name="right" />
</div>
</div>
{#if position === 'top'}
<div
class="border-b border-neutral-200 transition-[margin] duration-200 dark:border-neutral-800 {isStuck
? ''
: 'mx-8'}"
></div>
{:else}
<div
class="border-t border-neutral-200 transition-[margin] duration-200 dark:border-neutral-800 {isStuck
? ''
: 'mx-8'}"
></div>
{#if variant === 'default'}
{#if position === 'top'}
<div
class="border-b border-neutral-200 transition-[margin] duration-200 dark:border-neutral-800 {isStuck
? ''
: 'mx-8'}"
></div>
{:else}
<div
class="border-t border-neutral-200 transition-[margin] duration-200 dark:border-neutral-800 {isStuck
? ''
: 'mx-8'}"
></div>
{/if}
{/if}
</div>

View File

@@ -25,6 +25,8 @@
export let fixed: boolean = false;
// Custom width class (overrides fullWidth if set)
export let width: string | undefined = undefined;
// Disable the dropdown
export let disabled: boolean = false;
const dispatch = createEventDispatcher<{ change: string }>();
@@ -88,9 +90,10 @@
iconPosition="right"
size={buttonSize}
{fullWidth}
{disabled}
justify={fullWidth || width ? 'between' : 'center'}
textColor={isPlaceholder ? 'text-neutral-400 dark:text-neutral-500' : ''}
on:click={() => (open = !open)}
on:click={() => !disabled && (open = !open)}
/>
{#if open}
<Dropdown {position} {minWidth} compact={isCompactDropdown} {fixed} {triggerEl}>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { Eye, EyeOff } from 'lucide-svelte';
export let label: string;
@@ -12,15 +13,26 @@
export let autocomplete: string = '';
export let private_: boolean = false;
export let readonly: boolean = false;
export let mono: boolean = false;
const dispatch = createEventDispatcher<{ input: string }>();
$: fontClass = mono ? 'font-mono' : '';
let showPassword = false;
$: inputType = private_ && showPassword ? 'text' : type;
$: inputType = private_ ? (showPassword ? 'text' : 'password') : type;
function handleInput(e: Event) {
const target = e.target as HTMLInputElement | HTMLTextAreaElement;
value = target.value;
dispatch('input', value);
}
</script>
<div class="space-y-2">
<label for={name} class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
{label}
{label}{#if required}<span class="text-red-500">*</span>{/if}
</label>
{#if description}
@@ -33,10 +45,11 @@
<textarea
id={name}
{name}
bind:value
{value}
{placeholder}
rows="6"
class="block w-full rounded-xl border border-neutral-300 bg-white px-3 py-2 shadow text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-300 focus:outline-none dark:border-neutral-700/60 dark:bg-neutral-800/50 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-600"
oninput={handleInput}
class="block w-full rounded-xl border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-300 focus:outline-none dark:border-neutral-700/60 dark:bg-neutral-800/50 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-600 {fontClass}"
></textarea>
{:else if private_}
<div class="relative">
@@ -44,12 +57,13 @@
id={name}
{name}
type={inputType}
bind:value
{value}
{placeholder}
{required}
readonly={readonly}
oninput={handleInput}
autocomplete={autocomplete ? (autocomplete as typeof HTMLInputElement.prototype.autocomplete) : undefined}
class="block w-full rounded-xl border border-neutral-300 px-3 py-2 pr-10 shadow text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none dark:border-neutral-700/60 dark:text-neutral-50 dark:placeholder-neutral-500 {readonly ? 'bg-white cursor-default dark:bg-neutral-800/50' : 'bg-white focus:border-neutral-400 dark:bg-neutral-800/50 dark:focus:border-neutral-600'}"
class="block w-full rounded-xl border border-neutral-300 px-3 py-2 pr-10 text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none dark:border-neutral-700/60 dark:text-neutral-50 dark:placeholder-neutral-500 {fontClass} {readonly ? 'bg-white cursor-default dark:bg-neutral-800/50' : 'bg-white focus:border-neutral-400 dark:bg-neutral-800/50 dark:focus:border-neutral-600'}"
/>
<button
type="button"
@@ -68,12 +82,13 @@
id={name}
{name}
{type}
bind:value
{value}
{placeholder}
{required}
readonly={readonly}
oninput={handleInput}
autocomplete={autocomplete ? (autocomplete as typeof HTMLInputElement.prototype.autocomplete) : undefined}
class="block w-full rounded-xl border border-neutral-300 px-3 py-2 shadow text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none dark:border-neutral-700/60 dark:text-neutral-50 dark:placeholder-neutral-500 {readonly ? 'bg-white cursor-default dark:bg-neutral-800/50' : 'bg-white focus:border-neutral-400 dark:bg-neutral-800/50 dark:focus:border-neutral-600'}"
class="block w-full rounded-xl border border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none dark:border-neutral-700/60 dark:text-neutral-50 dark:placeholder-neutral-500 {fontClass} {readonly ? 'bg-white cursor-default dark:bg-neutral-800/50' : 'bg-white focus:border-neutral-400 dark:bg-neutral-800/50 dark:focus:border-neutral-600'}"
/>
{/if}
</div>

View File

@@ -37,7 +37,7 @@
</script>
<div
class="flex min-h-[2.5rem] flex-wrap items-center gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-2 transition-colors focus-within:border-neutral-400 dark:border-neutral-700 dark:bg-neutral-800 dark:focus-within:border-neutral-500"
class="flex min-h-[2.5rem] flex-wrap items-center gap-2 rounded-xl border border-neutral-300 bg-white px-3 py-2 transition-colors focus-within:border-neutral-400 dark:border-neutral-700/60 dark:bg-neutral-800/50 dark:focus-within:border-neutral-600"
>
{#each tags as tag, index (tag)}
<span class="inline-flex items-center gap-1">

View File

@@ -162,5 +162,25 @@ export const arrInstancesQueries = {
name
);
return (result?.count ?? 0) > 0;
},
/**
* Check if an instance with the same API key already exists
*/
apiKeyExists(apiKey: string, excludeId?: number): boolean {
if (excludeId !== undefined) {
const result = db.queryFirst<{ count: number }>(
'SELECT COUNT(*) as count FROM arr_instances WHERE api_key = ? AND id != ?',
apiKey,
excludeId
);
return (result?.count ?? 0) > 0;
}
const result = db.queryFirst<{ count: number }>(
'SELECT COUNT(*) as count FROM arr_instances WHERE api_key = ?',
apiKey
);
return (result?.count ?? 0) > 0;
}
};

View File

@@ -126,7 +126,7 @@
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-1">
<a href="/arr/{row.id}/edit" on:click={(e) => e.stopPropagation()}>
<a href="/arr/{row.id}/settings" on:click={(e) => e.stopPropagation()}>
<TableActionButton icon={Pencil} title="Edit instance" />
</a>
<a

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import { page } from '$app/stores';
import { Library, RefreshCw, ArrowUpCircle, FileEdit, ScrollText } from 'lucide-svelte';
import { Library, RefreshCw, ArrowUpCircle, FileEdit, ScrollText, Settings } from 'lucide-svelte';
import type { LayoutData } from './$types';
export let data: LayoutData;
@@ -10,6 +10,12 @@
$: currentPath = $page.url.pathname;
$: tabs = [
{
label: 'Settings',
href: `/arr/${instanceId}/settings`,
active: currentPath.includes('/settings'),
icon: Settings
},
{
label: 'Library',
href: `/arr/${instanceId}/library`,

View File

@@ -2,6 +2,6 @@ import { redirect } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = ({ params }) => {
// Redirect to the library tab by default
redirect(302, `/arr/${params.id}/library`);
// Redirect to the settings tab by default
redirect(302, `/arr/${params.id}/settings`);
};

View File

@@ -1,5 +0,0 @@
<script lang="ts">
// Reset to root layout - edit page doesn't need the tabs
</script>
<slot />

View File

@@ -84,7 +84,7 @@
}
function handleEdit() {
goto(`/arr/${data.instance.id}/edit`);
goto(`/arr/${data.instance.id}/settings`);
}
async function handleDelete() {

View File

@@ -1,28 +1,8 @@
import { error, redirect, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { redirect, fail } from '@sveltejs/kit';
import type { Actions } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = ({ params }) => {
const id = parseInt(params.id || '', 10);
// Validate ID
if (isNaN(id)) {
error(404, `Invalid instance ID: ${params.id}`);
}
// Fetch the specific instance
const instance = arrInstancesQueries.getById(id);
if (!instance) {
error(404, `Instance not found: ${id}`);
}
return {
instance
};
};
export const actions: Actions = {
update: async ({ params, request }) => {
const id = parseInt(params.id || '', 10);
@@ -64,6 +44,11 @@ export const actions: Actions = {
return fail(400, { error: 'An instance with this name already exists' });
}
// Check if API key already exists (each Arr instance has a unique API key)
if (arrInstancesQueries.apiKeyExists(apiKey, id)) {
return fail(400, { error: 'This instance is already connected' });
}
// Parse tags
let tags: string[] = [];
if (tagsJson) {
@@ -84,19 +69,14 @@ export const actions: Actions = {
});
await logger.info(`Updated arr instance: ${name}`, {
source: 'arr/[id]/edit',
source: 'arr/[id]/settings',
meta: { id, name, type: instance.type, url }
});
redirect(303, `/arr/${id}`);
return { success: true };
} catch (err) {
// Re-throw redirect errors
if (err && typeof err === 'object' && 'status' in err && 'location' in err) {
throw err;
}
await logger.error('Failed to update arr instance', {
source: 'arr/[id]/edit',
source: 'arr/[id]/settings',
meta: { error: err instanceof Error ? err.message : String(err) }
});
@@ -110,7 +90,7 @@ export const actions: Actions = {
// Validate ID
if (isNaN(id)) {
await logger.warn('Delete failed: Invalid instance ID', {
source: 'arr/[id]/edit',
source: 'arr/[id]/settings',
meta: { id: params.id }
});
return fail(400, { error: 'Invalid instance ID' });
@@ -121,7 +101,7 @@ export const actions: Actions = {
if (!instance) {
await logger.warn('Delete failed: Instance not found', {
source: 'arr/[id]/edit',
source: 'arr/[id]/settings',
meta: { id }
});
return fail(404, { error: 'Instance not found' });
@@ -132,14 +112,14 @@ export const actions: Actions = {
if (!deleted) {
await logger.error('Failed to delete instance', {
source: 'arr/[id]/edit',
source: 'arr/[id]/settings',
meta: { id, name: instance.name, type: instance.type }
});
return fail(500, { error: 'Failed to delete instance' });
}
await logger.info(`Deleted ${instance.type} instance: ${instance.name}`, {
source: 'arr/[id]/edit',
source: 'arr/[id]/settings',
meta: { id, name: instance.name, type: instance.type, url: instance.url }
});

View File

@@ -6,4 +6,8 @@
export let data: PageData;
</script>
<svelte:head>
<title>{data.instance.name} - Settings - Profilarr</title>
</svelte:head>
<InstanceForm mode="edit" {form} instance={data.instance} />

View File

@@ -84,7 +84,7 @@
</p>
</div>
<div slot="right" class="flex items-center gap-2">
<Button text="Info" icon={Info} href="/arr/{data.instance.id}/upgrades/info" />
<Button text="Info" icon={Info} href="/arr/upgrades/info" />
{#if !isNewConfig && data.config?.dryRun}
<Button
text={clearing ? 'Clearing...' : 'Reset Cache'}

View File

@@ -1,21 +0,0 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
export const load: ServerLoad = ({ params }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
error(404, `Invalid instance ID: ${params.id}`);
}
const instance = arrInstancesQueries.getById(id);
if (!instance) {
error(404, `Instance not found: ${id}`);
}
return {
instance
};
};

View File

@@ -1,10 +1,16 @@
<script lang="ts">
import { Check, X, Loader2, Save, Wifi, Trash2 } from 'lucide-svelte';
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import { Save, Wifi, Trash2 } from 'lucide-svelte';
import { alertStore } from '$alerts/store';
import { isDirty, initEdit, initCreate, update, current, clear } from '$lib/client/stores/dirty';
import type { ArrInstance } from '$db/queries/arrInstances.ts';
import FormInput from '$ui/form/FormInput.svelte';
import DropdownSelect from '$ui/dropdown/DropdownSelect.svelte';
import TagInput from '$ui/form/TagInput.svelte';
import Modal from '$ui/modal/Modal.svelte';
import { enhance } from '$app/forms';
import { alertStore } from '$alerts/store';
import type { ArrInstance } from '$db/queries/arrInstances.ts';
import DirtyModal from '$ui/modal/DirtyModal.svelte';
import Button from '$ui/button/Button.svelte';
// Props
export let mode: 'create' | 'edit';
@@ -23,38 +29,61 @@
}
};
// Form values
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let name = (form as any)?.values?.name ?? (mode === 'edit' ? instance?.name : '') ?? '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let type = mode === 'edit' ? instance?.type : ((form as any)?.values?.type ?? initialType ?? '');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let url = (form as any)?.values?.url ?? (mode === 'edit' ? instance?.url : '') ?? '';
let apiKey = ''; // Never pre-populate API key for security
let tags: string[] = mode === 'edit' && instance ? parseTags(instance.tags) : [];
let enabled: boolean = mode === 'edit' ? Boolean(instance?.enabled) : true;
// Initialize dirty tracking on mount
onMount(() => {
if (mode === 'edit' && instance) {
initEdit({
name: instance.name,
type: instance.type,
url: instance.url,
apiKey: '', // Never pre-populate for security
enabled: instance.enabled ? 'true' : 'false',
tags: JSON.stringify(parseTags(instance.tags))
});
} else {
initCreate({
name: '',
type: initialType,
url: '',
apiKey: '',
enabled: 'true',
tags: '[]'
});
}
return () => clear();
});
// Connection test state
type ConnectionStatus = 'idle' | 'testing' | 'success' | 'error';
let connectionStatus: ConnectionStatus = 'idle';
let connectionError = '';
// Read current values from dirty store
$: name = ($current.name ?? '') as string;
$: type = ($current.type ?? '') as string;
$: url = ($current.url ?? '') as string;
$: apiKey = ($current.apiKey ?? '') as string;
$: enabled = ($current.enabled ?? 'true') as string;
$: tags = JSON.parse(($current.tags ?? '[]') as string) as string[];
// Delete modal state
// UI state
let saving = false;
let deleting = false;
let showDeleteModal = false;
let deleteFormElement: HTMLFormElement;
// Test connection function
// Options for dropdowns
const typeOptions = [
{ value: 'radarr', label: 'Radarr' },
{ value: 'sonarr', label: 'Sonarr' }
];
const enabledOptions = [
{ value: 'true', label: 'Enabled' },
{ value: 'false', label: 'Disabled' }
];
// Manual test connection
async function testConnection() {
// Validation
if (!type || !url || !apiKey) {
connectionError = 'Please fill in Type, URL, and API Key';
connectionStatus = 'error';
alertStore.add('error', 'Please fill in Type, URL, and API Key');
return;
}
connectionStatus = 'testing';
connectionError = '';
try {
const response = await fetch('/arr/test', {
method: 'POST',
@@ -62,356 +91,273 @@
body: JSON.stringify({ type, url, apiKey })
});
const data = await response.json();
const result = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Connection test failed');
throw new Error(result.error || result.message || 'Connection test failed');
}
connectionStatus = 'success';
alertStore.add('success', 'Connection successful!');
} catch (error) {
connectionStatus = 'error';
connectionError = error instanceof Error ? error.message : 'Connection test failed';
const errorMessage = error instanceof Error ? error.message : 'Connection test failed';
alertStore.add('error', errorMessage);
}
}
// Reset connection status when form fields change
function resetConnectionStatus() {
if (connectionStatus !== 'idle') {
connectionStatus = 'idle';
connectionError = '';
// Test connection and submit if successful
async function handleSave() {
if (!type || !url || !apiKey) {
alertStore.add('error', 'Please fill in Type, URL, and API Key');
return;
}
saving = true;
try {
const response = await fetch('/arr/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, url, apiKey })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || result.message || 'Connection test failed');
}
// Connection successful, submit the form
const saveForm = document.getElementById('save-form');
if (saveForm instanceof HTMLFormElement) {
saveForm.requestSubmit();
}
} catch (error) {
saving = false;
const errorMessage = error instanceof Error ? error.message : 'Connection test failed';
alertStore.add('error', errorMessage);
}
}
$: canSubmit = connectionStatus === 'success';
$: canSubmit = $isDirty && !!name && !!url && !!apiKey && (mode === 'edit' || !!type);
// Handle form response
let lastFormId: unknown = null;
$: if (form && form !== lastFormId) {
lastFormId = form;
if (form.success) {
alertStore.add('success', 'Settings saved successfully');
// Reset dirty state with new values (keep apiKey empty)
initEdit({
name,
type,
url,
apiKey: '',
enabled,
tags: JSON.stringify(tags)
});
}
if (form.error) {
alertStore.add('error', form.error);
}
}
// Display text based on mode
$: title = mode === 'create' ? 'Add Arr Instance' : 'Edit Instance';
$: description =
mode === 'create'
? 'Configure a new Radarr or Sonarr instance'
: `Update the configuration for ${instance?.name || 'this instance'}`;
$: submitButtonText = mode === 'create' ? 'Save' : 'Save';
$: successMessage =
mode === 'create' ? 'Instance created successfully!' : 'Instance updated successfully!';
$: errorMessage = mode === 'create' ? 'Failed to save instance' : 'Failed to update instance';
$: title = mode === 'create' ? 'Add Instance' : 'Settings';
$: description = mode === 'create'
? 'Configure a new Radarr or Sonarr instance.'
: `Configure connection and sync settings for ${instance?.name || 'this instance'}.`;
</script>
<div class="space-y-8 p-8">
<div class="space-y-3">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">{title}</h1>
<p class="text-lg text-neutral-600 dark:text-neutral-400">
{description}
</p>
<div class="space-y-6" class:mt-6={mode === 'edit'}>
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-50">{title}</h1>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">{description}</p>
</div>
<div class="flex items-center gap-2">
{#if mode === 'edit'}
<Button
text="Delete"
icon={Trash2}
iconColor="text-red-600 dark:text-red-400"
disabled={saving || deleting}
on:click={() => (showDeleteModal = true)}
/>
{/if}
<Button
text={saving ? 'Saving...' : 'Save'}
icon={Save}
iconColor="text-blue-600 dark:text-blue-400"
disabled={saving || !canSubmit}
on:click={handleSave}
/>
</div>
</div>
<div
class="space-y-4 rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900"
>
<!-- Type Row -->
<div class="space-y-2">
<label class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
Type{#if mode === 'create'}<span class="text-red-500">*</span>{/if}
</label>
{#if mode === 'edit'}
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Type cannot be changed after creation
</p>
{/if}
<DropdownSelect
value={type}
options={typeOptions}
placeholder="Select type..."
disabled={mode === 'edit'}
on:change={(e) => update('type', e.detail)}
/>
</div>
<!-- Name + Status Row -->
<div class="flex items-end gap-4">
<div class="flex-1">
<FormInput
label="Name"
name="name"
value={name}
placeholder="e.g., Main Radarr, 4K Sonarr"
required
on:input={(e) => update('name', e.detail)}
/>
</div>
<div class="flex items-center gap-2">
<DropdownSelect
label="Status:"
value={enabled}
options={enabledOptions}
responsiveButton
on:change={(e) => update('enabled', e.detail)}
/>
</div>
</div>
{#if enabled === 'false'}
<p class="text-xs text-amber-600 dark:text-amber-400">
Disabled instances are excluded from sync operations
</p>
{/if}
<!-- URL Row -->
<FormInput
label="URL"
name="url"
type="url"
value={url}
placeholder="http://localhost:7878"
description="Use container name if on the same Docker network, e.g. http://radarr:7878"
required
on:input={(e) => update('url', e.detail)}
/>
<!-- API Key + Test Connection Row -->
<div class="flex items-end gap-4">
<div class="flex-1">
<FormInput
label="API Key"
name="api_key"
value={apiKey}
placeholder="Enter API key"
description={mode === 'edit' ? 'Re-enter API key to save changes' : ''}
required
private_
on:input={(e) => update('apiKey', e.detail)}
/>
</div>
<Button
text="Test Connection"
icon={Wifi}
disabled={!apiKey || !url || (mode === 'create' && !type)}
on:click={testConnection}
/>
</div>
<!-- Tags Row -->
<div class="space-y-2">
<label class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
Tags
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Press Enter to add a tag, Backspace to remove
</p>
<TagInput
{tags}
on:change={(e) => update('tags', JSON.stringify(e.detail))}
/>
</div>
</div>
</div>
<!-- Hidden save form -->
<form
id="save-form"
method="POST"
action={mode === 'edit' ? '?/update' : undefined}
class="hidden"
use:enhance={() => {
saving = true;
return async ({ result, update: formUpdate }) => {
if (result.type === 'redirect') {
// For create mode, clear dirty state before redirect
clear();
alertStore.add('success', 'Instance created successfully');
}
await formUpdate({ reset: false });
saving = false;
};
}}
>
<input type="hidden" name="name" value={name} />
<input type="hidden" name="type" value={type} />
<input type="hidden" name="url" value={url} />
<input type="hidden" name="api_key" value={apiKey} />
<input type="hidden" name="enabled" value={enabled === 'true' ? '1' : '0'} />
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
</form>
<!-- Hidden delete form (edit mode only) -->
{#if mode === 'edit'}
<form
id="delete-form"
method="POST"
action={mode === 'edit' ? '?/update' : undefined}
class="space-y-6"
action="?/delete"
class="hidden"
use:enhance={() => {
deleting = true;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add('error', (result.data as { error?: string }).error || errorMessage);
alertStore.add('error', (result.data as { error?: string }).error || 'Failed to delete');
} else if (result.type === 'redirect') {
alertStore.add('success', successMessage);
alertStore.add('success', 'Instance deleted successfully');
}
await update();
deleting = false;
};
}}
>
<!-- Instance Details -->
<div
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
>
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Instance Details
</h2>
<div class="space-y-4">
<!-- Name -->
<div>
<label
for="name"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={name}
required
placeholder="e.g., Main Radarr, 4K Sonarr"
class="mt-1 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-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
/>
</div>
<!-- Type -->
<div>
<label
for="type"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Type <span class="text-red-500">*</span>
</label>
{#if mode === 'edit'}
<input
type="text"
id="type"
name="type"
value={type.charAt(0).toUpperCase() + type.slice(1)}
disabled
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-neutral-100 px-3 py-2 text-sm text-neutral-500 disabled:cursor-not-allowed dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400"
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Type cannot be changed after creation
</p>
<input type="hidden" name="type" value={type} />
{:else}
<select
id="type"
name="type"
bind:value={type}
on:change={resetConnectionStatus}
required
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:focus:border-neutral-500"
>
<option value="">Select type...</option>
<option value="radarr">Radarr</option>
<option value="sonarr">Sonarr</option>
</select>
{/if}
</div>
<!-- Enabled Toggle -->
<div class="flex items-center justify-between">
<div>
<label
for="enabled"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Enabled
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Disable to exclude this instance from sync operations
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="Toggle enabled status"
on:click={() => (enabled = !enabled)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none {enabled
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-neutral-200 dark:bg-neutral-700'}"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {enabled
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
<input type="hidden" name="enabled" value={enabled ? '1' : '0'} />
</div>
</div>
</div>
<!-- Connection -->
<div
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
>
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Connection Settings
</h2>
<div class="space-y-4">
<!-- URL -->
<div>
<label for="url" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
URL <span class="text-red-500">*</span>
</label>
<input
type="url"
id="url"
name="url"
bind:value={url}
on:input={resetConnectionStatus}
required
placeholder="http://localhost:7878"
class="mt-1 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-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
/>
</div>
<!-- API Key -->
<div>
<label
for="api_key"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
API Key <span class="text-red-500">*</span>
</label>
<input
type="text"
id="api_key"
name="api_key"
bind:value={apiKey}
on:input={resetConnectionStatus}
required
placeholder={mode === 'edit' ? 'Enter API key to test connection' : 'Enter API key'}
class="mt-1 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-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
/>
{#if mode === 'edit'}
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Re-enter API key to update or test connection
</p>
{/if}
</div>
</div>
<div class="mt-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1 text-sm">
{#if connectionStatus === 'success'}
<p class="text-green-600 dark:text-green-400">
Connection test passed! You can now save this instance.
</p>
{/if}
{#if connectionStatus === 'error'}
<p class="text-red-600 dark:text-red-400">
{connectionError}
</p>
{/if}
{#if !canSubmit && connectionStatus !== 'success'}
<p class="text-neutral-600 dark:text-neutral-400">
Please test the connection before saving
</p>
{/if}
</div>
<button
type="button"
on:click={testConnection}
disabled={connectionStatus === 'testing'}
class="flex items-center gap-2 self-start 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 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
{#if connectionStatus === 'testing'}
<Loader2 size={14} class="animate-spin" />
Testing...
{:else if connectionStatus === 'success'}
<Check size={14} class="text-green-600 dark:text-green-400" />
Connected
{:else if connectionStatus === 'error'}
<X size={14} class="text-red-600 dark:text-red-400" />
Test Again
{:else}
<Wifi size={14} />
Test Connection
{/if}
</button>
</div>
</div>
<!-- Tags -->
<div
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
>
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">Tags</h2>
<label
for="tags-input"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Optional Tags
</label>
<div class="mt-1">
<TagInput bind:tags />
</div>
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
Press Enter to add a tag, Backspace to remove.
</p>
<input type="hidden" name="tags" value={tags.length > 0 ? JSON.stringify(tags) : ''} />
</div>
<!-- Actions -->
<div class="flex flex-wrap items-center justify-end gap-3">
{#if mode === 'edit'}
<a
href="/arr/{instance?.id}"
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
</a>
{/if}
<button
type="submit"
disabled={!canSubmit}
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<Save size={14} />
{submitButtonText}
</button>
</div>
</form>
<!-- Delete Section (Edit Mode Only) -->
{#if mode === 'edit'}
<div
class="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-950/40"
>
<h2 class="text-lg font-semibold text-red-700 dark:text-red-300">Danger Zone</h2>
<p class="mt-2 text-sm text-red-600 dark:text-red-400">
Once you delete this instance, there is no going back. Please be certain.
</p>
<button
type="button"
on:click={() => (showDeleteModal = true)}
class="mt-4 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 dark:border-red-700 dark:bg-neutral-900 dark:text-red-300 dark:hover:bg-red-900"
>
<Trash2 size={14} />
Delete Instance
</button>
<form
bind:this={deleteFormElement}
method="POST"
action="?/delete"
class="hidden"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to delete instance'
);
} else if (result.type === 'redirect') {
alertStore.add('success', 'Instance deleted successfully');
}
await update();
};
}}
>
<!-- Empty form, just for submission -->
</form>
</div>
{/if}
</div>
></form>
{/if}
<!-- Delete Confirmation Modal -->
{#if mode === 'edit'}
<Modal
open={showDeleteModal}
header="Delete Instance"
bodyMessage={`Are you sure you want to delete "${instance?.name}"? This action cannot be undone and all data will be permanently lost.`}
bodyMessage={`Are you sure you want to delete "${instance?.name}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
confirmDanger={true}
on:confirm={() => {
showDeleteModal = false;
deleteFormElement?.requestSubmit();
const deleteForm = document.getElementById('delete-form');
if (deleteForm instanceof HTMLFormElement) {
deleteForm.requestSubmit();
}
}}
on:cancel={() => (showDeleteModal = false)}
/>
{/if}
<DirtyModal />

View File

@@ -58,6 +58,19 @@ export const actions = {
});
}
// Check if API key already exists (each Arr instance has a unique API key)
if (arrInstancesQueries.apiKeyExists(apiKey)) {
await logger.warn('Attempted to create duplicate instance', {
source: 'arr/new',
meta: { name, type, url }
});
return fail(400, {
error: 'This instance is already connected',
values: { name, type, url }
});
}
// Parse tags
let tags: string[] = [];
if (tagsJson) {
@@ -132,6 +145,6 @@ export const actions = {
}
// Redirect to the new instance page (outside try-catch since redirect throws)
redirect(303, `/arr/${id}`);
redirect(303, `/arr/${id}/settings`);
}
} satisfies Actions;

View File

@@ -6,7 +6,13 @@
export let form: ActionData;
// Get type from URL params if provided
const typeFromUrl = $page.url.searchParams.get('type') || '';
$: typeFromUrl = $page.url.searchParams.get('type') || '';
</script>
<InstanceForm mode="create" {form} initialType={typeFromUrl} />
<svelte:head>
<title>Add Instance - Profilarr</title>
</svelte:head>
<div class="p-8">
<InstanceForm mode="create" {form} initialType={typeFromUrl} />
</div>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import type { PageData } from './$types';
import { filterFields, filterModes } from '$lib/shared/filters';
import { selectors } from '$lib/shared/selectors';
import { ArrowLeft } from 'lucide-svelte';
@@ -9,8 +8,6 @@
import Table from '$ui/table/Table.svelte';
import type { Column } from '$ui/table/types';
export let data: PageData;
// Filter fields with type labels
const typeLabels: Record<string, string> = {
boolean: 'Yes/No',
@@ -43,7 +40,7 @@
html: row.operators
.map((op) => `<span class="${badgeBase} ${badgeNeutral}">${op.label}</span>`)
.join(' ')
})
})
}
];
@@ -109,71 +106,77 @@
{ key: 'name', header: 'Concept', sortable: false },
{ key: 'summary', header: 'Summary', sortable: false }
];
function handleBack() {
history.back();
}
</script>
<svelte:head>
<title>{data.instance.name} - Upgrades Info - Profilarr</title>
<title>How Upgrades Work - Profilarr</title>
</svelte:head>
<StickyCard position="top">
<div slot="left">
<h1 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">How Upgrades Work</h1>
</div>
<div slot="right">
<Button text="Back" icon={ArrowLeft} href="/arr/{data.instance.id}/upgrades" />
</div>
</StickyCard>
<div class="p-8">
<StickyCard position="top">
<div slot="left">
<h1 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">How Upgrades Work</h1>
</div>
<div slot="right">
<Button text="Back" icon={ArrowLeft} on:click={handleBack} />
</div>
</StickyCard>
<div class="mt-6 space-y-8 px-4">
<!-- Intro -->
<div class="text-neutral-600 dark:text-neutral-400">
<p>
Radarr and Sonarr don't search for the best release. They monitor RSS feeds and grab the
first thing that qualifies as an upgrade. To get optimal releases, you need manual searches.
This module automates that: <span class="font-medium text-neutral-700 dark:text-neutral-300"
>Filter</span
<div class="mt-6 space-y-8 px-4">
<!-- Intro -->
<div class="text-neutral-600 dark:text-neutral-400">
<p>
Radarr and Sonarr don't search for the best release. They monitor RSS feeds and grab the
first thing that qualifies as an upgrade. To get optimal releases, you need manual searches.
This module automates that: <span class="font-medium text-neutral-700 dark:text-neutral-300"
>Filter</span
>
your library,
<span class="font-medium text-neutral-700 dark:text-neutral-300">Select</span> items to search,
then
<span class="font-medium text-neutral-700 dark:text-neutral-300">Search</span> for better releases.
</p>
</div>
<!-- Concepts -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Concepts</h2>
<ExpandableTable
columns={conceptColumns}
data={concepts}
getRowId={(row) => row.id}
emptyMessage="No concepts"
chevronPosition="right"
flushExpanded
>
your library,
<span class="font-medium text-neutral-700 dark:text-neutral-300">Select</span> items to search,
then
<span class="font-medium text-neutral-700 dark:text-neutral-300">Search</span> for better releases.
</p>
<svelte:fragment slot="expanded" let:row>
<div class="px-6 py-4">
<p class="text-sm text-neutral-600 dark:text-neutral-400">{row.details}</p>
</div>
</svelte:fragment>
</ExpandableTable>
</section>
<!-- Selectors Reference -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Selectors</h2>
<Table columns={selectorColumns} data={selectors} emptyMessage="No selectors" />
</section>
<!-- Filter Modes Reference -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Filter Modes</h2>
<Table columns={modeColumns} data={filterModes} emptyMessage="No modes" />
</section>
<!-- Filter Fields Reference -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Filter Fields</h2>
<Table columns={fieldColumns} data={filterFields} emptyMessage="No fields" />
</section>
</div>
<!-- Concepts -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Concepts</h2>
<ExpandableTable
columns={conceptColumns}
data={concepts}
getRowId={(row) => row.id}
emptyMessage="No concepts"
chevronPosition="right"
flushExpanded
>
<svelte:fragment slot="expanded" let:row>
<div class="px-6 py-4">
<p class="text-sm text-neutral-600 dark:text-neutral-400">{row.details}</p>
</div>
</svelte:fragment>
</ExpandableTable>
</section>
<!-- Selectors Reference -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Selectors</h2>
<Table columns={selectorColumns} data={selectors} emptyMessage="No selectors" />
</section>
<!-- Filter Modes Reference -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Filter Modes</h2>
<Table columns={modeColumns} data={filterModes} emptyMessage="No modes" />
</section>
<!-- Filter Fields Reference -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Filter Fields</h2>
<Table columns={fieldColumns} data={filterFields} emptyMessage="No fields" />
</section>
</div>