Files
profilarr/src/routes/arr/components/InstanceForm.svelte
2025-11-03 17:27:33 +10:30

392 lines
13 KiB
Svelte

<script lang="ts">
import { Check, X, Loader2, Save, Wifi, Trash2 } from 'lucide-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';
// Props
export let mode: 'create' | 'edit';
export let instance: ArrInstance | undefined = undefined;
export let initialType: string = '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let form: any = undefined;
// Parse tags from JSON string
const parseTags = (tagsJson: string | null): string[] => {
if (!tagsJson) return [];
try {
return JSON.parse(tagsJson);
} catch {
return [];
}
};
// 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) : [];
// Connection test state
type ConnectionStatus = 'idle' | 'testing' | 'success' | 'error';
let connectionStatus: ConnectionStatus = 'idle';
let connectionError = '';
// Delete modal state
let showDeleteModal = false;
let deleteFormElement: HTMLFormElement;
// Test connection function
async function testConnection() {
// Validation
if (!type || !url || !apiKey) {
connectionError = 'Please fill in Type, URL, and API Key';
connectionStatus = 'error';
return;
}
connectionStatus = 'testing';
connectionError = '';
try {
const response = await fetch('/arr/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, url, apiKey })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Connection test failed');
}
connectionStatus = 'success';
alertStore.add('success', 'Connection successful!');
} catch (error) {
connectionStatus = 'error';
connectionError = error instanceof Error ? error.message : 'Connection test failed';
}
}
// Reset connection status when form fields change
function resetConnectionStatus() {
if (connectionStatus !== 'idle') {
connectionStatus = 'idle';
connectionError = '';
}
}
$: canSubmit = connectionStatus === 'success';
// Display text based on mode
$: title = mode === 'create' ? 'Add Arr Instance' : 'Edit Instance';
$: description =
mode === 'create'
? 'Configure a new Radarr, Sonarr, Lidarr, or Chaptarr 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';
</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>
<form
method="POST"
class="space-y-6"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add('error', (result.data as { error?: string }).error || errorMessage);
} else if (result.type === 'redirect') {
alertStore.add('success', successMessage);
}
await update();
};
}}
>
<!-- 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-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-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-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
<option value="">Select type...</option>
<option value="radarr">Radarr</option>
<option value="sonarr">Sonarr</option>
<option value="lidarr">Lidarr</option>
<option value="chaptarr">Chaptarr</option>
</select>
{/if}
</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-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-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 text-sm font-mono text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-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/{type}/{instance?.id}"
data-sveltekit-reload
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>
<!-- 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.`}
confirmText="Delete"
cancelText="Cancel"
confirmDanger={true}
on:confirm={() => {
showDeleteModal = false;
deleteFormElement?.requestSubmit();
}}
on:cancel={() => (showDeleteModal = false)}
/>
{/if}