mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-29 05:50:51 +01:00
style(cards): add default card views for database and arr pages
This commit is contained in:
@@ -383,9 +383,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Overlay scrollbars - never reserve space, float on top like Firefox */
|
||||
/* Overlay scrollbars - style scrollbars without forcing overflow */
|
||||
* {
|
||||
overflow: overlay; /* Chromium: float on top, no layout space */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(163, 163, 163, 0.5) transparent;
|
||||
}
|
||||
|
||||
@@ -57,9 +57,11 @@ export function createDataPageStore<T>(
|
||||
// Search store
|
||||
const search = createSearchStore({ debounceMs });
|
||||
|
||||
// View store with localStorage persistence
|
||||
// Determine initial view: localStorage > mobile detection > defaultView
|
||||
const storedView = browser ? (localStorage.getItem(storageKey) as ViewMode | null) : null;
|
||||
const view = writable<ViewMode>(storedView ?? defaultView);
|
||||
const isMobile = browser ? window.innerWidth < 768 : false;
|
||||
const initialView = storedView ?? (isMobile ? 'cards' : defaultView);
|
||||
const view = writable<ViewMode>(initialView);
|
||||
|
||||
// Persist view changes to localStorage
|
||||
if (browser) {
|
||||
|
||||
@@ -34,12 +34,14 @@
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="sticky z-10 -mx-8 {bgClass}
|
||||
class="sticky z-10 -mx-4 md:-mx-8 {bgClass}
|
||||
{position === 'top' ? 'top-0' : 'bottom-0'}"
|
||||
>
|
||||
<div class="px-12 py-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<slot name="left" />
|
||||
<div class="px-4 py-3 md:px-12 md:py-4">
|
||||
<div class="flex items-center justify-between gap-3 md:gap-4">
|
||||
<div class="[&_h1]:text-sm [&_h1]:md:text-xl [&_p]:text-xs [&_p]:md:text-sm">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,13 +50,13 @@
|
||||
<div
|
||||
class="border-b border-neutral-200 transition-[margin] duration-200 dark:border-neutral-800 {isStuck
|
||||
? ''
|
||||
: 'mx-8'}"
|
||||
: 'mx-4 md:mx-8'}"
|
||||
></div>
|
||||
{:else}
|
||||
<div
|
||||
class="border-t border-neutral-200 transition-[margin] duration-200 dark:border-neutral-800 {isStuck
|
||||
? ''
|
||||
: 'mx-8'}"
|
||||
: 'mx-4 md:mx-8'}"
|
||||
></div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -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 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
on:click={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -65,7 +65,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 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
on:click={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Check } from 'lucide-svelte';
|
||||
|
||||
let open = false;
|
||||
let triggerEl: HTMLElement;
|
||||
|
||||
$: currentColor = accentColors.find((c) => c.value === $accentStore) ?? accentColors[0];
|
||||
|
||||
@@ -24,6 +25,7 @@
|
||||
|
||||
<div class="accent-picker relative">
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
on:click|stopPropagation={() => (open = !open)}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md transition-colors hover:bg-neutral-200 dark:hover:bg-neutral-800"
|
||||
aria-label="Select accent color"
|
||||
@@ -32,7 +34,7 @@
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<Dropdown position="middle" minWidth="auto">
|
||||
<Dropdown position="middle" minWidth="auto" fixed={true} {triggerEl}>
|
||||
<div class="flex flex-wrap gap-2 p-2">
|
||||
{#each accentColors as accent}
|
||||
<button
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
import { Menu } from 'lucide-svelte';
|
||||
import { mobileNavOpen } from '$stores/mobileNav';
|
||||
import logo from '$assets/logo-512.png';
|
||||
|
||||
export let collapsed: boolean = false;
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="fixed top-0 left-0 z-50 w-full border-b border-neutral-200 bg-neutral-50 md:w-80 md:border-r md:transition-transform md:duration-200 dark:border-neutral-800 dark:bg-neutral-900 {collapsed ? 'md:-translate-x-[calc(100%-24px)]' : ''}"
|
||||
class="fixed top-0 left-0 z-50 w-full border-b border-r-0 border-neutral-200 bg-neutral-50 md:z-[80] md:w-80 md:border-r dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4 py-4">
|
||||
<!-- Left: Hamburger (mobile) + Brand name with logo (desktop) -->
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { page } from '$app/stores';
|
||||
import logo from '$assets/logo-512.png';
|
||||
|
||||
export let collapsed: boolean = false;
|
||||
export let version: string = '';
|
||||
|
||||
$: useEmoji = $navIconStore === 'emoji';
|
||||
@@ -36,7 +35,7 @@
|
||||
<nav
|
||||
class="fixed top-0 left-0 z-[70] flex h-full w-[90vw] flex-col border-r border-neutral-200 bg-neutral-50 transition-transform duration-200 dark:border-neutral-800 dark:bg-neutral-900
|
||||
{$mobileNavOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
md:top-16 md:h-[calc(100vh-4rem)] md:w-80 md:translate-x-0 {collapsed ? 'md:-translate-x-[calc(100%-24px)]' : ''}"
|
||||
md:top-16 md:h-[calc(100vh-4rem)] md:w-80 md:translate-x-0 md:border-t"
|
||||
>
|
||||
<!-- Mobile header with logo and close button -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 py-4 pl-8 pr-4 md:hidden dark:border-neutral-800">
|
||||
@@ -125,6 +124,14 @@
|
||||
<GroupItem label="Log Out" href="/auth/logout" />
|
||||
</Group>
|
||||
|
||||
<!-- Version scrolls with content on mobile -->
|
||||
<div class="mt-2 md:hidden">
|
||||
<Version {version} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version pinned to bottom on desktop only -->
|
||||
<div class="hidden shrink-0 p-4 md:block">
|
||||
<Version {version} />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
import Navbar from '$ui/navigation/navbar/navbar.svelte';
|
||||
import PageNav from '$ui/navigation/pageNav/pageNav.svelte';
|
||||
import BottomNav from '$ui/navigation/bottomNav/BottomNav.svelte';
|
||||
import AlertContainer from '$alerts/AlertContainer.svelte';
|
||||
import { sidebarCollapsed } from '$lib/client/stores/sidebar';
|
||||
import AlertContainer from '$alerts/AlertContainer.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let data;
|
||||
@@ -20,29 +19,12 @@
|
||||
</svelte:head>
|
||||
|
||||
{#if !isAuthPage}
|
||||
<Navbar collapsed={$sidebarCollapsed} />
|
||||
<PageNav collapsed={$sidebarCollapsed} version={data.version} />
|
||||
<Navbar />
|
||||
<PageNav version={data.version} />
|
||||
<BottomNav />
|
||||
{/if}
|
||||
<AlertContainer />
|
||||
|
||||
{#if !isAuthPage}
|
||||
<!-- Sidebar collapse toggle button (desktop only) -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => sidebarCollapsed.toggle()}
|
||||
class="fixed top-16 z-50 hidden h-6 w-6 -translate-x-1/2 -translate-y-1/3 items-center justify-center rounded-md border border-neutral-300 bg-white shadow-sm transition-all hover:bg-neutral-50 md:flex dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
style="left: {$sidebarCollapsed ? '24px' : '320px'}"
|
||||
aria-label={$sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
<div class="flex flex-col gap-[3px]">
|
||||
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
|
||||
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
|
||||
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<main class={isAuthPage ? '' : `pt-16 pb-16 transition-all duration-200 md:pb-0 md:pt-0 ${$sidebarCollapsed ? 'md:pl-[24px]' : 'md:pl-80'}`}>
|
||||
<main class={isAuthPage ? '' : 'pt-16 pb-16 md:pb-0 md:pt-0 md:pl-80'}>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
@@ -1,88 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { Server, Plus, Trash2, Info, ExternalLink } from 'lucide-svelte';
|
||||
import { Server, Plus, Info } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import EmptyState from '$ui/state/EmptyState.svelte';
|
||||
import Table from '$ui/table/Table.svelte';
|
||||
import TableActionButton from '$ui/table/TableActionButton.svelte';
|
||||
import Badge from '$ui/badge/Badge.svelte';
|
||||
import Modal from '$ui/modal/Modal.svelte';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
import SearchAction from '$ui/actions/SearchAction.svelte';
|
||||
import { createSearchStore } from '$stores/search';
|
||||
import ViewToggle from '$ui/actions/ViewToggle.svelte';
|
||||
import TableView from './views/TableView.svelte';
|
||||
import CardView from './views/CardView.svelte';
|
||||
import { createDataPageStore } from '$lib/client/stores/dataPage';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import type { PageData } from './$types';
|
||||
import type { Column } from '$ui/table/types';
|
||||
import type { ArrInstance } from '$db/queries/arrInstances.ts';
|
||||
import radarrLogo from '$lib/client/assets/Radarr.svg';
|
||||
import sonarrLogo from '$lib/client/assets/Sonarr.svg';
|
||||
|
||||
// Logo lookup by type
|
||||
const logos: Record<string, string> = {
|
||||
radarr: radarrLogo,
|
||||
sonarr: sonarrLogo
|
||||
};
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// Search store
|
||||
const searchStore = createSearchStore();
|
||||
|
||||
// Filter instances based on search
|
||||
$: filteredInstances = data.instances.filter((instance) => {
|
||||
const query = $searchStore.query.toLowerCase();
|
||||
if (!query) return true;
|
||||
return (
|
||||
instance.name.toLowerCase().includes(query) ||
|
||||
instance.url.toLowerCase().includes(query) ||
|
||||
instance.type.toLowerCase().includes(query)
|
||||
);
|
||||
// Initialize data page store
|
||||
const { search, view, filtered, setItems } = createDataPageStore(data.instances, {
|
||||
storageKey: 'arrInstancesView',
|
||||
searchKeys: ['name', 'url', 'type']
|
||||
});
|
||||
|
||||
// Update items when data changes
|
||||
$: setItems(data.instances);
|
||||
|
||||
// Modal state
|
||||
let showDeleteModal = false;
|
||||
let showInfoModal = false;
|
||||
let selectedInstance: ArrInstance | null = null;
|
||||
let deleteFormElement: HTMLFormElement;
|
||||
|
||||
// Track loaded images
|
||||
let loadedImages: Set<number> = new Set();
|
||||
|
||||
// Get logo path based on arr type
|
||||
function getLogoPath(type: string): string {
|
||||
return logos[type] || '';
|
||||
}
|
||||
|
||||
function handleImageLoad(id: number) {
|
||||
loadedImages.add(id);
|
||||
loadedImages = loadedImages;
|
||||
}
|
||||
|
||||
// Format type for display with proper casing
|
||||
function formatType(type: string): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
// Handle row click
|
||||
function handleRowClick(instance: ArrInstance) {
|
||||
goto(`/arr/${instance.id}`);
|
||||
}
|
||||
|
||||
// Handle delete click
|
||||
function handleDeleteClick(e: MouseEvent, instance: ArrInstance) {
|
||||
e.stopPropagation();
|
||||
selectedInstance = instance;
|
||||
// Handle delete from view components
|
||||
function handleDelete(event: CustomEvent<ArrInstance>) {
|
||||
selectedInstance = event.detail;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
// Define table columns
|
||||
const columns: Column<ArrInstance>[] = [
|
||||
{ key: 'name', header: 'Name', align: 'left' },
|
||||
{ key: 'url', header: 'URL', align: 'left' },
|
||||
{ key: 'enabled', header: 'Enabled', align: 'center', width: 'w-24' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -99,70 +54,29 @@
|
||||
buttonIcon={Plus}
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-6 p-8">
|
||||
<div class="space-y-6 p-4 sm:p-8">
|
||||
<!-- Actions Bar -->
|
||||
<ActionsBar>
|
||||
<SearchAction {searchStore} placeholder="Search instances..." />
|
||||
<SearchAction searchStore={search} placeholder="Search instances..." />
|
||||
<ActionButton icon={Plus} title="Add Instance" on:click={() => goto('/arr/new')} />
|
||||
<ActionButton icon={Info} title="Info" on:click={() => (showInfoModal = true)} />
|
||||
<ViewToggle bind:value={$view} />
|
||||
</ActionsBar>
|
||||
|
||||
<!-- Instance Table -->
|
||||
<Table {columns} data={filteredInstances} hoverable={true} onRowClick={handleRowClick}>
|
||||
<svelte:fragment slot="cell" let:row let:column>
|
||||
{#if column.key === 'name'}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative h-8 w-8">
|
||||
{#if !loadedImages.has(row.id)}
|
||||
<div
|
||||
class="absolute inset-0 animate-pulse rounded-lg bg-neutral-200 dark:bg-neutral-700"
|
||||
></div>
|
||||
{/if}
|
||||
<img
|
||||
src={getLogoPath(row.type)}
|
||||
alt="{formatType(row.type)} logo"
|
||||
class="h-8 w-8 rounded-lg {loadedImages.has(row.id) ? 'opacity-100' : 'opacity-0'}"
|
||||
on:load={() => handleImageLoad(row.id)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-50">
|
||||
{row.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if column.key === 'url'}
|
||||
<Badge variant="neutral" mono>{row.url}</Badge>
|
||||
{:else if column.key === 'enabled'}
|
||||
<div class="flex justify-center">
|
||||
{#if row.enabled}
|
||||
<Badge variant="success">Enabled</Badge>
|
||||
{:else}
|
||||
<Badge variant="neutral">Disabled</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="actions" let:row>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<a
|
||||
href={row.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TableActionButton icon={ExternalLink} title="Open in {formatType(row.type)}" />
|
||||
</a>
|
||||
<TableActionButton
|
||||
icon={Trash2}
|
||||
title="Delete instance"
|
||||
variant="danger"
|
||||
on:click={(e) => handleDeleteClick(e, row)}
|
||||
/>
|
||||
<!-- Content -->
|
||||
<div class="mt-6">
|
||||
{#if $filtered.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">No instances match your search</p>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Table>
|
||||
{:else if $view === 'table'}
|
||||
<TableView instances={$filtered} on:delete={handleDelete} />
|
||||
{:else}
|
||||
<CardView instances={$filtered} on:delete={handleDelete} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
120
src/routes/arr/views/CardView.svelte
Normal file
120
src/routes/arr/views/CardView.svelte
Normal file
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ExternalLink, Trash2, Link } from 'lucide-svelte';
|
||||
import Badge from '$ui/badge/Badge.svelte';
|
||||
import type { ArrInstance } from '$db/queries/arrInstances.ts';
|
||||
import radarrLogo from '$lib/client/assets/Radarr.svg';
|
||||
import sonarrLogo from '$lib/client/assets/Sonarr.svg';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let instances: ArrInstance[];
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
delete: ArrInstance;
|
||||
}>();
|
||||
|
||||
// Logo lookup by type
|
||||
const logos: Record<string, string> = {
|
||||
radarr: radarrLogo,
|
||||
sonarr: sonarrLogo
|
||||
};
|
||||
|
||||
// Track loaded images
|
||||
let loadedImages: Set<number> = new Set();
|
||||
|
||||
// Get logo path based on arr type
|
||||
function getLogoPath(type: string): string {
|
||||
return logos[type] || '';
|
||||
}
|
||||
|
||||
function handleImageLoad(id: number) {
|
||||
loadedImages.add(id);
|
||||
loadedImages = loadedImages;
|
||||
}
|
||||
|
||||
// Format type for display with proper casing
|
||||
function formatType(type: string): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
// Handle card click
|
||||
function handleCardClick(instance: ArrInstance) {
|
||||
goto(`/arr/${instance.id}`);
|
||||
}
|
||||
|
||||
// Handle delete click
|
||||
function handleDeleteClick(e: MouseEvent, instance: ArrInstance) {
|
||||
e.stopPropagation();
|
||||
dispatch('delete', instance);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each instances as instance}
|
||||
<div
|
||||
on:click={() => handleCardClick(instance)}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleCardClick(instance)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group relative flex cursor-pointer flex-col gap-3 rounded-lg border border-neutral-200 bg-white p-4 text-left transition-all hover:border-neutral-300 hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-neutral-700"
|
||||
>
|
||||
<!-- Header with logo, name, and status -->
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="relative h-10 w-10 flex-shrink-0">
|
||||
{#if !loadedImages.has(instance.id)}
|
||||
<div
|
||||
class="absolute inset-0 animate-pulse rounded-lg bg-neutral-200 dark:bg-neutral-700"
|
||||
></div>
|
||||
{/if}
|
||||
<img
|
||||
src={getLogoPath(instance.type)}
|
||||
alt="{formatType(instance.type)} logo"
|
||||
class="h-10 w-10 rounded-lg {loadedImages.has(instance.id)
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'}"
|
||||
on:load={() => handleImageLoad(instance.id)}
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{instance.name}
|
||||
</h3>
|
||||
<div class="mt-1">
|
||||
{#if instance.enabled}
|
||||
<Badge variant="success">Enabled</Badge>
|
||||
{:else}
|
||||
<Badge variant="neutral">Disabled</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div class="flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<Link size={12} class="flex-shrink-0" />
|
||||
<span class="truncate">{instance.url}</span>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons (absolute positioned) -->
|
||||
<div
|
||||
class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<a
|
||||
href={instance.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
<button
|
||||
on:click={(e) => handleDeleteClick(e, instance)}
|
||||
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
116
src/routes/arr/views/TableView.svelte
Normal file
116
src/routes/arr/views/TableView.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ExternalLink, Trash2 } from 'lucide-svelte';
|
||||
import Table from '$ui/table/Table.svelte';
|
||||
import TableActionButton from '$ui/table/TableActionButton.svelte';
|
||||
import Badge from '$ui/badge/Badge.svelte';
|
||||
import type { Column } from '$ui/table/types';
|
||||
import type { ArrInstance } from '$db/queries/arrInstances.ts';
|
||||
import radarrLogo from '$lib/client/assets/Radarr.svg';
|
||||
import sonarrLogo from '$lib/client/assets/Sonarr.svg';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let instances: ArrInstance[];
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
delete: ArrInstance;
|
||||
}>();
|
||||
|
||||
// Logo lookup by type
|
||||
const logos: Record<string, string> = {
|
||||
radarr: radarrLogo,
|
||||
sonarr: sonarrLogo
|
||||
};
|
||||
|
||||
// Track loaded images
|
||||
let loadedImages: Set<number> = new Set();
|
||||
|
||||
// Get logo path based on arr type
|
||||
function getLogoPath(type: string): string {
|
||||
return logos[type] || '';
|
||||
}
|
||||
|
||||
function handleImageLoad(id: number) {
|
||||
loadedImages.add(id);
|
||||
loadedImages = loadedImages;
|
||||
}
|
||||
|
||||
// Format type for display with proper casing
|
||||
function formatType(type: string): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
// Handle row click
|
||||
function handleRowClick(instance: ArrInstance) {
|
||||
goto(`/arr/${instance.id}`);
|
||||
}
|
||||
|
||||
// Handle delete click
|
||||
function handleDeleteClick(e: MouseEvent, instance: ArrInstance) {
|
||||
e.stopPropagation();
|
||||
dispatch('delete', instance);
|
||||
}
|
||||
|
||||
// Define table columns
|
||||
const columns: Column<ArrInstance>[] = [
|
||||
{ key: 'name', header: 'Name', align: 'left' },
|
||||
{ key: 'url', header: 'URL', align: 'left' },
|
||||
{ key: 'enabled', header: 'Enabled', align: 'center', width: 'w-24' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<Table {columns} data={instances} hoverable={true} onRowClick={handleRowClick}>
|
||||
<svelte:fragment slot="cell" let:row let:column>
|
||||
{#if column.key === 'name'}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative h-8 w-8">
|
||||
{#if !loadedImages.has(row.id)}
|
||||
<div
|
||||
class="absolute inset-0 animate-pulse rounded-lg bg-neutral-200 dark:bg-neutral-700"
|
||||
></div>
|
||||
{/if}
|
||||
<img
|
||||
src={getLogoPath(row.type)}
|
||||
alt="{formatType(row.type)} logo"
|
||||
class="h-8 w-8 rounded-lg {loadedImages.has(row.id) ? 'opacity-100' : 'opacity-0'}"
|
||||
on:load={() => handleImageLoad(row.id)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-50">
|
||||
{row.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if column.key === 'url'}
|
||||
<Badge variant="neutral" mono>{row.url}</Badge>
|
||||
{:else if column.key === 'enabled'}
|
||||
<div class="flex justify-center">
|
||||
{#if row.enabled}
|
||||
<Badge variant="success">Enabled</Badge>
|
||||
{:else}
|
||||
<Badge variant="neutral">Disabled</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="actions" let:row>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<a
|
||||
href={row.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TableActionButton icon={ExternalLink} title="Open in {formatType(row.type)}" />
|
||||
</a>
|
||||
<TableActionButton
|
||||
icon={Trash2}
|
||||
title="Delete instance"
|
||||
variant="danger"
|
||||
on:click={(e) => handleDeleteClick(e, row)}
|
||||
/>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Table>
|
||||
@@ -1,44 +1,32 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Database,
|
||||
Plus,
|
||||
Lock,
|
||||
Code,
|
||||
Unlink,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
Info
|
||||
} from 'lucide-svelte';
|
||||
import { Database, Plus, Info } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import EmptyState from '$ui/state/EmptyState.svelte';
|
||||
import Table from '$ui/table/Table.svelte';
|
||||
import TableActionButton from '$ui/table/TableActionButton.svelte';
|
||||
import Badge from '$ui/badge/Badge.svelte';
|
||||
import Modal from '$ui/modal/Modal.svelte';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
import SearchAction from '$ui/actions/SearchAction.svelte';
|
||||
import { createSearchStore } from '$stores/search';
|
||||
import ViewToggle from '$ui/actions/ViewToggle.svelte';
|
||||
import TableView from './views/TableView.svelte';
|
||||
import CardView from './views/CardView.svelte';
|
||||
import { createDataPageStore } from '$lib/client/stores/dataPage';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import { parseUTC } from '$shared/utils/dates';
|
||||
import type { PageData } from './$types';
|
||||
import type { Column } from '$ui/table/types';
|
||||
import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// Search store
|
||||
const searchStore = createSearchStore();
|
||||
|
||||
// Filter databases based on search
|
||||
$: filteredDatabases = data.databases.filter((db) => {
|
||||
const query = $searchStore.query.toLowerCase();
|
||||
if (!query) return true;
|
||||
return db.name.toLowerCase().includes(query) || db.repository_url.toLowerCase().includes(query);
|
||||
// Initialize data page store
|
||||
const { search, view, filtered, setItems } = createDataPageStore(data.databases, {
|
||||
storageKey: 'databasesView',
|
||||
searchKeys: ['name', 'repository_url']
|
||||
});
|
||||
|
||||
// Update items when data changes
|
||||
$: setItems(data.databases);
|
||||
|
||||
// Modal state
|
||||
let showUnlinkModal = false;
|
||||
let showInfoModal = false;
|
||||
@@ -46,63 +34,11 @@
|
||||
let unlinkFormElement: HTMLFormElement;
|
||||
let unlinkLoading = false;
|
||||
|
||||
// Track loaded images
|
||||
let loadedImages: Set<number> = new Set();
|
||||
|
||||
// Extract GitHub username/org from repository URL and use local proxy
|
||||
function getGitHubAvatar(repoUrl: string): string {
|
||||
const match = repoUrl.match(/github\.com\/([^\/]+)\//);
|
||||
if (match) {
|
||||
return `/api/github/avatar/${match[1]}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function handleImageLoad(id: number) {
|
||||
loadedImages.add(id);
|
||||
loadedImages = loadedImages;
|
||||
}
|
||||
|
||||
// Format sync strategy for display
|
||||
function formatSyncStrategy(minutes: number): string {
|
||||
if (minutes === 0) return 'Manual';
|
||||
if (minutes < 60) return `Every ${minutes} min`;
|
||||
if (minutes === 60) return 'Hourly';
|
||||
if (minutes < 1440) return `Every ${minutes / 60}h`;
|
||||
return `Every ${minutes / 1440}d`;
|
||||
}
|
||||
|
||||
// Format last synced date
|
||||
function formatLastSynced(date: string | null): string {
|
||||
const d = parseUTC(date);
|
||||
if (!d) return 'Never';
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle row click - navigate to database details
|
||||
function handleRowClick(database: DatabaseInstance) {
|
||||
goto(`/databases/${database.id}`);
|
||||
}
|
||||
|
||||
// Handle unlink click
|
||||
function handleUnlinkClick(e: MouseEvent, database: DatabaseInstance) {
|
||||
e.stopPropagation(); // Prevent row click
|
||||
selectedDatabase = database;
|
||||
// Handle unlink from view components
|
||||
function handleUnlink(event: CustomEvent<DatabaseInstance>) {
|
||||
selectedDatabase = event.detail;
|
||||
showUnlinkModal = true;
|
||||
}
|
||||
|
||||
// Define table columns
|
||||
const columns: Column<DatabaseInstance>[] = [
|
||||
{ key: 'name', header: 'Name', align: 'left' },
|
||||
{ key: 'repository_url', header: 'Repository', align: 'left' },
|
||||
{ key: 'sync_strategy', header: 'Sync', align: 'left', width: 'w-32' },
|
||||
{ key: 'last_synced_at', header: 'Last Synced', align: 'left', width: 'w-40' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -119,84 +55,29 @@
|
||||
buttonIcon={Plus}
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-6 p-8">
|
||||
<div class="space-y-6 p-4 sm:p-8">
|
||||
<!-- Actions Bar -->
|
||||
<ActionsBar>
|
||||
<SearchAction {searchStore} placeholder="Search databases..." />
|
||||
<SearchAction searchStore={search} placeholder="Search databases..." />
|
||||
<ActionButton icon={Plus} title="Link Database" on:click={() => goto('/databases/new')} />
|
||||
<ActionButton icon={Info} title="Info" on:click={() => (showInfoModal = true)} />
|
||||
<ViewToggle bind:value={$view} />
|
||||
</ActionsBar>
|
||||
|
||||
<!-- Database Table -->
|
||||
<Table {columns} data={filteredDatabases} hoverable={true}>
|
||||
<svelte:fragment slot="cell" let:row let:column>
|
||||
<!-- Content -->
|
||||
<div class="mt-6">
|
||||
{#if $filtered.length === 0}
|
||||
<div
|
||||
on:click={() => handleRowClick(row)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:keydown={(e) => e.key === 'Enter' && handleRowClick(row)}
|
||||
class="cursor-pointer"
|
||||
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
{#if column.key === 'name'}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative h-8 w-8">
|
||||
{#if !loadedImages.has(row.id)}
|
||||
<div
|
||||
class="absolute inset-0 animate-pulse rounded-lg bg-neutral-200 dark:bg-neutral-700"
|
||||
></div>
|
||||
{/if}
|
||||
<img
|
||||
src={getGitHubAvatar(row.repository_url)}
|
||||
alt="{row.name} avatar"
|
||||
class="h-8 w-8 rounded-lg {loadedImages.has(row.id)
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'}"
|
||||
on:load={() => handleImageLoad(row.id)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-50">
|
||||
{row.name}
|
||||
</div>
|
||||
{#if row.is_private}
|
||||
<Badge variant="neutral" icon={Lock} mono>Private</Badge>
|
||||
{/if}
|
||||
{#if row.personal_access_token}
|
||||
<Badge variant="info" icon={Code} mono>Dev</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if column.key === 'repository_url'}
|
||||
<Badge variant="neutral" mono
|
||||
>{row.repository_url.replace('https://github.com/', '')}</Badge
|
||||
>
|
||||
{:else if column.key === 'sync_strategy'}
|
||||
<Badge variant="neutral" mono>{formatSyncStrategy(row.sync_strategy)}</Badge>
|
||||
{:else if column.key === 'last_synced_at'}
|
||||
<Badge variant="neutral" mono>{formatLastSynced(row.last_synced_at)}</Badge>
|
||||
{/if}
|
||||
<p class="text-neutral-600 dark:text-neutral-400">No databases match your search</p>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="actions" let:row>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<a
|
||||
href={row.repository_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TableActionButton icon={ExternalLink} title="View on GitHub" />
|
||||
</a>
|
||||
<TableActionButton
|
||||
icon={Unlink}
|
||||
title="Unlink database"
|
||||
variant="danger"
|
||||
on:click={(e) => handleUnlinkClick(e, row)}
|
||||
/>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Table>
|
||||
{:else if $view === 'table'}
|
||||
<TableView databases={$filtered} on:unlink={handleUnlink} />
|
||||
{:else}
|
||||
<CardView databases={$filtered} on:unlink={handleUnlink} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
135
src/routes/databases/views/CardView.svelte
Normal file
135
src/routes/databases/views/CardView.svelte
Normal file
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ExternalLink, Unlink, Lock, Code } from 'lucide-svelte';
|
||||
import Badge from '$ui/badge/Badge.svelte';
|
||||
import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
|
||||
import { parseUTC } from '$shared/utils/dates';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let databases: DatabaseInstance[];
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
unlink: DatabaseInstance;
|
||||
}>();
|
||||
|
||||
// Track loaded images
|
||||
let loadedImages: Set<number> = new Set();
|
||||
|
||||
// Extract GitHub username/org from repository URL and use local proxy
|
||||
function getGitHubAvatar(repoUrl: string): string {
|
||||
const match = repoUrl.match(/github\.com\/([^\/]+)\//);
|
||||
if (match) {
|
||||
return `/api/github/avatar/${match[1]}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function handleImageLoad(id: number) {
|
||||
loadedImages.add(id);
|
||||
loadedImages = loadedImages;
|
||||
}
|
||||
|
||||
// Format sync strategy for display
|
||||
function formatSyncStrategy(minutes: number): string {
|
||||
if (minutes === 0) return 'Manual';
|
||||
if (minutes < 60) return `Every ${minutes} min`;
|
||||
if (minutes === 60) return 'Hourly';
|
||||
if (minutes < 1440) return `Every ${minutes / 60}h`;
|
||||
return `Every ${minutes / 1440}d`;
|
||||
}
|
||||
|
||||
// Format last synced date
|
||||
function formatLastSynced(date: string | null): string {
|
||||
const d = parseUTC(date);
|
||||
if (!d) return 'Never';
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle card click - navigate to database details
|
||||
function handleCardClick(database: DatabaseInstance) {
|
||||
goto(`/databases/${database.id}`);
|
||||
}
|
||||
|
||||
// Handle unlink click
|
||||
function handleUnlinkClick(e: MouseEvent, database: DatabaseInstance) {
|
||||
e.stopPropagation();
|
||||
dispatch('unlink', database);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
{#each databases as database}
|
||||
<div
|
||||
on:click={() => handleCardClick(database)}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleCardClick(database)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group flex cursor-pointer items-center gap-4 rounded-lg border border-neutral-200 bg-white p-4 transition-all hover:border-neutral-300 hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-neutral-700"
|
||||
>
|
||||
<!-- Left: Avatar + Name -->
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div class="relative h-10 w-10 flex-shrink-0">
|
||||
{#if !loadedImages.has(database.id)}
|
||||
<div
|
||||
class="absolute inset-0 animate-pulse rounded-lg bg-neutral-200 dark:bg-neutral-700"
|
||||
></div>
|
||||
{/if}
|
||||
<img
|
||||
src={getGitHubAvatar(database.repository_url)}
|
||||
alt="{database.name} avatar"
|
||||
class="h-10 w-10 rounded-lg {loadedImages.has(database.id)
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'}"
|
||||
on:load={() => handleImageLoad(database.id)}
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{database.name}
|
||||
</span>
|
||||
{#if database.is_private}
|
||||
<Lock size={14} class="flex-shrink-0 text-neutral-400" />
|
||||
{/if}
|
||||
{#if database.personal_access_token}
|
||||
<Code size={14} class="flex-shrink-0 text-blue-500" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Badges (stacked vertically) -->
|
||||
<div class="flex flex-shrink-0 flex-col items-end gap-1">
|
||||
<Badge variant="neutral" mono>
|
||||
{database.repository_url.replace('https://github.com/', '')}
|
||||
</Badge>
|
||||
<Badge variant="neutral" mono>{formatSyncStrategy(database.sync_strategy)}</Badge>
|
||||
<Badge variant="neutral" mono>{formatLastSynced(database.last_synced_at)}</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex flex-shrink-0 flex-col gap-1">
|
||||
<a
|
||||
href={database.repository_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
class="flex items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 p-2.5 text-neutral-600 transition-colors active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:active:bg-neutral-700"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</a>
|
||||
<button
|
||||
on:click={(e) => handleUnlinkClick(e, database)}
|
||||
class="flex items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 p-2.5 text-neutral-600 transition-colors active:border-red-200 active:bg-red-100 active:text-red-600 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:active:border-red-800 dark:active:bg-red-900 dark:active:text-red-400"
|
||||
>
|
||||
<Unlink size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
141
src/routes/databases/views/TableView.svelte
Normal file
141
src/routes/databases/views/TableView.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ExternalLink, Unlink, Lock, Code } from 'lucide-svelte';
|
||||
import Table from '$ui/table/Table.svelte';
|
||||
import TableActionButton from '$ui/table/TableActionButton.svelte';
|
||||
import Badge from '$ui/badge/Badge.svelte';
|
||||
import type { Column } from '$ui/table/types';
|
||||
import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
|
||||
import { parseUTC } from '$shared/utils/dates';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let databases: DatabaseInstance[];
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
unlink: DatabaseInstance;
|
||||
}>();
|
||||
|
||||
// Track loaded images
|
||||
let loadedImages: Set<number> = new Set();
|
||||
|
||||
// Extract GitHub username/org from repository URL and use local proxy
|
||||
function getGitHubAvatar(repoUrl: string): string {
|
||||
const match = repoUrl.match(/github\.com\/([^\/]+)\//);
|
||||
if (match) {
|
||||
return `/api/github/avatar/${match[1]}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function handleImageLoad(id: number) {
|
||||
loadedImages.add(id);
|
||||
loadedImages = loadedImages;
|
||||
}
|
||||
|
||||
// Format sync strategy for display
|
||||
function formatSyncStrategy(minutes: number): string {
|
||||
if (minutes === 0) return 'Manual';
|
||||
if (minutes < 60) return `Every ${minutes} min`;
|
||||
if (minutes === 60) return 'Hourly';
|
||||
if (minutes < 1440) return `Every ${minutes / 60}h`;
|
||||
return `Every ${minutes / 1440}d`;
|
||||
}
|
||||
|
||||
// Format last synced date
|
||||
function formatLastSynced(date: string | null): string {
|
||||
const d = parseUTC(date);
|
||||
if (!d) return 'Never';
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle row click - navigate to database details
|
||||
function handleRowClick(database: DatabaseInstance) {
|
||||
goto(`/databases/${database.id}`);
|
||||
}
|
||||
|
||||
// Handle unlink click
|
||||
function handleUnlinkClick(e: MouseEvent, database: DatabaseInstance) {
|
||||
e.stopPropagation();
|
||||
dispatch('unlink', database);
|
||||
}
|
||||
|
||||
// Define table columns
|
||||
const columns: Column<DatabaseInstance>[] = [
|
||||
{ key: 'name', header: 'Name', align: 'left' },
|
||||
{ key: 'repository_url', header: 'Repository', align: 'left' },
|
||||
{ key: 'sync_strategy', header: 'Sync', align: 'left', width: 'w-32' },
|
||||
{ key: 'last_synced_at', header: 'Last Synced', align: 'left', width: 'w-40' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<Table {columns} data={databases} hoverable={true}>
|
||||
<svelte:fragment slot="cell" let:row let:column>
|
||||
<div
|
||||
on:click={() => handleRowClick(row)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:keydown={(e) => e.key === 'Enter' && handleRowClick(row)}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{#if column.key === 'name'}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative h-8 w-8">
|
||||
{#if !loadedImages.has(row.id)}
|
||||
<div
|
||||
class="absolute inset-0 animate-pulse rounded-lg bg-neutral-200 dark:bg-neutral-700"
|
||||
></div>
|
||||
{/if}
|
||||
<img
|
||||
src={getGitHubAvatar(row.repository_url)}
|
||||
alt="{row.name} avatar"
|
||||
class="h-8 w-8 rounded-lg {loadedImages.has(row.id) ? 'opacity-100' : 'opacity-0'}"
|
||||
on:load={() => handleImageLoad(row.id)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-50">
|
||||
{row.name}
|
||||
</div>
|
||||
{#if row.is_private}
|
||||
<Badge variant="neutral" icon={Lock} mono>Private</Badge>
|
||||
{/if}
|
||||
{#if row.personal_access_token}
|
||||
<Badge variant="info" icon={Code} mono>Dev</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if column.key === 'repository_url'}
|
||||
<Badge variant="neutral" mono>{row.repository_url.replace('https://github.com/', '')}</Badge
|
||||
>
|
||||
{:else if column.key === 'sync_strategy'}
|
||||
<Badge variant="neutral" mono>{formatSyncStrategy(row.sync_strategy)}</Badge>
|
||||
{:else if column.key === 'last_synced_at'}
|
||||
<Badge variant="neutral" mono>{formatLastSynced(row.last_synced_at)}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="actions" let:row>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<a
|
||||
href={row.repository_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TableActionButton icon={ExternalLink} title="View on GitHub" />
|
||||
</a>
|
||||
<TableActionButton
|
||||
icon={Unlink}
|
||||
title="Unlink database"
|
||||
variant="danger"
|
||||
on:click={(e) => handleUnlinkClick(e, row)}
|
||||
/>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Table>
|
||||
Reference in New Issue
Block a user