Compare commits

...

2 Commits

Author SHA1 Message Date
Jason Rasmussen
8ba1fae29d refactor(web): sidebar 2026-01-19 14:38:32 -05:00
Brandon Wees
3e21174dd8 chore: web editor improvements (#25169) 2026-01-19 18:57:15 +00:00
21 changed files with 460 additions and 403 deletions

View File

@@ -3,11 +3,9 @@
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
Container,
ContextMenuButton, ContextMenuButton,
HStack, HStack,
MenuItemType, MenuItemType,
Scrollable,
isMenuItemType, isMenuItemType,
type BreadcrumbItem, type BreadcrumbItem,
} from '@immich/ui'; } from '@immich/ui';
@@ -55,7 +53,5 @@
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" /> <ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
{/if} {/if}
</div> </div>
<Scrollable class="grow"> {@render children?.()}
<Container class="p-2 pb-16" {children} />
</Scrollable>
</div> </div>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Container, Scrollable, type Size } from '@immich/ui';
import type { Snippet } from 'svelte';
type Props = {
size?: Size;
center?: boolean;
children?: Snippet;
class?: string;
};
const { size, center, class: className, children }: Props = $props();
</script>
<Scrollable class="grow">
<Container {size} {center} {children} class="p-2 pb-16 {className ?? ''}" />
</Scrollable>

View File

