style: mobile improvements for arr pages

This commit is contained in:
Sam Chau
2026-01-29 04:10:18 +10:30
parent 284c44d108
commit c61114d13d
22 changed files with 430 additions and 480 deletions

View File

@@ -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'}

View File

@@ -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

View File

@@ -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>

View File

@@ -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"
>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)}
/>

View File

@@ -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"

View File

@@ -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)} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

@@ -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'}

View File

@@ -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"

View File

@@ -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 -->

View File

@@ -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">