refactor: update arr and databases pages with action bars, badges, and info modals

This commit is contained in:
Sam Chau
2026-01-16 21:26:55 +10:30
parent ad7e77dcea
commit bab8eeb946
7 changed files with 196 additions and 141 deletions

View File

@@ -4,6 +4,7 @@
export let variant: 'accent' | 'neutral' | 'success' | 'warning' | 'danger' = 'accent';
export let size: 'sm' | 'md' = 'sm';
export let icon: ComponentType | null = null;
export let mono: boolean = false;
const variantClasses: Record<typeof variant, string> = {
accent: 'bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200',
@@ -22,7 +23,7 @@
</script>
<span
class="inline-flex items-center gap-1 rounded font-medium {variantClasses[variant]} {sizeClasses[size]}"
class="inline-flex items-center gap-1 rounded font-medium {variantClasses[variant]} {sizeClasses[size]} {mono ? 'font-mono' : ''}"
>
{#if icon}
<svelte:component this={icon} size={iconSize} />

View File

@@ -1,10 +1,17 @@
<script lang="ts">
import { Server, Plus, Trash2, Pencil, Check, X } from 'lucide-svelte';
import { Server, Plus, Trash2, Pencil, Check, X, Info, ExternalLink } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import EmptyState from '$ui/state/EmptyState.svelte';
import Table from '$ui/table/Table.svelte';
import TableActionButton from '$ui/table/TableActionButton.svelte';
import Badge from '$ui/badge/Badge.svelte';
import Modal from '$ui/modal/Modal.svelte';
import InfoModal from '$ui/modal/InfoModal.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import { createSearchStore } from '$stores/search';
import { alertStore } from '$alerts/store';
import type { PageData } from './$types';
import type { Column } from '$ui/table/types';
@@ -12,8 +19,15 @@
export let data: PageData;
// Search store
const searchStore = createSearchStore();
// Filter instances based on search
$: filteredInstances = searchStore.filterItems(data.instances, ['name', 'url', 'type']);
// Modal state
let showDeleteModal = false;
let showInfoModal = false;
let selectedInstance: ArrInstance | null = null;
let deleteFormElement: HTMLFormElement;
@@ -22,13 +36,11 @@
return type.charAt(0).toUpperCase() + type.slice(1);
}
// Get type badge color classes
function getTypeBadgeClasses(type: string): string {
const colors: Record<string, string> = {
radarr: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
sonarr: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-400'
};
return colors[type] || 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200';
// 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
@@ -67,38 +79,24 @@
/>
{:else}
<div class="space-y-6 p-8">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">Arr Instances</h1>
<p class="mt-1 text-neutral-600 dark:text-neutral-400">
Manage your Radarr and Sonarr instances
</p>
</div>
<a
href="/arr/new"
class="inline-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"
>
<Plus size={16} />
Add Instance
</a>
</div>
<!-- Actions Bar -->
<ActionsBar>
<SearchAction {searchStore} placeholder="Search instances..." />
<ActionButton icon={Plus} title="Add Instance" on:click={() => goto('/arr/new')} />
<ActionButton icon={Info} title="Info" on:click={() => (showInfoModal = true)} />
</ActionsBar>
<!-- Instance Table -->
<Table {columns} data={data.instances} hoverable={true} onRowClick={handleRowClick}>
<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>
{:else if column.key === 'type'}
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {getTypeBadgeClasses(row.type)}">
{formatType(row.type)}
</span>
<Badge variant={getTypeVariant(row.type)} mono>{formatType(row.type)}</Badge>
{:else if column.key === 'url'}
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{row.url}
</code>
<Badge variant="neutral" mono>{row.url}</Badge>
{:else if column.key === 'enabled'}
<div class="flex justify-center">
{#if row.enabled}
@@ -115,26 +113,19 @@
</svelte:fragment>
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-2">
<!-- Edit Button -->
<a
href="/arr/{row.id}/edit"
on:click={(e) => e.stopPropagation()}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
title="Edit instance"
>
<Pencil size={14} />
<div class="flex items-center justify-end gap-1">
<a href="/arr/{row.id}/edit" on:click={(e) => e.stopPropagation()}>
<TableActionButton icon={Pencil} title="Edit instance" />
</a>
<!-- Delete Button -->
<button
type="button"
on:click={(e) => handleDeleteClick(e, row)}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-red-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-neutral-700"
<a href={row.url} target="_blank" rel="noopener noreferrer" on:click={(e) => e.stopPropagation()}>
<TableActionButton icon={ExternalLink} title="Open in {formatType(row.type)}" />
</a>
<TableActionButton
icon={Trash2}
title="Delete instance"
>
<Trash2 size={14} />
</button>
variant="danger"
on:click={(e) => handleDeleteClick(e, row)}
/>
</div>
</svelte:fragment>
</Table>
@@ -184,3 +175,41 @@
>
<input type="hidden" name="id" value={selectedInstance?.id || ''} />
</form>
<!-- Info Modal -->
<InfoModal bind:open={showInfoModal} header="Arr Instances">
<div class="space-y-4 text-sm text-neutral-600 dark:text-neutral-400">
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">What are Arr Instances?</div>
<div class="mt-1">
Arr instances are your Radarr and Sonarr applications. Profilarr connects to these
instances to sync quality profiles, custom formats, and other configurations.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Adding an Instance</div>
<div class="mt-1">
To add an instance, you'll need the URL and API key from your Radarr or Sonarr
application. You can find the API key in Settings → General → Security.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Syncing</div>
<div class="mt-1">
Once connected, you can configure sync settings to push profiles and formats from your
linked databases to each instance. Sync can be triggered manually, on a schedule, or
automatically when changes are detected.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Enabled/Disabled</div>
<div class="mt-1">
Disabled instances are excluded from sync operations but remain configured. This is
useful for temporarily pausing sync without removing the instance.
</div>
</div>
</div>
</InfoModal>

View File

@@ -307,6 +307,7 @@
{activeFilters}
uniqueQualities={loading ? [] : uniqueQualities}
uniqueProfiles={loading ? [] : uniqueProfiles}
instanceName={data.instance.name}
onToggleColumn={toggleColumn}
onToggleFilter={toggleFilter}
onRefresh={handleRefresh}

View File

@@ -5,6 +5,7 @@
import ActionButton from '$ui/actions/ActionButton.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import Modal from '$ui/modal/Modal.svelte';
import { type SearchStore } from '$stores/search';
type FilterOperator = 'eq' | 'neq';
@@ -24,6 +25,7 @@
export let activeFilters: ActiveFilter[];
export let uniqueQualities: string[];
export let uniqueProfiles: string[];
export let instanceName: string = '';
export let onToggleColumn: (key: string) => void;
export let onToggleFilter: (field: FilterField, operator: FilterOperator, value: string | number | boolean, label: string) => void;
@@ -32,6 +34,8 @@
export let onEdit: () => void;
export let onDelete: () => void;
export let onInfo: () => void;
let showDeleteModal = false;
</script>
<ActionsBar>
@@ -173,7 +177,7 @@
<Dropdown position={dropdownPosition} {open} minWidth="10rem">
<button
type="button"
on:click={onDelete}
on:click={() => (showDeleteModal = true)}
class="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 rounded-lg"
>
Delete instance
@@ -182,3 +186,17 @@
</svelte:fragment>
</ActionButton>
</ActionsBar>
<Modal
open={showDeleteModal}
header="Delete Instance"
bodyMessage={`Are you sure you want to delete "${instanceName}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
confirmDanger={true}
on:confirm={() => {
showDeleteModal = false;
onDelete();
}}
on:cancel={() => (showDeleteModal = false)}
/>

View File

@@ -2,6 +2,7 @@
import { Check, ExternalLink, CircleAlert } from 'lucide-svelte';
import Score from '$ui/arr/Score.svelte';
import CustomFormatBadge from '$ui/arr/CustomFormatBadge.svelte';
import Badge from '$ui/badge/Badge.svelte';
import type { RadarrLibraryItem } from '$utils/arr/types.ts';
import type { Column } from '$ui/table/types';
@@ -34,14 +35,13 @@
</div>
{:else if column.key === 'qualityProfileName'}
<div class="relative group inline-flex">
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium {row.isProfilarrProfile ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400'}"
<Badge
variant={row.isProfilarrProfile ? 'accent' : 'warning'}
icon={row.isProfilarrProfile ? null : CircleAlert}
mono
>
{#if !row.isProfilarrProfile}
<CircleAlert size={12} />
{/if}
{row.qualityProfileName}
</span>
</Badge>
{#if !row.isProfilarrProfile}
<div class="absolute left-1/2 -translate-x-1/2 bottom-full mb-1 px-2 py-1 text-xs text-white bg-neutral-800 dark:bg-neutral-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-10">
Not managed by Profilarr
@@ -49,9 +49,7 @@
{/if}
</div>
{:else if column.key === 'qualityName'}
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{row.qualityName ?? 'N/A'}
</code>
<Badge variant="neutral" mono>{row.qualityName ?? 'N/A'}</Badge>
{:else if column.key === 'customFormatScore'}
<div class="text-right">
<Score score={row.customFormatScore} showSign={false} colored={false} />
@@ -76,13 +74,9 @@
{/if}
</div>
{:else if column.key === 'popularity'}
<span class="font-mono text-sm text-neutral-600 dark:text-neutral-400">
{row.popularity?.toFixed(1) ?? '-'}
</span>
<Badge variant="neutral" mono>{row.popularity?.toFixed(1) ?? '-'}</Badge>
{:else if column.key === 'dateAdded'}
<span class="text-sm text-neutral-600 dark:text-neutral-400">
{formatDate(row.dateAdded)}
</span>
<Badge variant="neutral" mono>{formatDate(row.dateAdded)}</Badge>
{:else if column.key === 'actions'}
<div class="flex items-center justify-center">
{#if row.tmdbId}

View File

@@ -1,10 +1,17 @@
<script lang="ts">
import { Database, Plus, Lock, Code, Trash2, Pencil, ExternalLink, ChevronRight } from 'lucide-svelte';
import { Database, Plus, Lock, Code, Trash2, Pencil, ExternalLink, ChevronRight, Info } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import EmptyState from '$ui/state/EmptyState.svelte';
import Table from '$ui/table/Table.svelte';
import TableActionButton from '$ui/table/TableActionButton.svelte';
import Badge from '$ui/badge/Badge.svelte';
import Modal from '$ui/modal/Modal.svelte';
import InfoModal from '$ui/modal/InfoModal.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import { createSearchStore } from '$stores/search';
import { alertStore } from '$alerts/store';
import type { PageData } from './$types';
import type { Column } from '$ui/table/types';
@@ -12,8 +19,15 @@
export let data: PageData;
// Search store
const searchStore = createSearchStore();
// Filter databases based on search
$: filteredDatabases = searchStore.filterItems(data.databases, ['name', 'repository_url']);
// Modal state
let showUnlinkModal = false;
let showInfoModal = false;
let selectedDatabase: DatabaseInstance | null = null;
let unlinkFormElement: HTMLFormElement;
@@ -85,25 +99,15 @@
/>
{:else}
<div class="space-y-6 p-8">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">Databases</h1>
<p class="mt-1 text-neutral-600 dark:text-neutral-400">
Manage your linked Profilarr Compliant Databases
</p>
</div>
<a
href="/databases/new"
class="inline-flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600"
>
<Plus size={16} />
Link Database
</a>
</div>
<!-- Actions Bar -->
<ActionsBar>
<SearchAction {searchStore} placeholder="Search databases..." />
<ActionButton icon={Plus} title="Link Database" on:click={() => goto('/databases/new')} />
<ActionButton icon={Info} title="Info" on:click={() => (showInfoModal = true)} />
</ActionsBar>
<!-- Database Table -->
<Table {columns} data={data.databases} hoverable={true}>
<Table {columns} data={filteredDatabases} hoverable={true}>
<svelte:fragment slot="cell" let:row let:column>
<div on:click={() => handleRowClick(row)} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && handleRowClick(row)} class="cursor-pointer">
{#if column.key === 'name'}
@@ -124,78 +128,37 @@
{row.name}
</div>
{#if row.is_private}
<span class="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
<Lock size={10} />
Private
</span>
<Badge variant="warning" icon={Lock} mono>Private</Badge>
{/if}
{#if row.personal_access_token}
<span class="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/30 dark:text-green-400">
<Code size={10} />
Dev
</span>
<Badge variant="success" icon={Code} mono>Dev</Badge>
{/if}
</div>
</div>
{:else if column.key === 'repository_url'}
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{row.repository_url.replace('https://github.com/', '')}
</code>
<Badge variant="neutral" mono>{row.repository_url.replace('https://github.com/', '')}</Badge>
{:else if column.key === 'sync_strategy'}
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{formatSyncStrategy(row.sync_strategy)}
</code>
<Badge variant="neutral" mono>{formatSyncStrategy(row.sync_strategy)}</Badge>
{:else if column.key === 'last_synced_at'}
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{formatLastSynced(row.last_synced_at)}
</code>
<Badge variant="neutral" mono>{formatLastSynced(row.last_synced_at)}</Badge>
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-2">
<!-- GitHub Link Button -->
<a
href={row.repository_url}
target="_blank"
rel="noopener noreferrer"
on:click={(e) => e.stopPropagation()}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-accent-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-accent-400 dark:hover:bg-neutral-700"
title="View on GitHub"
>
<ExternalLink size={14} />
<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>
<!-- Edit Button -->
<a
href="/databases/{row.id}/edit"
on:click={(e) => e.stopPropagation()}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
title="Edit database"
>
<Pencil size={14} />
<a href={row.repository_url} target="_blank" rel="noopener noreferrer" on:click={(e) => e.stopPropagation()}>
<TableActionButton icon={ExternalLink} title="View on GitHub" />
</a>
<!-- Unlink Button -->
<button
type="button"
on:click={(e) => handleUnlinkClick(e, row)}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-red-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-neutral-700"
<TableActionButton
icon={Trash2}
title="Unlink database"
>
<Trash2 size={14} />
</button>
<!-- View Button -->
<button
type="button"
on:click={() => handleRowClick(row)}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
title={row.personal_access_token ? "View changes" : "View commits"}
>
<ChevronRight size={14} />
</button>
variant="danger"
on:click={(e) => handleUnlinkClick(e, row)}
/>
</div>
</svelte:fragment>
</Table>
@@ -245,3 +208,44 @@
>
<input type="hidden" name="id" value={selectedDatabase?.id || ''} />
</form>
<!-- Info Modal -->
<InfoModal bind:open={showInfoModal} header="Databases">
<div class="space-y-4 text-sm text-neutral-600 dark:text-neutral-400">
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">What are Databases?</div>
<div class="mt-1">
Databases are Profilarr Compliant Database (PCD) repositories containing quality profiles,
custom formats, and other configurations. Link a database to import and sync configurations
to your Arr instances.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Private & Dev Badges</div>
<div class="mt-1">
<strong>Private</strong> indicates the repository requires authentication.
<strong>Dev</strong> means you have a personal access token configured, allowing you to
push changes back to the repository.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Sync Strategy</div>
<div class="mt-1">
Controls how often Profilarr checks for updates from the remote repository. Set to
"Manual" to only sync when you explicitly trigger it, or choose an interval for
automatic updates.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Unlinking</div>
<div class="mt-1">
Unlinking a database removes all local data associated with it. Your Arr instances
will keep any configurations that were already synced, but you won't be able to
sync updates until you re-link the database.
</div>
</div>
</div>
</InfoModal>

View File

@@ -98,6 +98,7 @@
},
{
name: 'Seraphys',
remark: 'Your sync broke? But the conditions are in order now!',
tags: ['Dictionarry Database Lead', 'Sexy God']
}
];
@@ -315,8 +316,15 @@
</div>
</div>
<!-- Greetz -->
<div class="mt-6 text-center">
<p class="text-sm text-neutral-500 dark:text-neutral-400">
<span class="font-medium">Greetz:</span> Ba11in0nABudget, SFusion, some_guy, delavicci
</p>
</div>
<!-- Dedication -->
<div class="mt-8 text-center">
<div class="mt-4 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>