Compare commits

...

5 Commits

Author SHA1 Message Date
Jason Rasmussen
c55564487e chore: remove unused props 2026-01-15 15:09:50 -05:00
Jason Rasmussen
887033ced3 refactor(web): admin page layout 2026-01-15 14:09:09 -05:00
Alex
95eb3e26c3 chore: sidebar spacing (#25278) 2026-01-15 10:35:01 -06:00
Alex
613dc858cb chore: tweak table text size (#25276) 2026-01-15 11:06:34 -05:00
shenlong
2f3fbd7dc5 fix: ignore duplicate cloud ID updates (#25271)
* fix: ignore duplicate remote updates

* update cloudId when any one of the ETag part is mismatched

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-01-15 09:15:56 -06:00
14 changed files with 119 additions and 121 deletions

View File

@@ -21,6 +21,7 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
if (!CurrentPlatform.isIOS) {
return;
}
final logger = Logger('migrateCloudIds');
final db = ref.read(driftProvider);
// Populate cloud IDs for local assets that don't have one yet
@@ -29,9 +30,7 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
final serverInfo = await ref.read(serverInfoProvider.notifier).getServerInfo();
final canUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 4);
if (!canUpdateMetadata) {
Logger(
'migrateCloudIds',
).fine('Server version does not support asset metadata updates. Skipping cloudId migration.');
logger.fine('Server version does not support asset metadata updates. Skipping cloudId migration.');
return;
}
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
@@ -40,25 +39,35 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
try {
await ref.read(syncStreamServiceProvider).sync();
} catch (e, s) {
Logger('migrateCloudIds').fine('Failed to complete remote sync before cloudId migration.', e, s);
logger.fine('Failed to complete remote sync before cloudId migration.', e, s);
return;
}
// Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
Logger('migrateCloudIds').warning('Current user is null. Aborting cloudId migration.');
logger.warning('Current user is null. Aborting cloudId migration.');
return;
}
final mappingsToUpdate = await _fetchCloudIdMappings(db, currentUser.id);
// Deduplicate mappings as a single remote asset ID can match multiple local assets
final seenRemoteAssetIds = <String>{};
final uniqueMapping = mappingsToUpdate.where((mapping) {
if (!seenRemoteAssetIds.add(mapping.remoteAssetId)) {
logger.fine('Duplicate remote asset ID found: ${mapping.remoteAssetId}. Skipping duplicate entry.');
return false;
}
return true;
}).toList();
final assetApi = ref.read(apiServiceProvider).assetsApi;
if (canBulkUpdateMetadata) {
await _bulkUpdateCloudIds(assetApi, mappingsToUpdate);
await _bulkUpdateCloudIds(assetApi, uniqueMapping);
return;
}
await _sequentialUpdateCloudIds(assetApi, mappingsToUpdate);
await _sequentialUpdateCloudIds(assetApi, uniqueMapping);
}
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
@@ -152,10 +161,10 @@ Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId)
// Skip locked assets as we cannot update them without unlocking first
drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) &
(drift.remoteAssetCloudIdEntity.cloudId.isNull() |
((drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime)) &
(drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude)) &
(drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude)) &
(drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)))),
drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) |
drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) |
drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) |
drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)),
);
return query.map((row) {
return (

View File

@@ -1,21 +0,0 @@
<script lang="ts">
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import { AppRoute } from '$lib/constants';
import { NavbarItem } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<div class="h-full flex flex-col justify-between gap-2">
<div class="flex flex-col pt-8 pe-4 gap-1">
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARIES} icon={mdiBookshelf} />
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
</div>
<div class="mb-2 me-4">
<BottomInfo />
</div>
</div>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import type { HeaderButtonActionItem } from '$lib/types';
import {
Breadcrumbs,
Button,
Container,
ContextMenuButton,
HStack,
MenuItemType,
Scrollable,
isMenuItemType,
type BreadcrumbItem,
} from '@immich/ui';
import { mdiSlashForward } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
breadcrumbs?: BreadcrumbItem[];
actions?: Array<HeaderButtonActionItem | MenuItemType>;
children?: Snippet;
};
let { breadcrumbs = [], actions = [], children }: Props = $props();
const enabledActions = $derived(
actions
.filter((action): action is HeaderButtonActionItem => !isMenuItemType(action))
.filter((action) => action.$if?.() ?? true),
);
</script>
<div class="h-full flex flex-col">
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
{#if enabledActions.length > 0}
<div class="hidden md:block">
<HStack gap={0}>
{#each enabledActions as action, i (i)}
<Button
variant="ghost"
size="small"
color={action.color ?? 'secondary'}
leadingIcon={action.icon}
onclick={() => action.onAction(action)}
title={action.data?.title}
>
{action.title}
</Button>
{/each}
</HStack>
</div>
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
{/if}
</div>
<Scrollable class="grow">
<Container class="p-2 pb-16" {children} />
</Scrollable>
</div>

View File

@@ -1,23 +1,12 @@
<script lang="ts">
import AdminSidebar from '$lib/components/AdminSidebar.svelte';
import PageContent from '$lib/components/layouts/PageContent.svelte';
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 { AppRoute } from '$lib/constants';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import type { HeaderButtonActionItem } from '$lib/types';
import {
AppShell,
AppShellHeader,
AppShellSidebar,
Breadcrumbs,
Button,
ContextMenuButton,
HStack,
MenuItemType,
Scrollable,
isMenuItemType,
type BreadcrumbItem,
} from '@immich/ui';
import { mdiSlashForward } from '@mdi/js';
import { AppShell, AppShellHeader, AppShellSidebar, MenuItemType, NavbarItem, type BreadcrumbItem } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -27,52 +16,31 @@
children?: Snippet;
};
let { breadcrumbs, actions = [], children }: Props = $props();
const enabledActions = $derived(
actions
.filter((action): action is HeaderButtonActionItem => !isMenuItemType(action))
.filter((action) => action.$if?.() ?? true),
);
let { breadcrumbs, actions, children }: Props = $props();
</script>
<AppShell>
<AppShellHeader>
<NavigationBar showUploadButton={false} noBorder />
<NavigationBar noBorder />
</AppShellHeader>
<AppShellSidebar bind:open={sidebarStore.isOpen} class="border-none shadow-none">
<AdminSidebar />
<AppShellSidebar
bind:open={sidebarStore.isOpen}
class="border-none shadow-none h-full flex flex-col justify-between gap-2"
>
<div class="flex flex-col pt-8 pe-4 gap-1">
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARIES} icon={mdiBookshelf} />
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
</div>
<div class="mb-2 me-4">
<BottomInfo />
</div>
</AppShellSidebar>
<div class="h-full flex flex-col">
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
{#if enabledActions.length > 0}
<div class="hidden md:block">
<HStack gap={0}>
{#each enabledActions as action, i (i)}
<Button
variant="ghost"
size="small"
color={action.color ?? 'secondary'}
leadingIcon={action.icon}
onclick={() => action.onAction(action)}
title={action.data?.title}
>
{action.title}
</Button>
{/each}
</HStack>
</div>
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
{/if}
</div>
<Scrollable class="grow">
<PageContent>
{@render children?.()}
</PageContent>
</Scrollable>
</div>
<BreadcrumbActionPage {breadcrumbs} {actions}>
{@render children?.()}
</BreadcrumbActionPage>
</AppShell>

View File

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

View File

@@ -14,13 +14,11 @@
interface Props {
hideNavbar?: boolean;
showUploadButton?: boolean;
title?: string | undefined;
description?: string | undefined;
scrollbar?: boolean;
use?: ActionArray;
actions?: Array<HeaderButtonActionItem | MenuItemType>;
header?: Snippet;
sidebar?: Snippet;
buttons?: Snippet;
children?: Snippet;
@@ -28,13 +26,11 @@
let {
hideNavbar = false,
showUploadButton = false,
title = undefined,
description = undefined,
scrollbar = true,
use = [],
actions = [],
header,
sidebar,
buttons,
children,
@@ -52,10 +48,8 @@
<header>
{#if !hideNavbar}
<NavigationBar {showUploadButton} onUploadClick={() => openFileUploadDialog()} />
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
{/if}
{@render header?.()}
</header>
<div
tabindex="-1"

View File

@@ -25,14 +25,13 @@
import UserAvatar from '../user-avatar.svelte';
import AccountInfoPanel from './account-info-panel.svelte';
interface Props {
showUploadButton?: boolean;
type Props = {
onUploadClick?: () => void;
// TODO: remove once this is only used in <AppShellHeader>
noBorder?: boolean;
}
};
let { showUploadButton = true, onUploadClick, noBorder = false }: Props = $props();
let { onUploadClick, noBorder = false }: Props = $props();
let shouldShowAccountInfoPanel = $state(false);
let shouldShowNotificationPanel = $state(false);
@@ -105,7 +104,7 @@
/>
{/if}
{#if !page.url.pathname.includes('/admin') && showUploadButton && onUploadClick}
{#if !page.url.pathname.includes('/admin') && onUploadClick}
<Button
leadingIcon={mdiTrayArrowUp}
onclick={onUploadClick}

View File

@@ -87,7 +87,7 @@
bind:isSelected={isSharingSelected}
></SideBarLink>
<p class="text-xs p-6 dark:text-immich-dark-fg uppercase">{$t('library')}</p>
<p class="text-xs py-5 ps-6 dark:text-immich-dark-fg uppercase">{$t('library')}</p>
<SideBarLink
title={$t('favorites')}

View File

@@ -166,7 +166,7 @@
</div>
</SettingAccordion>
<div class="flex justify-end">
<div class="flex justify-end mt-4">
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
</div>
</div>

View File

@@ -41,7 +41,7 @@
</div>
{#if keys.length > 0}
<Table class="mt-4" striped spacing="small">
<Table class="mt-4" striped spacing="small" size="small">
<TableHeader>
<TableHeading>{$t('name')}</TableHeading>
<TableHeading>{$t('permission')}</TableHeading>

View File

@@ -67,7 +67,7 @@
<section class="my-6 w-full">
<Heading size="tiny">{$t('photos_and_videos')}</Heading>
<Table striped spacing="medium" class="mt-4">
<Table striped spacing="small" class="mt-4" size="small">
<TableHeader>
<TableHeading class="w-1/4">{$t('view_name')}</TableHeading>
<TableHeading class="w-1/4">{$t('photos')}</TableHeading>
@@ -83,7 +83,7 @@
</Table>
<Heading size="tiny" class="mt-8">{$t('albums')}</Heading>
<Table striped spacing="medium" class="mt-4">
<Table striped spacing="small" class="mt-4" size="small">
<TableHeader>
<TableHeading class="w-1/2">{$t('owned')}</TableHeading>
<TableHeading class="w-1/2">{$t('shared')}</TableHeading>

View File

@@ -102,7 +102,7 @@
);
</script>
<UserPageLayout hideNavbar={assetInteraction.selectionActive} showUploadButton scrollbar={false}>
<UserPageLayout hideNavbar={assetInteraction.selectionActive} scrollbar={false}>
<Timeline
enableRouting={true}
bind:timelineManager

View File

@@ -76,7 +76,7 @@
<Container size="large" center class="my-4">
<div class="flex flex-col items-center gap-2" in:fade={{ duration: 500 }}>
{#if libraries.length > 0}
<Table striped>
<Table striped size="small" spacing="small">
<TableHeader>
<TableHeading class={classes.column1}>{$t('name')}</TableHeading>
<TableHeading class={classes.column2}>{$t('owner')}</TableHeading>

View File

@@ -66,7 +66,7 @@
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
<Container center size="large">
<Table class="mt-4" striped spacing="large">
<Table class="mt-4" striped spacing="small" size="small">
<TableHeader>
<TableHeading class={classes.column1}>{$t('email')}</TableHeading>
<TableHeading class={classes.column2}>{$t('name')}</TableHeading>