Merge remote-tracking branch 'origin/main' into lighter_buckets_web

This commit is contained in:
Min Idzelis
2025-05-02 00:56:41 +00:00
144 changed files with 2533 additions and 4567 deletions

View File

@@ -1,8 +1,11 @@
import FocusTrapTest from '$lib/actions/__test__/focus-trap-test.svelte';
import { setDefaultTabbleOptions } from '$lib/utils/focus-util';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { tick } from 'svelte';
setDefaultTabbleOptions({ displayCheck: 'none' });
describe('focusTrap action', () => {
const user = userEvent.setup();
@@ -38,6 +41,7 @@ describe('focusTrap action', () => {
const openButton = screen.getByText('Open');
await user.click(openButton);
await tick();
expect(document.activeElement).toEqual(screen.getByTestId('one'));
screen.getByText('Close').click();

View File

@@ -1,5 +1,5 @@
import { shortcuts } from '$lib/actions/shortcut';
import { getFocusable } from '$lib/utils/focus-util';
import { getTabbable } from '$lib/utils/focus-util';
import { tick } from 'svelte';
interface Options {
@@ -18,18 +18,21 @@ export function focusTrap(container: HTMLElement, options?: Options) {
};
};
const setInitialFocus = () => {
const focusableElement = getFocusable(container)[0];
// Use tick() to ensure focus trap works correctly inside <Portal />
void tick().then(() => focusableElement?.focus());
const setInitialFocus = async () => {
const focusableElement = getTabbable(container, false)[0];
if (focusableElement) {
// Use tick() to ensure focus trap works correctly inside <Portal />
await tick();
focusableElement?.focus();
}
};
if (withDefaults(options).active) {
setInitialFocus();
void setInitialFocus();
}
const getFocusableElements = () => {
const focusableElements = getFocusable(container);
const focusableElements = getTabbable(container);
return [
focusableElements.at(0), //
focusableElements.at(-1),
@@ -67,7 +70,7 @@ export function focusTrap(container: HTMLElement, options?: Options) {
update(newOptions?: Options) {
options = newOptions;
if (withDefaults(options).active) {
setInitialFocus();
void setInitialFocus();
}
},
destroy() {

View File

@@ -1,16 +1,17 @@
<script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { type SystemConfigDto } from '@immich/sdk';
import { SettingInputFieldType } from '$lib/constants';
import { OAuthTokenEndpointAuthMethod, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { SettingInputFieldType } from '$lib/constants';
interface Props {
savedConfig: SystemConfigDto;
@@ -108,7 +109,7 @@
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_issuer_url').toUpperCase()}
label="ISSUER_URL"
bind:value={config.oauth.issuerUrl}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -117,7 +118,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_client_id').toUpperCase()}
label="CLIENT_ID"
bind:value={config.oauth.clientId}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -126,16 +127,30 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_client_secret').toUpperCase()}
label="CLIENT_SECRET"
description={$t('admin.oauth_client_secret_description')}
bind:value={config.oauth.clientSecret}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientSecret == savedConfig.oauth.clientSecret)}
/>
{#if config.oauth.clientSecret}
<SettingSelect
label="TOKEN_ENDPOINT_AUTH_METHOD"
bind:value={config.oauth.tokenEndpointAuthMethod}
disabled={disabled || !config.oauth.enabled || !config.oauth.clientSecret}
isEdited={!(config.oauth.tokenEndpointAuthMethod == savedConfig.oauth.tokenEndpointAuthMethod)}
options={[
{ value: OAuthTokenEndpointAuthMethod.ClientSecretPost, text: 'client_secret_post' },
{ value: OAuthTokenEndpointAuthMethod.ClientSecretBasic, text: 'client_secret_basic' },
]}
name="tokenEndpointAuthMethod"
/>
{/if}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_scope').toUpperCase()}
label="SCOPE"
bind:value={config.oauth.scope}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -144,7 +159,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_signing_algorithm').toUpperCase()}
label="ID_TOKEN_SIGNED_RESPONSE_ALG"
bind:value={config.oauth.signingAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -153,14 +168,23 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_profile_signing_algorithm').toUpperCase()}
description={$t('admin.oauth_profile_signing_algorithm_description')}
label="USERINFO_SIGNED_RESPONSE_ALG"
bind:value={config.oauth.profileSigningAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.profileSigningAlgorithm == savedConfig.oauth.profileSigningAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_timeout').toUpperCase()}
description={$t('admin.oauth_timeout_description')}
required={true}
bind:value={config.oauth.timeout}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.timeout == savedConfig.oauth.timeout)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_storage_label_claim').toUpperCase()}

View File

@@ -1,32 +1,24 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { getAssetType } from '$lib/utils/asset-utils';
import { autoGrowHeight } from '$lib/actions/autogrow';
import { shortcut } from '$lib/actions/shortcut';
import Icon from '$lib/components/elements/icon.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetType } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
import {
ReactionType,
createActivity,
deleteActivity,
getActivities,
type ActivityResponseDto,
type AssetTypeEnum,
type UserResponseDto,
} from '@immich/sdk';
import { mdiClose, mdiDotsVertical, mdiHeart, mdiSend, mdiDeleteOutline } from '@mdi/js';
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiHeart, mdiSend } from '@mdi/js';
import * as luxon from 'luxon';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import UserAvatar from '../shared-components/user-avatar.svelte';
import { locale } from '$lib/stores/preferences.store';
import { shortcut } from '$lib/actions/shortcut';
import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
@@ -48,34 +40,16 @@
};
interface Props {
reactions: ActivityResponseDto[];
user: UserResponseDto;
assetId?: string | undefined;
albumId: string;
assetType?: AssetTypeEnum | undefined;
albumOwnerId: string;
disabled: boolean;
isLiked: ActivityResponseDto | null;
onDeleteComment: () => void;
onDeleteLike: () => void;
onAddComment: () => void;
onClose: () => void;
}
let {
reactions = $bindable(),
user,
assetId = undefined,
albumId,
assetType = undefined,
albumOwnerId,
disabled,
isLiked,
onDeleteComment,
onDeleteLike,
onAddComment,
onClose,
}: Props = $props();
let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled, onClose }: Props = $props();
let innerHeight: number = $state(0);
let activityHeight: number = $state(0);
@@ -85,36 +59,18 @@
let message = $state('');
let isSendingMessage = $state(false);
onMount(async () => {
await getReactions();
});
const getReactions = async () => {
try {
reactions = await getActivities({ assetId, albumId });
} catch (error) {
handleError(error, $t('errors.unable_to_load_asset_activity'));
}
};
const timeOptions = {
const timeOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
} as Intl.DateTimeFormatOptions;
};
const handleDeleteReaction = async (reaction: ActivityResponseDto, index: number) => {
try {
await deleteActivity({ id: reaction.id });
reactions.splice(index, 1);
if (isLiked && reaction.type === ReactionType.Like && reaction.id == isLiked.id) {
onDeleteLike();
} else {
onDeleteComment();
}
await activityManager.deleteActivity(reaction, index);
const deleteMessages: Record<ReactionType, string> = {
[ReactionType.Comment]: $t('comment_deleted'),
@@ -135,13 +91,9 @@
}
const timeout = setTimeout(() => (isSendingMessage = true), timeBeforeShowLoadingSpinner);
try {
const data = await createActivity({
activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message },
});
reactions.push(data);
await activityManager.addActivity({ albumId, assetId, type: ReactionType.Comment, comment: message });
message = '';
onAddComment();
} catch (error) {
handleError(error, $t('errors.unable_to_add_comment'));
} finally {
@@ -156,7 +108,6 @@
});
$effect(() => {
if (assetId && previousAssetId != assetId) {
handlePromiseError(getReactions());
previousAssetId = assetId;
}
});
@@ -184,7 +135,7 @@
class="overflow-y-auto immich-scrollbar relative w-full px-2"
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
>
{#each reactions as reaction, index (reaction.id)}
{#each activityManager.activities as reaction, index (reaction.id)}
{#if reaction.type === ReactionType.Comment}
<div class="flex dark:bg-gray-800 bg-gray-200 py-3 ps-3 mt-3 rounded-lg gap-4 justify-start">
<div class="flex items-center">
@@ -221,7 +172,7 @@
{/if}
</div>
{#if (index != reactions.length - 1 && !shouldGroup(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1}
{#if (index != activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
<div
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
@@ -273,7 +224,7 @@
</div>
{/if}
</div>
{#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1}
{#if (index != activityManager.activities.length - 1 && isTenMinutesApart(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
<div
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(navigator.language, timeOptions)}

View File

@@ -5,8 +5,8 @@
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { updateNumberOfComments } from '$lib/stores/activity.store';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
@@ -21,15 +21,9 @@
import {
AssetJobName,
AssetTypeEnum,
ReactionType,
createActivity,
deleteActivity,
getActivities,
getActivityStatistics,
getAllAlbums,
getStack,
runAssetJobs,
type ActivityResponseDto,
type AlbumResponseDto,
type AssetResponseDto,
type PersonResponseDto,
@@ -63,7 +57,6 @@
person?: PersonResponseDto | null;
preAction?: PreAction | undefined;
onAction?: OnAction | undefined;
reactions?: ActivityResponseDto[];
showCloseButton?: boolean;
onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
@@ -82,7 +75,6 @@
person = null,
preAction = undefined,
onAction = undefined,
reactions = $bindable([]),
showCloseButton,
onClose,
onNext,
@@ -109,8 +101,6 @@
let previewStackedAsset: AssetResponseDto | undefined = $state();
let isShowActivity = $state(false);
let isShowEditor = $state(false);
let isLiked: ActivityResponseDto | null = $state(null);
let numberOfComments = $state(0);
let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
let selectedEditType: string = $state('');
@@ -138,59 +128,20 @@
});
};
const handleAddComment = () => {
numberOfComments++;
updateNumberOfComments(1);
};
const handleRemoveComment = () => {
numberOfComments--;
updateNumberOfComments(-1);
};
const handleFavorite = async () => {
if (album && album.isActivityEnabled) {
try {
if (isLiked) {
const activityId = isLiked.id;
await deleteActivity({ id: activityId });
reactions = reactions.filter((reaction) => reaction.id !== activityId);
isLiked = null;
} else {
const data = await createActivity({
activityCreateDto: { albumId: album.id, assetId: asset.id, type: ReactionType.Like },
});
isLiked = data;
reactions = [...reactions, isLiked];
}
await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
}
}
};
const getFavorite = async () => {
if (album && $user) {
try {
const data = await getActivities({
userId: $user.id,
assetId: asset.id,
albumId: album.id,
$type: ReactionType.Like,
});
isLiked = data.length > 0 ? data[0] : null;
} catch (error) {
handleError(error, $t('errors.unable_to_load_liked_status'));
}
}
};
const getNumberOfComments = async () => {
const updateComments = async () => {
if (album) {
try {
const { comments } = await getActivityStatistics({ assetId: asset.id, albumId: album.id });
numberOfComments = comments;
await activityManager.refreshActivities(album.id, asset.id);
} catch (error) {
handleError(error, $t('errors.unable_to_get_comments_number'));
}
@@ -229,6 +180,10 @@
if (!sharedLink) {
await handleGetAllAlbums();
}
if (album) {
activityManager.init(album.id, asset.id);
}
});
onDestroy(() => {
@@ -243,6 +198,8 @@
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
activityManager.reset();
});
const handleGetAllAlbums = async () => {
@@ -403,14 +360,13 @@
}
});
$effect(() => {
if (album && !album.isActivityEnabled && numberOfComments === 0) {
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false;
}
});
$effect(() => {
if (isShared && asset.id) {
handlePromiseError(getFavorite());
handlePromiseError(getNumberOfComments());
handlePromiseError(updateComments());
}
});
$effect(() => {
@@ -548,12 +504,12 @@
onVideoStarted={handleVideoStarted}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0)}
<div class="z-[9999] absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
disabled={!album?.isActivityEnabled}
{isLiked}
{numberOfComments}
isLiked={activityManager.isLiked}
numberOfComments={activityManager.commentCount}
onFavorite={handleFavorite}
onOpenActivityTab={handleOpenActivity}
/>
@@ -643,11 +599,6 @@
albumOwnerId={album.ownerId}
albumId={album.id}
assetId={asset.id}
{isLiked}
bind:reactions
onAddComment={handleAddComment}
onDeleteComment={handleRemoveComment}
onDeleteLike={() => (isLiked = null)}
onClose={() => (isShowActivity = false)}
/>
</div>

View File

@@ -2,14 +2,15 @@
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { notificationController } from '$lib/components/shared-components/notification/notification';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { onMount } from 'svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handleError } from '$lib/utils/handle-error';
import { t } from 'svelte-i18n';
interface Props {
htmlElement: HTMLImageElement | HTMLVideoElement;
@@ -316,7 +317,7 @@
bind:this={faceSelectorEl}
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800"
>
<p class="text-center text-sm">Select a person to tag</p>
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
<div class="my-3 relative">
<Input placeholder="Search person..." bind:value={searchTerm} size="tiny" />
@@ -348,11 +349,11 @@
</div>
{:else}
<div class="flex items-center justify-center py-4">
<p class="text-sm text-gray-500">No matching people found</p>
<p class="text-sm text-gray-500">{$t('no_people_found')}</p>
</div>
{/if}
</div>
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">Cancel</Button>
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
</div>
</div>

View File

@@ -1,7 +1,8 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { assetFactory } from '@test-data/factories/asset-factory';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { fireEvent, render } from '@testing-library/svelte';
vi.hoisted(() => {
Object.defineProperty(globalThis, 'matchMedia', {
@@ -31,51 +32,47 @@ describe('Thumbnail component', () => {
it('should only contain a single tabbable element (the container)', () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
render(Thumbnail, {
const { baseElement } = render(Thumbnail, {
asset,
focussed: false,
selected: true,
});
const container = screen.getByTestId('container-with-tabindex');
expect(container.getAttribute('tabindex')).toBe('0');
const container = baseElement.querySelector('[data-thumbnail-focus-container]');
expect(container).not.toBeNull();
expect(container!.getAttribute('tabindex')).toBe('0');
// This isn't capturing all tabbable elements, but should be the most likely ones. Mainly guarding against
// inserting extra tabbable elments in future in <Thumbnail/>
let allTabbableElements = screen.queryAllByRole('link');
allTabbableElements = allTabbableElements.concat(screen.queryAllByRole('checkbox'));
expect(allTabbableElements.length).toBeGreaterThan(0);
for (const tabbableElement of allTabbableElements) {
const testIdValue = tabbableElement.dataset.testid;
if (testIdValue === null || testIdValue !== 'container-with-tabindex') {
expect(tabbableElement.getAttribute('tabindex')).toBe('-1');
}
}
// Guarding against inserting extra tabbable elments in future in <Thumbnail/>
const tabbables = getTabbable(container!);
expect(tabbables.length).toBe(0);
});
it('handleFocus should be called on focus of container', async () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const handleFocusSpy = vi.fn();
render(Thumbnail, {
const { baseElement } = render(Thumbnail, {
asset,
handleFocus: handleFocusSpy,
});
const container = screen.getByTestId('container-with-tabindex');
await fireEvent(container, new FocusEvent('focus'));
const container = baseElement.querySelector('[data-thumbnail-focus-container]');
expect(container).not.toBeNull();
await fireEvent(container as HTMLElement, new FocusEvent('focus'));
expect(handleFocusSpy).toBeCalled();
});
it('element will be focussed if not already', () => {
it('element will be focussed if not already', async () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const handleFocusSpy = vi.fn();
render(Thumbnail, {
const { baseElement } = render(Thumbnail, {
asset,
focussed: true,
handleFocus: handleFocusSpy,
});
const container = baseElement.querySelector('[data-thumbnail-focus-container]');
expect(container).not.toBeNull();
await fireEvent(container as HTMLElement, new FocusEvent('focus'));
expect(handleFocusSpy).toBeCalled();
});
});

View File

@@ -20,7 +20,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getFocusable } from '$lib/utils/focus-util';
import { focusNext } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables';
import { onMount } from 'svelte';
@@ -36,7 +36,6 @@
thumbnailWidth?: number;
thumbnailHeight?: number;
selected?: boolean;
focussed?: boolean;
selectionCandidate?: boolean;
disabled?: boolean;
disableLinkMouseOver?: boolean;
@@ -59,7 +58,6 @@
thumbnailWidth = undefined,
thumbnailHeight = undefined,
selected = false,
focussed = false,
selectionCandidate = false,
disabled = false,
disableLinkMouseOver = false,
@@ -80,17 +78,11 @@
} = TUNABLES;
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let focussableElement: HTMLElement | undefined = $state();
let element: HTMLElement | undefined = $state();
let mouseOver = $state(false);
let loaded = $state(false);
let thumbError = $state(false);
$effect(() => {
if (focussed && document.activeElement !== focussableElement) {
focussableElement?.focus();
}
});
let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235);
@@ -237,31 +229,14 @@
if (evt.key === 'x') {
onSelect?.(asset);
}
if (document.activeElement === focussableElement && evt.key === 'Escape') {
const focusable = getFocusable(document);
const index = focusable.indexOf(focussableElement);
let i = index + 1;
while (i !== index) {
const next = focusable[i];
if (next.dataset.thumbnailFocusContainer !== undefined) {
if (i === focusable.length - 1) {
i = 0;
} else {
i++;
}
continue;
}
next.focus();
break;
}
if (document.activeElement === element && evt.key === 'Escape') {
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
}
}}
onclick={handleClick}
bind:this={focussableElement}
bind:this={element}
onfocus={handleFocus}
data-thumbnail-focus-container
data-testid="container-with-tabindex"
tabindex={0}
role="link"
>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import Button from './button.svelte';
import { getTabbable } from '$lib/utils/focus-util';
interface Props {
/**
@@ -23,7 +24,12 @@
const moveFocus = () => {
const targetEl = document.querySelector<HTMLElement>(target);
targetEl?.focus();
if (targetEl) {
const element = getTabbable(targetEl)[0];
if (element) {
element.focus();
}
}
};
const getBreakpoint = () => {

View File

@@ -49,7 +49,7 @@
{@render header?.()}
</header>
<main
<div
tabindex="-1"
class="relative grid h-dvh grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]"
>
@@ -59,7 +59,7 @@
<SideBar />
{/if}
<section class="relative">
<main class="relative">
{#if title || buttons}
<div
class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 dark:border-immich-dark-gray dark:text-immich-dark-fg"
@@ -79,5 +79,5 @@
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto" use:useActions={use}>
{@render children?.()}
</div>
</section>
</main>
</main>
</div>

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { mdiArrowRight, mdiThemeLightDark } from '@mdi/js';
import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import OnboardingCard from './onboarding-card.svelte';
import { colorTheme } from '$lib/stores/preferences.store';
import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
import { Theme } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import { mdiArrowRight, mdiThemeLightDark } from '@mdi/js';
import { t } from 'svelte-i18n';
import OnboardingCard from './onboarding-card.svelte';
interface Props {
onDone: () => void;
@@ -24,7 +24,7 @@
<button
type="button"
class="w-1/2 aspect-square bg-immich-bg rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-dark-primary/80 border-immich-primary dark:border dark:border-transparent"
onclick={() => ($colorTheme.value = Theme.LIGHT)}
onclick={() => themeManager.setTheme(Theme.LIGHT)}
>
<div
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary"
@@ -36,7 +36,7 @@
<button
type="button"
class="w-1/2 aspect-square bg-immich-dark-bg rounded-3xl dark:border-[3px] dark:border-immich-dark-primary/80 dark:border-immich-dark-primary border border-transparent"
onclick={() => ($colorTheme.value = Theme.DARK)}
onclick={() => themeManager.setTheme(Theme.DARK)}
>
<div
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary"

View File

@@ -100,9 +100,6 @@
}
};
const assetOnFocusHandler = (asset: TimelineAsset) => {
assetInteraction.focussedAssetId = asset.id;
};
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
return intersectable.filter((int) => int.intersecting);
}
@@ -131,7 +128,7 @@
>
<!-- Date group title -->
<div
class="flex z-[100] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
class="flex z-[100] pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
style:width={dateGroup.width + 'px'}
>
{#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
@@ -182,13 +179,11 @@
{showArchiveIcon}
{asset}
{groupIndex}
focussed={assetInteraction.isFocussedAsset(asset.id)}
onClick={(asset) => onClick(assetStore, dateGroup.getAssets(), dateGroup.groupTitle, asset)}
onSelect={(asset) => assetSelectHandler(assetStore, asset, dateGroup.getAssets(), dateGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
handleFocus={() => assetOnFocusHandler(asset)}
disabled={dateGroup.bucket.store.albumAssets.has(asset.id)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}

View File

@@ -23,6 +23,7 @@
import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { focusNext } from '$lib/utils/focus-util';
import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
@@ -95,7 +96,16 @@
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
$effect(() => {
assetStore.rowHeight = maxMd ? 100 : 235;
const layoutOptions = maxMd
? {
rowHeight: 100,
headerHeight: 32,
}
: {
rowHeight: 235,
headerHeight: 48,
};
assetStore.setLayoutOptions(layoutOptions);
});
const scrollTo = (top: number) => {
@@ -616,34 +626,8 @@
}
};
const focusNextAsset = async () => {
if (assetInteraction.focussedAssetId === null) {
const firstAsset = assetStore.getFirstAsset();
if (firstAsset) {
assetInteraction.focussedAssetId = firstAsset.id;
}
} else {
const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
if (focussedAsset) {
const nextAsset = await assetStore.getNextAsset(focussedAsset);
if (nextAsset) {
assetInteraction.focussedAssetId = nextAsset.id;
}
}
}
};
const focusPreviousAsset = async () => {
if (assetInteraction.focussedAssetId !== null) {
const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
if (focussedAsset) {
const previousAsset = await assetStore.getPreviousAsset(focussedAsset);
if (previousAsset) {
assetInteraction.focussedAssetId = previousAsset.id;
}
}
}
};
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);

View File

@@ -9,7 +9,7 @@
<div class="overflow-clip" style:height={height + 'px'}>
<div
class="flex z-[100] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
class="flex z-[100] pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
>
{title}
</div>

View File

@@ -67,7 +67,7 @@
</script>
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
<div
<nav
id="asset-selection-app-bar"
class={[
'grid',
@@ -94,5 +94,5 @@
<div class="me-4 flex place-items-center gap-1 justify-self-end">
{@render trailing?.()}
</div>
</div>
</nav>
</div>

View File

@@ -12,6 +12,7 @@
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { focusNext } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
@@ -267,25 +268,8 @@
}
};
const focusNextAsset = () => {
if (assetInteraction.focussedAssetId === null && assets.length > 0) {
assetInteraction.focussedAssetId = assets[0].id;
} else if (assetInteraction.focussedAssetId !== null && assets.length > 0) {
const currentIndex = assets.findIndex((a) => a.id === assetInteraction.focussedAssetId);
if (currentIndex !== -1 && currentIndex + 1 < assets.length) {
assetInteraction.focussedAssetId = assets[currentIndex + 1].id;
}
}
};
const focusPreviousAsset = () => {
if (assetInteraction.focussedAssetId !== null && assets.length > 0) {
const currentIndex = assets.findIndex((a) => a.id === assetInteraction.focussedAssetId);
if (currentIndex >= 1) {
assetInteraction.focussedAssetId = assets[currentIndex - 1].id;
}
}
};
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
let shortcutList = $derived(
(() => {
@@ -502,7 +486,6 @@
asset={toTimelineAsset(asset)}
selected={assetInteraction.hasSelectedAsset(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
focussed={assetInteraction.isFocussedAsset(asset.id)}
thumbnailWidth={layout.width}
thumbnailHeight={layout.height}
/>

View File

@@ -9,15 +9,15 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { Theme } from '$lib/constants';
import { colorTheme, mapSettings } from '$lib/stores/preferences.store';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import { mapSettings } from '$lib/stores/preferences.store';
import { serverConfig } from '$lib/stores/server-config.store';
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { type MapMarkerResponseDto } from '@immich/sdk';
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url';
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
import { type GeoJSONSource, GlobeControl, type LngLatLike } from 'maplibre-gl';
import maplibregl from 'maplibre-gl';
import maplibregl, { GlobeControl, type GeoJSONSource, type LngLatLike } from 'maplibre-gl';
import { t } from 'svelte-i18n';
import {
AttributionControl,
@@ -68,7 +68,7 @@
let map: maplibregl.Map | undefined = $state();
let marker: maplibregl.Marker | null = null;
const theme = $derived($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT);
const theme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.LIGHT);
const styleUrl = $derived(theme === Theme.DARK ? $serverConfig.mapDarkStyleUrl : $serverConfig.mapLightStyleUrl);
export function addClipMapMarker(lng: number, lat: number) {

View File

@@ -55,7 +55,7 @@
<HelpAndFeedbackModal onClose={() => (shouldShowHelpPanel = false)} {info} />
{/if}
<section
<nav
id="dashboard-navbar"
class="fixed z-[900] max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm"
>
@@ -209,4 +209,4 @@
</section>
</div>
</div>
</section>
</nav>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import QRCode from 'qrcode';
import { colorTheme } from '$lib/stores/preferences.store';
import { Theme } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import QRCode from 'qrcode';
import { t } from 'svelte-i18n';
type Props = {
@@ -14,7 +14,7 @@
let promise = $derived(
QRCode.toDataURL(value, {
color: { dark: $colorTheme.value === Theme.DARK ? '#ffffffff' : '#000000ff', light: '#00000000' },
color: { dark: themeManager.value === Theme.DARK ? '#ffffffff' : '#000000ff', light: '#00000000' },
margin: 0,
width,
}),

View File

@@ -2,7 +2,7 @@
import Icon from '$lib/components/elements/icon.svelte';
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getFocusable } from '$lib/utils/focus-util';
import { getTabbable } from '$lib/utils/focus-util';
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es';
@@ -376,7 +376,7 @@
if (forward || backward) {
event.preventDefault();
const focusable = getFocusable(document);
const focusable = getTabbable(document.body);
if (scrollBar) {
const index = focusable.indexOf(scrollBar);
if (index !== -1) {

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import { SettingInputFieldType } from '$lib/constants';
import { onMount, tick, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import type { FormEventHandler } from 'svelte/elements';
import { fly } from 'svelte/transition';
import PasswordField from '../password-field.svelte';
import { t } from 'svelte-i18n';
import { onMount, tick, type Snippet } from 'svelte';
import { SettingInputFieldType } from '$lib/constants';
interface Props {
inputType: SettingInputFieldType;
value: string | number;
value: string | number | undefined;
min?: number;
max?: number;
step?: string;
@@ -147,7 +147,7 @@
name={label}
autocomplete={passwordAutocomplete}
{required}
password={value.toString()}
password={(value || '').toString()}
onInput={(passwordValue) => (value = passwordValue)}
{disabled}
{title}

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiChevronDown } from '@mdi/js';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
interface Props {
value: string | number;
value: string | number | undefined;
options: { value: string | number; text: string }[];
label?: string;
desc?: string;

View File

@@ -7,14 +7,12 @@
import { t } from 'svelte-i18n';
</script>
<SideBarSection>
<nav aria-label={$t('primary')}>
<SideBarLink title={$t('users')} routeId={AppRoute.ADMIN_USER_MANAGEMENT} icon={mdiAccountMultipleOutline} />
<SideBarLink title={$t('jobs')} routeId={AppRoute.ADMIN_JOBS} icon={mdiSync} />
<SideBarLink title={$t('settings')} routeId={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<SideBarLink title={$t('external_libraries')} routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
<SideBarLink title={$t('server_stats')} routeId={AppRoute.ADMIN_STATS} icon={mdiServer} />
</nav>
<SideBarSection ariaLabel={$t('primary')}>
<SideBarLink title={$t('users')} routeId={AppRoute.ADMIN_USER_MANAGEMENT} icon={mdiAccountMultipleOutline} />
<SideBarLink title={$t('jobs')} routeId={AppRoute.ADMIN_JOBS} icon={mdiSync} />
<SideBarLink title={$t('settings')} routeId={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<SideBarLink title={$t('external_libraries')} routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
<SideBarLink title={$t('server_stats')} routeId={AppRoute.ADMIN_STATS} icon={mdiServer} />
<BottomInfo />
</SideBarSection>

View File

@@ -7,10 +7,11 @@
import { onMount, type Snippet } from 'svelte';
interface Props {
ariaLabel?: string;
children?: Snippet;
}
let { children }: Props = $props();
let { ariaLabel, children }: Props = $props();
const isHidden = $derived(!sidebarStore.isOpen && !mobileDevice.isFullSidebar);
const isExpanded = $derived(sidebarStore.isOpen && !mobileDevice.isFullSidebar);
@@ -30,8 +31,9 @@
};
</script>
<section
<nav
id="sidebar"
aria-label={ariaLabel}
tabindex="-1"
class="immich-scrollbar relative z-10 w-0 sidebar:w-[16rem] overflow-y-auto overflow-x-hidden bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg"
class:shadow-2xl={isExpanded}
@@ -46,4 +48,4 @@
<div class="pe-6 flex flex-col gap-1 h-max min-h-full">
{@render children?.()}
</div>
</section>
</nav>

View File

@@ -42,102 +42,100 @@
let isUtilitiesSelected: boolean = $state(false);
</script>
<SideBarSection>
<nav aria-label={$t('primary')}>
<SideBarSection ariaLabel={$t('primary')}>
<SideBarLink
title={$t('photos')}
routeId="/(user)/photos"
bind:isSelected={isPhotosSelected}
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
></SideBarLink>
{#if $featureFlags.search}
<SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
{/if}
{#if $featureFlags.map}
<SideBarLink
title={$t('photos')}
routeId="/(user)/photos"
bind:isSelected={isPhotosSelected}
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
title={$t('map')}
routeId="/(user)/map"
bind:isSelected={isMapSelected}
icon={isMapSelected ? mdiMap : mdiMapOutline}
/>
{/if}
{#if $preferences.people.enabled && $preferences.people.sidebarWeb}
<SideBarLink
title={$t('people')}
routeId="/(user)/people"
bind:isSelected={isPeopleSelected}
icon={isPeopleSelected ? mdiAccount : mdiAccountOutline}
/>
{/if}
{#if $preferences.sharedLinks.enabled && $preferences.sharedLinks.sidebarWeb}
<SideBarLink title={$t('shared_links')} routeId="/(user)/shared-links" icon={mdiLink} />
{/if}
<SideBarLink
title={$t('sharing')}
routeId="/(user)/sharing"
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
bind:isSelected={isSharingSelected}
></SideBarLink>
<p class="text-xs p-6 dark:text-immich-dark-fg">{$t('library').toUpperCase()}</p>
<SideBarLink
title={$t('favorites')}
routeId="/(user)/favorites"
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
bind:isSelected={isFavoritesSelected}
></SideBarLink>
<SideBarLink
title={$t('albums')}
routeId="/(user)/albums"
icon={mdiImageAlbum}
flippedLogo
bind:dropdownOpen={$recentAlbumsDropdown}
>
{#snippet dropDownContent()}
<span in:fly={{ y: -20 }} class="hidden md:block">
<RecentAlbums />
</span>
{/snippet}
</SideBarLink>
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
{/if}
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
<SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
{/if}
<SideBarLink
title={$t('utilities')}
routeId="/(user)/utilities"
bind:isSelected={isUtilitiesSelected}
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
></SideBarLink>
<SideBarLink
title={$t('archive')}
routeId="/(user)/archive"
bind:isSelected={isArchiveSelected}
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
></SideBarLink>
{#if $featureFlags.trash}
<SideBarLink
title={$t('trash')}
routeId="/(user)/trash"
bind:isSelected={isTrashSelected}
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
></SideBarLink>
{#if $featureFlags.search}
<SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
{/if}
{#if $featureFlags.map}
<SideBarLink
title={$t('map')}
routeId="/(user)/map"
bind:isSelected={isMapSelected}
icon={isMapSelected ? mdiMap : mdiMapOutline}
/>
{/if}
{#if $preferences.people.enabled && $preferences.people.sidebarWeb}
<SideBarLink
title={$t('people')}
routeId="/(user)/people"
bind:isSelected={isPeopleSelected}
icon={isPeopleSelected ? mdiAccount : mdiAccountOutline}
/>
{/if}
{#if $preferences.sharedLinks.enabled && $preferences.sharedLinks.sidebarWeb}
<SideBarLink title={$t('shared_links')} routeId="/(user)/shared-links" icon={mdiLink} />
{/if}
<SideBarLink
title={$t('sharing')}
routeId="/(user)/sharing"
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
bind:isSelected={isSharingSelected}
></SideBarLink>
<p class="text-xs p-6 dark:text-immich-dark-fg">{$t('library').toUpperCase()}</p>
<SideBarLink
title={$t('favorites')}
routeId="/(user)/favorites"
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
bind:isSelected={isFavoritesSelected}
></SideBarLink>
<SideBarLink
title={$t('albums')}
routeId="/(user)/albums"
icon={mdiImageAlbum}
flippedLogo
bind:dropdownOpen={$recentAlbumsDropdown}
>
{#snippet dropDownContent()}
<span in:fly={{ y: -20 }} class="hidden md:block">
<RecentAlbums />
</span>
{/snippet}
</SideBarLink>
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
{/if}
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
<SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
{/if}
<SideBarLink
title={$t('utilities')}
routeId="/(user)/utilities"
bind:isSelected={isUtilitiesSelected}
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
></SideBarLink>
<SideBarLink
title={$t('archive')}
routeId="/(user)/archive"
bind:isSelected={isArchiveSelected}
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
></SideBarLink>
{#if $featureFlags.trash}
<SideBarLink
title={$t('trash')}
routeId="/(user)/trash"
bind:isSelected={isTrashSelected}
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
></SideBarLink>
{/if}
</nav>
{/if}
<BottomInfo />
</SideBarSection>

View File

@@ -1,13 +1,11 @@
<script lang="ts">
import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
import CircleIconButton, { type Padding } from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { Theme } from '$lib/constants';
import { colorTheme, handleToggleTheme } from '$lib/stores/preferences.store';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import { t } from 'svelte-i18n';
let icon = $derived($colorTheme.value === Theme.LIGHT ? moonPath : sunPath);
let viewBox = $derived($colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox);
let isDark = $derived($colorTheme.value === Theme.DARK);
let icon = $derived(themeManager.isDark ? sunPath : moonPath);
let viewBox = $derived(themeManager.isDark ? sunViewBox : moonViewBox);
interface Props {
padding?: Padding;
@@ -16,14 +14,14 @@
let { padding = '3' }: Props = $props();
</script>
{#if !$colorTheme.system}
{#if !themeManager.theme.system}
<CircleIconButton
title={$t('toggle_theme')}
{icon}
{viewBox}
role="switch"
aria-checked={isDark ? 'true' : 'false'}
onclick={handleToggleTheme}
aria-checked={themeManager.isDark ? 'true' : 'false'}
onclick={() => themeManager.toggleTheme()}
{padding}
/>
{/if}

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { defaultLang, fallbackLocale, langs, locales } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import {
alwaysLoadOriginalFile,
colorTheme,
lang,
locale,
loopVideo,
@@ -17,7 +18,6 @@
import { onMount } from 'svelte';
import { locale as i18nLocale, t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import { invalidateAll } from '$app/navigation';
let time = $state(new Date());
@@ -40,10 +40,6 @@
}));
};
const handleToggleColorTheme = () => {
$colorTheme.system = !$colorTheme.system;
};
const handleToggleLocaleBrowser = () => {
$locale = $locale ? undefined : fallbackLocale.code;
};
@@ -101,8 +97,8 @@
<SettingSwitch
title={$t('theme_selection')}
subtitle={$t('theme_selection_description')}
bind:checked={$colorTheme.system}
onToggle={handleToggleColorTheme}
checked={themeManager.theme.system}
onToggle={(isChecked) => themeManager.setSystem(isChecked)}
/>
</div>

View File

@@ -0,0 +1,113 @@
import { user } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
import {
createActivity,
deleteActivity,
getActivities,
getActivityStatistics,
ReactionLevel,
ReactionType,
type ActivityCreateDto,
type ActivityResponseDto,
} from '@immich/sdk';
import { get } from 'svelte/store';
class ActivityManager {
#albumId = $state<string | undefined>();
#assetId = $state<string | undefined>();
#activities = $state<ActivityResponseDto[]>([]);
#commentCount = $state(0);
#isLiked = $state<ActivityResponseDto | null>(null);
get activities() {
return this.#activities;
}
get commentCount() {
return this.#commentCount;
}
get isLiked() {
return this.#isLiked;
}
init(albumId: string, assetId?: string) {
this.#albumId = albumId;
this.#assetId = assetId;
}
async addActivity(dto: ActivityCreateDto) {
if (this.#albumId === undefined) {
return;
}
const activity = await createActivity({ activityCreateDto: dto });
this.#activities = [...this.#activities, activity];
if (activity.type === ReactionType.Comment) {
this.#commentCount++;
}
handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId));
return activity;
}
async deleteActivity(activity: ActivityResponseDto, index?: number) {
if (!this.#albumId) {
return;
}
if (activity.type === ReactionType.Comment) {
this.#commentCount--;
}
this.#activities = index
? this.#activities.splice(index, 1)
: this.#activities.filter(({ id }) => id !== activity.id);
await deleteActivity({ id: activity.id });
handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId));
}
async toggleLike() {
if (!this.#albumId) {
return;
}
if (this.#isLiked) {
await this.deleteActivity(this.#isLiked);
this.#isLiked = null;
} else {
this.#isLiked = (await this.addActivity({
albumId: this.#albumId,
assetId: this.#assetId,
type: ReactionType.Like,
}))!;
}
}
async refreshActivities(albumId: string, assetId?: string) {
this.#activities = await getActivities({ albumId, assetId });
const [liked] = await getActivities({
albumId,
assetId,
userId: get(user).id,
$type: ReactionType.Like,
level: assetId ? undefined : ReactionLevel.Album,
});
this.#isLiked = liked ?? null;
const { comments } = await getActivityStatistics({ albumId, assetId });
this.#commentCount = comments;
}
reset() {
this.#albumId = undefined;
this.#assetId = undefined;
this.#activities = [];
this.#commentCount = 0;
}
}
export const activityManager = new ActivityManager();

View File

@@ -1,3 +1,4 @@
import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
import type { LoginResponseDto } from '@immich/sdk';
type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void;
@@ -56,4 +57,5 @@ export const eventManager = new EventManager<{
'auth.login': [LoginResponseDto];
'auth.logout': [];
'language.change': [{ name: string; code: string; rtl?: boolean }];
'theme.change': [ThemeSetting];
}>();

View File

@@ -0,0 +1,78 @@
import { browser } from '$app/environment';
import { Theme } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { PersistedLocalStorage } from '$lib/utils/persisted';
export interface ThemeSetting {
value: Theme;
system: boolean;
}
const getDefaultTheme = () => {
if (!browser) {
return Theme.DARK;
}
return globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT;
};
class ThemeManager {
#theme = new PersistedLocalStorage<ThemeSetting>(
'color-theme',
{ value: getDefaultTheme(), system: false },
{
valid: (value): value is ThemeSetting => {
return Object.values(Theme).includes((value as ThemeSetting)?.value);
},
},
);
get theme() {
return this.#theme.current;
}
value = $derived(this.theme.value);
isDark = $derived(this.value === Theme.DARK);
constructor() {
eventManager.on('app.init', () => this.#onAppInit());
}
setSystem(system: boolean) {
this.#update(system ? 'system' : getDefaultTheme());
}
setTheme(theme: Theme) {
this.#update(theme);
}
toggleTheme() {
this.#update(this.value === Theme.DARK ? Theme.LIGHT : Theme.DARK);
}
#onAppInit() {
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.theme.system) {
this.#update('system');
}
});
}
#update(value: Theme | 'system') {
const theme: ThemeSetting =
value === 'system' ? { system: true, value: getDefaultTheme() } : { system: false, value };
if (theme.value === Theme.LIGHT) {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
this.#theme.current = theme;
eventManager.emit('theme.change', theme);
}
}
export const themeManager = new ThemeManager();

View File

@@ -1,11 +0,0 @@
import { writable } from 'svelte/store';
export const numberOfComments = writable<number>(0);
export const setNumberOfComments = (number: number) => {
numberOfComments.set(number);
};
export const updateNumberOfComments = (addOrRemove: 1 | -1) => {
numberOfComments.update((n) => n + addOrRemove);
};

View File

@@ -15,7 +15,6 @@ export class AssetInteraction {
return this.assetSelectionCandidates.some((asset) => asset.id === assetId);
}
assetSelectionStart = $state<TimelineAsset | null>(null);
focussedAssetId = $state<string | null>(null);
selectionActive = $derived(this.selectedAssets.length > 0);
private user = fromStore<UserAdminResponseDto | undefined>(user);
@@ -74,8 +73,4 @@ export class AssetInteraction {
this.assetSelectionCandidates = [];
this.assetSelectionStart = null;
}
isFocussedAsset(assetId: string) {
return this.focussedAssetId === assetId;
}
}

View File

@@ -48,15 +48,15 @@ describe('AssetStore', () => {
expect(plainBuckets).toEqual(
expect.arrayContaining([
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 304 }),
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4515.333_333_333_333 }),
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 303 }),
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4514.333_333_333_333 }),
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
]),
);
});
it('calculates timeline height', () => {
expect(assetStore.timelineHeight).toBe(5105.333_333_333_333);
expect(assetStore.timelineHeight).toBe(5103.333_333_333_333);
});
});

View File

@@ -34,9 +34,7 @@ export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
timelineAlbumId?: string;
deferInit?: boolean;
};
export type AssetStoreLayoutOptions = {
rowHeight: number;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function updateObject(target: any, source: any): boolean {
if (!target) {
@@ -131,7 +129,6 @@ export class AssetDateGroup {
readonly date: DateTime;
readonly dayOfMonth: number;
intersetingAssets: IntersectingAsset[] = $state([]);
dodo: IntersectingAsset[] = $state([]);
height = $state(0);
width = $state(0);
@@ -142,6 +139,7 @@ export class AssetDateGroup {
left: number = $state(0);
row = $state(0);
col = $state(0);
deferredLayout = false;
constructor(bucket: AssetBucket, index: number, date: DateTime, dayOfMonth: number) {
this.index = index;
@@ -216,6 +214,10 @@ export class AssetDateGroup {
}
layout(options: CommonLayoutOptions) {
if (!this.bucket.intersecting) {
this.deferredLayout = true;
return;
}
const assets = this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!);
const geometry = getJustifiedLayoutFromAssets(assets, options);
this.width = geometry.containerWidth;
@@ -583,6 +585,11 @@ export type LiteBucket = {
bucketDateFormattted: string;
};
type AssetStoreLayoutOptions = {
rowHeight?: number;
headerHeight?: number;
gap?: number;
};
export class AssetStore {
// --- public ----
isInitialized = $state(false);
@@ -632,7 +639,7 @@ export class AssetStore {
#unsubscribers: Unsubscriber[] = [];
#rowHeight = $state(235);
#headerHeight = $state(49);
#headerHeight = $state(48);
#gap = $state(12);
#options: AssetStoreOptions = AssetStore.#INIT_OPTIONS;
@@ -644,36 +651,46 @@ export class AssetStore {
constructor() {}
set headerHeight(value) {
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: AssetStoreLayoutOptions) {
let changed = false;
changed ||= this.#setHeaderHeight(headerHeight);
changed ||= this.#setGap(gap);
changed ||= this.#setRowHeight(rowHeight);
if (changed) {
this.refreshLayout();
}
}
#setHeaderHeight(value: number) {
if (this.#headerHeight == value) {
return;
return false;
}
this.#headerHeight = value;
this.refreshLayout();
return true;
}
get headerHeight() {
return this.#headerHeight;
}
set gap(value) {
#setGap(value: number) {
if (this.#gap == value) {
return;
return false;
}
this.#gap = value;
this.refreshLayout();
return true;
}
get gap() {
return this.#gap;
}
set rowHeight(value) {
#setRowHeight(value: number) {
if (this.#rowHeight == value) {
return;
return false;
}
this.#rowHeight = value;
this.refreshLayout();
return true;
}
get rowHeight() {
@@ -855,6 +872,15 @@ export class AssetStore {
}
bucket.intersecting = actuallyIntersecting || preIntersecting;
bucket.actuallyIntersecting = actuallyIntersecting;
if (preIntersecting || actuallyIntersecting) {
const hasDeferred = bucket.dateGroups.some((group) => group.deferredLayout);
if (hasDeferred) {
this.#updateGeometry(bucket, true);
for (const group of bucket.dateGroups) {
group.deferredLayout = false;
}
}
}
}
#processPendingChanges = throttle(() => {

View File

@@ -2,39 +2,12 @@ import { browser } from '$app/environment';
import { Theme, defaultLang } from '$lib/constants';
import { getPreferredLocale } from '$lib/utils/i18n';
import { persisted } from 'svelte-persisted-store';
import { get } from 'svelte/store';
export interface ThemeSetting {
value: Theme;
system: boolean;
}
export const handleToggleTheme = () => {
const theme = get(colorTheme);
theme.value = theme.value === Theme.DARK ? Theme.LIGHT : Theme.DARK;
colorTheme.set(theme);
};
const initTheme = (): ThemeSetting => {
if (browser && globalThis.matchMedia && !globalThis.matchMedia('(prefers-color-scheme: dark)').matches) {
return { value: Theme.LIGHT, system: false };
}
return { value: Theme.DARK, system: false };
};
const initialTheme = initTheme();
// The 'color-theme' key is also used by app.html to prevent FOUC on page load.
export const colorTheme = persisted<ThemeSetting>('color-theme', initialTheme, {
serializer: {
parse: (text: string): ThemeSetting => {
const parsedText: ThemeSetting = JSON.parse(text);
return Object.values(Theme).includes(parsedText.value) ? parsedText : initTheme();
},
stringify: (object) => JSON.stringify(object),
},
});
// Locale to use for formatting dates, numbers, etc.
export const locale = persisted<string | undefined>('locale', undefined, {
serializer: {

View File

@@ -1,4 +1,39 @@
const selectors =
'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)';
import { focusable, isTabbable, tabbable, type CheckOptions, type TabbableOptions } from 'tabbable';
export const getFocusable = (container: ParentNode) => [...container.querySelectorAll<HTMLElement>(selectors)];
type TabbableOpts = TabbableOptions & CheckOptions;
let defaultOpts: TabbableOpts = {
includeContainer: false,
};
export const setDefaultTabbleOptions = (options: TabbableOpts) => {
defaultOpts = options;
};
export const getTabbable = (container: Element, includeContainer: boolean = false) =>
tabbable(container, { ...defaultOpts, includeContainer });
export const focusNext = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => {
const focusElements = focusable(document.body, { includeContainer: true });
const current = document.activeElement as HTMLElement;
const index = focusElements.indexOf(current);
if (index === -1) {
for (const element of focusElements) {
if (selector(element)) {
element.focus();
return;
}
}
focusElements[0].focus();
return;
}
const totalElements = focusElements.length;
let i = index;
do {
i = (i + (forwardDirection ? 1 : -1) + totalElements) % totalElements;
const next = focusElements[i];
if (isTabbable(next) && selector(next)) {
next.focus();
break;
}
} while (i !== index);
};

View File

@@ -0,0 +1,81 @@
import { browser } from '$app/environment';
import { createSubscriber } from 'svelte/reactivity';
type PersistedBaseOptions<T> = {
read: (key: string) => T | undefined;
write: (key: string, value: T) => void;
};
class PersistedBase<T> {
#value: T;
#subscribe: () => void;
#update = () => {};
#write: (value: T) => void;
get current() {
this.#subscribe();
return this.#value as T;
}
set current(value: T) {
this.#write(value);
this.#update();
this.#value = value;
}
constructor(key: string, defaultValue: T, options: PersistedBaseOptions<T>) {
const value = options.read(key);
this.#value = value === undefined ? defaultValue : value;
this.#write = (value: T) => options.write(key, value);
this.#subscribe = createSubscriber((update) => {
this.#update = update;
return () => {
this.#update = () => {};
};
});
}
}
type PersistedLocalStorageOptions<T> = {
serializer?: {
stringify(value: T): string;
parse(text: string): T;
};
valid?: (value: T | unknown) => value is T;
};
export class PersistedLocalStorage<T> extends PersistedBase<T> {
constructor(key: string, defaultValue: T, options: PersistedLocalStorageOptions<T> = {}) {
const valid = options.valid || (() => true);
const serializer = options.serializer || JSON;
super(key, defaultValue, {
read: (key: string) => {
if (!browser) {
return;
}
const item = localStorage.getItem(key) ?? undefined;
if (item === undefined) {
return;
}
const parsed = serializer.parse(item);
if (!valid(parsed)) {
return;
}
return parsed;
},
write: (key: string, value: T) => {
if (browser) {
localStorage.setItem(key, serializer.stringify(value));
}
},
});
}
}

View File

@@ -35,7 +35,6 @@
} from '$lib/components/shared-components/notification/notification';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets-store.svelte';
@@ -56,18 +55,11 @@
import {
AlbumUserRole,
AssetOrder,
ReactionLevel,
ReactionType,
addAssetsToAlbum,
addUsersToAlbum,
createActivity,
deleteActivity,
deleteAlbum,
getActivities,
getActivityStatistics,
getAlbumInfo,
updateAlbumInfo,
type ActivityResponseDto,
type AlbumUserAddDto,
} from '@immich/sdk';
import {
@@ -103,8 +95,6 @@
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
let isCreatingSharedAlbum = $state(false);
let isShowActivity = $state(false);
let isLiked: ActivityResponseDto | null = $state(null);
let reactions: ActivityResponseDto[] = $state([]);
let albumOrder: AssetOrder | undefined = $state(data.album.order);
const assetInteraction = new AssetInteraction();
@@ -154,44 +144,15 @@
const handleFavorite = async () => {
try {
if (isLiked) {
const activityId = isLiked.id;
await deleteActivity({ id: activityId });
reactions = reactions.filter((reaction) => reaction.id !== activityId);
isLiked = null;
} else {
isLiked = await createActivity({
activityCreateDto: { albumId: album.id, type: ReactionType.Like },
});
reactions = [...reactions, isLiked];
}
await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.cant_change_asset_favorite'));
}
};
const getFavorite = async () => {
if ($user) {
try {
const data = await getActivities({
userId: $user.id,
albumId: album.id,
$type: ReactionType.Like,
level: ReactionLevel.Album,
});
if (data.length > 0) {
isLiked = data[0];
}
} catch (error) {
handleError(error, $t('errors.unable_to_load_liked_status'));
}
}
};
const getNumberOfComments = async () => {
const updateComments = async () => {
try {
const { comments } = await getActivityStatistics({ albumId: album.id });
setNumberOfComments(comments);
await activityManager.refreshActivities(album.id);
} catch (error) {
handleError(error, $t('errors.cant_get_number_of_comments'));
}
@@ -397,7 +358,7 @@
let albumId = $derived(album.id);
$effect(() => {
if (!album.isActivityEnabled && $numberOfComments === 0) {
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false;
}
});
@@ -411,7 +372,16 @@
void assetStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId });
}
});
onDestroy(() => assetStore.destroy());
$effect(() => {
activityManager.reset();
activityManager.init(album.id);
});
onDestroy(() => {
activityManager.reset();
assetStore.destroy();
});
// let timelineStore = new AssetStore();
// $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }));
// onDestroy(() => timelineStore.destroy());
@@ -419,7 +389,7 @@
let isOwned = $derived($user.id == album.ownerId);
let showActivityStatus = $derived(
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0),
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || activityManager.commentCount > 0),
);
let isEditor = $derived(
album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
@@ -429,8 +399,7 @@
let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
$effect(() => {
if (album.albumUsers.length > 0) {
handlePromiseError(getFavorite());
handlePromiseError(getNumberOfComments());
handlePromiseError(updateComments());
}
});
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
@@ -710,8 +679,8 @@
<div class="absolute z-[2] bottom-0 end-0 mb-6 me-6 justify-self-end">
<ActivityStatus
disabled={!album.isActivityEnabled}
{isLiked}
numberOfComments={$numberOfComments}
isLiked={activityManager.isLiked}
numberOfComments={activityManager.commentCount}
onFavorite={handleFavorite}
onOpenActivityTab={handleOpenAndCloseActivityTab}
/>
@@ -732,11 +701,6 @@
disabled={!album.isActivityEnabled}
albumOwnerId={album.ownerId}
albumId={album.id}
{isLiked}
bind:reactions
onAddComment={() => updateNumberOfComments(1)}
onDeleteComment={() => updateNumberOfComments(-1)}
onDeleteLike={() => (isLiked = null)}
onClose={handleOpenAndCloseActivityTab}
/>
</div>

View File

@@ -10,16 +10,14 @@
import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
import VersionAnnouncementBox from '$lib/components/shared-components/version-announcement-box.svelte';
import { Theme } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { colorTheme, handleToggleTheme, type ThemeSetting } from '$lib/stores/preferences.store';
import { serverConfig } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket';
import { copyToClipboard } from '$lib/utils';
import { isAssetViewerRoute } from '$lib/utils/navigation';
import { setTranslations } from '@immich/ui';
import { onDestroy, onMount, type Snippet } from 'svelte';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
import '../app.css';
@@ -40,24 +38,6 @@
let showNavigationLoadingBar = $state(false);
const changeTheme = (theme: ThemeSetting) => {
if (theme.system) {
theme.value = globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT;
}
if (theme.value === Theme.LIGHT) {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
};
const handleChangeTheme = () => {
if ($colorTheme.system) {
handleToggleTheme();
}
};
const getMyImmichLink = () => {
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
};
@@ -66,11 +46,6 @@
const element = document.querySelector('#stencil');
element?.remove();
// if the browser theme changes, changes the Immich theme too
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handleChangeTheme);
});
onDestroy(() => {
document.removeEventListener('change', handleChangeTheme);
});
eventManager.emit('app.init');
@@ -85,9 +60,6 @@
afterNavigate(() => {
showNavigationLoadingBar = false;
});
run(() => {
changeTheme($colorTheme);
});
run(() => {
if ($user) {
openWebsocketConnection();

View File

@@ -1,358 +0,0 @@
<script lang="ts">
import empty4Url from '$lib/assets/empty-4.svg';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import { copyToClipboard } from '$lib/utils';
import { downloadBlob } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { fixAuditFiles, getAuditFiles, getFileChecksums, type FileReportItemDto } from '@immich/sdk';
import { Button, HStack, Text } from '@immich/ui';
import { mdiCheckAll, mdiContentCopy, mdiDownload, mdiRefresh, mdiWrench } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
interface UntrackedFile {
filename: string;
checksum: string | null;
}
interface Match {
orphan: FileReportItemDto;
extra: UntrackedFile;
}
const normalize = (filenames: string[]) => filenames.map((filename) => ({ filename, checksum: null }));
let checking = $state(false);
let repairing = $state(false);
let orphans: FileReportItemDto[] = $state(data.orphans);
let extras: UntrackedFile[] = $state(normalize(data.extras));
let matches: Match[] = $state([]);
const handleDownload = () => {
if (extras.length > 0) {
const blob = new Blob([extras.map(({ filename }) => filename).join('\n')], { type: 'text/plain' });
const downloadKey = 'untracked.txt';
downloadManager.add(downloadKey, blob.size);
downloadManager.update(downloadKey, blob.size);
downloadBlob(blob, downloadKey);
setTimeout(() => downloadManager.clear(downloadKey), 5000);
}
if (orphans.length > 0) {
const blob = new Blob([JSON.stringify(orphans, null, 4)], { type: 'application/json' });
const downloadKey = 'orphans.json';
downloadManager.add(downloadKey, blob.size);
downloadManager.update(downloadKey, blob.size);
downloadBlob(blob, downloadKey);
setTimeout(() => downloadManager.clear(downloadKey), 5000);
}
};
const handleRepair = async () => {
if (matches.length === 0) {
return;
}
repairing = true;
try {
await fixAuditFiles({
fileReportFixDto: {
items: matches.map(({ orphan, extra }) => ({
entityId: orphan.entityId,
entityType: orphan.entityType,
pathType: orphan.pathType,
pathValue: extra.filename,
})),
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('admin.repaired_items', { values: { count: matches.length } }),
});
matches = [];
} catch (error) {
handleError(error, $t('errors.unable_to_repair_items'));
} finally {
repairing = false;
}
};
const handleSplit = (match: Match) => {
matches = matches.filter((_match) => _match !== match);
orphans = [match.orphan, ...orphans];
extras = [match.extra, ...extras];
};
const handleRefresh = async () => {
matches = [];
orphans = [];
extras = [];
try {
const report = await getAuditFiles();
orphans = report.orphans;
extras = normalize(report.extras);
notificationController.show({ message: $t('refreshed'), type: NotificationType.Info });
} catch (error) {
handleError(error, $t('errors.unable_to_load_items'));
}
};
const handleCheckOne = async (filename: string) => {
try {
const matched = await loadAndMatch([filename]);
if (matched) {
notificationController.show({
message: $t('admin.repair_matched_items', { values: { count: 1 } }),
type: NotificationType.Info,
});
}
} catch (error) {
handleError(error, $t('errors.repair_unable_to_check_items', { values: { count: 'one' } }));
}
};
const handleCheckAll = async () => {
checking = true;
let count = 0;
try {
const chunkSize = 10;
const filenames = extras.filter(({ checksum }) => !checksum).map(({ filename }) => filename);
for (let index = 0; index < filenames.length; index += chunkSize) {
count += await loadAndMatch(filenames.slice(index, index + chunkSize));
}
} catch (error) {
handleError(error, $t('errors.repair_unable_to_check_items', { values: { count: 'other' } }));
} finally {
checking = false;
}
notificationController.show({
message: $t('admin.repair_matched_items', { values: { count } }),
type: NotificationType.Info,
});
};
const loadAndMatch = async (filenames: string[]) => {
const items = await getFileChecksums({
fileChecksumDto: { filenames },
});
let count = 0;
for (const { checksum, filename } of items) {
const extra = extras.find((extra) => extra.filename === filename);
if (extra) {
extra.checksum = checksum;
extras = [...extras];
}
const orphan = orphans.find((orphan) => orphan.checksum === checksum);
if (orphan) {
count++;
matches = [...matches, { orphan, extra: { filename, checksum } }];
orphans = orphans.filter((_orphan) => _orphan !== orphan);
extras = extras.filter((extra) => extra.filename !== filename);
}
}
return count;
};
</script>
<UserPageLayout title={data.meta.title} admin>
{#snippet buttons()}
<HStack gap={0}>
<Button
leadingIcon={mdiWrench}
onclick={() => handleRepair()}
disabled={matches.length === 0 || repairing}
size="small"
variant="ghost"
color="secondary"
>
<Text class="hidden md:block">{$t('admin.repair_all')}</Text>
</Button>
<Button
leadingIcon={mdiCheckAll}
onclick={() => handleCheckAll()}
disabled={extras.length === 0 || checking}
size="small"
variant="ghost"
color="secondary"
>
<Text class="hidden md:block">{$t('admin.check_all')}</Text>
</Button>
<Button
leadingIcon={mdiDownload}
onclick={() => handleDownload()}
disabled={extras.length + orphans.length === 0}
size="small"
variant="ghost"
color="secondary"
>
<Text class="hidden md:block">{$t('export')}</Text>
</Button>
<Button leadingIcon={mdiRefresh} onclick={() => handleRefresh()} size="small" variant="ghost" color="secondary">
<Text class="hidden md:block">{$t('refresh')}</Text>
</Button>
</HStack>
{/snippet}
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-[850px]">
{#if matches.length + extras.length + orphans.length === 0}
<div class="w-full">
<EmptyPlaceholder fullWidth text={$t('repair_no_results_message')} src={empty4Url} />
</div>
{:else}
<div class="gap-2">
<table class="table-fixed mt-5 w-full text-start">
<thead
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center p-2 md:p-5">
<th class="w-full text-sm place-items-center font-medium flex justify-between" colspan="2">
<div class="px-3">
<p>
{$t('matches').toUpperCase()}
{matches.length > 0 ? `(${matches.length.toLocaleString($locale)})` : ''}
</p>
<p class="text-gray-600 dark:text-gray-300 mt-1">{$t('admin.these_files_matched_by_checksum')}</p>
</div>
</th>
</tr>
</thead>
<tbody
class="w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg max-h-[500px] block overflow-x-hidden"
>
{#each matches as match (match.extra.filename)}
<tr
class="w-full h-[75px] place-items-center border-[3px] border-transparent p-2 odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 flex justify-between"
tabindex="0"
onclick={() => handleSplit(match)}
>
<td class="text-sm text-ellipsis flex flex-col gap-1 font-mono">
<span>{match.orphan.pathValue} =></span>
<span>{match.extra.filename}</span>
</td>
<td class="text-sm text-ellipsis d-flex font-mono">
<span>({match.orphan.entityType}/{match.orphan.pathType})</span>
</td>
</tr>
{/each}
</tbody>
</table>
<table class="table-fixed mt-5 w-full text-start">
<thead
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center p-1 md:p-5">
<th class="w-full text-sm font-medium justify-between place-items-center flex" colspan="2">
<div class="px-3">
<p>
{$t('admin.offline_paths').toUpperCase()}
{orphans.length > 0 ? `(${orphans.length.toLocaleString($locale)})` : ''}
</p>
<p class="text-gray-600 dark:text-gray-300 mt-1">
{$t('admin.offline_paths_description')}
</p>
</div>
</th>
</tr>
</thead>
<tbody
class="w-full rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg overflow-y-auto max-h-[500px] block overflow-x-hidden"
>
{#each orphans as orphan, index (index)}
<tr
class="w-full h-[50px] place-items-center border-[3px] border-transparent odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 flex justify-between"
tabindex="0"
title={orphan.pathValue}
>
<td onclick={() => copyToClipboard(orphan.pathValue)}>
<CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" onclick={() => {}} />
</td>
<td class="truncate text-sm font-mono text-start" title={orphan.pathValue}>
{orphan.pathValue}
</td>
<td class="text-sm font-mono">
<span>({orphan.entityType})</span>
</td>
</tr>
{/each}
</tbody>
</table>
<table class="table-fixed mt-5 w-full text-start max-h-[300px]">
<thead
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center p-2 md:p-5">
<th class="w-full text-sm font-medium place-items-center flex justify-between" colspan="2">
<div class="px-3">
<p>
{$t('admin.untracked_files').toUpperCase()}
{extras.length > 0 ? `(${extras.length.toLocaleString($locale)})` : ''}
</p>
<p class="text-gray-600 dark:text-gray-300 mt-1">
{$t('admin.untracked_files_description')}
</p>
</div>
</th>
</tr>
</thead>
<tbody
class="w-full rounded-md border-2 dark:border-immich-dark-gray dark:text-immich-dark-fg overflow-y-auto max-h-[500px] block overflow-x-hidden"
>
{#each extras as extra (extra.filename)}
<tr
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-1 odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 justify-between"
tabindex="0"
onclick={() => handleCheckOne(extra.filename)}
title={extra.filename}
>
<td onclick={() => copyToClipboard(extra.filename)}>
<CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" onclick={() => {}} />
</td>
<td class="w-full text-md text-ellipsis flex justify-between pe-5">
<span class="text-ellipsis grow truncate font-mono text-sm pe-5" title={extra.filename}
>{extra.filename}</span
>
<span class="text-sm font-mono dark:text-immich-dark-primary text-immich-primary pes-5">
{#if extra.checksum}
[sha1:{extra.checksum}]
{/if}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</section>
</UserPageLayout>

View File

@@ -1,18 +0,0 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAuditFiles } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate({ admin: true });
const { orphans, extras } = await getAuditFiles();
const $t = await getFormatter();
return {
orphans,
extras,
meta: {
title: $t('repair'),
},
};
}) satisfies PageLoad;

View File

@@ -4,3 +4,15 @@ import { init } from 'svelte-i18n';
beforeAll(async () => {
await init({ fallbackLocale: 'dev' });
});
Object.defineProperty(globalThis, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});