Merge remote-tracking branch 'origin/main' into lighter_buckets_web
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
113
web/src/lib/managers/activity-manager.svelte.ts
Normal file
113
web/src/lib/managers/activity-manager.svelte.ts
Normal 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();
|
||||
@@ -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];
|
||||
}>();
|
||||
|
||||
78
web/src/lib/managers/theme-manager.svelte.ts
Normal file
78
web/src/lib/managers/theme-manager.svelte.ts
Normal 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();
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
81
web/src/lib/utils/persisted.ts
Normal file
81
web/src/lib/utils/persisted.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
})),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user