@@ -127,17 +127,14 @@
} = $derived(getAssetActions($t, asset)); } = $derived(getAssetActions($t, asset));
const sharedLink = getSharedLink(); const sharedLink = getSharedLink();
// TODO: Enable when edits are ready for release const editorDisabled = $derived(
let showEditorButton = $derived( !isOwner ||
isOwner && asset.type !== AssetTypeEnum.Image ||
asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId ||
!( (asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR &&
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || asset.originalPath.toLowerCase().endsWith('.insp')) ||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) asset.originalPath.toLowerCase().endsWith('.gif') ||
) && asset.originalPath.toLowerCase().endsWith('.svg'),
!(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) &&
!(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) &&
!asset.livePhotoVideoId,
); );
</script> </script>
@@ -191,7 +188,7 @@
<RatingAction {asset} {onAction} /> <RatingAction {asset} {onAction} />
{/if} {/if}
{#if showEditorButton} {#if !editorDisabled}
<EditAction onAction={onEdit} /> <EditAction onAction={onEdit} />
{/if} {/if}

View File

@@ -62,7 +62,7 @@
/> />
<p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p> <p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p>
</HStack> </HStack>
<Button shape="round" size="small" onclick={applyEdits}>{$t('save')}</Button> <Button shape="round" size="small" onclick={applyEdits} loading={editManager.isApplyingEdits}>{$t('save')}</Button>
</HStack> </HStack>
<section> <section>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import BreadcrumbActionPage from '$lib/components/BreadcrumbActionPage.svelte'; import BreadcrumbActionPage from '$lib/components/BreadcrumbActionPage.svelte';
import PageContent from '$lib/components/PageContent.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte'; import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
@@ -35,12 +36,12 @@
<NavbarItem title={$t('server_stats')} href={Route.systemStatistics()} icon={mdiServer} /> <NavbarItem title={$t('server_stats')} href={Route.systemStatistics()} icon={mdiServer} />
</div> </div>
<div class="mb-2 me-4"> <div class="pe-6">
<BottomInfo /> <BottomInfo />
</div> </div>
</AppShellSidebar> </AppShellSidebar>
<BreadcrumbActionPage {breadcrumbs} {actions}> <BreadcrumbActionPage {breadcrumbs} {actions}>
{@render children?.()} <PageContent {children} />
</BreadcrumbActionPage> </BreadcrumbActionPage>
</AppShell> </AppShell>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import BreadcrumbActionPage from '$lib/components/BreadcrumbActionPage.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import type { HeaderButtonActionItem } from '$lib/types';
import { AppShell, AppShellHeader, AppShellSidebar, MenuItemType, type BreadcrumbItem } from '@immich/ui';
import type { Snippet } from 'svelte';
type Props = {
title: string;
breadcrumbs?: BreadcrumbItem[];
actions?: Array<HeaderButtonActionItem | MenuItemType>;
sidebar?: Snippet;
children?: Snippet;
};
let { title, breadcrumbs = [], actions, sidebar, children }: Props = $props();
</script>
<AppShell>
<AppShellHeader>
<NavigationBar noBorder />
</AppShellHeader>
<AppShellSidebar bind:open={sidebarStore.isOpen} border={false} class="h-full flex flex-col justify-between gap-2">
{#if sidebar}
{@render sidebar()}
{:else}
<div class="flex flex-col pt-8 pe-6 gap-1">
<UserSidebar />
</div>
<div class="pe-6">
<BottomInfo />
</div>
{/if}
</AppShellSidebar>
<BreadcrumbActionPage breadcrumbs={[{ title }, ...breadcrumbs]} {actions}>
{@render children?.()}
</BreadcrumbActionPage>
</AppShell>

View File

@@ -1,11 +1,10 @@
<script lang="ts" module>
export const headerId = 'user-page-header';
</script>
<script lang="ts"> <script lang="ts">
import { useActions, type ActionArray } from '$lib/actions/use-actions'; import { useActions, type ActionArray } from '$lib/actions/use-actions';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte'; import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import { headerId } from '$lib/constants';
import type { HeaderButtonActionItem } from '$lib/types'; import type { HeaderButtonActionItem } from '$lib/types';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui'; import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
@@ -61,7 +60,10 @@
{#if sidebar} {#if sidebar}
{@render sidebar()} {@render sidebar()}
{:else} {:else}
<UserSidebar /> <Sidebar ariaLabel={$t('primary')}>
<UserSidebar />
<BottomInfo />
</Sidebar>
{/if} {/if}
<main class="relative"> <main class="relative">

View File

@@ -4,12 +4,8 @@
import StorageSpace from './storage-space.svelte'; import StorageSpace from './storage-space.svelte';
</script> </script>
<div class="mt-auto"> <div class="mt-auto flex flex-col gap-2 mb-4">
<StorageSpace /> <StorageSpace />
</div> <PurchaseInfo />
<PurchaseInfo />
<div class="mb-6 mt-2">
<ServerStatus /> <ServerStatus />
</div> </div>

View File

@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte'; import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { recentAlbumsDropdown } from '$lib/stores/preferences.store'; import { recentAlbumsDropdown } from '$lib/stores/preferences.store';
@@ -36,71 +34,67 @@
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
</script> </script>
<Sidebar ariaLabel={$t('primary')}> <NavbarItem title={$t('photos')} href={Route.photos()} icon={mdiImageMultipleOutline} activeIcon={mdiImageMultiple} />
<NavbarItem title={$t('photos')} href={Route.photos()} icon={mdiImageMultipleOutline} activeIcon={mdiImageMultiple} />
{#if featureFlagsManager.value.search} {#if featureFlagsManager.value.search}
<NavbarItem title={$t('explore')} href={Route.explore()} icon={mdiMagnify} /> <NavbarItem title={$t('explore')} href={Route.explore()} icon={mdiMagnify} />
{/if} {/if}
{#if featureFlagsManager.value.map} {#if featureFlagsManager.value.map}
<NavbarItem title={$t('map')} href={Route.map()} icon={mdiMapOutline} activeIcon={mdiMap} /> <NavbarItem title={$t('map')} href={Route.map()} icon={mdiMapOutline} activeIcon={mdiMap} />
{/if} {/if}
{#if $preferences.people.enabled && $preferences.people.sidebarWeb} {#if $preferences.people.enabled && $preferences.people.sidebarWeb}
<NavbarItem title={$t('people')} href={Route.people()} icon={mdiAccountOutline} activeIcon={mdiAccount} /> <NavbarItem title={$t('people')} href={Route.people()} icon={mdiAccountOutline} activeIcon={mdiAccount} />
{/if} {/if}
{#if $preferences.sharedLinks.enabled && $preferences.sharedLinks.sidebarWeb} {#if $preferences.sharedLinks.enabled && $preferences.sharedLinks.sidebarWeb}
<NavbarItem title={$t('shared_links')} href={Route.sharedLinks()} icon={mdiLink} /> <NavbarItem title={$t('shared_links')} href={Route.sharedLinks()} icon={mdiLink} />
{/if} {/if}
<NavbarItem <NavbarItem
title={$t('sharing')} title={$t('sharing')}
href={Route.sharing()} href={Route.sharing()}
icon={mdiAccountMultipleOutline} icon={mdiAccountMultipleOutline}
activeIcon={mdiAccountMultiple} activeIcon={mdiAccountMultiple}
/> />
<NavbarGroup title={$t('library')} size="tiny" /> <NavbarGroup title={$t('library')} size="tiny" />
<NavbarItem title={$t('favorites')} href={Route.favorites()} icon={mdiHeartOutline} activeIcon={mdiHeart} /> <NavbarItem title={$t('favorites')} href={Route.favorites()} icon={mdiHeartOutline} activeIcon={mdiHeart} />
<NavbarItem <NavbarItem
title={$t('albums')} title={$t('albums')}
href={Route.albums()} href={Route.albums()}
icon={{ icon: mdiImageAlbum, flipped: true }} icon={{ icon: mdiImageAlbum, flipped: true }}
bind:expanded={$recentAlbumsDropdown} bind:expanded={$recentAlbumsDropdown}
> >
{#snippet items()} {#snippet items()}
<span in:fly={{ y: -20 }} class="hidden md:block"> <span in:fly={{ y: -20 }} class="hidden md:block">
<RecentAlbums /> <RecentAlbums />
</span> </span>
{/snippet} {/snippet}
</NavbarItem> </NavbarItem>
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb} {#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
<NavbarItem title={$t('tags')} href={Route.tags()} icon={{ icon: mdiTagMultipleOutline, flipped: true }} /> <NavbarItem title={$t('tags')} href={Route.tags()} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
{/if} {/if}
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb} {#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
<NavbarItem title={$t('folders')} href={Route.folders()} icon={{ icon: mdiFolderOutline, flipped: true }} /> <NavbarItem title={$t('folders')} href={Route.folders()} icon={{ icon: mdiFolderOutline, flipped: true }} />
{/if} {/if}
<NavbarItem title={$t('utilities')} href={Route.utilities()} icon={mdiToolboxOutline} activeIcon={mdiToolbox} /> <NavbarItem title={$t('utilities')} href={Route.utilities()} icon={mdiToolboxOutline} activeIcon={mdiToolbox} />
<NavbarItem <NavbarItem
title={$t('archive')} title={$t('archive')}
href={Route.archive()} href={Route.archive()}
icon={mdiArchiveArrowDownOutline} icon={mdiArchiveArrowDownOutline}
activeIcon={mdiArchiveArrowDown} activeIcon={mdiArchiveArrowDown}
/> />
<NavbarItem title={$t('locked_folder')} href={Route.locked()} icon={mdiLockOutline} activeIcon={mdiLock} /> <NavbarItem title={$t('locked_folder')} href={Route.locked()} icon={mdiLockOutline} activeIcon={mdiLock} />
{#if featureFlagsManager.value.trash} {#if featureFlagsManager.value.trash}
<NavbarItem title={$t('trash')} href={Route.trash()} icon={mdiTrashCanOutline} activeIcon={mdiTrashCan} /> <NavbarItem title={$t('trash')} href={Route.trash()} icon={mdiTrashCanOutline} activeIcon={mdiTrashCan} />
{/if} {/if}
<BottomInfo />
</Sidebar>

View File

@@ -1,57 +0,0 @@
<script lang="ts">
import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte';
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
import { Route } from '$lib/route';
import { Icon, modalManager } from '@immich/ui';
import {
mdiCellphoneArrowDownVariant,
mdiContentDuplicate,
mdiCrosshairsGps,
mdiImageSizeSelectLarge,
mdiLinkEdit,
mdiStateMachine,
} from '@mdi/js';
import { t } from 'svelte-i18n';
const links = [
{ href: Route.duplicatesUtility(), icon: mdiContentDuplicate, label: $t('review_duplicates') },
{ href: Route.largeFileUtility(), icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
{ href: Route.geolocationUtility(), icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
{ href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
];
</script>
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="uppercase text-xs font-medium p-4">{$t('organize_your_library')}</p>
{#each links as link (link.href)}
<a href={link.href} class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4">
<span><Icon icon={link.icon} class="text-primary" size="24" /> </span>
{link.label}
</a>
{/each}
</div>
<br />
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="uppercase text-xs font-medium p-4">{$t('download')}</p>
<button
type="button"
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
>
<span>
<Icon icon={mdiLinkEdit} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
{$t('obtainium_configurator')}
</button>
<button
type="button"
onclick={() => modalManager.show(AppDownloadModal, {})}
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
>
<span>
<Icon icon={mdiCellphoneArrowDownVariant} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
{$t('app_download_links')}
</button>
</div>

View File

@@ -397,3 +397,5 @@ export enum ToggleVisibility {
} }
export const assetViewerFadeDuration: number = 150; export const assetViewerFadeDuration: number = 150;
export const headerId = 'user-page-header';

View File

@@ -1,28 +1,29 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import PageContent from '$lib/components/PageContent.svelte';
import LicenseActivationSuccess from '$lib/components/shared-components/purchasing/purchase-activation-success.svelte'; import LicenseActivationSuccess from '$lib/components/shared-components/purchasing/purchase-activation-success.svelte';
import LicenseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte'; import LicenseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte';
import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte'; import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { purchaseStore } from '$lib/stores/purchase.store'; import { purchaseStore } from '$lib/stores/purchase.store';
import { Alert, Container, Stack } from '@immich/ui'; import { Alert, Stack } from '@immich/ui';
import { mdiAlertCircleOutline } from '@mdi/js'; import { mdiAlertCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
interface Props { type Props = {
data: PageData; data: PageData;
} };
let { data }: Props = $props(); let { data }: Props = $props();
let showLicenseActivated = $state(false); let showLicenseActivated = $state(false);
const { isPurchased } = purchaseStore; const { isPurchased } = purchaseStore;
</script> </script>
<UserPageLayout title={$t('buy')}> <UserPageLayout title={data.meta.title}>
<Container size="medium" center> <PageContent size="medium" center class="pt-10">
<Stack gap={4} class="mt-4"> <Stack gap={4}>
{#if data.isActivated === false} {#if data.isActivated === false}
<Alert icon={mdiAlertCircleOutline} color="danger" title={$t('purchase_failed_activation')} /> <Alert icon={mdiAlertCircleOutline} color="danger" title={$t('purchase_failed_activation')} />
{/if} {/if}
@@ -41,5 +42,5 @@
/> />
{/if} {/if}
</Stack> </Stack>
</Container> </PageContent>
</UserPageLayout> </UserPageLayout>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import PageContent from '$lib/components/PageContent.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte'; import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
@@ -13,9 +14,9 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
interface Props { type Props = {
data: PageData; data: PageData;
} };
let { data }: Props = $props(); let { data }: Props = $props();
@@ -41,74 +42,76 @@
</script> </script>
<UserPageLayout title={data.meta.title}> <UserPageLayout title={data.meta.title}>
{#if hasPeople} <PageContent>
<div class="mb-6 mt-2"> {#if hasPeople}
<div class="flex justify-between"> <div class="mb-6 mt-2">
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('people')}</p> <div class="flex justify-between">
<a <p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('people')}</p>
href={Route.people()} <a
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary" href={Route.people()}
draggable="false">{$t('view_all')}</a class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
> draggable="false">{$t('view_all')}</a
</div> >
<SingleGridRow class="grid grid-flow-col md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4"> </div>
{#snippet children({ itemCount })} <SingleGridRow class="grid grid-flow-col md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4">
{#each people.slice(0, itemCount) as person (person.id)} {#snippet children({ itemCount })}
<a href={Route.viewPerson(person)} class="text-center relative"> {#each people.slice(0, itemCount) as person (person.id)}
<ImageThumbnail <a href={Route.viewPerson(person)} class="text-center relative">
circle <ImageThumbnail
shadow circle
url={getPeopleThumbnailUrl(person)} shadow
altText={person.name} url={getPeopleThumbnailUrl(person)}
widthStyle="100%" altText={person.name}
/> widthStyle="100%"
{#if person.isFavorite}
<div class="absolute top-2 start-2">
<Icon icon={mdiHeart} size="24" class="text-white" />
</div>
{/if}
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
</a>
{/each}
{/snippet}
</SingleGridRow>
</div>
{/if}
{#if places.length > 0}
<div class="mb-6 mt-2">
<div class="flex justify-between">
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('places')}</p>
<a
href={Route.places()}
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
draggable="false">{$t('view_all')}</a
>
</div>
<SingleGridRow class="grid grid-flow-col md:grid-auto-fill-36 grid-auto-fill-28 gap-x-4">
{#snippet children({ itemCount })}
{#each places.slice(0, itemCount) as item (item.data.id)}
<a class="relative" href={Route.search({ city: item.value })} draggable="false">
<div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter">
<img
src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={item.value}
class="object-cover aspect-square w-full"
/> />
</div> {#if person.isFavorite}
<span <div class="absolute top-2 start-2">
class="absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer" <Icon icon={mdiHeart} size="24" class="text-white" />
> </div>
{item.value} {/if}
</span> <p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
</a> </a>
{/each} {/each}
{/snippet} {/snippet}
</SingleGridRow> </SingleGridRow>
</div> </div>
{/if} {/if}
{#if !hasPeople && places.length === 0} {#if places.length > 0}
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mt-10 mx-auto" /> <div class="mb-6 mt-2">
{/if} <div class="flex justify-between">
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('places')}</p>
<a
href={Route.places()}
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
draggable="false">{$t('view_all')}</a
>
</div>
<SingleGridRow class="grid grid-flow-col md:grid-auto-fill-36 grid-auto-fill-28 gap-x-4">
{#snippet children({ itemCount })}
{#each places.slice(0, itemCount) as item (item.data.id)}
<a class="relative" href={Route.search({ city: item.value })} draggable="false">
<div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter">
<img
src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={item.value}
class="object-cover aspect-square w-full"
/>
</div>
<span
class="absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer"
>
{item.value}
</span>
</a>
{/each}
{/snippet}
</SingleGridRow>
</div>
{/if}
{#if !hasPeople && places.length === 0}
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mt-10 mx-auto" />
{/if}
</PageContent>
</UserPageLayout> </UserPageLayout>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto, invalidateAll } from '$app/navigation'; import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte'; import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
@@ -19,6 +19,7 @@
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte'; import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte'; import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { headerId } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte'; import SkipLink from '$lib/elements/SkipLink.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types'; import type { Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route'; import { Route } from '$lib/route';

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte'; import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import { timeToLoadTheMap } from '$lib/constants'; import { timeToLoadTheMap } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import empty2Url from '$lib/assets/empty-2.svg'; import empty2Url from '$lib/assets/empty-2.svg';
import Albums from '$lib/components/album-page/albums-list.svelte'; import Albums from '$lib/components/album-page/albums-list.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import PageContent from '$lib/components/PageContent.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
@@ -38,8 +39,8 @@
const { ViewAll: ViewSharedLinks } = $derived(getSharedLinksActions($t)); const { ViewAll: ViewSharedLinks } = $derived(getSharedLinksActions($t));
</script> </script>
<UserPageLayout title={data.meta.title} actions={[ViewSharedLinks, CreateAlbum]}> <UserPageLayout title={data.meta.title} actions={[CreateAlbum, ViewSharedLinks]}>
<div class="flex flex-col"> <PageContent>
{#if data.partners.length > 0} {#if data.partners.length > 0}
<div class="mb-6 mt-2"> <div class="mb-6 mt-2">
<div> <div>
@@ -84,5 +85,5 @@
</Albums> </Albums>
</div> </div>
</div> </div>
</div> </PageContent>
</UserPageLayout> </UserPageLayout>

View File

@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte'; import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
@@ -21,7 +20,7 @@
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte'; import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte'; import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte'; import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction, headerId } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte'; import SkipLink from '$lib/elements/SkipLink.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
@@ -30,13 +29,14 @@
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
import { joinPaths, TreeNode } from '$lib/utils/tree-utils'; import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
import { getAllTags, type TagResponseDto } from '@immich/sdk'; import { getAllTags, type TagResponseDto } from '@immich/sdk';
import { NavbarGroup } from '@immich/ui';
import { mdiDotsVertical, mdiPlus, mdiTag, mdiTagMultiple } from '@mdi/js'; import { mdiDotsVertical, mdiPlus, mdiTag, mdiTagMultiple } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
interface Props { type Props = {
data: PageData; data: PageData;
} };
let { data }: Props = $props(); let { data }: Props = $props();
@@ -79,20 +79,17 @@
<UserPageLayout title={data.meta.title} actions={[Create, Update, Delete]}> <UserPageLayout title={data.meta.title} actions={[Create, Update, Delete]}>
{#snippet sidebar()} {#snippet sidebar()}
<Sidebar> <SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" />
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" /> <section class="me-6">
<section> <NavbarGroup title={$t('explorer')} />
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div> <div class="h-full">
<div class="h-full"> <TreeItems icons={{ default: mdiTag, active: mdiTag }} {tree} active={tag.path} {getLink} />
<TreeItems icons={{ default: mdiTag, active: mdiTag }} {tree} active={tag.path} {getLink} /> </div>
</div> </section>
</section>
</Sidebar>
{/snippet} {/snippet}
<Breadcrumbs node={tag} icon={mdiTagMultiple} title={$t('tags')} {getLink} /> <Breadcrumbs node={tag} icon={mdiTagMultiple} title={$t('tags')} {getLink} />
<div class="p-2 h-full w-full">
<section class="mt-2 h-[calc(100%-(--spacing(20)))] overflow-auto immich-scrollbar">
{#if tag.hasAssets} {#if tag.hasAssets}
<Timeline <Timeline
enableRouting={true} enableRouting={true}
@@ -108,7 +105,7 @@
{:else} {:else}
<TreeItemThumbnails items={tag.children} icon={mdiTag} onClick={handleNavigation} /> <TreeItemThumbnails items={tag.children} icon={mdiTag} onClick={handleNavigation} />
{/if} {/if}
</section> </div>
</UserPageLayout> </UserPageLayout>
<section> <section>

View File

@@ -1,31 +1,39 @@
<script lang="ts"> <script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import PageContent from '$lib/components/PageContent.svelte';
import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte'; import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Container, IconButton, modalManager } from '@immich/ui'; import { CommandPaletteDefaultProvider, modalManager, type ActionItem } from '@immich/ui';
import { mdiKeyboard } from '@mdi/js'; import { mdiKeyboard } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
interface Props { type Props = {
data: PageData; data: PageData;
} };
let { data }: Props = $props(); let { data }: Props = $props();
let open = $state(false);
const Shortcuts = $derived<ActionItem>({
title: $t('show_keyboard_shortcuts'),
icon: mdiKeyboard,
onAction: async () => {
if (!open) {
open = true;
await modalManager.show(ShortcutsModal, {});
open = false;
}
},
shortcuts: [{ key: '?', shift: true }],
});
</script> </script>
<UserPageLayout title={data.meta.title}> <CommandPaletteDefaultProvider name={data.meta.title} actions={[Shortcuts]} />
{#snippet buttons()}
<IconButton <UserPageLayout title={data.meta.title} actions={[Shortcuts]}>
shape="round" <PageContent size="medium" center>
color="secondary"
variant="ghost"
icon={mdiKeyboard}
aria-label={$t('show_keyboard_shortcuts')}
onclick={() => modalManager.show(ShortcutsModal, {})}
/>
{/snippet}
<Container size="medium" center>
<UserSettingsList keys={data.keys} sessions={data.sessions} /> <UserSettingsList keys={data.keys} sessions={data.sessions} />
</Container> </PageContent>
</UserPageLayout> </UserPageLayout>

View File

@@ -1,7 +1,27 @@
<script lang="ts"> <script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import PageContent from '$lib/components/PageContent.svelte';
import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte';
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
import { Route } from '$lib/route';
import { Icon, modalManager } from '@immich/ui';
import {
mdiCellphoneArrowDownVariant,
mdiContentDuplicate,
mdiCrosshairsGps,
mdiImageSizeSelectLarge,
mdiLinkEdit,
mdiStateMachine,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import UtilitiesMenu from '$lib/components/utilities-page/utilities-menu.svelte';
const links = [
{ href: Route.duplicatesUtility(), icon: mdiContentDuplicate, label: $t('review_duplicates') },
{ href: Route.largeFileUtility(), icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
{ href: Route.geolocationUtility(), icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
{ href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
];
interface Props { interface Props {
data: PageData; data: PageData;
@@ -11,9 +31,44 @@
</script> </script>
<UserPageLayout title={data.meta.title}> <UserPageLayout title={data.meta.title}>
<div class="w-full max-w-xl m-auto"> <PageContent center size="small" class="pt-10">
<div class="mt-5"> <div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<UtilitiesMenu /> <p class="uppercase text-xs font-medium p-4">{$t('organize_your_library')}</p>
{#each links as link (link.href)}
<a href={link.href} class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4">
<span><Icon icon={link.icon} class="text-primary" size="24" /> </span>
{link.label}
</a>
{/each}
</div> </div>
</div> <br />
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="uppercase text-xs font-medium p-4">{$t('download')}</p>
<button
type="button"
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
>
<span>
<Icon icon={mdiLinkEdit} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
{$t('obtainium_configurator')}
</button>
<button
type="button"
onclick={() => modalManager.show(AppDownloadModal, {})}
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
>
<span>
<Icon
icon={mdiCellphoneArrowDownVariant}
class="text-immich-primary dark:text-immich-dark-primary"
size="24"
/>
</span>
{$t('app_download_links')}
</button>
</div>
</PageContent>
</UserPageLayout> </UserPageLayout>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import PageContent from '$lib/components/PageContent.svelte';
import LargeAssetData from '$lib/components/utilities-page/large-assets/large-asset-data.svelte'; import LargeAssetData from '$lib/components/utilities-page/large-assets/large-asset-data.svelte';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -54,18 +55,20 @@
}); });
</script> </script>
<UserPageLayout title={data.meta.title} scrollbar={true}> <UserPageLayout title={data.meta.title}>
<div class="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"> <PageContent>
{#if assets && data.assets.length > 0} <div class="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{#each assets as asset (asset.id)} {#if assets && data.assets.length > 0}
<LargeAssetData {asset} {onViewAsset} /> {#each assets as asset (asset.id)}
{/each} <LargeAssetData {asset} {onViewAsset} />
{:else} {/each}
<p class="text-center text-lg dark:text-white flex place-items-center place-content-center"> {:else}
{$t('no_assets_to_show')} <p class="text-center text-lg dark:text-white flex place-items-center place-content-center">
</p> {$t('no_assets_to_show')}
{/if} </p>
</div> {/if}
</div>
</PageContent>
</UserPageLayout> </UserPageLayout>
{#if $showAssetViewer} {#if $showAssetViewer}

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import emptyWorkflows from '$lib/assets/empty-workflows.svg'; import emptyWorkflows from '$lib/assets/empty-workflows.svg';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import PageContent from '$lib/components/PageContent.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { import {
@@ -151,131 +152,128 @@
</span> </span>
{/snippet} {/snippet}
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}> <UserPageLayout title={data.meta.title} actions={[Create]}>
<section class="flex place-content-center sm:mx-4"> <PageContent center size="large" class="pt-10">
<section class="w-full pb-28 sm:w-5/6 md:w-4xl"> {#if workflows.length === 0}
{#if workflows.length === 0} <EmptyPlaceholder
<EmptyPlaceholder title={$t('create_first_workflow')}
title={$t('create_first_workflow')} text={$t('workflows_help_text')}
text={$t('workflows_help_text')} onClick={() => Create.onAction(Create)}
onClick={() => Create.onAction(Create)} src={emptyWorkflows}
src={emptyWorkflows} class="mt-10 mx-auto"
class="mt-10 mx-auto" />
/> {:else}
{:else} <div class="grid gap-6">
<div class="my-6 grid gap-6"> {#each workflows as workflow (workflow.id)}
{#each workflows as workflow (workflow.id)} <Card class="border border-light-200">
<Card class="border border-light-200"> <CardHeader
<CardHeader class={`flex flex-row px-8 py-6 gap-4 sm:items-center sm:gap-6 ${
class={`flex flex-row px-8 py-6 gap-4 sm:items-center sm:gap-6 ${ workflow.enabled
workflow.enabled ? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45'
? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45' : 'bg-neutral-50 dark:bg-neutral-900'
: 'bg-neutral-50 dark:bg-neutral-900' }`}
}`} >
> <div class="flex-1">
<div class="flex-1"> <div class="flex items-center gap-3">
<div class="flex items-center gap-3"> <span class="rounded-full {workflow.enabled ? 'h-3 w-3 bg-success' : 'h-3 w-3 rounded-full bg-muted'}"
<span ></span>
class="rounded-full {workflow.enabled ? 'h-3 w-3 bg-success' : 'h-3 w-3 rounded-full bg-muted'}" <CardTitle>{workflow.name}</CardTitle>
></span> </div>
<CardTitle>{workflow.name}</CardTitle> <CardDescription class="mt-1 text-sm">
{workflow.description || $t('workflows_help_text')}
</CardDescription>
</div>
<div class="flex items-center gap-4">
<div class="text-right hidden sm:block">
<Text size="tiny">{$t('created_at')}</Text>
<Text size="small" fontWeight="medium">
{formatTimestamp(workflow.createdAt)}
</Text>
</div>
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiDotsVertical}
aria-label={$t('menu')}
onclick={(event: MouseEvent) => showWorkflowMenu(event, workflow)}
/>
</div>
</CardHeader>
<CardBody class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<!-- Trigger Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
>{$t('trigger')}</Text
>
</div> </div>
<CardDescription class="mt-1 text-sm"> {@render chipItem(getTriggerLabel(workflow.triggerType))}
{workflow.description || $t('workflows_help_text')}
</CardDescription>
</div> </div>
<div class="flex items-center gap-4"> <!-- Filters Section -->
<div class="text-right hidden sm:block"> <div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<Text size="tiny">{$t('created_at')}</Text> <div class="mb-3">
<Text size="small" fontWeight="medium"> <Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
{formatTimestamp(workflow.createdAt)} >{$t('filters')}</Text
</Text> >
</div> </div>
<IconButton <div class="flex flex-wrap gap-2">
shape="round" {#if workflow.filters.length === 0}
<span class="text-sm text-light-600">
{$t('no_filters_added')}
</span>
{:else}
{#each workflow.filters as workflowFilter (workflowFilter.id)}
{@render chipItem(getFilterLabel(workflowFilter.pluginFilterId))}
{/each}
{/if}
</div>
</div>
<!-- Actions Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
>{$t('actions')}</Text
>
</div>
<div>
{#if workflow.actions.length === 0}
<span class="text-sm text-light-600">
{$t('no_actions_added')}
</span>
{:else}
<div class="flex flex-wrap gap-2">
{#each workflow.actions as workflowAction (workflowAction.id)}
{@render chipItem(getActionLabel(workflowAction.pluginActionId))}
{/each}
</div>
{/if}
</div>
</div>
</div>
{#if expandedWorkflows.has(workflow.id)}
<VStack gap={2} class="w-full rounded-2xl border bg-light-50 p-4 border-light-200 ">
<CodeBlock code={getJson(workflow)} lineNumbers />
<Button
leadingIcon={mdiClose}
fullWidth
variant="ghost" variant="ghost"
color="secondary" color="secondary"
icon={mdiDotsVertical} onclick={() => toggleShowingSchema(workflow.id)}>{$t('close')}</Button
aria-label={$t('menu')} >
onclick={(event: MouseEvent) => showWorkflowMenu(event, workflow)} </VStack>
/> {/if}
</div> </CardBody>
</CardHeader> </Card>
{/each}
<CardBody class="space-y-6"> </div>
<div class="grid gap-4 md:grid-cols-3"> {/if}
<!-- Trigger Section --> </PageContent>
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
>{$t('trigger')}</Text
>
</div>
{@render chipItem(getTriggerLabel(workflow.triggerType))}
</div>
<!-- Filters Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
>{$t('filters')}</Text
>
</div>
<div class="flex flex-wrap gap-2">
{#if workflow.filters.length === 0}
<span class="text-sm text-light-600">
{$t('no_filters_added')}
</span>
{:else}
{#each workflow.filters as workflowFilter (workflowFilter.id)}
{@render chipItem(getFilterLabel(workflowFilter.pluginFilterId))}
{/each}
{/if}
</div>
</div>
<!-- Actions Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
>{$t('actions')}</Text
>
</div>
<div>
{#if workflow.actions.length === 0}
<span class="text-sm text-light-600">
{$t('no_actions_added')}
</span>
{:else}
<div class="flex flex-wrap gap-2">
{#each workflow.actions as workflowAction (workflowAction.id)}
{@render chipItem(getActionLabel(workflowAction.pluginActionId))}
{/each}
</div>
{/if}
</div>
</div>
</div>
{#if expandedWorkflows.has(workflow.id)}
<VStack gap={2} class="w-full rounded-2xl border bg-light-50 p-4 border-light-200 ">
<CodeBlock code={getJson(workflow)} lineNumbers />
<Button
leadingIcon={mdiClose}
fullWidth
variant="ghost"
color="secondary"
onclick={() => toggleShowingSchema(workflow.id)}>{$t('close')}</Button
>
</VStack>
{/if}
</CardBody>
</Card>
{/each}
</div>
{/if}
</section>
</section>
</UserPageLayout> </UserPageLayout>