From f89ba678993bf0d137f5c3a91ec1c440f6aff439 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Mon, 26 Jan 2026 01:57:38 +1030 Subject: [PATCH] refactor: move arr settings page into tabbed layout --- src/lib/client/ui/card/StickyCard.svelte | 36 +- .../client/ui/dropdown/DropdownSelect.svelte | 5 +- src/lib/client/ui/form/FormInput.svelte | 31 +- src/lib/client/ui/form/TagInput.svelte | 2 +- src/lib/server/db/queries/arrInstances.ts | 20 + src/routes/arr/+page.svelte | 2 +- src/routes/arr/[id]/+layout.svelte | 8 +- src/routes/arr/[id]/+page.server.ts | 4 +- src/routes/arr/[id]/edit/+layout@.svelte | 5 - src/routes/arr/[id]/library/+page.svelte | 2 +- .../[id]/{edit => settings}/+page.server.ts | 48 +- .../arr/[id]/{edit => settings}/+page.svelte | 4 + src/routes/arr/[id]/upgrades/+page.svelte | 2 +- .../arr/[id]/upgrades/info/+page.server.ts | 21 - src/routes/arr/components/InstanceForm.svelte | 620 ++++++++---------- src/routes/arr/new/+page.server.ts | 15 +- src/routes/arr/new/+page.svelte | 10 +- .../arr/{[id] => }/upgrades/info/+page.svelte | 129 ++-- 18 files changed, 472 insertions(+), 492 deletions(-) delete mode 100644 src/routes/arr/[id]/edit/+layout@.svelte rename src/routes/arr/[id]/{edit => settings}/+page.server.ts (78%) rename src/routes/arr/[id]/{edit => settings}/+page.svelte (75%) delete mode 100644 src/routes/arr/[id]/upgrades/info/+page.server.ts rename src/routes/arr/{[id] => }/upgrades/info/+page.svelte (65%) diff --git a/src/lib/client/ui/card/StickyCard.svelte b/src/lib/client/ui/card/StickyCard.svelte index a6e2043..e121d0e 100644 --- a/src/lib/client/ui/card/StickyCard.svelte +++ b/src/lib/client/ui/card/StickyCard.svelte @@ -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' + : '';
@@ -35,17 +43,19 @@
- {#if position === 'top'} -
- {:else} -
+ {#if variant === 'default'} + {#if position === 'top'} +
+ {:else} +
+ {/if} {/if} diff --git a/src/lib/client/ui/dropdown/DropdownSelect.svelte b/src/lib/client/ui/dropdown/DropdownSelect.svelte index de4c263..71bf1a5 100644 --- a/src/lib/client/ui/dropdown/DropdownSelect.svelte +++ b/src/lib/client/ui/dropdown/DropdownSelect.svelte @@ -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} diff --git a/src/lib/client/ui/form/FormInput.svelte b/src/lib/client/ui/form/FormInput.svelte index 8b99727..3263cc3 100644 --- a/src/lib/client/ui/form/FormInput.svelte +++ b/src/lib/client/ui/form/FormInput.svelte @@ -1,4 +1,5 @@
{#if description} @@ -33,10 +45,11 @@ {:else if private_}
@@ -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'}" />
diff --git a/src/lib/client/ui/form/TagInput.svelte b/src/lib/client/ui/form/TagInput.svelte index f2166c0..ea468d7 100644 --- a/src/lib/client/ui/form/TagInput.svelte +++ b/src/lib/client/ui/form/TagInput.svelte @@ -37,7 +37,7 @@
{#each tags as tag, index (tag)} diff --git a/src/lib/server/db/queries/arrInstances.ts b/src/lib/server/db/queries/arrInstances.ts index 9bc320e..6496d1b 100644 --- a/src/lib/server/db/queries/arrInstances.ts +++ b/src/lib/server/db/queries/arrInstances.ts @@ -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; } }; diff --git a/src/routes/arr/+page.svelte b/src/routes/arr/+page.svelte index e761520..6352a30 100644 --- a/src/routes/arr/+page.svelte +++ b/src/routes/arr/+page.svelte @@ -126,7 +126,7 @@
- e.stopPropagation()}> + e.stopPropagation()}> 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`, diff --git a/src/routes/arr/[id]/+page.server.ts b/src/routes/arr/[id]/+page.server.ts index b58e859..2afbef2 100644 --- a/src/routes/arr/[id]/+page.server.ts +++ b/src/routes/arr/[id]/+page.server.ts @@ -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`); }; diff --git a/src/routes/arr/[id]/edit/+layout@.svelte b/src/routes/arr/[id]/edit/+layout@.svelte deleted file mode 100644 index af1293c..0000000 --- a/src/routes/arr/[id]/edit/+layout@.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/src/routes/arr/[id]/library/+page.svelte b/src/routes/arr/[id]/library/+page.svelte index 738eed6..6c05d27 100644 --- a/src/routes/arr/[id]/library/+page.svelte +++ b/src/routes/arr/[id]/library/+page.svelte @@ -84,7 +84,7 @@ } function handleEdit() { - goto(`/arr/${data.instance.id}/edit`); + goto(`/arr/${data.instance.id}/settings`); } async function handleDelete() { diff --git a/src/routes/arr/[id]/edit/+page.server.ts b/src/routes/arr/[id]/settings/+page.server.ts similarity index 78% rename from src/routes/arr/[id]/edit/+page.server.ts rename to src/routes/arr/[id]/settings/+page.server.ts index 9bb4457..72c211c 100644 --- a/src/routes/arr/[id]/edit/+page.server.ts +++ b/src/routes/arr/[id]/settings/+page.server.ts @@ -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 } }); diff --git a/src/routes/arr/[id]/edit/+page.svelte b/src/routes/arr/[id]/settings/+page.svelte similarity index 75% rename from src/routes/arr/[id]/edit/+page.svelte rename to src/routes/arr/[id]/settings/+page.svelte index 230fb97..c0205f3 100644 --- a/src/routes/arr/[id]/edit/+page.svelte +++ b/src/routes/arr/[id]/settings/+page.svelte @@ -6,4 +6,8 @@ export let data: PageData; + + {data.instance.name} - Settings - Profilarr + + diff --git a/src/routes/arr/[id]/upgrades/+page.svelte b/src/routes/arr/[id]/upgrades/+page.svelte index 601d128..f058cc6 100644 --- a/src/routes/arr/[id]/upgrades/+page.svelte +++ b/src/routes/arr/[id]/upgrades/+page.svelte @@ -84,7 +84,7 @@

