mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-27 05:00:53 +01:00
refactor(arr): create shared instance component for new / edit instance
- add ability to edit existing instances - add ability to delete existing instances
This commit is contained in:
357
src/components/arr/ArrInstanceForm.svelte
Normal file
357
src/components/arr/ArrInstanceForm.svelte
Normal file
@@ -0,0 +1,357 @@
|
||||
<script lang="ts">
|
||||
import { Check, X, Loader2, Save, Wifi, Trash2 } from 'lucide-svelte';
|
||||
import { apiRequest } from '$api';
|
||||
import TagInput from '$components/form/TagInput.svelte';
|
||||
import Modal from '$components/modal/Modal.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { toastStore } from '$stores/toast';
|
||||
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 {
|
||||
await apiRequest<{ success: boolean; error?: string }>('/arr/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, url, apiKey }),
|
||||
showSuccessToast: true,
|
||||
successMessage: 'Connection successful!'
|
||||
});
|
||||
|
||||
connectionStatus = 'success';
|
||||
} 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="p-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">{title}</h1>
|
||||
<p class="mt-3 text-lg text-neutral-600 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<form
|
||||
method="POST"
|
||||
class="space-y-6"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' && result.data) {
|
||||
// Show error toast
|
||||
toastStore.add('error', (result.data as { error?: string }).error || errorMessage);
|
||||
} else if (result.type === 'redirect') {
|
||||
// Show success toast before redirect
|
||||
toastStore.add('success', successMessage);
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
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-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Type <span class="text-red-500">*</span>
|
||||
</label>
|
||||
{#if mode === 'edit'}
|
||||
<!-- Disabled type field for edit mode -->
|
||||
<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-neutral-500 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Type cannot be changed after creation
|
||||
</p>
|
||||
<input type="hidden" name="type" value={type} />
|
||||
{:else}
|
||||
<!-- Editable type dropdown for create mode -->
|
||||
<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-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label for="url" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
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-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div>
|
||||
<label
|
||||
for="api_key"
|
||||
class="block text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
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-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
{#if mode === 'edit'}
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Re-enter API key to update or test connection
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tags (optional) -->
|
||||
<div>
|
||||
<label
|
||||
for="tags-input"
|
||||
class="block text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Tags
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<TagInput bind:tags />
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Optional. Press Enter to add a tag, Backspace to remove.
|
||||
</p>
|
||||
<!-- Hidden input to submit tags as JSON -->
|
||||
<input type="hidden" name="tags" value={tags.length > 0 ? JSON.stringify(tags) : ''} />
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between gap-3">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
on:click={testConnection}
|
||||
disabled={connectionStatus === 'testing'}
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 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
|
||||
{:else}
|
||||
<Wifi size={14} />
|
||||
Test
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#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-3 py-1.5 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}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-1.5 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>
|
||||
|
||||
<!-- Connection Status Messages -->
|
||||
{#if connectionStatus === 'success'}
|
||||
<p class="text-sm text-green-600 dark:text-green-400">
|
||||
Connection test passed! You can now save this instance.
|
||||
</p>
|
||||
{/if}
|
||||
{#if connectionStatus === 'error'}
|
||||
<p class="text-sm text-red-600 dark:text-red-400">
|
||||
{connectionError}
|
||||
</p>
|
||||
{/if}
|
||||
{#if !canSubmit && connectionStatus !== 'success'}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Please test the connection before saving
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Delete Section (Edit Mode Only) -->
|
||||
{#if mode === 'edit'}
|
||||
<div class="mt-8 border-t border-neutral-200 pt-8 dark:border-neutral-800">
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Danger Zone</h2>
|
||||
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-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-3 py-1.5 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-700 dark:bg-neutral-900 dark:text-red-400 dark:hover:bg-red-950"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete Instance
|
||||
</button>
|
||||
|
||||
<!-- Hidden delete form -->
|
||||
<form
|
||||
bind:this={deleteFormElement}
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
class="hidden"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' && result.data) {
|
||||
toastStore.add(
|
||||
'error',
|
||||
(result.data as { error?: string }).error || 'Failed to delete instance'
|
||||
);
|
||||
} else if (result.type === 'redirect') {
|
||||
toastStore.add('success', 'Instance deleted successfully');
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- Empty form, just for submission -->
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</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}
|
||||
93
src/components/modal/Modal.svelte
Normal file
93
src/components/modal/Modal.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { X, Check } from 'lucide-svelte';
|
||||
|
||||
// Props
|
||||
export let open = false;
|
||||
export let header = 'Confirm';
|
||||
export let bodyMessage = 'Are you sure?';
|
||||
export let confirmText = 'Confirm';
|
||||
export let cancelText = 'Cancel';
|
||||
export let confirmDanger = false; // If true, confirm button is styled as danger (red)
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleConfirm() {
|
||||
dispatch('confirm');
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
dispatch('cancel');
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open) {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
on:click={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative w-full max-w-md rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">{header}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-4">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">{bodyMessage}</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="flex justify-between border-t border-neutral-200 px-6 py-4 dark:border-neutral-800"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleCancel}
|
||||
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"
|
||||
>
|
||||
<X size={16} />
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleConfirm}
|
||||
class={confirmDanger
|
||||
? 'flex items-center gap-2 rounded-lg border border-red-600 bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 dark:border-red-500 dark:bg-red-500 dark:hover:bg-red-600'
|
||||
: '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 dark:bg-blue-500 dark:hover:bg-blue-600'}
|
||||
>
|
||||
<Check size={16} />
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -18,9 +18,12 @@
|
||||
|
||||
// Style mapping
|
||||
const styles = {
|
||||
success: 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200',
|
||||
error: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200',
|
||||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200',
|
||||
success:
|
||||
'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200',
|
||||
error:
|
||||
'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200',
|
||||
warning:
|
||||
'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-200'
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Toast from './Toast.svelte';
|
||||
</script>
|
||||
|
||||
<div class="pointer-events-none fixed right-4 top-20 z-50 flex flex-col gap-3">
|
||||
<div class="pointer-events-none fixed top-20 right-4 z-50 flex flex-col gap-3">
|
||||
{#each $toastStore as toast (toast.id)}
|
||||
<div class="pointer-events-auto">
|
||||
<Toast id={toast.id} type={toast.type} message={toast.message} />
|
||||
|
||||
@@ -1,233 +1,212 @@
|
||||
import { db } from "./db.ts";
|
||||
import { logger } from "$logger";
|
||||
import { db } from './db.ts';
|
||||
import { logger } from '$logger';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
name: string;
|
||||
up: string;
|
||||
down?: string;
|
||||
version: number;
|
||||
name: string;
|
||||
up: string;
|
||||
down?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration runner for database schema management
|
||||
*/
|
||||
class MigrationRunner {
|
||||
private migrationsTable = "migrations";
|
||||
private migrationsTable = 'migrations';
|
||||
|
||||
/**
|
||||
* Initialize the migrations table
|
||||
*/
|
||||
initialize(): void {
|
||||
const sql = `
|
||||
/**
|
||||
* Initialize the migrations table
|
||||
*/
|
||||
initialize(): void {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS ${this.migrationsTable} (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
db.exec(sql);
|
||||
}
|
||||
db.exec(sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current migration version
|
||||
*/
|
||||
getCurrentVersion(): number {
|
||||
const result = db.queryFirst<{ version: number }>(
|
||||
`SELECT MAX(version) as version FROM ${this.migrationsTable}`,
|
||||
);
|
||||
return result?.version ?? 0;
|
||||
}
|
||||
/**
|
||||
* Get the current migration version
|
||||
*/
|
||||
getCurrentVersion(): number {
|
||||
const result = db.queryFirst<{ version: number }>(
|
||||
`SELECT MAX(version) as version FROM ${this.migrationsTable}`
|
||||
);
|
||||
return result?.version ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a migration has been applied
|
||||
*/
|
||||
isApplied(version: number): boolean {
|
||||
const result = db.queryFirst<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM ${this.migrationsTable} WHERE version = ?`,
|
||||
version,
|
||||
);
|
||||
return (result?.count ?? 0) > 0;
|
||||
}
|
||||
/**
|
||||
* Check if a migration has been applied
|
||||
*/
|
||||
isApplied(version: number): boolean {
|
||||
const result = db.queryFirst<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM ${this.migrationsTable} WHERE version = ?`,
|
||||
version
|
||||
);
|
||||
return (result?.count ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single migration
|
||||
*/
|
||||
private async applyMigration(migration: Migration): Promise<void> {
|
||||
try {
|
||||
await db.transaction(async () => {
|
||||
// Execute the migration
|
||||
db.exec(migration.up);
|
||||
/**
|
||||
* Apply a single migration
|
||||
*/
|
||||
private async applyMigration(migration: Migration): Promise<void> {
|
||||
try {
|
||||
await db.transaction(async () => {
|
||||
// Execute the migration
|
||||
db.exec(migration.up);
|
||||
|
||||
// Record the migration
|
||||
db.execute(
|
||||
`INSERT INTO ${this.migrationsTable} (version, name) VALUES (?, ?)`,
|
||||
migration.version,
|
||||
migration.name,
|
||||
);
|
||||
// Record the migration
|
||||
db.execute(
|
||||
`INSERT INTO ${this.migrationsTable} (version, name) VALUES (?, ?)`,
|
||||
migration.version,
|
||||
migration.name
|
||||
);
|
||||
|
||||
await logger.info(
|
||||
`✓ Applied migration ${migration.version}: ${migration.name}`,
|
||||
{
|
||||
source: "MigrationRunner",
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
await logger.error(
|
||||
`✗ Failed to apply migration ${migration.version}: ${migration.name}`,
|
||||
{
|
||||
source: "MigrationRunner",
|
||||
meta: error,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await logger.info(`✓ Applied migration ${migration.version}: ${migration.name}`, {
|
||||
source: 'MigrationRunner'
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
await logger.error(`✗ Failed to apply migration ${migration.version}: ${migration.name}`, {
|
||||
source: 'MigrationRunner',
|
||||
meta: error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback a single migration
|
||||
*/
|
||||
private async rollbackMigration(migration: Migration): Promise<void> {
|
||||
if (!migration.down) {
|
||||
throw new Error(
|
||||
`Migration ${migration.version} does not support rollback`,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Rollback a single migration
|
||||
*/
|
||||
private async rollbackMigration(migration: Migration): Promise<void> {
|
||||
if (!migration.down) {
|
||||
throw new Error(`Migration ${migration.version} does not support rollback`);
|
||||
}
|
||||
|
||||
try {
|
||||
await db.transaction(async () => {
|
||||
// Execute the rollback
|
||||
db.exec(migration.down!);
|
||||
try {
|
||||
await db.transaction(async () => {
|
||||
// Execute the rollback
|
||||
db.exec(migration.down!);
|
||||
|
||||
// Remove the migration record
|
||||
db.execute(
|
||||
`DELETE FROM ${this.migrationsTable} WHERE version = ?`,
|
||||
migration.version,
|
||||
);
|
||||
// Remove the migration record
|
||||
db.execute(`DELETE FROM ${this.migrationsTable} WHERE version = ?`, migration.version);
|
||||
|
||||
await logger.info(
|
||||
`✓ Rolled back migration ${migration.version}: ${migration.name}`,
|
||||
{
|
||||
source: "MigrationRunner",
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
await logger.error(
|
||||
`✗ Failed to rollback migration ${migration.version}: ${migration.name}`,
|
||||
{
|
||||
source: "MigrationRunner",
|
||||
meta: error,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await logger.info(`✓ Rolled back migration ${migration.version}: ${migration.name}`, {
|
||||
source: 'MigrationRunner'
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
await logger.error(`✗ Failed to rollback migration ${migration.version}: ${migration.name}`, {
|
||||
source: 'MigrationRunner',
|
||||
meta: error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all pending migrations
|
||||
*/
|
||||
async up(migrations: Migration[]): Promise<void> {
|
||||
this.initialize();
|
||||
/**
|
||||
* Run all pending migrations
|
||||
*/
|
||||
async up(migrations: Migration[]): Promise<void> {
|
||||
this.initialize();
|
||||
|
||||
// Sort migrations by version
|
||||
const sortedMigrations = [...migrations].sort((a, b) =>
|
||||
a.version - b.version
|
||||
);
|
||||
// Sort migrations by version
|
||||
const sortedMigrations = [...migrations].sort((a, b) => a.version - b.version);
|
||||
|
||||
let applied = 0;
|
||||
for (const migration of sortedMigrations) {
|
||||
if (this.isApplied(migration.version)) {
|
||||
continue;
|
||||
}
|
||||
let applied = 0;
|
||||
for (const migration of sortedMigrations) {
|
||||
if (this.isApplied(migration.version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.applyMigration(migration);
|
||||
applied++;
|
||||
}
|
||||
await this.applyMigration(migration);
|
||||
applied++;
|
||||
}
|
||||
|
||||
if (applied === 0) {
|
||||
await logger.info("✓ Database is up to date", {
|
||||
source: "MigrationRunner",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (applied === 0) {
|
||||
await logger.info('✓ Database is up to date', {
|
||||
source: 'MigrationRunner'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback to a specific version
|
||||
*/
|
||||
async down(migrations: Migration[], targetVersion = 0): Promise<void> {
|
||||
this.initialize();
|
||||
/**
|
||||
* Rollback to a specific version
|
||||
*/
|
||||
async down(migrations: Migration[], targetVersion = 0): Promise<void> {
|
||||
this.initialize();
|
||||
|
||||
const currentVersion = this.getCurrentVersion();
|
||||
if (currentVersion <= targetVersion) {
|
||||
await logger.info("✓ Already at target version or below", {
|
||||
source: "MigrationRunner",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const currentVersion = this.getCurrentVersion();
|
||||
if (currentVersion <= targetVersion) {
|
||||
await logger.info('✓ Already at target version or below', {
|
||||
source: 'MigrationRunner'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort migrations by version in descending order
|
||||
const sortedMigrations = [...migrations]
|
||||
.filter((m) => m.version > targetVersion && m.version <= currentVersion)
|
||||
.sort((a, b) => b.version - a.version);
|
||||
// Sort migrations by version in descending order
|
||||
const sortedMigrations = [...migrations]
|
||||
.filter((m) => m.version > targetVersion && m.version <= currentVersion)
|
||||
.sort((a, b) => b.version - a.version);
|
||||
|
||||
let rolledBack = 0;
|
||||
for (const migration of sortedMigrations) {
|
||||
if (!this.isApplied(migration.version)) {
|
||||
continue;
|
||||
}
|
||||
let rolledBack = 0;
|
||||
for (const migration of sortedMigrations) {
|
||||
if (!this.isApplied(migration.version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.rollbackMigration(migration);
|
||||
rolledBack++;
|
||||
}
|
||||
await this.rollbackMigration(migration);
|
||||
rolledBack++;
|
||||
}
|
||||
|
||||
await logger.info(`✓ Rolled back ${rolledBack} migration(s)`, {
|
||||
source: "MigrationRunner",
|
||||
});
|
||||
}
|
||||
await logger.info(`✓ Rolled back ${rolledBack} migration(s)`, {
|
||||
source: 'MigrationRunner'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of applied migrations
|
||||
*/
|
||||
getAppliedMigrations(): Array<
|
||||
{ version: number; name: string; applied_at: string }
|
||||
> {
|
||||
return db.query(
|
||||
`SELECT version, name, applied_at FROM ${this.migrationsTable} ORDER BY version`,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Get list of applied migrations
|
||||
*/
|
||||
getAppliedMigrations(): Array<{ version: number; name: string; applied_at: string }> {
|
||||
return db.query(
|
||||
`SELECT version, name, applied_at FROM ${this.migrationsTable} ORDER BY version`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of pending migrations
|
||||
*/
|
||||
getPendingMigrations(migrations: Migration[]): Migration[] {
|
||||
const pending: Migration[] = [];
|
||||
for (const migration of migrations) {
|
||||
if (!this.isApplied(migration.version)) {
|
||||
pending.push(migration);
|
||||
}
|
||||
}
|
||||
return pending.sort((a, b) => a.version - b.version);
|
||||
}
|
||||
/**
|
||||
* Get list of pending migrations
|
||||
*/
|
||||
getPendingMigrations(migrations: Migration[]): Migration[] {
|
||||
const pending: Migration[] = [];
|
||||
for (const migration of migrations) {
|
||||
if (!this.isApplied(migration.version)) {
|
||||
pending.push(migration);
|
||||
}
|
||||
}
|
||||
return pending.sort((a, b) => a.version - b.version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the database (rollback all migrations)
|
||||
*/
|
||||
async reset(migrations: Migration[]): Promise<void> {
|
||||
await this.down(migrations, 0);
|
||||
}
|
||||
/**
|
||||
* Reset the database (rollback all migrations)
|
||||
*/
|
||||
async reset(migrations: Migration[]): Promise<void> {
|
||||
await this.down(migrations, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh migration (reset and reapply all)
|
||||
*/
|
||||
async fresh(migrations: Migration[]): Promise<void> {
|
||||
await logger.warn("⚠ Resetting database...", { source: "MigrationRunner" });
|
||||
await this.reset(migrations);
|
||||
await logger.info("↻ Reapplying all migrations...", {
|
||||
source: "MigrationRunner",
|
||||
});
|
||||
await this.up(migrations);
|
||||
}
|
||||
/**
|
||||
* Fresh migration (reset and reapply all)
|
||||
*/
|
||||
async fresh(migrations: Migration[]): Promise<void> {
|
||||
await logger.warn('⚠ Resetting database...', { source: 'MigrationRunner' });
|
||||
await this.reset(migrations);
|
||||
await logger.info('↻ Reapplying all migrations...', {
|
||||
source: 'MigrationRunner'
|
||||
});
|
||||
await this.up(migrations);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
@@ -238,64 +217,57 @@ export const migrationRunner = new MigrationRunner();
|
||||
* Loads all migration files from src/db/migrations/ directory
|
||||
*/
|
||||
export async function loadMigrations(): Promise<Migration[]> {
|
||||
const migrations: Migration[] = [];
|
||||
const migrationsDir = new URL("./migrations/", import.meta.url).pathname;
|
||||
const migrations: Migration[] = [];
|
||||
const migrationsDir = new URL('./migrations/', import.meta.url).pathname;
|
||||
|
||||
try {
|
||||
// Read all files in migrations directory
|
||||
for await (const entry of Deno.readDir(migrationsDir)) {
|
||||
// Skip template files (starting with _)
|
||||
if (entry.name.startsWith("_")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Read all files in migrations directory
|
||||
for await (const entry of Deno.readDir(migrationsDir)) {
|
||||
// Skip template files (starting with _)
|
||||
if (entry.name.startsWith('_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process TypeScript/JavaScript files
|
||||
if (
|
||||
entry.isFile &&
|
||||
(entry.name.endsWith(".ts") || entry.name.endsWith(".js"))
|
||||
) {
|
||||
try {
|
||||
// Dynamically import the migration file
|
||||
const migrationModule = await import(/* @vite-ignore */ `./migrations/${entry.name}`);
|
||||
// Only process TypeScript/JavaScript files
|
||||
if (entry.isFile && (entry.name.endsWith('.ts') || entry.name.endsWith('.js'))) {
|
||||
try {
|
||||
// Dynamically import the migration file
|
||||
const migrationModule = await import(/* @vite-ignore */ `./migrations/${entry.name}`);
|
||||
|
||||
// Get the migration object (could be default export or named export)
|
||||
const migration = migrationModule.default ||
|
||||
migrationModule.migration;
|
||||
// Get the migration object (could be default export or named export)
|
||||
const migration = migrationModule.default || migrationModule.migration;
|
||||
|
||||
if (migration && typeof migration.version === "number") {
|
||||
migrations.push(migration);
|
||||
} else {
|
||||
await logger.warn(
|
||||
`Migration file ${entry.name} does not export a valid migration`,
|
||||
{
|
||||
source: "MigrationRunner",
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await logger.error(`Failed to load migration file ${entry.name}`, {
|
||||
source: "MigrationRunner",
|
||||
meta: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// If directory doesn't exist or can't be read, return empty array
|
||||
await logger.info("No migrations directory found or empty", {
|
||||
source: "MigrationRunner",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
if (migration && typeof migration.version === 'number') {
|
||||
migrations.push(migration);
|
||||
} else {
|
||||
await logger.warn(`Migration file ${entry.name} does not export a valid migration`, {
|
||||
source: 'MigrationRunner'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await logger.error(`Failed to load migration file ${entry.name}`, {
|
||||
source: 'MigrationRunner',
|
||||
meta: error
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// If directory doesn't exist or can't be read, return empty array
|
||||
await logger.info('No migrations directory found or empty', {
|
||||
source: 'MigrationRunner'
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sort by version number
|
||||
return migrations.sort((a, b) => a.version - b.version);
|
||||
// Sort by version number
|
||||
return migrations.sort((a, b) => a.version - b.version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run migrations
|
||||
*/
|
||||
export async function runMigrations(migrations?: Migration[]): Promise<void> {
|
||||
const migrationsToRun = migrations ?? await loadMigrations();
|
||||
await migrationRunner.up(migrationsToRun);
|
||||
const migrationsToRun = migrations ?? (await loadMigrations());
|
||||
await migrationRunner.up(migrationsToRun);
|
||||
}
|
||||
|
||||
@@ -78,10 +78,7 @@ export const arrInstancesQueries = {
|
||||
* Get arr instances by type
|
||||
*/
|
||||
getByType(type: string): ArrInstance[] {
|
||||
return db.query<ArrInstance>(
|
||||
'SELECT * FROM arr_instances WHERE type = ? ORDER BY name',
|
||||
type
|
||||
);
|
||||
return db.query<ArrInstance>('SELECT * FROM arr_instances WHERE type = ? ORDER BY name', type);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
|
||||
|
||||
// Valid arr types
|
||||
const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr'];
|
||||
@@ -12,7 +13,17 @@ export const load: ServerLoad = ({ params }) => {
|
||||
error(404, `Invalid arr type: ${type}`);
|
||||
}
|
||||
|
||||
// Fetch instances for this type
|
||||
const instances = arrInstancesQueries.getByType(type);
|
||||
|
||||
// If instances exist, redirect to the first one
|
||||
if (instances.length > 0) {
|
||||
redirect(302, `/arr/${type}/${instances[0].id}`);
|
||||
}
|
||||
|
||||
// If no instances, continue to show the page
|
||||
return {
|
||||
type
|
||||
type,
|
||||
instances
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { Plus } from 'lucide-svelte';
|
||||
import { Plus, Inbox } from 'lucide-svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// Capitalize first letter for display (reactive statement)
|
||||
// Capitalize first letter for display
|
||||
$: displayName = data.type ? data.type.charAt(0).toUpperCase() + data.type.slice(1) : '';
|
||||
</script>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">{displayName}</h1>
|
||||
<p class="mt-3 text-lg text-neutral-600 dark:text-neutral-400">
|
||||
Manage your {displayName} instances
|
||||
</p>
|
||||
<div class="flex min-h-[calc(100vh-4rem)] items-center justify-center p-8">
|
||||
<div class="w-full max-w-md text-center">
|
||||
<!-- Icon -->
|
||||
<div class="mb-6 flex justify-center">
|
||||
<div class="rounded-full bg-neutral-100 p-6 dark:bg-neutral-800">
|
||||
<Inbox class="h-12 w-12 text-neutral-400 dark:text-neutral-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="mb-3 text-2xl font-bold text-neutral-900 dark:text-neutral-50">
|
||||
No {displayName} instances yet
|
||||
</h1>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="mb-8 text-neutral-600 dark:text-neutral-400">
|
||||
Get started by adding your first {displayName} instance to begin managing your media library.
|
||||
</p>
|
||||
|
||||
<!-- Action Button -->
|
||||
<a
|
||||
href="/arr/new?type={data.type}"
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Instance
|
||||
<Plus size={18} />
|
||||
Add {displayName} Instance
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Instance list will go here later -->
|
||||
<div class="text-neutral-600 dark:text-neutral-400">
|
||||
<p>No instances configured yet. Click "Add Instance" to get started.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
42
src/routes/arr/[type]/[id]/+page.server.ts
Normal file
42
src/routes/arr/[type]/[id]/+page.server.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
|
||||
|
||||
// Valid arr types
|
||||
const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr'];
|
||||
|
||||
export const load: ServerLoad = ({ params }) => {
|
||||
const type = params.type;
|
||||
const id = parseInt(params.id || '', 10);
|
||||
|
||||
// Validate type
|
||||
if (!type || !VALID_TYPES.includes(type)) {
|
||||
error(404, `Invalid arr type: ${type}`);
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
// Verify instance type matches route type
|
||||
if (instance.type !== type) {
|
||||
error(404, `Instance ${id} is not a ${type} instance`);
|
||||
}
|
||||
|
||||
// Fetch all instances of this type for the tabs
|
||||
const allInstances = arrInstancesQueries.getByType(type);
|
||||
|
||||
return {
|
||||
type,
|
||||
instance,
|
||||
allInstances
|
||||
};
|
||||
};
|
||||
53
src/routes/arr/[type]/[id]/+page.svelte
Normal file
53
src/routes/arr/[type]/[id]/+page.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { Plus, Pencil } from 'lucide-svelte';
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<div class="p-8">
|
||||
<!-- Tabs Section -->
|
||||
<div class="mb-8">
|
||||
<div class="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<nav class="-mb-px flex gap-2" aria-label="Tabs">
|
||||
<!-- Instance Tabs -->
|
||||
{#each data.allInstances as instance (instance.id)}
|
||||
<a
|
||||
href="/arr/{data.type}/{instance.id}"
|
||||
class="border-b-2 px-4 py-3 text-sm font-medium transition-colors {data.instance.id ===
|
||||
instance.id
|
||||
? 'border-blue-600 text-blue-600 dark:border-blue-500 dark:text-blue-500'
|
||||
: 'border-transparent text-neutral-600 hover:border-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:text-neutral-50'}"
|
||||
>
|
||||
{instance.name}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- Add Instance Tab (always visible) -->
|
||||
<a
|
||||
href="/arr/new?type={data.type}"
|
||||
class="flex items-center gap-2 border-b-2 border-transparent px-4 py-3 text-sm font-medium text-neutral-600 transition-colors hover:border-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:text-neutral-50"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Instance
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="text-neutral-600 dark:text-neutral-400">
|
||||
<p>Instance content for: {data.instance.name} (ID: {data.instance.id})</p>
|
||||
<p class="mt-2">URL: {data.instance.url}</p>
|
||||
<p class="mt-2">Type: {data.instance.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Edit Button -->
|
||||
<a
|
||||
href="/arr/{data.type}/{data.instance.id}/edit"
|
||||
class="group fixed right-8 bottom-8 z-50 flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-700 shadow-md transition-all hover:scale-110 hover:border-neutral-300 hover:shadow-lg dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:border-neutral-600"
|
||||
aria-label="Edit instance"
|
||||
>
|
||||
<Pencil size={18} class="transition-transform duration-300 group-hover:rotate-12" />
|
||||
</a>
|
||||
103
src/routes/arr/[type]/[id]/edit/+page.server.ts
Normal file
103
src/routes/arr/[type]/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { error, redirect, fail } from '@sveltejs/kit';
|
||||
import type { ServerLoad, Actions } from '@sveltejs/kit';
|
||||
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
|
||||
import { logger } from '$logger';
|
||||
|
||||
// Valid arr types
|
||||
const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr'];
|
||||
|
||||
export const load: ServerLoad = ({ params }) => {
|
||||
const type = params.type;
|
||||
const id = parseInt(params.id || '', 10);
|
||||
|
||||
// Validate type
|
||||
if (!type || !VALID_TYPES.includes(type)) {
|
||||
error(404, `Invalid arr type: ${type}`);
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
// Verify instance type matches route type
|
||||
if (instance.type !== type) {
|
||||
error(404, `Instance ${id} is not a ${type} instance`);
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
instance
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
delete: async ({ params }) => {
|
||||
const type = params.type;
|
||||
const id = parseInt(params.id || '', 10);
|
||||
|
||||
// Validate type
|
||||
if (!type || !VALID_TYPES.includes(type)) {
|
||||
await logger.warn('Delete failed: Invalid arr type', {
|
||||
source: 'arr/[type]/[id]/edit',
|
||||
meta: { type }
|
||||
});
|
||||
return fail(400, { error: 'Invalid arr type' });
|
||||
}
|
||||
|
||||
// Validate ID
|
||||
if (isNaN(id)) {
|
||||
await logger.warn('Delete failed: Invalid instance ID', {
|
||||
source: 'arr/[type]/[id]/edit',
|
||||
meta: { id: params.id }
|
||||
});
|
||||
return fail(400, { error: 'Invalid instance ID' });
|
||||
}
|
||||
|
||||
// Fetch the instance to verify it exists
|
||||
const instance = arrInstancesQueries.getById(id);
|
||||
|
||||
if (!instance) {
|
||||
await logger.warn('Delete failed: Instance not found', {
|
||||
source: 'arr/[type]/[id]/edit',
|
||||
meta: { id, type }
|
||||
});
|
||||
return fail(404, { error: 'Instance not found' });
|
||||
}
|
||||
|
||||
// Verify instance type matches route type
|
||||
if (instance.type !== type) {
|
||||
await logger.warn('Delete failed: Instance type mismatch', {
|
||||
source: 'arr/[type]/[id]/edit',
|
||||
meta: { id, expectedType: type, actualType: instance.type }
|
||||
});
|
||||
return fail(400, { error: 'Instance type mismatch' });
|
||||
}
|
||||
|
||||
// Delete the instance
|
||||
const deleted = arrInstancesQueries.delete(id);
|
||||
|
||||
if (!deleted) {
|
||||
await logger.error('Failed to delete instance', {
|
||||
source: 'arr/[type]/[id]/edit',
|
||||
meta: { id, name: instance.name, type: instance.type }
|
||||
});
|
||||
return fail(500, { error: 'Failed to delete instance' });
|
||||
}
|
||||
|
||||
await logger.info(`Deleted ${type} instance: ${instance.name}`, {
|
||||
source: 'arr/[type]/[id]/edit',
|
||||
meta: { id, name: instance.name, type: instance.type, url: instance.url }
|
||||
});
|
||||
|
||||
// Redirect to the arr type page
|
||||
redirect(303, `/arr/${type}`);
|
||||
}
|
||||
};
|
||||
9
src/routes/arr/[type]/[id]/edit/+page.svelte
Normal file
9
src/routes/arr/[type]/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import ArrInstanceForm from '$components/arr/ArrInstanceForm.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
export let form: ActionData;
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<ArrInstanceForm mode="edit" {form} instance={data.instance} />
|
||||
@@ -83,7 +83,6 @@ export const actions = {
|
||||
source: 'arr/new',
|
||||
meta: { id, name, type, url }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await logger.error('Failed to create arr instance', {
|
||||
source: 'arr/new',
|
||||
|
||||
@@ -1,229 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Check, X, Loader2, Save, Wifi } from 'lucide-svelte';
|
||||
import { apiRequest } from '$api';
|
||||
import TagInput from '$components/form/TagInput.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { toastStore } from '$stores/toast';
|
||||
import ArrInstanceForm from '$components/arr/ArrInstanceForm.svelte';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
export let form: ActionData;
|
||||
|
||||
// Get type from URL params if provided
|
||||
const typeFromUrl = $page.url.searchParams.get('type') || '';
|
||||
|
||||
// Form values (restore from form action if there was an error)
|
||||
let name = form?.values?.name ?? '';
|
||||
let type = form?.values?.type ?? typeFromUrl;
|
||||
let url = form?.values?.url ?? '';
|
||||
let apiKey = '';
|
||||
let tags: string[] = [];
|
||||
|
||||
// Connection test state
|
||||
type ConnectionStatus = 'idle' | 'testing' | 'success' | 'error';
|
||||
let connectionStatus: ConnectionStatus = 'idle';
|
||||
let connectionError = '';
|
||||
|
||||
// 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 data = await apiRequest<{ success: boolean; error?: string }>('/arr/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, url, apiKey }),
|
||||
showSuccessToast: true,
|
||||
successMessage: 'Connection successful!'
|
||||
});
|
||||
|
||||
connectionStatus = 'success';
|
||||
} 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';
|
||||
</script>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">Add Arr Instance</h1>
|
||||
<p class="mt-3 text-lg text-neutral-600 dark:text-neutral-400">
|
||||
Configure a new Radarr, Sonarr, Lidarr, or Chaptarr instance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<form
|
||||
method="POST"
|
||||
class="space-y-6"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' && result.data) {
|
||||
// Show error toast
|
||||
toastStore.add('error', (result.data as { error?: string }).error || 'Failed to save instance');
|
||||
} else if (result.type === 'redirect') {
|
||||
// Show success toast before redirect
|
||||
toastStore.add('success', 'Instance created successfully!');
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
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-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-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Type <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<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-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-50"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label for="url" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
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-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-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div>
|
||||
<label for="api_key" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
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="Enter API key"
|
||||
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 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-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags (optional) -->
|
||||
<div>
|
||||
<label for="tags-input" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Tags
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<TagInput bind:tags />
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Optional. Press Enter to add a tag, Backspace to remove.
|
||||
</p>
|
||||
<!-- Hidden input to submit tags as JSON -->
|
||||
<input type="hidden" name="tags" value={tags.length > 0 ? JSON.stringify(tags) : ''} />
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
on:click={testConnection}
|
||||
disabled={connectionStatus === 'testing'}
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 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
|
||||
{:else}
|
||||
<Wifi size={14} />
|
||||
Test
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-1.5 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} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connection Status Messages -->
|
||||
{#if connectionStatus === 'success'}
|
||||
<p class="text-sm text-green-600 dark:text-green-400">
|
||||
Connection test passed! You can now save this instance.
|
||||
</p>
|
||||
{/if}
|
||||
{#if connectionStatus === 'error'}
|
||||
<p class="text-sm text-red-600 dark:text-red-400">
|
||||
{connectionError}
|
||||
</p>
|
||||
{/if}
|
||||
{#if !canSubmit && connectionStatus !== 'success'}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Please test the connection before saving
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<ArrInstanceForm mode="create" {form} initialType={typeFromUrl} />
|
||||
|
||||
@@ -121,184 +121,194 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Application (special case with version badge) -->
|
||||
<InfoTable title="Application" icon={Info}>
|
||||
<tr class="bg-white dark:bg-neutral-900">
|
||||
<td class="w-1/3 px-6 py-4 text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Version
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<code
|
||||
class="rounded bg-neutral-100 px-2 py-1 font-mono text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
<!-- Application (special case with version badge) -->
|
||||
<InfoTable title="Application" icon={Info}>
|
||||
<tr class="bg-white dark:bg-neutral-900">
|
||||
<td class="w-1/3 px-6 py-4 text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Version
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<code
|
||||
class="rounded bg-neutral-100 px-2 py-1 font-mono text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
>
|
||||
v{data.version}
|
||||
</code>
|
||||
<VersionBadge status={data.versionStatus} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<InfoRow label="Timezone" value={data.timezone} type="code" />
|
||||
</InfoTable>
|
||||
|
||||
{#each sections as section (section.title)}
|
||||
<InfoTable title={section.title} icon={section.icon}>
|
||||
{#each section.rows as row (row.label)}
|
||||
<InfoRow label={row.label} value={row.value} type={row.type} href={row.href} />
|
||||
{/each}
|
||||
</InfoTable>
|
||||
{/each}
|
||||
|
||||
<!-- Database (special case with custom content) -->
|
||||
{#if data.migration.applied.length > 0}
|
||||
<InfoTable title="Database" icon={Database}>
|
||||
<tr class="bg-white dark:bg-neutral-900">
|
||||
<td
|
||||
class="w-1/3 px-6 py-4 align-top text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
v{data.version}
|
||||
</code>
|
||||
<VersionBadge status={data.versionStatus} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<InfoRow label="Timezone" value={data.timezone} type="code" />
|
||||
</InfoTable>
|
||||
|
||||
{#each sections as section (section.title)}
|
||||
<InfoTable title={section.title} icon={section.icon}>
|
||||
{#each section.rows as row (row.label)}
|
||||
<InfoRow label={row.label} value={row.value} type={row.type} href={row.href} />
|
||||
{/each}
|
||||
</InfoTable>
|
||||
{/each}
|
||||
|
||||
<!-- Database (special case with custom content) -->
|
||||
{#if data.migration.applied.length > 0}
|
||||
<InfoTable title="Database" icon={Database}>
|
||||
<tr class="bg-white dark:bg-neutral-900">
|
||||
<td
|
||||
class="w-1/3 px-6 py-4 align-top text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Migrations
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="space-y-2">
|
||||
{#each data.migration.applied as migration (migration.version)}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<code
|
||||
class="rounded bg-neutral-100 px-2 py-1 font-mono text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
>
|
||||
v{migration.version}
|
||||
</code>
|
||||
<span class="text-neutral-600 dark:text-neutral-400">
|
||||
{migration.name}
|
||||
Migrations
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="space-y-2">
|
||||
{#each data.migration.applied as migration (migration.version)}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<code
|
||||
class="rounded bg-neutral-100 px-2 py-1 font-mono text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
>
|
||||
v{migration.version}
|
||||
</code>
|
||||
<span class="text-neutral-600 dark:text-neutral-400">
|
||||
{migration.name}
|
||||
</span>
|
||||
{#if migration.latest}
|
||||
<span
|
||||
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
Latest
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-neutral-500">
|
||||
{new Date(migration.applied_at).toLocaleDateString()}
|
||||
</span>
|
||||
{#if migration.latest}
|
||||
<span
|
||||
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
Latest
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-neutral-500">
|
||||
{new Date(migration.applied_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</InfoTable>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</InfoTable>
|
||||
{/if}
|
||||
|
||||
<!-- Releases Section -->
|
||||
{#if data.releases.length > 0}
|
||||
<InfoTable title="Releases" icon={Package}>
|
||||
<tr class="bg-white dark:bg-neutral-900">
|
||||
<td colspan="2" class="px-6 py-4">
|
||||
<div class="space-y-3">
|
||||
{#each data.releases as release, index (release.tag_name)}
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-neutral-200 pb-3 last:border-0 last:pb-0 dark:border-neutral-800"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
href={release.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-sveltekit-reload
|
||||
class="font-mono text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{release.tag_name}
|
||||
</a>
|
||||
{#if index === 0}
|
||||
<span
|
||||
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
<!-- Releases Section -->
|
||||
{#if data.releases.length > 0}
|
||||
<InfoTable title="Releases" icon={Package}>
|
||||
<tr class="bg-white dark:bg-neutral-900">
|
||||
<td colspan="2" class="px-6 py-4">
|
||||
<div class="space-y-3">
|
||||
{#each data.releases as release, index (release.tag_name)}
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-neutral-200 pb-3 last:border-0 last:pb-0 dark:border-neutral-800"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
href={release.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-sveltekit-reload
|
||||
class="font-mono text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Latest
|
||||
</span>
|
||||
{/if}
|
||||
{#if release.prerelease}
|
||||
<span
|
||||
class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
>
|
||||
Pre-release
|
||||
</span>
|
||||
{/if}
|
||||
{release.tag_name}
|
||||
</a>
|
||||
{#if index === 0}
|
||||
<span
|
||||
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
Latest
|
||||
</span>
|
||||
{/if}
|
||||
{#if release.prerelease}
|
||||
<span
|
||||
class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
>
|
||||
Pre-release
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-neutral-500 dark:text-neutral-500">
|
||||
{new Date(release.published_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-neutral-500 dark:text-neutral-500">
|
||||
{new Date(release.published_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</InfoTable>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</InfoTable>
|
||||
{/if}
|
||||
|
||||
<!-- Dev Team Section -->
|
||||
<div class="space-y-3">
|
||||
<!-- Section Title -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Users class="h-5 w-5 text-neutral-600 dark:text-neutral-400" />
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Dev Team</h2>
|
||||
</div>
|
||||
<!-- Dev Team Section -->
|
||||
<div class="space-y-3">
|
||||
<!-- Section Title -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Users class="h-5 w-5 text-neutral-600 dark:text-neutral-400" />
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Dev Team</h2>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
Name
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
Remark
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
Tags
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{#each devTeam as member (member.name)}
|
||||
<tr class="bg-white dark:bg-neutral-900">
|
||||
<td class="px-6 py-4 text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
{member.name}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{#if member.remark}
|
||||
{member.remark}
|
||||
{:else}
|
||||
<span class="italic text-neutral-400 dark:text-neutral-500">Remark pending - someone should probably ask them</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each member.tags as tag}
|
||||
<span
|
||||
class="rounded-full bg-neutral-100 px-3 py-1 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Table -->
|
||||
<div
|
||||
class="overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Remark
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Tags
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{#each devTeam as member (member.name)}
|
||||
<tr class="bg-white dark:bg-neutral-900">
|
||||
<td class="px-6 py-4 text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
{member.name}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{#if member.remark}
|
||||
{member.remark}
|
||||
{:else}
|
||||
<span class="text-neutral-400 italic dark:text-neutral-500"
|
||||
>Remark pending - someone should probably ask them</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each member.tags as tag}
|
||||
<span
|
||||
class="rounded-full bg-neutral-100 px-3 py-1 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dedication -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-sm italic text-neutral-500 dark:text-neutral-400">
|
||||
This project is dedicated to Faiza, for helping me find my heart.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Dedication -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-sm text-neutral-500 italic dark:text-neutral-400">
|
||||
This project is dedicated to Faiza, for helping me find my heart.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Arr HTTP Client Utilities
|
||||
|
||||
Object-oriented HTTP client architecture for communicating with *arr
|
||||
Object-oriented HTTP client architecture for communicating with \*arr
|
||||
applications (Radarr, Sonarr, Lidarr, Chaptarr).
|
||||
|
||||
## Architecture
|
||||
@@ -64,7 +64,7 @@ new BaseHttpClient(baseUrl: string, options?: HttpClientOptions)
|
||||
|
||||
### BaseArrClient (`src/utils/arr/base.ts`)
|
||||
|
||||
Base client for all *arr applications. Extends `BaseHttpClient`.
|
||||
Base client for all \*arr applications. Extends `BaseHttpClient`.
|
||||
|
||||
**Features:**
|
||||
|
||||
@@ -86,9 +86,9 @@ new BaseArrClient(url: string, apiKey: string)
|
||||
### Future Usage (when specific methods are implemented)
|
||||
|
||||
```typescript
|
||||
import { createArrClient } from "$utils/arr/factory.ts";
|
||||
import { createArrClient } from '$utils/arr/factory.ts';
|
||||
|
||||
const radarr = createArrClient("radarr", "http://localhost:7878", "api-key");
|
||||
const radarr = createArrClient('radarr', 'http://localhost:7878', 'api-key');
|
||||
|
||||
// Get movies
|
||||
const movies = await radarr.getMovies();
|
||||
@@ -98,8 +98,8 @@ const profiles = await radarr.getQualityProfiles();
|
||||
|
||||
// Add movie
|
||||
await radarr.addMovie({
|
||||
title: "Inception",
|
||||
tmdbId: 27205,
|
||||
qualityProfileId: 1,
|
||||
title: 'Inception',
|
||||
tmdbId: 27205,
|
||||
qualityProfileId: 1
|
||||
});
|
||||
```
|
||||
|
||||
@@ -12,11 +12,7 @@ import { ChaptarrClient } from './clients/chaptarr.ts';
|
||||
* @param apiKey - API key for authentication
|
||||
* @returns Arr client instance
|
||||
*/
|
||||
export function createArrClient(
|
||||
type: ArrType,
|
||||
url: string,
|
||||
apiKey: string
|
||||
): BaseArrClient {
|
||||
export function createArrClient(type: ArrType, url: string, apiKey: string): BaseArrClient {
|
||||
switch (type) {
|
||||
case 'radarr':
|
||||
return new RadarrClient(url, apiKey);
|
||||
|
||||
@@ -52,7 +52,7 @@ class Config {
|
||||
},
|
||||
get database(): string {
|
||||
return `${config.basePath}/data/profilarr.db`;
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -113,10 +113,7 @@ export class BaseHttpClient {
|
||||
}
|
||||
|
||||
// Wrap other errors
|
||||
throw new HttpError(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
0
|
||||
);
|
||||
throw new HttpError(error instanceof Error ? error.message : 'Unknown error', 0);
|
||||
}
|
||||
} catch (error) {
|
||||
// If it's not an HttpError or not retryable, throw immediately
|
||||
|
||||
Reference in New Issue
Block a user