mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-28 05:20:57 +01:00
refactor: move arr settings page into tabbed layout
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<script lang="ts">
|
||||
// Reset to root layout - edit page doesn't need the tabs
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
@@ -84,7 +84,7 @@
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
goto(`/arr/${data.instance.id}/edit`);
|
||||
goto(`/arr/${data.instance.id}/settings`);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user