refactor: move database settings page into tabbed layout, make style more consistent with arrs

This commit is contained in:
Sam Chau
2026-01-26 22:14:50 +10:30
parent f89ba67899
commit 60049737b3
12 changed files with 397 additions and 413 deletions

View File

@@ -0,0 +1,16 @@
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">
<defs>
<rect width="1024" height="1024" id="artboard_1" />
<clipPath id="clip_1">
<use xlink:href="#artboard_1" clip-rule="evenodd" />
</clipPath>
</defs>
<g id="Simple-Logo-Light" clip-path="url(#clip_1)">
<use xlink:href="#artboard_1" stroke="none" fill="#FFFFFF" fill-opacity="0" />
<g id="Group-Copy" transform="translate(70 21.00012)">
<path d="M105.302 154.943L112.824 869.492C52.651 877.014 7.52158 846.927 7.52158 786.755L0 192.55C0 4.51106 172.996 -40.6184 278.298 34.5974L812.33 342.982C887.546 395.633 902.589 493.413 864.981 561.107C857.46 508.456 834.895 478.37 789.765 448.284L188.039 109.813C142.91 79.7268 105.302 87.2484 105.302 154.943Z" id="Shape" fill="#24292E" stroke="none" />
<path d="M0 376.079C45.1295 391.122 90.259 383.6 127.867 361.036L744.636 0C782.244 52.651 774.723 105.302 729.593 135.388L210.604 436.251C135.388 473.859 37.6079 436.251 0 376.079Z" transform="translate(60.17249 531.0214)" id="Shape" fill="#24292E" stroke="none" />
<path d="M0 413.687L368.557 203.083L7.52157 0L0 413.687Z" transform="translate(240.6902 282.8092)" id="Shape" fill="#FFC230" stroke="none" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,9 @@
<svg height="216.9" viewBox="0 0 216.7 216.9" width="216.7" xmlns="http://www.w3.org/2000/svg">
<path clip-rule="evenodd" d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" fill="#EEE" fill-rule="evenodd"/>
<path clip-rule="evenodd" d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" fill="#3A3F51" fill-rule="evenodd"/>
<g clip-rule="evenodd">
<path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd"/>
<path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2"/>
<path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Server, Plus, Trash2, Pencil, Check, X, Info, ExternalLink } from 'lucide-svelte';
import { Server, Plus, Trash2, Info, ExternalLink } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import EmptyState from '$ui/state/EmptyState.svelte';
@@ -16,6 +16,14 @@
import type { PageData } from './$types';
import type { Column } from '$ui/table/types';
import type { ArrInstance } from '$db/queries/arrInstances.ts';
import radarrLogo from '$lib/client/assets/Radarr.svg';
import sonarrLogo from '$lib/client/assets/Sonarr.svg';
// Logo lookup by type
const logos: Record<string, string> = {
radarr: radarrLogo,
sonarr: sonarrLogo
};
export let data: PageData;
@@ -39,18 +47,24 @@
let selectedInstance: ArrInstance | null = null;
let deleteFormElement: HTMLFormElement;
// Track loaded images
let loadedImages: Set<number> = new Set();
// Get logo path based on arr type
function getLogoPath(type: string): string {
return logos[type] || '';
}
function handleImageLoad(id: number) {
loadedImages.add(id);
loadedImages = loadedImages;
}
// Format type for display with proper casing
function formatType(type: string): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
// Get badge variant for arr type
function getTypeVariant(type: string): 'warning' | 'accent' | 'neutral' {
if (type === 'radarr') return 'warning';
if (type === 'sonarr') return 'accent';
return 'neutral';
}
// Handle row click
function handleRowClick(instance: ArrInstance) {
goto(`/arr/${instance.id}`);
@@ -66,7 +80,6 @@
// Define table columns
const columns: Column<ArrInstance>[] = [
{ key: 'name', header: 'Name', align: 'left' },
{ key: 'type', header: 'Type', align: 'left', width: 'w-32' },
{ key: 'url', header: 'URL', align: 'left' },
{ key: 'enabled', header: 'Enabled', align: 'center', width: 'w-24' }
];
@@ -98,27 +111,34 @@
<Table {columns} data={filteredInstances} hoverable={true} onRowClick={handleRowClick}>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'name'}
<div class="font-medium text-neutral-900 dark:text-neutral-50">
{row.name}
<div class="flex items-center gap-3">
<div class="relative h-8 w-8">
{#if !loadedImages.has(row.id)}
<div
class="absolute inset-0 animate-pulse rounded-lg bg-neutral-200 dark:bg-neutral-700"
></div>
{/if}
<img
src={getLogoPath(row.type)}
alt="{formatType(row.type)} logo"
class="h-8 w-8 rounded-lg {loadedImages.has(row.id) ? 'opacity-100' : 'opacity-0'}"
on:load={() => handleImageLoad(row.id)}
/>
</div>
<div class="flex items-center gap-2">
<div class="font-medium text-neutral-900 dark:text-neutral-50">
{row.name}
</div>
</div>
</div>
{:else if column.key === 'type'}
<Badge variant={getTypeVariant(row.type)} mono>{formatType(row.type)}</Badge>
{:else if column.key === 'url'}
<Badge variant="neutral" mono>{row.url}</Badge>
{:else if column.key === 'enabled'}
<div class="flex justify-center">
{#if row.enabled}
<span
class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400"
>
<Check size={14} />
</span>
<Badge variant="success">Enabled</Badge>
{:else}
<span
class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-100 text-neutral-400 dark:bg-neutral-800 dark:text-neutral-500"
>
<X size={14} />
</span>
<Badge variant="neutral">Disabled</Badge>
{/if}
</div>
{/if}
@@ -126,9 +146,6 @@
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-1">
<a href="/arr/{row.id}/settings" on:click={(e) => e.stopPropagation()}>
<TableActionButton icon={Pencil} title="Edit instance" />
</a>
<a
href={row.url}
target="_blank"

View File

@@ -10,18 +10,6 @@
$: currentPath = $page.url.pathname;
$: tabs = [
{
label: 'Settings',
href: `/arr/${instanceId}/settings`,
active: currentPath.includes('/settings'),
icon: Settings
},
{
label: 'Library',
href: `/arr/${instanceId}/library`,
active: currentPath.includes('/library'),
icon: Library
},
{
label: 'Sync',
href: `/arr/${instanceId}/sync`,
@@ -40,11 +28,23 @@
active: currentPath.includes('/rename'),
icon: FileEdit
},
{
label: 'Library',
href: `/arr/${instanceId}/library`,
active: currentPath.includes('/library'),
icon: Library
},
{
label: 'Logs',
href: `/arr/${instanceId}/logs`,
active: currentPath.includes('/logs'),
icon: ScrollText
},
{
label: 'Settings',
href: `/arr/${instanceId}/settings`,
active: currentPath.includes('/settings'),
icon: Settings
}
];

View File

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

View File

@@ -5,7 +5,6 @@
Lock,
Code,
Trash2,
Pencil,
ExternalLink,
ChevronRight,
Info
@@ -180,9 +179,6 @@
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-1">
<a href="/databases/{row.id}/edit" on:click={(e) => e.stopPropagation()}>
<TableActionButton icon={Pencil} title="Edit database" />
</a>
<a
href={row.repository_url}
target="_blank"

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import { GitBranch, History, Wrench, Settings } from 'lucide-svelte';
import { GitBranch, History, Wrench, Settings, FileCog } from 'lucide-svelte';
import { page } from '$app/stores';
$: database = $page.data.database;
@@ -31,21 +31,30 @@
{
label: 'Config',
href: `/databases/${database.id}/config`,
icon: Settings,
icon: FileCog,
active: currentPath.includes('/config')
}
]
: [])
: []),
{
label: 'Settings',
href: `/databases/${database.id}/settings`,
icon: Settings,
active: currentPath.includes('/settings')
}
]
: [];
$: backButton = {
label: 'Back',
href: '/databases'
$: breadcrumb = {
parent: {
label: 'Databases',
href: '/databases'
},
current: database?.name ?? ''
};
</script>
<div class="p-8">
<Tabs {tabs} {backButton} />
<Tabs {tabs} {breadcrumb} />
<slot />
</div>

View File

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

View File

@@ -1,29 +1,9 @@
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 { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { pcdManager } from '$pcd/pcd.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 database ID: ${params.id}`);
}
// Fetch the specific instance
const instance = databaseInstancesQueries.getById(id);
if (!instance) {
error(404, `Database not found: ${id}`);
}
return {
instance
};
};
export const actions: Actions = {
update: async ({ params, request }) => {
const id = parseInt(params.id || '', 10);
@@ -31,7 +11,7 @@ export const actions: Actions = {
// Validate ID
if (isNaN(id)) {
await logger.warn('Update failed: Invalid database ID', {
source: 'databases/[id]/edit',
source: 'databases/[id]/settings',
meta: { id: params.id }
});
return fail(400, { error: 'Invalid database ID' });
@@ -42,7 +22,7 @@ export const actions: Actions = {
if (!instance) {
await logger.warn('Update failed: Database not found', {
source: 'databases/[id]/edit',
source: 'databases/[id]/settings',
meta: { id }
});
return fail(404, { error: 'Database not found' });
@@ -59,7 +39,7 @@ export const actions: Actions = {
// Validation
if (!name) {
await logger.warn('Attempted to update database with missing required fields', {
source: 'databases/[id]/edit',
source: 'databases/[id]/settings',
meta: { id, name }
});
@@ -70,13 +50,9 @@ export const actions: Actions = {
}
// Check if name already exists (excluding current instance)
const existingWithName = databaseInstancesQueries
.getAll()
.find((db) => db.name === name && db.id !== id);
if (existingWithName) {
if (databaseInstancesQueries.nameExists(name, id)) {
await logger.warn('Attempted to update database with duplicate name', {
source: 'databases/[id]/edit',
source: 'databases/[id]/settings',
meta: { id, name }
});
@@ -100,21 +76,15 @@ export const actions: Actions = {
}
await logger.info(`Updated database: ${name}`, {
source: 'databases/[id]/edit',
source: 'databases/[id]/settings',
meta: { id, name }
});
// Redirect to database detail page
redirect(303, `/databases/${id}`);
} catch (error) {
// Re-throw redirect errors (they're not actual errors)
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
return { success: true };
} catch (err) {
await logger.error('Failed to update database', {
source: 'databases/[id]/edit',
meta: { error: error instanceof Error ? error.message : String(error) }
source: 'databases/[id]/settings',
meta: { error: err instanceof Error ? err.message : String(err) }
});
return fail(500, {
@@ -130,7 +100,7 @@ export const actions: Actions = {
// Validate ID
if (isNaN(id)) {
await logger.warn('Delete failed: Invalid database ID', {
source: 'databases/[id]/edit',
source: 'databases/[id]/settings',
meta: { id: params.id }
});
return fail(400, { error: 'Invalid database ID' });
@@ -141,7 +111,7 @@ export const actions: Actions = {
if (!instance) {
await logger.warn('Delete failed: Database not found', {
source: 'databases/[id]/edit',
source: 'databases/[id]/settings',
meta: { id }
});
return fail(404, { error: 'Database not found' });
@@ -152,21 +122,21 @@ export const actions: Actions = {
await pcdManager.unlink(id);
await logger.info(`Unlinked database: ${instance.name}`, {
source: 'databases/[id]/edit',
source: 'databases/[id]/settings',
meta: { id, name: instance.name, repositoryUrl: instance.repository_url }
});
// Redirect to databases list
redirect(303, '/databases');
} catch (error) {
} catch (err) {
// Re-throw redirect errors (they're not actual errors)
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
if (err && typeof err === 'object' && 'status' in err && 'location' in err) {
throw err;
}
await logger.error('Failed to unlink database', {
source: 'databases/[id]/edit',
meta: { error: error instanceof Error ? error.message : String(error) }
source: 'databases/[id]/settings',
meta: { error: err instanceof Error ? err.message : String(err) }
});
return fail(500, { error: 'Failed to unlink database' });

View File

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

View File

@@ -1,9 +1,15 @@
<script lang="ts">
import { Save, Trash2, Loader2 } from 'lucide-svelte';
import Modal from '$ui/modal/Modal.svelte';
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import { Save, Trash2 } from 'lucide-svelte';
import { alertStore } from '$alerts/store';
import { isDirty, initEdit, initCreate, update, current, clear } from '$lib/client/stores/dirty';
import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
import FormInput from '$ui/form/FormInput.svelte';
import DropdownSelect from '$ui/dropdown/DropdownSelect.svelte';
import Modal from '$ui/modal/Modal.svelte';
import DirtyModal from '$ui/modal/DirtyModal.svelte';
import Button from '$ui/button/Button.svelte';
// Props
export let mode: 'create' | 'edit';
@@ -13,329 +19,280 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let data: any = undefined;
// Loading state
let isLoading = false;
// Initialize dirty tracking on mount
onMount(() => {
if (mode === 'edit' && instance) {
initEdit({
name: instance.name,
repositoryUrl: instance.repository_url,
personalAccessToken: '', // Never pre-populate for security
syncStrategy: String(instance.sync_strategy),
autoPull: instance.auto_pull ? 'true' : 'false'
});
} else {
initCreate({
name: data?.formData?.name ?? '',
repositoryUrl: '',
branch: data?.formData?.branch ?? '',
personalAccessToken: data?.formData?.personalAccessToken ?? '',
syncStrategy: data?.formData?.syncStrategy ? String(data.formData.syncStrategy) : '60',
autoPull: data?.formData?.autoPull === '0' ? 'false' : 'true'
});
}
return () => clear();
});
// Form values
let name =
(form as any)?.values?.name ?? (mode === 'edit' ? instance?.name : data?.formData?.name) ?? '';
let repositoryUrl =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form as any)?.values?.repository_url ??
(mode === 'edit' ? instance?.repository_url : '') ??
'';
let branch =
(form as any)?.values?.branch ?? (mode === 'create' ? data?.formData?.branch : '') ?? '';
let personalAccessToken =
(form as any)?.values?.personal_access_token ??
(mode === 'edit' ? instance?.personal_access_token : data?.formData?.personalAccessToken) ??
'';
let syncStrategy =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form as any)?.values?.sync_strategy ??
(mode === 'edit'
? instance?.sync_strategy
: data?.formData?.syncStrategy
? parseInt(data.formData.syncStrategy)
: 60) ??
60;
let autoPull =
(form as any)?.values?.auto_pull ??
(mode === 'edit'
? instance?.auto_pull
: data?.formData?.autoPull === '1'
? 1
: data?.formData?.autoPull === '0'
? 0
: 1) ??
1;
// Read current values from dirty store
$: name = ($current.name ?? '') as string;
$: repositoryUrl = ($current.repositoryUrl ?? '') as string;
$: branch = ($current.branch ?? '') as string;
$: personalAccessToken = ($current.personalAccessToken ?? '') as string;
$: syncStrategy = ($current.syncStrategy ?? '60') as string;
$: autoPull = ($current.autoPull ?? 'true') as string;
// Delete modal state
// UI state
let saving = false;
let deleting = false;
let showDeleteModal = false;
let deleteFormElement: HTMLFormElement;
// Options for dropdowns
const syncStrategyOptions = [
{ value: '0', label: 'Manual (no auto-sync)' },
{ value: '5', label: 'Every 5 minutes' },
{ value: '15', label: 'Every 15 minutes' },
{ value: '30', label: 'Every 30 minutes' },
{ value: '60', label: 'Every hour' },
{ value: '360', label: 'Every 6 hours' },
{ value: '720', label: 'Every 12 hours' },
{ value: '1440', label: 'Every 24 hours' }
];
const autoPullOptions = [
{ value: 'true', label: 'Enabled' },
{ value: 'false', label: 'Disabled' }
];
// Submit handler
function handleSave() {
if (!name) {
alertStore.add('error', 'Name is required');
return;
}
if (mode === 'create' && !repositoryUrl) {
alertStore.add('error', 'Repository URL is required');
return;
}
saving = true;
const saveForm = document.getElementById('save-form');
if (saveForm instanceof HTMLFormElement) {
saveForm.requestSubmit();
}
}
$: canSubmit = $isDirty && !!name && (mode === 'edit' || !!repositoryUrl);
// 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 personalAccessToken empty)
initEdit({
name,
repositoryUrl,
personalAccessToken: '',
syncStrategy,
autoPull
});
}
if (form.error) {
alertStore.add('error', form.error);
}
}
// Display text based on mode
$: title = mode === 'create' ? 'Link Database' : 'Edit Database';
$: title = mode === 'create' ? 'Link Database' : 'Settings';
$: description =
mode === 'create'
? 'Link a Profilarr Compliant Database from a Git repository'
: `Update the configuration for ${instance?.name || 'this database'}`;
$: submitButtonText = mode === 'create' ? 'Link Database' : 'Save Changes';
$: successMessage =
mode === 'create' ? 'Database linked successfully!' : 'Database updated successfully!';
$: errorMessage = mode === 'create' ? 'Failed to link database' : 'Failed to update database';
? 'Link a Profilarr Compliant Database from a Git repository.'
: `Configure settings for ${instance?.name || 'this database'}.`;
</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="Unlink"
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"
>
<!-- Name Row -->
<FormInput
label="Name"
name="name"
value={name}
placeholder="e.g., Main Database, 4K Profiles"
description="A friendly name to identify this database"
required
on:input={(e) => update('name', e.detail)}
/>
<!-- Repository URL Row -->
<FormInput
label="Repository URL"
name="repository_url"
type="url"
value={repositoryUrl}
placeholder="https://github.com/username/database"
description={mode === 'edit'
? 'Repository URL cannot be changed after linking'
: 'Git repository URL containing the PCD manifest'}
required
readonly={mode === 'edit'}
on:input={(e) => update('repositoryUrl', e.detail)}
/>
<!-- Branch Row (create mode only) -->
{#if mode === 'create'}
<FormInput
label="Branch"
name="branch"
value={branch}
placeholder="main"
description="Branch to checkout on link. Leave empty for the default branch."
on:input={(e) => update('branch', e.detail)}
/>
{/if}
<!-- Personal Access Token Row -->
<FormInput
label="Personal Access Token"
name="personal_access_token"
value={personalAccessToken}
placeholder="ghp_..."
description={mode === 'edit'
? 'Re-enter to update. Required for private repos and to push changes.'
: 'Required for private repositories and to push changes back to GitHub.'}
private_
on:input={(e) => update('personalAccessToken', e.detail)}
/>
<!-- Sync Strategy Row -->
<div class="space-y-2">
<label class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
Sync Strategy
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
How often to check for updates from the remote repository
</p>
<DropdownSelect
value={syncStrategy}
options={syncStrategyOptions}
fullWidth
on:change={(e) => update('syncStrategy', e.detail)}
/>
</div>
<!-- Auto Pull Row -->
<div class="space-y-2">
<label class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
Auto Pull
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Automatically pull updates when available, or just receive notifications
</p>
<DropdownSelect
value={autoPull}
options={autoPullOptions}
on:change={(e) => update('autoPull', e.detail)}
/>
</div>
{#if autoPull === 'false'}
<p class="text-xs text-amber-600 dark:text-amber-400">
You will receive notifications when updates are available but they won't be applied
automatically
</p>
{/if}
</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', 'Database linked successfully');
}
await formUpdate({ reset: false });
saving = false;
};
}}
>
<input type="hidden" name="name" value={name} />
<input type="hidden" name="repository_url" value={repositoryUrl} />
{#if mode === 'create'}
<input type="hidden" name="branch" value={branch} />
{/if}
<input type="hidden" name="personal_access_token" value={personalAccessToken} />
<input type="hidden" name="sync_strategy" value={syncStrategy} />
<input type="hidden" name="auto_pull" value={autoPull === 'true' ? '1' : '0'} />
</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={() => {
isLoading = true;
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 unlink database'
);
} else if (result.type === 'redirect') {
// Don't show success message if redirecting to bruh page
if (result.location && !result.location.includes('/databases/bruh')) {
alertStore.add('success', successMessage);
}
alertStore.add('success', 'Database unlinked successfully');
}
await update();
isLoading = false;
deleting = false;
};
}}
>
<!-- Database 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">
Database 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 Database, 4K Profiles"
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"
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
A friendly name to identify this database
</p>
</div>
<!-- Repository URL -->
<div>
<label
for="repository_url"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Repository URL <span class="text-red-500">*</span>
</label>
<input
type="url"
id="repository_url"
name="repository_url"
bind:value={repositoryUrl}
required
disabled={mode === 'edit'}
placeholder="https://github.com/username/database"
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 disabled:cursor-not-allowed disabled:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500 dark:disabled:bg-neutral-900"
/>
{#if mode === 'edit'}
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Repository URL cannot be changed after linking
</p>
{:else}
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Git repository URL containing the PCD manifest
</p>
{/if}
</div>
<!-- Branch -->
{#if mode === 'create'}
<div>
<label
for="branch"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Branch
</label>
<input
type="text"
id="branch"
name="branch"
bind:value={branch}
placeholder="main"
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"
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Branch to checkout on link. Leave empty to use the default branch. You can change this
later.
</p>
</div>
{/if}
<!-- Personal Access Token -->
<div>
<label
for="personal_access_token"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Personal Access Token (Optional)
</label>
<input
type="password"
id="personal_access_token"
name="personal_access_token"
bind:value={personalAccessToken}
placeholder="ghp_..."
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"
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Required for private repositories to clone and for developers to push back to GitHub.
</p>
</div>
</div>
</div>
<!-- Sync Settings -->
<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">
Sync Settings
</h2>
<div class="space-y-4">
<!-- Sync Strategy -->
<div>
<label
for="sync_strategy"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Sync Strategy
</label>
<select
id="sync_strategy"
name="sync_strategy"
bind:value={syncStrategy}
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={0}>Manual (no auto-sync)</option>
<option value={5}>Every 5 minutes</option>
<option value={15}>Every 15 minutes</option>
<option value={30}>Every 30 minutes</option>
<option value={60}>Every hour</option>
<option value={360}>Every 6 hours</option>
<option value={720}>Every 12 hours</option>
<option value={1440}>Every 24 hours</option>
</select>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
How often to check for updates from the remote repository
</p>
</div>
<!-- Auto Pull -->
<div class="flex items-start gap-3">
<input
type="checkbox"
id="auto_pull"
name="auto_pull"
bind:checked={autoPull}
value="1"
class="mt-0.5 h-4 w-4 rounded border-neutral-300 text-neutral-600 focus:ring-0 dark:border-neutral-700 dark:bg-neutral-800"
/>
<div>
<label
for="auto_pull"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Automatically pull updates
</label>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
If enabled, updates will be pulled automatically. If disabled, you'll only receive
notifications when updates are available.
</p>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex flex-wrap items-center justify-end gap-3">
{#if mode === 'edit'}
<a
href="/databases/{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={isLoading}
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"
>
{#if isLoading}
<Loader2 size={14} class="animate-spin" />
{mode === 'create' ? 'Linking...' : 'Saving...'}
{:else}
<Save size={14} />
{submitButtonText}
{/if}
</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 unlink this database, there is no going back. All local data will be removed.
</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} />
Unlink Database
</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 unlink database'
);
} else if (result.type === 'redirect') {
alertStore.add('success', 'Database unlinked successfully');
}
await update();
};
}}
>
<!-- Empty form, just for submission -->
</form>
</div>
{/if}
</div>
></form>
{/if}
<!-- Delete Confirmation Modal -->
{#if mode === 'edit'}
@@ -348,8 +305,13 @@
confirmDanger={true}
on:confirm={() => {
showDeleteModal = false;
deleteFormElement?.requestSubmit();
const deleteForm = document.getElementById('delete-form');
if (deleteForm instanceof HTMLFormElement) {
deleteForm.requestSubmit();
}
}}
on:cancel={() => (showDeleteModal = false)}
/>
{/if}
<DirtyModal />

View File

@@ -6,4 +6,10 @@
export let data: PageData;
</script>
<InstanceForm mode="create" {form} {data} />
<svelte:head>
<title>Link Database - Profilarr</title>
</svelte:head>
<div class="p-8">
<InstanceForm mode="create" {form} {data} />
</div>