style(cards): add default card views for database and arr pages

This commit is contained in:
Sam Chau
2026-01-28 09:22:31 +10:30
parent 2f17e786fe
commit dc38ce3b40
15 changed files with 604 additions and 305 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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