mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-30 22:30:55 +01:00
style: mobile improvements for arr pages
This commit is contained in:
@@ -11,8 +11,10 @@
|
||||
export let textColor: string = '';
|
||||
export let iconPosition: 'left' | 'right' = 'left';
|
||||
export let type: 'button' | 'submit' = 'button';
|
||||
// Responsive: auto-switch to xs on smaller screens (< 1280px)
|
||||
// Responsive: auto-switch to xs on smaller screens (< 768px)
|
||||
export let responsive: boolean = false;
|
||||
// Hide text on mobile (show icon only)
|
||||
export let hideTextOnMobile: boolean = false;
|
||||
export let fullWidth: boolean = false;
|
||||
// Optional href - renders as anchor instead of button
|
||||
export let href: string | undefined = undefined;
|
||||
@@ -75,7 +77,7 @@
|
||||
<svelte:component this={icon} size={effectiveSize === 'xs' ? 12 : effectiveSize === 'sm' ? 14 : 16} class={baseIconColor} />
|
||||
{/if}
|
||||
{#if text}
|
||||
<span class={baseTextColor}>{text}</span>
|
||||
<span class="{baseTextColor} {hideTextOnMobile ? 'hidden md:inline' : ''}">{text}</span>
|
||||
{/if}
|
||||
<slot />
|
||||
{#if icon && iconPosition === 'right'}
|
||||
@@ -88,7 +90,7 @@
|
||||
<svelte:component this={icon} size={effectiveSize === 'xs' ? 12 : effectiveSize === 'sm' ? 14 : 16} class={baseIconColor} />
|
||||
{/if}
|
||||
{#if text}
|
||||
<span class={baseTextColor}>{text}</span>
|
||||
<span class="{baseTextColor} {hideTextOnMobile ? 'hidden md:inline' : ''}">{text}</span>
|
||||
{/if}
|
||||
<slot />
|
||||
{#if icon && iconPosition === 'right'}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let position: 'left' | 'right' | 'middle' = 'left';
|
||||
export let mobilePosition: 'left' | 'right' | 'middle' | null = null;
|
||||
export let minWidth: string = '12rem';
|
||||
export let compact: boolean = false;
|
||||
// Fixed positioning to escape overflow containers
|
||||
@@ -11,13 +12,24 @@
|
||||
let dropdownEl: HTMLElement;
|
||||
let fixedStyle = '';
|
||||
|
||||
const positionClasses = {
|
||||
left: 'left-0',
|
||||
right: 'right-0',
|
||||
middle: 'left-1/2 -translate-x-1/2'
|
||||
};
|
||||
|
||||
const responsivePositionClasses = {
|
||||
'middle-to-right': 'left-1/2 -translate-x-1/2 md:left-auto md:right-0 md:translate-x-0',
|
||||
'middle-to-left': 'left-1/2 -translate-x-1/2 md:left-0 md:translate-x-0',
|
||||
'left-to-right': 'left-0 md:left-auto md:right-0',
|
||||
'right-to-left': 'right-0 md:right-auto md:left-0'
|
||||
};
|
||||
|
||||
$: positionClass = fixed
|
||||
? ''
|
||||
: {
|
||||
left: 'left-0',
|
||||
right: 'right-0',
|
||||
middle: 'left-1/2 -translate-x-1/2'
|
||||
}[position];
|
||||
: mobilePosition && mobilePosition !== position
|
||||
? responsivePositionClasses[`${mobilePosition}-to-${position}`] || positionClasses[position]
|
||||
: positionClasses[position];
|
||||
|
||||
$: marginClass = compact ? 'mt-1' : 'mt-3';
|
||||
$: gap = compact ? 4 : 12; // pixels gap below trigger
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<!-- 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 p-4 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
on:click={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -42,11 +42,11 @@
|
||||
>
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative w-full max-w-lg rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||
class="relative flex max-h-[90vh] w-full max-w-lg flex-col rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-neutral-200 px-6 py-4 dark:border-neutral-800"
|
||||
class="flex flex-shrink-0 items-center justify-between 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>
|
||||
<button
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-4">
|
||||
<div class="overflow-auto px-6 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
>
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative flex w-full flex-col {sizeClasses[size]} {heightClasses[
|
||||
class="relative flex max-h-[90vh] w-full flex-col {sizeClasses[size]} {heightClasses[
|
||||
height
|
||||
]} rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||
>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="p-8">
|
||||
<Tabs {tabs} {breadcrumb} />
|
||||
<div class="p-4 md:p-8">
|
||||
<Tabs {tabs} {breadcrumb} responsive />
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { AlertTriangle, Film } from 'lucide-svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import { AlertTriangle, Film, ExternalLink } from 'lucide-svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
|
||||
@@ -14,7 +13,6 @@
|
||||
import LibraryActionBar from './components/LibraryActionBar.svelte';
|
||||
import MovieRow from './components/MovieRow.svelte';
|
||||
import MovieRowSkeleton from './components/MovieRowSkeleton.svelte';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
@@ -83,27 +81,6 @@
|
||||
window.open(baseUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
goto(`/arr/${data.instance.id}/settings`);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Are you sure you want to delete this instance?')) return;
|
||||
|
||||
const response = await fetch(`/api/arr/${data.instance.id}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
goto('/arr');
|
||||
} else {
|
||||
alertStore.add('error', 'Failed to delete instance');
|
||||
}
|
||||
}
|
||||
|
||||
let infoModalOpen = false;
|
||||
|
||||
function handleInfo() {
|
||||
infoModalOpen = true;
|
||||
}
|
||||
|
||||
let currentInstanceId: number | null = null;
|
||||
|
||||
onMount(() => {
|
||||
@@ -280,15 +257,13 @@
|
||||
sortable: true,
|
||||
sortAccessor: (row) => (row.dateAdded ? new Date(row.dateAdded).getTime() : 0),
|
||||
defaultSortDirection: 'desc'
|
||||
},
|
||||
{ key: 'actions', header: '', align: 'center', width: 'w-12' }
|
||||
}
|
||||
];
|
||||
|
||||
$: columns = allColumns.filter(
|
||||
(col) =>
|
||||
col.key === 'title' ||
|
||||
col.key === 'qualityProfileName' ||
|
||||
col.key === 'actions' ||
|
||||
visibleColumns.has(col.key as ToggleableColumn)
|
||||
);
|
||||
|
||||
@@ -358,14 +333,10 @@
|
||||
{activeFilters}
|
||||
uniqueQualities={loading ? [] : uniqueQualities}
|
||||
uniqueProfiles={loading ? [] : uniqueProfiles}
|
||||
instanceName={data.instance.name}
|
||||
onToggleColumn={toggleColumn}
|
||||
onToggleFilter={toggleFilter}
|
||||
onRefresh={handleRefresh}
|
||||
onOpen={handleOpen}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onInfo={handleInfo}
|
||||
/>
|
||||
|
||||
{#if allMoviesWithFiles.length === 0 && !loading}
|
||||
@@ -390,6 +361,7 @@
|
||||
getRowId={(row) => row.id}
|
||||
compact={true}
|
||||
{defaultSort}
|
||||
responsive
|
||||
emptyMessage={activeFilters.length > 0 || debouncedQuery
|
||||
? 'No movies match the current filters'
|
||||
: 'No movies with files'}
|
||||
@@ -402,6 +374,21 @@
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="actions" let:row>
|
||||
{#if !loading && row.tmdbId}
|
||||
<a
|
||||
href="{baseUrl}/movie/{row.tmdbId}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
title="Open in Radarr"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="expanded" let:row>
|
||||
{#if !loading}
|
||||
<MovieRow {row} column={allColumns[0]} {baseUrl} mode="expanded" />
|
||||
@@ -412,9 +399,3 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<InfoModal bind:open={infoModalOpen} header="Library">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Placeholder content. More information coming soon.
|
||||
</p>
|
||||
</InfoModal>
|
||||
|
||||
@@ -4,17 +4,13 @@
|
||||
SlidersHorizontal,
|
||||
TableProperties,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Info
|
||||
ExternalLink
|
||||
} from 'lucide-svelte';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import SearchAction from '$ui/actions/SearchAction.svelte';
|
||||
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';
|
||||
@@ -34,7 +30,6 @@
|
||||
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: (
|
||||
@@ -45,18 +40,13 @@
|
||||
) => void;
|
||||
export let onRefresh: () => void;
|
||||
export let onOpen: () => void;
|
||||
export let onEdit: () => void;
|
||||
export let onDelete: () => void;
|
||||
export let onInfo: () => void;
|
||||
|
||||
let showDeleteModal = false;
|
||||
</script>
|
||||
|
||||
<ActionsBar>
|
||||
<SearchAction {searchStore} placeholder="Search movies..." />
|
||||
<SearchAction {searchStore} placeholder="Search movies..." responsive />
|
||||
<ActionButton icon={SlidersHorizontal} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} minWidth="16rem">
|
||||
<Dropdown position={dropdownPosition} mobilePosition="middle" minWidth="16rem">
|
||||
<div class="border-b border-neutral-100 px-4 py-3 dark:border-neutral-700">
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Filter movies by quality or profile
|
||||
@@ -130,7 +120,7 @@
|
||||
</ActionButton>
|
||||
<ActionButton icon={TableProperties} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} minWidth="14rem">
|
||||
<Dropdown position={dropdownPosition} mobilePosition="middle" minWidth="14rem">
|
||||
<div class="border-b border-neutral-100 px-4 py-3 dark:border-neutral-700">
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-400">Toggle visible table columns</p>
|
||||
</div>
|
||||
@@ -158,19 +148,6 @@
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
<ActionButton icon={Info} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} minWidth="10rem">
|
||||
<button
|
||||
type="button"
|
||||
on:click={onInfo}
|
||||
class="w-full rounded-lg px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
About this page
|
||||
</button>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
<ActionButton icon={RefreshCw} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} minWidth="10rem">
|
||||
@@ -197,44 +174,4 @@
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
<ActionButton icon={Pencil} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} minWidth="10rem">
|
||||
<button
|
||||
type="button"
|
||||
on:click={onEdit}
|
||||
class="w-full rounded-lg px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Edit instance
|
||||
</button>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
<ActionButton icon={Trash2} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} minWidth="10rem">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showDeleteModal = true)}
|
||||
class="w-full rounded-lg px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Delete instance
|
||||
</button>
|
||||
</Dropdown>
|
||||
</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)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, ExternalLink, CircleAlert } from 'lucide-svelte';
|
||||
import { Check, 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';
|
||||
@@ -79,25 +79,10 @@
|
||||
<Badge variant="neutral" mono>{row.popularity?.toFixed(1) ?? '-'}</Badge>
|
||||
{:else if column.key === 'dateAdded'}
|
||||
<Badge variant="neutral" mono>{formatDate(row.dateAdded)}</Badge>
|
||||
{:else if column.key === 'actions'}
|
||||
<div class="flex items-center justify-center">
|
||||
{#if row.tmdbId}
|
||||
<a
|
||||
href="{baseUrl}/movie/{row.tmdbId}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
title="Open in Radarr"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Expanded content -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-3 p-4">
|
||||
<!-- File Name -->
|
||||
{#if row.fileName}
|
||||
<code class="font-mono text-xs break-all text-neutral-600 dark:text-neutral-400"
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Log Table -->
|
||||
<Table data={filteredLogs} {columns} emptyMessage="No logs found" hoverable={true} compact={true}>
|
||||
<Table data={filteredLogs} {columns} emptyMessage="No logs found" hoverable={true} compact={true} responsive>
|
||||
<svelte:fragment slot="actions" let:row>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<TableActionButton icon={Copy} title="Copy log entry" on:click={() => copyLog(row)} />
|
||||
|
||||
@@ -139,14 +139,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="-mx-8 bg-neutral-50 px-8 pt-2 pb-6 dark:bg-neutral-900">
|
||||
<div class="-mx-4 bg-neutral-50 px-4 pt-2 pb-6 md:-mx-8 md:px-8 dark:bg-neutral-900">
|
||||
<div class="mb-4">
|
||||
<ActionsBar>
|
||||
<SearchAction {searchStore} placeholder="Search runs..." />
|
||||
|
||||
<!-- Date Filter -->
|
||||
<ActionButton icon={Calendar} hasDropdown square title="Filter by date">
|
||||
<Dropdown slot="dropdown" position="right">
|
||||
<Dropdown slot="dropdown" position="right" mobilePosition="middle">
|
||||
{#each [{ value: 'all', label: 'All time' }, { value: 'today', label: 'Today' }, { value: 'yesterday', label: 'Yesterday' }, { value: 'week', label: 'Last 7 days' }, { value: 'month', label: 'Last 30 days' }] as const as option}
|
||||
<DropdownItem
|
||||
label={option.label}
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
<!-- Status Filter -->
|
||||
<ActionButton icon={CircleDot} hasDropdown square title="Filter by status">
|
||||
<Dropdown slot="dropdown" position="right">
|
||||
<Dropdown slot="dropdown" position="right" mobilePosition="middle">
|
||||
{#each [{ value: 'all', label: 'All' }, { value: 'success', label: 'Success' }, { value: 'partial', label: 'Partial' }, { value: 'failed', label: 'Failed' }, { value: 'skipped', label: 'Skipped' }] as const as option}
|
||||
<DropdownItem
|
||||
label={option.label}
|
||||
@@ -180,6 +180,7 @@
|
||||
chevronPosition="right"
|
||||
flushExpanded={true}
|
||||
emptyMessage="No rename runs yet. Configure and enable rename to start."
|
||||
responsive
|
||||
>
|
||||
<svelte:fragment slot="cell" let:row let:column>
|
||||
{#if column.key === 'runNumber'}
|
||||
@@ -313,6 +314,7 @@
|
||||
getRowId={(item) => item.id}
|
||||
compact={true}
|
||||
emptyMessage="No items"
|
||||
responsive
|
||||
>
|
||||
<svelte:fragment slot="cell" let:row={item} let:column>
|
||||
{#if column.key === 'title'}
|
||||
@@ -323,7 +325,7 @@
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="expanded" let:row={item}>
|
||||
<div class="space-y-2 py-2 pl-2">
|
||||
<div class="space-y-3 p-4">
|
||||
{#each item.files as file}
|
||||
<div class="space-y-1">
|
||||
<div class="flex gap-2">
|
||||
@@ -332,8 +334,7 @@
|
||||
>From:</span
|
||||
>
|
||||
<span
|
||||
class="truncate font-mono text-xs text-neutral-700 dark:text-neutral-300"
|
||||
title={file.existingPath}
|
||||
class="break-all font-mono text-xs text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
{getFileName(file.existingPath)}
|
||||
</span>
|
||||
@@ -344,8 +345,7 @@
|
||||
>To:</span
|
||||
>
|
||||
<span
|
||||
class="truncate font-mono text-xs text-neutral-700 dark:text-neutral-300"
|
||||
title={file.newPath}
|
||||
class="break-all font-mono text-xs text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
{getFileName(file.newPath)}
|
||||
</span>
|
||||
|
||||
@@ -100,92 +100,101 @@
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-3">
|
||||
<!-- Left: Settings -->
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<!-- Status -->
|
||||
<!-- Mobile: 2-column grid, Desktop: inline flex -->
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-3 md:flex md:flex-wrap md:gap-x-6">
|
||||
<!-- Status -->
|
||||
<span class="text-sm text-neutral-500 md:hidden dark:text-neutral-400">Status</span>
|
||||
<div class="md:flex md:items-center md:gap-2">
|
||||
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">Status:</span>
|
||||
<DropdownSelect
|
||||
label="Status:"
|
||||
value={enabled ? 'true' : 'false'}
|
||||
options={enabledOptions}
|
||||
responsiveButton
|
||||
on:change={(e) => onEnabledChange?.(e.detail === 'true')}
|
||||
/>
|
||||
|
||||
<!-- Dry Run -->
|
||||
<DropdownSelect
|
||||
label="Dry Run:"
|
||||
value={dryRun ? 'true' : 'false'}
|
||||
options={enabledOptions}
|
||||
responsiveButton
|
||||
on:change={(e) => onDryRunChange?.(e.detail === 'true')}
|
||||
/>
|
||||
|
||||
<!-- Rename Folders -->
|
||||
<DropdownSelect
|
||||
label="Folders:"
|
||||
value={renameFolders ? 'true' : 'false'}
|
||||
options={enabledOptions}
|
||||
responsiveButton
|
||||
on:change={(e) => onRenameFoldersChange?.(e.detail === 'true')}
|
||||
/>
|
||||
|
||||
<!-- Summary Notifications -->
|
||||
<DropdownSelect
|
||||
label="Summary:"
|
||||
value={summaryNotifications ? 'true' : 'false'}
|
||||
options={enabledOptions}
|
||||
responsiveButton
|
||||
on:change={(e) => onSummaryNotificationsChange?.(e.detail === 'true')}
|
||||
/>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="hidden h-6 w-px bg-neutral-200 sm:block dark:bg-neutral-700"></div>
|
||||
|
||||
<!-- Schedule -->
|
||||
<DropdownSelect
|
||||
label="Schedule:"
|
||||
value={schedule}
|
||||
options={scheduleOptions}
|
||||
responsiveButton
|
||||
on:change={(e) => onScheduleChange?.(e.detail)}
|
||||
/>
|
||||
|
||||
<!-- Ignore Tag -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-neutral-500 dark:text-neutral-400">Ignore Tag:</span>
|
||||
<Input value={ignoreTag} placeholder="no-rename" on:input={(e) => onIgnoreTagChange?.(e.detail)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Status -->
|
||||
{#if lastRunAt}
|
||||
<div class="flex items-center gap-3 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{#if !enabled}
|
||||
<span
|
||||
class="rounded bg-amber-100 px-1.5 py-0.5 font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-400"
|
||||
>Paused</span
|
||||
>
|
||||
{:else if timeUntilNext !== null && timeUntilNext <= 0}
|
||||
<span
|
||||
class="rounded bg-green-100 px-1.5 py-0.5 font-medium text-green-700 dark:bg-green-900/50 dark:text-green-400"
|
||||
>Ready</span
|
||||
>
|
||||
{:else if timeUntilNext !== null}
|
||||
<span>
|
||||
Next Run: <span
|
||||
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
|
||||
>{formatTimeRemaining(timeUntilNext)}</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Dry Run -->
|
||||
<span class="text-sm text-neutral-500 md:hidden dark:text-neutral-400">Dry Run</span>
|
||||
<div class="md:flex md:items-center md:gap-2">
|
||||
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">Dry Run:</span>
|
||||
<DropdownSelect
|
||||
value={dryRun ? 'true' : 'false'}
|
||||
options={enabledOptions}
|
||||
on:change={(e) => onDryRunChange?.(e.detail === 'true')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Rename Folders -->
|
||||
<span class="text-sm text-neutral-500 md:hidden dark:text-neutral-400">Folders</span>
|
||||
<div class="md:flex md:items-center md:gap-2">
|
||||
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">Folders:</span>
|
||||
<DropdownSelect
|
||||
value={renameFolders ? 'true' : 'false'}
|
||||
options={enabledOptions}
|
||||
on:change={(e) => onRenameFoldersChange?.(e.detail === 'true')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Summary Notifications -->
|
||||
<span class="text-sm text-neutral-500 md:hidden dark:text-neutral-400">Summary</span>
|
||||
<div class="md:flex md:items-center md:gap-2">
|
||||
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">Summary:</span>
|
||||
<DropdownSelect
|
||||
value={summaryNotifications ? 'true' : 'false'}
|
||||
options={enabledOptions}
|
||||
on:change={(e) => onSummaryNotificationsChange?.(e.detail === 'true')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Divider (desktop only) -->
|
||||
<div class="hidden h-6 w-px bg-neutral-200 md:block dark:bg-neutral-700"></div>
|
||||
|
||||
<!-- Schedule -->
|
||||
<span class="text-sm text-neutral-500 md:hidden dark:text-neutral-400">Schedule</span>
|
||||
<div class="md:flex md:items-center md:gap-2">
|
||||
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">Schedule:</span>
|
||||
<DropdownSelect
|
||||
value={schedule}
|
||||
options={scheduleOptions}
|
||||
on:change={(e) => onScheduleChange?.(e.detail)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Ignore Tag -->
|
||||
<span class="text-sm text-neutral-500 md:hidden dark:text-neutral-400">Ignore Tag</span>
|
||||
<div class="md:flex md:items-center md:gap-2">
|
||||
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">Ignore Tag:</span>
|
||||
<Input value={ignoreTag} placeholder="no-rename" on:input={(e) => onIgnoreTagChange?.(e.detail)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status info -->
|
||||
{#if lastRunAt}
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-neutral-200 pt-4 text-xs text-neutral-500 md:mt-3 md:border-0 md:pt-0 dark:border-neutral-700 dark:text-neutral-400">
|
||||
{#if !enabled}
|
||||
<span
|
||||
class="rounded bg-amber-100 px-1.5 py-0.5 font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-400"
|
||||
>Paused</span
|
||||
>
|
||||
{:else if timeUntilNext !== null && timeUntilNext <= 0}
|
||||
<span
|
||||
class="rounded bg-green-100 px-1.5 py-0.5 font-medium text-green-700 dark:bg-green-900/50 dark:text-green-400"
|
||||
>Ready</span
|
||||
>
|
||||
{:else if timeUntilNext !== null}
|
||||
<span>
|
||||
Last Run: <span
|
||||
Next Run: <span
|
||||
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
|
||||
>{formatLastRun(lastRunAt)}</span
|
||||
>{formatTimeRemaining(timeUntilNext)}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span>
|
||||
Last Run: <span
|
||||
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
|
||||
>{formatLastRun(lastRunAt)}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import { Info } from 'lucide-svelte';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
import DirtyModal from '$ui/modal/DirtyModal.svelte';
|
||||
import StickyCard from '$ui/card/StickyCard.svelte';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
import QualityProfiles from './components/QualityProfiles.svelte';
|
||||
import DelayProfiles from './components/DelayProfiles.svelte';
|
||||
import MediaManagement from './components/MediaManagement.svelte';
|
||||
@@ -115,22 +117,21 @@
|
||||
|
||||
<div class="mt-6 space-y-6 pb-32">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-50">Sync Configuration</h1>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<StickyCard position="top">
|
||||
<svelte:fragment slot="left">
|
||||
<h1 class="text-neutral-900 dark:text-neutral-50">Sync Configuration</h1>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
Configure which profiles and settings to sync to this instance.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showInfoModal = true)}
|
||||
class="flex items-center gap-1.5 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-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<Info size={14} />
|
||||
How it works
|
||||
</button>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="right">
|
||||
<Button
|
||||
text="How it works"
|
||||
icon={Info}
|
||||
on:click={() => (showInfoModal = true)}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</StickyCard>
|
||||
|
||||
<MediaManagement
|
||||
databases={data.databases}
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
{#if database.delayProfiles.length === 0}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">No delay profiles</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
|
||||
{#each database.delayProfiles as profile}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
{#if database.qualityProfiles.length === 0}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">No quality profiles</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
|
||||
{#each database.qualityProfiles as profile}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,9 +25,10 @@
|
||||
$: syncDisabled = syncing || isDirty;
|
||||
</script>
|
||||
|
||||
<div class="border-t border-neutral-200 px-6 py-4 dark:border-neutral-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="border-t border-neutral-200 px-4 py-4 md:px-6 dark:border-neutral-800">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<!-- Trigger options -->
|
||||
<div class="flex flex-wrap items-center gap-3 md:gap-4">
|
||||
<span class="text-sm text-neutral-500 dark:text-neutral-400">Trigger</span>
|
||||
{#each triggerOptions as option}
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -51,40 +52,43 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Warning + Buttons -->
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{#if warning}
|
||||
<div class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={14} class="flex-shrink-0" />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
disabled={syncDisabled}
|
||||
on:click={() => dispatch('sync')}
|
||||
title={isDirty ? 'Save changes before syncing' : ''}
|
||||
class="flex items-center gap-1.5 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-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{#if syncing}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<RefreshCw size={14} />
|
||||
{/if}
|
||||
Sync Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saveDisabled}
|
||||
on:click={() => dispatch('save')}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if saving}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={syncDisabled}
|
||||
on:click={() => dispatch('sync')}
|
||||
title={isDirty ? 'Save changes before syncing' : ''}
|
||||
class="flex flex-1 items-center justify-center gap-1.5 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 sm:flex-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{#if syncing}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<RefreshCw size={14} />
|
||||
{/if}
|
||||
Sync Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saveDisabled}
|
||||
on:click={() => dispatch('save')}
|
||||
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none"
|
||||
>
|
||||
{#if saving}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
<Button text="Info" icon={Info} href="/arr/upgrades/info" />
|
||||
{#if !isNewConfig && data.config?.dryRun}
|
||||
<Button
|
||||
text={clearing ? 'Clearing...' : 'Reset Cache'}
|
||||
text={clearing ? 'Clearing...' : 'Reset'}
|
||||
icon={RotateCcw}
|
||||
disabled={clearing || running || saving}
|
||||
on:click={() => {
|
||||
@@ -98,7 +98,7 @@
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
text={running ? 'Running...' : 'Test Run'}
|
||||
text={running ? 'Running...' : 'Test'}
|
||||
icon={Play}
|
||||
iconColor="text-amber-600 dark:text-amber-400"
|
||||
disabled={running || saving || clearing || $isDirty}
|
||||
|
||||
@@ -98,78 +98,84 @@
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-3">
|
||||
<!-- Left: Settings -->
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<!-- Status -->
|
||||
<!-- Mobile: 2-column grid, Desktop: inline flex -->
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-3 md:flex md:flex-wrap md:gap-x-6">
|
||||
<!-- Status -->
|
||||
<span class="text-sm text-neutral-500 md:hidden dark:text-neutral-400">Status</span>
|
||||
<div class="md:flex md:items-center md:gap-2">
|
||||
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">Status:</span>
|
||||
<DropdownSelect
|
||||
label="Status:"
|
||||
value={enabled ? 'true' : 'false'}
|
||||
options={enabledOptions}
|
||||
responsiveButton
|
||||
on:change={(e) => onEnabledChange?.(e.detail === 'true')}
|
||||
/>
|
||||
|
||||
<!-- Dry Run -->
|
||||
<DropdownSelect
|
||||
label="Dry Run:"
|
||||
value={dryRun ? 'true' : 'false'}
|
||||
options={enabledOptions}
|
||||
responsiveButton
|
||||
on:change={(e) => onDryRunChange?.(e.detail === 'true')}
|
||||
/>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="hidden h-6 w-px bg-neutral-200 sm:block dark:bg-neutral-700"></div>
|
||||
|
||||
<!-- Schedule -->
|
||||
<DropdownSelect
|
||||
label="Schedule:"
|
||||
value={schedule}
|
||||
options={scheduleOptions}
|
||||
responsiveButton
|
||||
on:change={(e) => onScheduleChange?.(e.detail)}
|
||||
/>
|
||||
|
||||
<!-- Filter Mode -->
|
||||
<DropdownSelect
|
||||
label="Mode:"
|
||||
value={filterMode}
|
||||
options={modeOptions}
|
||||
minWidth="10rem"
|
||||
responsiveButton
|
||||
on:change={(e) => onFilterModeChange?.(e.detail as FilterMode)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right: Status -->
|
||||
{#if lastRunAt}
|
||||
<div class="flex items-center gap-3 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{#if !enabled}
|
||||
<span
|
||||
class="rounded bg-amber-100 px-1.5 py-0.5 font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-400"
|
||||
>Paused</span
|
||||
>
|
||||
{:else if timeUntilNext !== null && timeUntilNext <= 0}
|
||||
<span
|
||||
class="rounded bg-green-100 px-1.5 py-0.5 font-medium text-green-700 dark:bg-green-900/50 dark:text-green-400"
|
||||
>Ready</span
|
||||
>
|
||||
{:else if timeUntilNext !== null}
|
||||
<span>
|
||||
Next Run: <span
|
||||
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
|
||||
>{formatTimeRemaining(timeUntilNext)}</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Dry Run -->
|
||||
<span class="text-sm text-neutral-500 md:hidden dark:text-neutral-400">Dry Run</span>
|
||||
<div class="md:flex md:items-center md:gap-2">
|
||||
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">Dry Run:</span>
|
||||
<DropdownSelect
|
||||
value={dryRun ? 'true' : 'false'}
|
||||
options={enabledOptions}
|
||||
on:change={(e) => onDryRunChange?.(e.detail === 'true')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Divider (desktop only) -->
|
||||
<div class="hidden h-6 w-px bg-neutral-200 md:block dark:bg-neutral-700"></div>
|
||||
|
||||
<!-- Schedule -->
|
||||
<span class="text-sm text-neutral-500 md:hidden dark:text-neutral-400">Schedule</span>
|
||||
<div class="md:flex md:items-center md:gap-2">
|
||||
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">Schedule:</span>
|
||||
<DropdownSelect
|
||||
value={schedule}
|
||||
options={scheduleOptions}
|
||||
on:change={(e) => onScheduleChange?.(e.detail)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filter Mode -->
|
||||
<span class="text-sm text-neutral-500 md:hidden dark:text-neutral-400">Mode</span>
|
||||
<div class="md:flex md:items-center md:gap-2">
|
||||
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">Mode:</span>
|
||||
<DropdownSelect
|
||||
value={filterMode}
|
||||
options={modeOptions}
|
||||
minWidth="10rem"
|
||||
on:change={(e) => onFilterModeChange?.(e.detail as FilterMode)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status info -->
|
||||
{#if lastRunAt}
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-neutral-200 pt-4 text-xs text-neutral-500 md:mt-3 md:border-0 md:pt-0 dark:border-neutral-700 dark:text-neutral-400">
|
||||
{#if !enabled}
|
||||
<span
|
||||
class="rounded bg-amber-100 px-1.5 py-0.5 font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-400"
|
||||
>Paused</span
|
||||
>
|
||||
{:else if timeUntilNext !== null && timeUntilNext <= 0}
|
||||
<span
|
||||
class="rounded bg-green-100 px-1.5 py-0.5 font-medium text-green-700 dark:bg-green-900/50 dark:text-green-400"
|
||||
>Ready</span
|
||||
>
|
||||
{:else if timeUntilNext !== null}
|
||||
<span>
|
||||
Last Run: <span
|
||||
Next Run: <span
|
||||
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
|
||||
>{formatLastRun(lastRunAt)}</span
|
||||
>{formatTimeRemaining(timeUntilNext)}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span>
|
||||
Last Run: <span
|
||||
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
|
||||
>{formatLastRun(lastRunAt)}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="-mx-8 bg-neutral-50 px-8 pt-2 pb-6 dark:bg-neutral-900">
|
||||
<div class="-mx-4 bg-neutral-50 px-4 pt-2 pb-6 md:-mx-8 md:px-8 dark:bg-neutral-900">
|
||||
<div class="mb-4">
|
||||
<ActionsBar>
|
||||
<SearchAction {searchStore} placeholder="Search filters..." />
|
||||
@@ -220,41 +220,97 @@
|
||||
chevronPosition="right"
|
||||
flushExpanded={true}
|
||||
emptyMessage="No filters configured. Add a filter to start."
|
||||
responsive
|
||||
>
|
||||
<svelte:fragment slot="cell" let:row let:column>
|
||||
{#if column.key === 'name'}
|
||||
{#if editingId === row.id}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingName}
|
||||
on:click|stopPropagation
|
||||
on:keydown={(e) => e.key === 'Enter' && saveEditing()}
|
||||
on:blur={() => saveEditing()}
|
||||
class="w-40 rounded border border-neutral-300 bg-white px-2 py-1 text-sm font-medium text-neutral-900 focus:border-accent-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class={row.enabled
|
||||
? 'text-neutral-900 dark:text-neutral-100'
|
||||
: 'text-neutral-400 dark:text-neutral-500'}
|
||||
>
|
||||
{row.name}
|
||||
</span>
|
||||
{#if !row.enabled}
|
||||
<span
|
||||
class="ml-2 rounded bg-neutral-200 px-1.5 py-0.5 text-xs text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||
>
|
||||
Disabled
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center">
|
||||
{#if editingId === row.id}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingName}
|
||||
on:click|stopPropagation
|
||||
on:keydown={(e) => e.key === 'Enter' && saveEditing()}
|
||||
on:blur={() => saveEditing()}
|
||||
class="w-40 rounded border border-neutral-300 bg-white px-2 py-1 text-sm font-medium text-neutral-900 focus:border-accent-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class={row.enabled
|
||||
? 'text-neutral-900 dark:text-neutral-100'
|
||||
: 'text-neutral-400 dark:text-neutral-500'}
|
||||
>
|
||||
{row.name}
|
||||
</span>
|
||||
{#if !row.enabled}
|
||||
<span
|
||||
class="ml-2 rounded bg-neutral-200 px-1.5 py-0.5 text-xs text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||
>
|
||||
Disabled
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Mobile: buttons below name -->
|
||||
<div class="flex flex-wrap items-center gap-1 md:hidden">
|
||||
{#if editingId === row.id}
|
||||
<Button
|
||||
text="Save"
|
||||
icon={Check}
|
||||
iconColor="text-green-600 dark:text-green-400"
|
||||
on:click={() => saveEditing()}
|
||||
/>
|
||||
{:else}
|
||||
<Button
|
||||
text={row.enabled ? 'Disable' : 'Enable'}
|
||||
icon={Power}
|
||||
iconColor={row.enabled
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-neutral-400 dark:text-neutral-500'}
|
||||
on:click={() => toggleEnabled(row.id)}
|
||||
/>
|
||||
<Button
|
||||
text="Rename"
|
||||
icon={Pencil}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
on:click={() => startEditing(row)}
|
||||
/>
|
||||
<Button
|
||||
text="Copy"
|
||||
icon={ClipboardCopy}
|
||||
iconColor="text-amber-600 dark:text-amber-400"
|
||||
on:click={() => copyFilter(row.id)}
|
||||
/>
|
||||
<Button
|
||||
text="Paste"
|
||||
icon={ClipboardPaste}
|
||||
iconColor="text-amber-600 dark:text-amber-400"
|
||||
on:click={() => pasteIntoFilter(row.id)}
|
||||
/>
|
||||
<Button
|
||||
text="Duplicate"
|
||||
icon={Copy}
|
||||
iconColor="text-violet-600 dark:text-violet-400"
|
||||
on:click={() => duplicateFilter(row.id)}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
text="Delete"
|
||||
icon={Trash2}
|
||||
iconColor="text-red-600 dark:text-red-400"
|
||||
on:click={() => confirmDelete(row)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<!-- Desktop: buttons in actions slot -->
|
||||
<svelte:fragment slot="actions" let:row>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
{#if editingId === row.id}
|
||||
<Button
|
||||
text="Save"
|
||||
@@ -351,7 +407,6 @@
|
||||
label: `${s.label} - ${s.description}`
|
||||
}))}
|
||||
minWidth="14rem"
|
||||
responsiveButton
|
||||
compactDropdownThreshold={7}
|
||||
fullWidth
|
||||
fixed
|
||||
|
||||
@@ -168,14 +168,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="-mx-8 bg-neutral-50 px-8 pt-2 pb-6 dark:bg-neutral-900">
|
||||
<div class="-mx-4 bg-neutral-50 px-4 pt-2 pb-6 md:-mx-8 md:px-8 dark:bg-neutral-900">
|
||||
<div class="mb-4">
|
||||
<ActionsBar>
|
||||
<SearchAction {searchStore} placeholder="Search runs..." />
|
||||
|
||||
<!-- Date Filter -->
|
||||
<ActionButton icon={Calendar} hasDropdown square title="Filter by date">
|
||||
<Dropdown slot="dropdown" position="right">
|
||||
<Dropdown slot="dropdown" position="right" mobilePosition="middle">
|
||||
{#each [{ value: 'all', label: 'All time' }, { value: 'today', label: 'Today' }, { value: 'yesterday', label: 'Yesterday' }, { value: 'week', label: 'Last 7 days' }, { value: 'month', label: 'Last 30 days' }] as const as option}
|
||||
<DropdownItem
|
||||
label={option.label}
|
||||
@@ -188,7 +188,7 @@
|
||||
|
||||
<!-- Filter Name Filter -->
|
||||
<ActionButton icon={Filter} hasDropdown square title="Filter by filter name">
|
||||
<Dropdown slot="dropdown" position="right">
|
||||
<Dropdown slot="dropdown" position="right" mobilePosition="middle">
|
||||
<DropdownItem
|
||||
label="All filters"
|
||||
selected={filterFilter === 'all'}
|
||||
@@ -206,7 +206,7 @@
|
||||
|
||||
<!-- Status Filter -->
|
||||
<ActionButton icon={CircleDot} hasDropdown square title="Filter by status">
|
||||
<Dropdown slot="dropdown" position="right">
|
||||
<Dropdown slot="dropdown" position="right" mobilePosition="middle">
|
||||
{#each [{ value: 'all', label: 'All' }, { value: 'success', label: 'Success' }, { value: 'partial', label: 'Partial' }, { value: 'failed', label: 'Failed' }, { value: 'skipped', label: 'Skipped' }] as const as option}
|
||||
<DropdownItem
|
||||
label={option.label}
|
||||
@@ -227,6 +227,7 @@
|
||||
chevronPosition="right"
|
||||
flushExpanded={true}
|
||||
emptyMessage="No upgrade runs yet. Configure and enable upgrades to start."
|
||||
responsive
|
||||
>
|
||||
<svelte:fragment slot="cell" let:row let:column>
|
||||
{#if column.key === 'runNumber'}
|
||||
@@ -377,6 +378,7 @@
|
||||
getRowId={(item) => item.id}
|
||||
compact={true}
|
||||
emptyMessage="No items"
|
||||
responsive
|
||||
>
|
||||
<svelte:fragment slot="cell" let:row={item} let:column>
|
||||
{#if column.key === 'title'}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import Modal from '$ui/modal/Modal.svelte';
|
||||
import DirtyModal from '$ui/modal/DirtyModal.svelte';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
import StickyCard from '$ui/card/StickyCard.svelte';
|
||||
|
||||
// Props
|
||||
export let mode: 'create' | 'edit';
|
||||
@@ -170,12 +171,12 @@
|
||||
|
||||
<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">
|
||||
<StickyCard position="top">
|
||||
<svelte:fragment slot="left">
|
||||
<h1 class="text-neutral-900 dark:text-neutral-50">{title}</h1>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">{description}</p>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="right">
|
||||
{#if mode === 'edit'}
|
||||
<Button
|
||||
text="Delete"
|
||||
@@ -192,8 +193,8 @@
|
||||
disabled={saving || !canSubmit}
|
||||
on:click={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</StickyCard>
|
||||
|
||||
<div
|
||||
class="space-y-4 rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900"
|
||||
@@ -217,7 +218,7 @@
|
||||
/>
|
||||
</div>
|
||||
<!-- Name + Status Row -->
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div class="flex-1">
|
||||
<FormInput
|
||||
label="Name"
|
||||
@@ -228,12 +229,11 @@
|
||||
on:input={(e) => update('name', e.detail)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="space-y-1">
|
||||
<span class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Status</span>
|
||||
<DropdownSelect
|
||||
label="Status:"
|
||||
value={enabled}
|
||||
options={enabledOptions}
|
||||
responsiveButton
|
||||
on:change={(e) => update('enabled', e.detail)}
|
||||
/>
|
||||
</div>
|
||||
@@ -255,7 +255,7 @@
|
||||
on:input={(e) => update('url', e.detail)}
|
||||
/>
|
||||
<!-- API Key + Test Connection Row -->
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div class="flex-1">
|
||||
<FormInput
|
||||
label="API Key"
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="p-4 md:p-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-neutral-900 md:text-3xl dark:text-neutral-50">Logs</h1>
|
||||
@@ -229,87 +229,41 @@
|
||||
onDownload={downloadLogs}
|
||||
/>
|
||||
|
||||
<!-- Stats and Pagination Controls (Top) -->
|
||||
<!-- Mobile layout -->
|
||||
<div class="mt-6 mb-4 flex items-center justify-between gap-2 md:hidden">
|
||||
<!-- Stats -->
|
||||
<div class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
Showing {startIndex + 1}-{endIndex} of {filteredLogs.length}
|
||||
</div>
|
||||
|
||||
<!-- Page navigation -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
on:click={goToPreviousPage}
|
||||
disabled={currentPage <= 1}
|
||||
class="flex h-6 w-6 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span class="min-w-[50px] text-center text-xs text-neutral-600 dark:text-neutral-400">
|
||||
{currentPage}/{totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
on:click={goToNextPage}
|
||||
disabled={currentPage >= totalPages}
|
||||
class="flex h-6 w-6 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<div class="mt-6 mb-4 hidden md:grid md:grid-cols-3 md:items-center">
|
||||
<!-- Stats (Left) -->
|
||||
<div class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<!-- Stats -->
|
||||
<div
|
||||
class="mt-6 mb-4 flex items-center justify-between text-sm text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<span>
|
||||
Showing {startIndex + 1}-{endIndex} of {filteredLogs.length} logs
|
||||
{#if filteredLogs.length !== data.logs.length}
|
||||
(filtered from {data.logs.length})
|
||||
{/if}
|
||||
{#if data.selectedFile}
|
||||
from {data.selectedFile}
|
||||
{/if}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<!-- Page navigation (Center) -->
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
on:click={goToPreviousPage}
|
||||
disabled={currentPage <= 1}
|
||||
class="flex h-8 w-8 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span class="min-w-[80px] text-center text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
on:click={goToNextPage}
|
||||
disabled={currentPage >= totalPages}
|
||||
class="flex h-8 w-8 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Items per page (Right) -->
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<span class="text-sm text-neutral-600 dark:text-neutral-400">Per page:</span>
|
||||
<div class="w-24">
|
||||
<NumberInput
|
||||
name="itemsPerPage"
|
||||
bind:value={itemsPerPage}
|
||||
min={10}
|
||||
max={500}
|
||||
step={10}
|
||||
/>
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage <= 1}
|
||||
on:click={goToPreviousPage}
|
||||
class="rounded p-1 transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<span class="text-sm">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage >= totalPages}
|
||||
on:click={goToNextPage}
|
||||
class="rounded p-1 transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Log Table -->
|
||||
@@ -332,28 +286,30 @@
|
||||
</svelte:fragment>
|
||||
</Table>
|
||||
|
||||
<!-- Pagination Controls (Bottom) - hidden on mobile since we have top controls -->
|
||||
<div class="mt-4 hidden items-center justify-center gap-2 md:flex">
|
||||
<button
|
||||
type="button"
|
||||
on:click={goToPreviousPage}
|
||||
disabled={currentPage <= 1}
|
||||
class="flex h-8 w-8 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span class="min-w-[80px] text-center text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
on:click={goToNextPage}
|
||||
disabled={currentPage >= totalPages}
|
||||
class="flex h-8 w-8 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Bottom Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="mt-4 flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage <= 1}
|
||||
on:click={goToPreviousPage}
|
||||
class="rounded px-3 py-1.5 text-sm transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage >= totalPages}
|
||||
on:click={goToNextPage}
|
||||
class="rounded px-3 py-1.5 text-sm transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Meta Modal -->
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</script>
|
||||
|
||||
<ActionsBar className="justify-end">
|
||||
<SearchAction {searchStore} placeholder="Search logs..." />
|
||||
<SearchAction {searchStore} placeholder="Search logs..." responsive />
|
||||
|
||||
<!-- Log File Selector -->
|
||||
<ActionButton icon={FileText} hasDropdown={true} dropdownPosition="right">
|
||||
|
||||
Reference in New Issue
Block a user