-
+
+ +
+ + {#if mode === 'edit'} +

+ Type cannot be changed after creation +

+ {/if} + update('type', e.detail)} + /> +
+ +
+
+ update('name', e.detail)} + /> +
+
+ update('enabled', e.detail)} + /> +
+
+ {#if enabled === 'false'} +

+ Disabled instances are excluded from sync operations +

+ {/if} + + update('url', e.detail)} + /> + +
+
+ update('apiKey', e.detail)} + /> +
+
+ +
+ +

+ Press Enter to add a tag, Backspace to remove +

+ update('tags', JSON.stringify(e.detail))} + /> +
+
+
+ + + + + +{#if mode === 'edit'}
{ + 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 -

- -
- -
- - -
- - -
- - {#if mode === 'edit'} - -

- Type cannot be changed after creation -

- - {:else} - - {/if} -
- - -
-
- -

- Disable to exclude this instance from sync operations -

-
- - -
-
-
- - -
-

- Connection Settings -

- -
- -
- - -
- - -
- - - {#if mode === 'edit'} -

- Re-enter API key to update or test connection -

- {/if} -
-
- -
-
- {#if connectionStatus === 'success'} -

- Connection test passed! You can now save this instance. -

- {/if} - {#if connectionStatus === 'error'} -

- {connectionError} -

- {/if} - {#if !canSubmit && connectionStatus !== 'success'} -

- Please test the connection before saving -

- {/if} -
- - -
-
- - -
-

Tags

- - -
- -
-

- Press Enter to add a tag, Backspace to remove. -

- 0 ? JSON.stringify(tags) : ''} /> -
- - -
- {#if mode === 'edit'} - - Cancel - - {/if} - -
-
- - - {#if mode === 'edit'} -
-

Danger Zone

-

- Once you delete this instance, there is no going back. Please be certain. -

- - - -
- {/if} - + > +{/if} {#if mode === 'edit'} { showDeleteModal = false; - deleteFormElement?.requestSubmit(); + const deleteForm = document.getElementById('delete-form'); + if (deleteForm instanceof HTMLFormElement) { + deleteForm.requestSubmit(); + } }} on:cancel={() => (showDeleteModal = false)} /> {/if} + + diff --git a/src/routes/arr/new/+page.server.ts b/src/routes/arr/new/+page.server.ts index 19175f1..cff8c33 100644 --- a/src/routes/arr/new/+page.server.ts +++ b/src/routes/arr/new/+page.server.ts @@ -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; diff --git a/src/routes/arr/new/+page.svelte b/src/routes/arr/new/+page.svelte index 88b7c7b..d4150f8 100644 --- a/src/routes/arr/new/+page.svelte +++ b/src/routes/arr/new/+page.svelte @@ -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') || ''; - + + Add Instance - Profilarr + + +
+ +
diff --git a/src/routes/arr/[id]/upgrades/info/+page.svelte b/src/routes/arr/upgrades/info/+page.svelte similarity index 65% rename from src/routes/arr/[id]/upgrades/info/+page.svelte rename to src/routes/arr/upgrades/info/+page.svelte index 01c65fa..4e8cd8c 100644 --- a/src/routes/arr/[id]/upgrades/info/+page.svelte +++ b/src/routes/arr/upgrades/info/+page.svelte @@ -1,5 +1,4 @@ - {data.instance.name} - Upgrades Info - Profilarr + How Upgrades Work - Profilarr - -
-

How Upgrades Work

-
-
-
-
+
+ +
+

How Upgrades Work

+
+
+
+
-
- -
-

- 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: Filter + +

+

+ 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: Filter + your library, + Select items to search, + then + Search for better releases. +

+
+ + +
+

Concepts

+ row.id} + emptyMessage="No concepts" + chevronPosition="right" + flushExpanded > - your library, - Select items to search, - then - Search for better releases. -

+ +
+

{row.details}

+
+
+
+
+ + +
+

Selectors

+ + + + +
+

Filter Modes

+
+ + + +
+

Filter Fields

+
+ - - -
-

Concepts

- row.id} - emptyMessage="No concepts" - chevronPosition="right" - flushExpanded - > - -
-

{row.details}

-
-
-
-
- - -
-

Selectors

-
- - - -
-

Filter Modes

-
- - - -
-

Filter Fields

-
-