From ff5b0d532617c13f45cb8a241d88cd49f37876e9 Mon Sep 17 00:00:00 2001 From: Alex <alex.tran1502@gmail.com> Date: Thu, 14 Nov 2024 08:43:25 -0600 Subject: [PATCH] chore(web): migration svelte 5 syntax (#13883) --- web/package-lock.json | 6 +- web/package.json | 6 +- .../actions/__test__/focus-trap-test.svelte | 10 +- web/src/lib/actions/autogrow.ts | 2 +- .../lib/actions/context-menu-navigation.ts | 8 +- web/src/lib/actions/list-navigation.ts | 9 +- .../admin-page/delete-confirm-dialogue.svelte | 35 ++- .../admin-page/jobs/job-tile-button.svelte | 18 +- .../admin-page/jobs/job-tile-status.svelte | 13 +- .../admin-page/jobs/job-tile.svelte | 64 ++-- .../admin-page/jobs/jobs-panel.svelte | 12 +- .../jobs/storage-migration-description.svelte | 15 +- .../admin-page/restore-dialogue.svelte | 22 +- .../server-stats/server-stats-panel.svelte | 22 +- .../admin-page/server-stats/stats-card.svelte | 16 +- .../admin-page/settings/admin-settings.svelte | 24 +- .../settings/auth/auth-settings.svelte | 79 ++--- .../backup-settings/backup-settings.svelte | 57 ++-- .../settings/ffmpeg/ffmpeg-settings.svelte | 71 +++-- .../settings/image/image-settings.svelte | 41 ++- .../settings/job-settings/job-settings.svelte | 31 +- .../library-settings/library-settings.svelte | 65 ++-- .../logging-settings/logging-settings.svelte | 22 +- .../machine-learning-settings.svelte | 53 ++-- .../settings/map-settings/map-settings.svelte | 55 ++-- .../metadata-settings.svelte | 22 +- .../new-version-check-settings.svelte | 22 +- .../notification-settings.svelte | 41 +-- .../settings/server/server-settings.svelte | 31 +- .../storage-template-settings.svelte | 168 ++++++----- .../supported-datetime-panel.svelte | 6 +- .../settings/theme/theme-settings.svelte | 24 +- .../trash-settings/trash-settings.svelte | 29 +- .../user-settings/user-settings.svelte | 25 +- .../album-page/__tests__/album-card.spec.ts | 15 +- .../album-page/album-card-group.svelte | 37 ++- .../components/album-page/album-card.svelte | 25 +- .../components/album-page/album-cover.svelte | 17 +- .../album-page/album-description.svelte | 10 +- .../album-page/album-options.svelte | 38 ++- .../album-page/album-summary.svelte | 9 +- .../components/album-page/album-title.svelte | 20 +- .../components/album-page/album-viewer.svelte | 22 +- .../album-page/albums-controls.svelte | 102 +++---- .../components/album-page/albums-list.svelte | 78 +++-- .../album-page/albums-table-header.svelte | 28 +- .../album-page/albums-table-row.svelte | 18 +- .../components/album-page/albums-table.svelte | 13 +- .../album-page/share-info-modal.svelte | 20 +- .../album-page/user-selection-modal.svelte | 28 +- .../actions/add-to-album-action.svelte | 12 +- .../actions/archive-action.svelte | 8 +- .../asset-viewer/actions/close-action.svelte | 8 +- .../asset-viewer/actions/delete-action.svelte | 12 +- .../actions/download-action.svelte | 10 +- .../actions/favorite-action.svelte | 13 +- .../actions/motion-photo-action.svelte | 10 +- .../actions/next-asset-action.svelte | 6 +- .../actions/previous-asset-action.svelte | 6 +- .../actions/restore-action.svelte | 8 +- .../actions/set-album-cover-action.svelte | 8 +- .../actions/set-profile-picture-action.svelte | 8 +- .../asset-viewer/actions/share-action.svelte | 15 +- .../actions/show-detail-action.svelte | 8 +- .../actions/unstack-action.svelte | 8 +- .../asset-viewer/activity-status.svelte | 18 +- .../asset-viewer/activity-viewer.svelte | 97 +++--- .../album-list-item-details.svelte | 6 +- .../asset-viewer/album-list-item.svelte | 18 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 61 ++-- .../asset-viewer/asset-viewer.svelte | 152 +++++----- .../detail-panel-description.svelte | 14 +- .../asset-viewer/detail-panel-location.svelte | 14 +- .../detail-panel-star-rating.svelte | 10 +- .../asset-viewer/detail-panel-tags.svelte | 16 +- .../asset-viewer/detail-panel.svelte | 94 +++--- .../asset-viewer/download-panel.svelte | 2 +- .../editor/crop-tool/crop-area.svelte | 26 +- .../editor/crop-tool/crop-preset.svelte | 36 ++- .../editor/crop-tool/crop-tool.svelte | 17 +- .../asset-viewer/editor/editor-panel.svelte | 22 +- .../asset-viewer/navigation-area.svelte | 15 +- .../asset-viewer/panorama-viewer.svelte | 15 +- .../photo-sphere-viewer-adapter.svelte | 20 +- .../asset-viewer/photo-viewer.svelte | 80 +++-- .../asset-viewer/slideshow-bar.svelte | 56 ++-- .../asset-viewer/video-native-viewer.svelte | 75 +++-- .../asset-viewer/video-wrapper-viewer.svelte | 34 ++- .../lib/components/assets/broken-asset.svelte | 13 +- .../assets/thumbnail/image-thumbnail.svelte | 80 +++-- .../assets/thumbnail/thumbnail.svelte | 131 ++++---- .../assets/thumbnail/video-thumbnail.svelte | 70 +++-- web/src/lib/components/elements/badge.svelte | 15 +- .../components/elements/buttons/button.svelte | 103 +++---- .../buttons/circle-icon-button.svelte | 114 +++---- .../elements/buttons/link-button.svelte | 29 +- .../elements/buttons/skip-link.svelte | 22 +- .../lib/components/elements/checkbox.svelte | 30 +- .../lib/components/elements/date-input.svelte | 30 +- .../lib/components/elements/dropdown.svelte | 44 ++- .../lib/components/elements/group-tab.svelte | 14 +- web/src/lib/components/elements/icon.svelte | 51 +++- .../components/elements/radio-button.svelte | 14 +- .../lib/components/elements/search-bar.svelte | 33 +- web/src/lib/components/elements/slider.svelte | 34 ++- web/src/lib/components/error.svelte | 8 +- .../faces-page/assign-face-side-panel.svelte | 42 +-- .../faces-page/edit-name-input.svelte | 36 ++- .../faces-page/face-thumbnail.svelte | 28 +- .../manage-people-visibility.svelte | 98 +++--- .../faces-page/merge-face-selector.svelte | 32 +- .../faces-page/merge-suggestion-modal.svelte | 44 ++- .../components/faces-page/people-card.svelte | 24 +- .../faces-page/people-infinite-scroll.svelte | 27 +- .../components/faces-page/people-list.svelte | 26 +- .../faces-page/people-search.svelte | 52 ++-- .../faces-page/person-side-panel.svelte | 48 +-- .../faces-page/set-birth-date-modal.svelte | 24 +- .../faces-page/unmerge-face-selector.svelte | 48 +-- .../forms/admin-registration-form.svelte | 23 +- .../lib/components/forms/api-key-form.svelte | 36 ++- .../components/forms/api-key-secret.svelte | 16 +- .../forms/change-password-form.svelte | 25 +- .../components/forms/create-user-form.svelte | 56 ++-- .../components/forms/edit-album-form.svelte | 32 +- .../components/forms/edit-user-form.svelte | 46 ++- .../library-exclusion-pattern-form.svelte | 48 ++- .../forms/library-import-path-form.svelte | 54 +++- .../forms/library-import-paths-form.svelte | 37 ++- .../forms/library-rename-form.svelte | 19 +- .../forms/library-scan-settings-form.svelte | 33 +- .../forms/library-user-picker-form.svelte | 26 +- .../lib/components/forms/login-form.svelte | 31 +- .../components/forms/tag-asset-form.svelte | 42 ++- .../i18n/__test__/format-tag-b.svelte | 18 +- .../i18n/format-bold-message.svelte | 18 +- .../lib/components/i18n/format-message.svelte | 19 +- .../layouts/user-page-layout.svelte | 58 ++-- .../map-page/map-settings-modal.svelte | 34 ++- .../memory-page/memory-viewer.svelte | 91 +++--- .../onboarding-page/onboarding-card.svelte | 12 +- .../onboarding-page/onboarding-hello.svelte | 8 +- .../onboarding-page/onboarding-privacy.svelte | 79 ++--- .../onboarding-storage-template.svelte | 87 +++--- .../onboarding-page/onboarding-theme.svelte | 12 +- .../photos-page/actions/add-to-album.svelte | 10 +- .../photos-page/actions/archive-action.svelte | 19 +- .../actions/asset-job-actions.svelte | 14 +- .../actions/change-date-action.svelte | 8 +- .../actions/change-location-action.svelte | 8 +- .../actions/create-shared-link.svelte | 4 +- .../photos-page/actions/delete-assets.svelte | 20 +- .../actions/download-action.svelte | 12 +- .../actions/favorite-action.svelte | 19 +- .../actions/link-live-photo-action.svelte | 22 +- .../actions/remove-from-album.svelte | 12 +- .../actions/remove-from-shared-link.svelte | 8 +- .../photos-page/actions/restore-assets.svelte | 10 +- .../actions/select-all-assets.svelte | 12 +- .../photos-page/actions/stack-action.svelte | 10 +- .../photos-page/actions/tag-action.svelte | 14 +- .../components/photos-page/asset-grid.svelte | 281 ++++++++++-------- .../asset-select-control-bar.svelte | 28 +- .../photos-page/delete-asset-dialog.svelte | 22 +- .../photos-page/measure-date-group.svelte | 12 +- .../components/photos-page/memory-lane.svelte | 28 +- .../components/photos-page/skeleton.svelte | 8 +- .../individual-shared-viewer.svelte | 32 +- .../album-selection-modal.svelte | 38 +-- .../autogrow-textarea.svelte | 28 +- .../shared-components/change-date.spec.ts | 10 + .../shared-components/change-date.svelte | 64 ++-- .../shared-components/change-location.svelte | 192 ++++++------ .../shared-components/combobox.svelte | 82 ++--- .../context-menu/button-context-menu.svelte | 87 +++--- .../context-menu/context-menu.svelte | 49 ++- .../context-menu/menu-option.svelte | 35 ++- .../right-click-context-menu.svelte | 55 ++-- .../shared-components/control-app-bar.svelte | 42 ++- .../coordinates-input.svelte | 10 +- .../create-shared-link-modal.svelte | 68 +++-- .../dialog/confirm-dialog.svelte | 53 ++-- .../drag-and-drop-upload-overlay.svelte | 45 ++- .../empty-placeholder.svelte | 18 +- .../full-screen-modal.svelte | 68 +++-- .../fullscreen-container.svelte | 15 +- .../gallery-viewer/gallery-viewer.svelte | 70 +++-- .../help-and-feedback-modal.svelte | 7 +- .../immich-logo-small-link.svelte | 6 +- .../shared-components/immich-logo.svelte | 8 +- .../shared-components/loading-spinner.svelte | 6 +- .../shared-components/map/map.svelte | 237 ++++++++------- .../shared-components/modal-header.svelte | 34 ++- .../navigation-bar/account-info-panel.svelte | 18 +- .../navigation-bar/avatar-selector.svelte | 12 +- .../navigation-bar/navigation-bar.svelte | 37 ++- .../navigation-loading-bar.svelte | 2 +- .../notification-component-test.svelte | 6 +- .../notification/notification-card.svelte | 25 +- .../number-range-input.svelte | 49 ++- .../shared-components/password-field.svelte | 14 +- .../shared-components/portal/portal.svelte | 19 +- .../profile-image-cropper.svelte | 25 +- .../progress-bar/progress-bar.svelte | 60 ++-- .../purchase-activation-success.svelte | 8 +- .../purchasing/purchase-content.svelte | 15 +- .../purchasing/purchase-modal.svelte | 8 +- .../scrubber/scrubber.svelte | 79 +++-- .../search-bar/search-bar.svelte | 89 +++--- .../search-bar/search-camera-section.svelte | 25 +- .../search-bar/search-date-section.svelte | 8 +- .../search-bar/search-display-section.svelte | 8 +- .../search-bar/search-filter-modal.svelte | 35 ++- .../search-bar/search-history-box.svelte | 57 ++-- .../search-bar/search-location-section.svelte | 31 +- .../search-bar/search-media-section.svelte | 6 +- .../search-bar/search-people-section.svelte | 16 +- .../search-bar/search-text-section.svelte | 8 +- .../server-about-modal.svelte | 9 +- .../settings/setting-accordion-state.svelte | 34 ++- .../settings/setting-accordion.svelte | 50 +++- .../settings/setting-buttons-row.svelte | 18 +- .../settings/setting-checkboxes.svelte | 28 +- .../settings/setting-combobox.svelte | 31 +- .../settings/setting-dropdown.svelte | 27 +- .../settings/setting-input-field.spec.ts | 4 +- .../settings/setting-input-field.svelte | 77 +++-- .../settings/setting-select.svelte | 34 ++- .../settings/setting-switch.svelte | 32 +- .../settings/setting-textarea.svelte | 36 ++- .../shared-components/show-shortcuts.svelte | 42 +-- .../side-bar/more-information-albums.svelte | 6 +- .../side-bar/more-information-assets.svelte | 6 +- .../side-bar/purchase-info.svelte | 54 ++-- .../side-bar/server-status.svelte | 19 +- .../side-bar/side-bar-link.svelte | 45 ++- .../side-bar/side-bar-section.svelte | 9 +- .../side-bar/side-bar.svelte | 40 +-- .../side-bar/storage-space.svelte | 18 +- .../side-bar/supporter-badge.svelte | 8 +- .../shared-components/single-grid-row.svelte | 24 +- .../shared-components/star-rating.svelte | 38 ++- .../shared-components/theme-button.svelte | 17 +- .../shared-components/tree/breadcrumbs.svelte | 16 +- .../tree/tree-item-thumbnails.svelte | 12 +- .../shared-components/tree/tree-items.svelte | 16 +- .../shared-components/tree/tree.svelte | 42 +-- .../upload-asset-preview.svelte | 12 +- .../shared-components/upload-panel.svelte | 30 +- .../shared-components/user-avatar.svelte | 59 ++-- .../version-announcement-box.svelte | 43 +-- .../actions/shared-link-copy.svelte | 10 +- .../actions/shared-link-delete.svelte | 10 +- .../actions/shared-link-edit.svelte | 10 +- .../covers/asset-cover.svelte | 17 +- .../sharedlinks-page/covers/no-cover.svelte | 11 +- .../covers/share-cover.svelte | 11 +- .../sharedlinks-page/shared-link-card.svelte | 14 +- .../lib/components/slideshow-settings.svelte | 22 +- .../user-settings-page/app-settings.svelte | 42 +-- .../change-password-settings.svelte | 19 +- .../user-settings-page/device-card.svelte | 10 +- .../user-settings-page/device-list.svelte | 12 +- .../download-settings.svelte | 19 +- .../feature-settings.svelte | 24 +- .../notifications-settings.svelte | 14 +- .../user-settings-page/oauth-settings.svelte | 12 +- .../partner-selection-modal.svelte | 18 +- .../partner-settings.svelte | 14 +- .../user-api-key-list.svelte | 18 +- .../user-profile-settings.svelte | 14 +- .../user-purchase-settings.svelte | 9 +- .../user-settings-list.svelte | 8 +- .../duplicates/duplicate-asset.svelte | 20 +- .../duplicates-compare-control.svelte | 29 +- web/src/lib/constants.ts | 27 ++ web/src/routes/(user)/+layout.svelte | 14 +- web/src/routes/(user)/albums/+page.svelte | 22 +- .../[[assetId=id]]/+page.svelte | 236 +++++++-------- .../[[assetId=id]]/+page.svelte | 12 +- web/src/routes/(user)/buy/+page.svelte | 8 +- web/src/routes/(user)/explore/+page.svelte | 73 +++-- .../[[assetId=id]]/+page.svelte | 12 +- .../[[assetId=id]]/+page.svelte | 48 +-- .../[[assetId=id]]/+page.svelte | 22 +- .../[[assetId=id]]/+page.svelte | 10 +- web/src/routes/(user)/people/+page.svelte | 117 ++++---- .../[[assetId=id]]/+page.svelte | 143 +++++---- .../(user)/photos/[[assetId=id]]/+page.svelte | 17 +- web/src/routes/(user)/places/+page.svelte | 12 +- .../[[assetId=id]]/+page.svelte | 119 ++++---- .../[[assetId=id]]/+page.svelte | 32 +- web/src/routes/(user)/sharing/+page.svelte | 40 ++- .../(user)/sharing/sharedlinks/+page.svelte | 8 +- .../[[assetId=id]]/+page.svelte | 138 +++++---- .../[[assetId=id]]/+page.svelte | 40 ++- .../routes/(user)/user-settings/+page.svelte | 14 +- web/src/routes/(user)/utilities/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 80 ++--- web/src/routes/+layout.svelte | 28 +- web/src/routes/admin/jobs-status/+page.svelte | 69 +++-- .../admin/library-management/+page.svelte | 68 +++-- web/src/routes/admin/repair/+page.svelte | 83 +++--- .../routes/admin/server-status/+page.svelte | 6 +- .../routes/admin/system-settings/+page.svelte | 122 ++++---- .../routes/admin/user-management/+page.svelte | 42 +-- .../routes/auth/change-password/+page.svelte | 20 +- web/src/routes/auth/login/+page.svelte | 16 +- web/src/routes/auth/onboarding/+page.svelte | 16 +- web/src/routes/auth/register/+page.svelte | 14 +- 310 files changed, 6441 insertions(+), 4182 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 63e7c05ca4995..e9f26bee8067d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -36,7 +36,7 @@ "@faker-js/faker": "^9.0.0", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.5", - "@sveltejs/enhanced-img": "^0.3.0", + "@sveltejs/enhanced-img": "^0.3.9", "@sveltejs/kit": "^2.7.2", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@testing-library/jest-dom": "^6.4.2", @@ -53,7 +53,7 @@ "dotenv": "^16.4.5", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-svelte": "^2.43.0", + "eslint-plugin-svelte": "^2.45.1", "eslint-plugin-unicorn": "^55.0.0", "factory.ts": "^1.4.1", "globals": "^15.9.0", @@ -68,7 +68,7 @@ "tailwindcss": "^3.4.1", "tslib": "^2.6.2", "typescript": "^5.5.0", - "vite": "^5.1.4", + "vite": "^5.4.4", "vitest": "^2.0.5" } }, diff --git a/web/package.json b/web/package.json index af5e87c57e55a..c0c600f5bc809 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,7 @@ "@faker-js/faker": "^9.0.0", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.5", - "@sveltejs/enhanced-img": "^0.3.0", + "@sveltejs/enhanced-img": "^0.3.9", "@sveltejs/kit": "^2.7.2", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@testing-library/jest-dom": "^6.4.2", @@ -45,7 +45,7 @@ "dotenv": "^16.4.5", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-svelte": "^2.43.0", + "eslint-plugin-svelte": "^2.45.1", "eslint-plugin-unicorn": "^55.0.0", "factory.ts": "^1.4.1", "globals": "^15.9.0", @@ -60,7 +60,7 @@ "tailwindcss": "^3.4.1", "tslib": "^2.6.2", "typescript": "^5.5.0", - "vite": "^5.1.4", + "vite": "^5.4.4", "vitest": "^2.0.5" }, "type": "module", diff --git a/web/src/lib/actions/__test__/focus-trap-test.svelte b/web/src/lib/actions/__test__/focus-trap-test.svelte index 207c880cd9d8f..e1cb6fa4fba79 100644 --- a/web/src/lib/actions/__test__/focus-trap-test.svelte +++ b/web/src/lib/actions/__test__/focus-trap-test.svelte @@ -1,16 +1,20 @@ <script lang="ts"> import { focusTrap } from '$lib/actions/focus-trap'; - export let show: boolean; + interface Props { + show: boolean; + } + + let { show = $bindable() }: Props = $props(); </script> -<button type="button" on:click={() => (show = true)}>Open</button> +<button type="button" onclick={() => (show = true)}>Open</button> {#if show} <div use:focusTrap> <div> <span>text</span> - <button data-testid="one" type="button" on:click={() => (show = false)}>Close</button> + <button data-testid="one" type="button" onclick={() => (show = false)}>Close</button> </div> <input data-testid="two" disabled /> <input data-testid="three" /> diff --git a/web/src/lib/actions/autogrow.ts b/web/src/lib/actions/autogrow.ts index ff80454ef3e84..664039cb2a6bd 100644 --- a/web/src/lib/actions/autogrow.ts +++ b/web/src/lib/actions/autogrow.ts @@ -1,4 +1,4 @@ -export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => { +export const autoGrowHeight = (textarea?: HTMLTextAreaElement, height = 'auto') => { if (!textarea) { return; } diff --git a/web/src/lib/actions/context-menu-navigation.ts b/web/src/lib/actions/context-menu-navigation.ts index 3b45e7fe527f0..89b7b76d24f4f 100644 --- a/web/src/lib/actions/context-menu-navigation.ts +++ b/web/src/lib/actions/context-menu-navigation.ts @@ -10,7 +10,7 @@ interface Options { /** * The container element that with direct children that should be navigated. */ - container: HTMLElement; + container?: HTMLElement; /** * Indicates if the dropdown is open. */ @@ -52,7 +52,11 @@ export const contextMenuNavigation: Action<HTMLElement, Options> = (node, option await tick(); } - const children = Array.from(container?.children).filter((child) => child.tagName !== 'HR') as HTMLElement[]; + if (!container) { + return; + } + + const children = Array.from(container.children).filter((child) => child.tagName !== 'HR') as HTMLElement[]; if (children.length === 0) { return; } diff --git a/web/src/lib/actions/list-navigation.ts b/web/src/lib/actions/list-navigation.ts index 8f8ed62ed009e..cd4214f700570 100644 --- a/web/src/lib/actions/list-navigation.ts +++ b/web/src/lib/actions/list-navigation.ts @@ -6,8 +6,15 @@ import type { Action } from 'svelte/action'; * @param node Element which listens for keyboard events * @param container Element containing the list of elements */ -export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => { +export const listNavigation: Action<HTMLElement, HTMLElement | undefined> = ( + node: HTMLElement, + container?: HTMLElement, +) => { const moveFocus = (direction: 'up' | 'down') => { + if (!container) { + return; + } + const children = Array.from(container?.children); if (children.length === 0) { return; diff --git a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte index a2fbbe787a1e9..6eb603263ecec 100644 --- a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte +++ b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte @@ -7,13 +7,17 @@ import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; - export let user: UserResponseDto; - export let onSuccess: () => void; - export let onFail: () => void; - export let onCancel: () => void; + interface Props { + user: UserResponseDto; + onSuccess: () => void; + onFail: () => void; + onCancel: () => void; + } - let forceDelete = false; - let deleteButtonDisabled = false; + let { user, onSuccess, onFail, onCancel }: Props = $props(); + + let forceDelete = $state(false); + let deleteButtonDisabled = $state(false); let userIdInput: string = ''; const handleDeleteUser = async () => { @@ -47,12 +51,14 @@ {onCancel} disabled={deleteButtonDisabled} > - <svelte:fragment slot="prompt"> + {#snippet promptSnippet()} <div class="flex flex-col gap-4"> {#if forceDelete} <p> - <FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }} let:message> - <b>{message}</b> + <FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }}> + {#snippet children({ message })} + <b>{message}</b> + {/snippet} </FormatMessage> </p> {:else} @@ -60,9 +66,10 @@ <FormatMessage key="admin.user_delete_delay" values={{ user: user.name, delay: $serverConfig.userDeleteDelay }} - let:message > - <b>{message}</b> + {#snippet children({ message })} + <b>{message}</b> + {/snippet} </FormatMessage> </p> {/if} @@ -73,7 +80,7 @@ label={$t('admin.user_delete_immediately_checkbox')} labelClass="text-sm dark:text-immich-dark-fg" bind:checked={forceDelete} - on:change={() => { + onchange={() => { deleteButtonDisabled = forceDelete; }} /> @@ -92,9 +99,9 @@ aria-describedby="confirm-user-desc" name="confirm-user-id" type="text" - on:input={handleConfirm} + oninput={handleConfirm} /> {/if} </div> - </svelte:fragment> + {/snippet} </ConfirmDialog> diff --git a/web/src/lib/components/admin-page/jobs/job-tile-button.svelte b/web/src/lib/components/admin-page/jobs/job-tile-button.svelte index 69d3706230de9..f71d8a3e447af 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile-button.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile-button.svelte @@ -1,10 +1,18 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export type Colors = 'light-gray' | 'gray' | 'dark-gray'; </script> <script lang="ts"> - export let color: Colors; - export let disabled = false; + import type { Snippet } from 'svelte'; + + interface Props { + color: Colors; + disabled?: boolean; + children?: Snippet; + onClick?: () => void; + } + + let { color, disabled = false, onClick = () => {}, children }: Props = $props(); const colorClasses: Record<Colors, string> = { 'light-gray': 'bg-gray-300/80 dark:bg-gray-700', @@ -23,7 +31,7 @@ class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[ color ]} {hoverClasses}" - on:click + onclick={onClick} > - <slot /> + {@render children?.()} </button> diff --git a/web/src/lib/components/admin-page/jobs/job-tile-status.svelte b/web/src/lib/components/admin-page/jobs/job-tile-status.svelte index ca367647973f4..5bffa45b89eb1 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile-status.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile-status.svelte @@ -1,9 +1,16 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export type Color = 'success' | 'warning'; </script> <script lang="ts"> - export let color: Color; + import type { Snippet } from 'svelte'; + + interface Props { + color: Color; + children?: Snippet; + } + + let { color, children }: Props = $props(); const colorClasses: Record<Color, string> = { success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100', @@ -12,5 +19,5 @@ </script> <div class="w-full p-2 text-center text-sm {colorClasses[color]}"> - <slot /> + {@render children?.()} </div> diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index 81c23e927b684..0e39647c75974 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -19,22 +19,37 @@ import JobTileButton from './job-tile-button.svelte'; import JobTileStatus from './job-tile-status.svelte'; - export let title: string; - export let subtitle: string | undefined; - export let description: Component | undefined; - export let jobCounts: JobCountsDto; - export let queueStatus: QueueStatusDto; - export let icon: string; - export let disabled = false; + interface Props { + title: string; + subtitle: string | undefined; + description: Component | undefined; + jobCounts: JobCountsDto; + queueStatus: QueueStatusDto; + icon: string; + disabled?: boolean; + allText: string | undefined; + refreshText: string | undefined; + missingText: string; + onCommand: (command: JobCommandDto) => void; + } - export let allText: string | undefined; - export let refreshText: string | undefined; - export let missingText: string; - export let onCommand: (command: JobCommandDto) => void; + let { + title, + subtitle, + description, + jobCounts, + queueStatus, + icon, + disabled = false, + allText, + refreshText, + missingText, + onCommand, + }: Props = $props(); - $: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed; - $: isIdle = !queueStatus.isActive && !queueStatus.isPaused; - $: multipleButtons = allText || refreshText; + let waitingCount = $derived(jobCounts.waiting + jobCounts.paused + jobCounts.delayed); + let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused); + let multipleButtons = $derived(allText || refreshText); const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6'; </script> @@ -67,7 +82,7 @@ title={$t('clear_message')} size="12" padding="1" - on:click={() => onCommand({ command: JobCommand.ClearFailed, force: false })} + onclick={() => onCommand({ command: JobCommand.ClearFailed, force: false })} /> </div> </Badge> @@ -87,8 +102,9 @@ {/if} {#if description} + {@const SvelteComponent = description} <div class="text-sm dark:text-white"> - <svelte:component this={description} /> + <SvelteComponent /> </div> {/if} @@ -118,7 +134,7 @@ <JobTileButton disabled={true} color="light-gray" - on:click={() => onCommand({ command: JobCommand.Start, force: false })} + onClick={() => onCommand({ command: JobCommand.Start, force: false })} > <Icon path={mdiAlertCircle} size="36" /> {$t('disabled').toUpperCase()} @@ -127,20 +143,20 @@ {#if !disabled && !isIdle} {#if waitingCount > 0} - <JobTileButton color="gray" on:click={() => onCommand({ command: JobCommand.Empty, force: false })}> + <JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Empty, force: false })}> <Icon path={mdiClose} size="24" /> {$t('clear').toUpperCase()} </JobTileButton> {/if} {#if queueStatus.isPaused} {@const size = waitingCount > 0 ? '24' : '48'} - <JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Resume, force: false })}> + <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Resume, force: false })}> <!-- size property is not reactive, so have to use width and height --> <Icon path={mdiFastForward} {size} /> {$t('resume').toUpperCase()} </JobTileButton> {:else} - <JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Pause, force: false })}> + <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Pause, force: false })}> <Icon path={mdiPause} size="24" /> {$t('pause').toUpperCase()} </JobTileButton> @@ -149,25 +165,25 @@ {#if !disabled && multipleButtons && isIdle} {#if allText} - <JobTileButton color="dark-gray" on:click={() => onCommand({ command: JobCommand.Start, force: true })}> + <JobTileButton color="dark-gray" onClick={() => onCommand({ command: JobCommand.Start, force: true })}> <Icon path={mdiAllInclusive} size="24" /> {allText} </JobTileButton> {/if} {#if refreshText} - <JobTileButton color="gray" on:click={() => onCommand({ command: JobCommand.Start, force: undefined })}> + <JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Start, force: undefined })}> <Icon path={mdiImageRefreshOutline} size="24" /> {refreshText} </JobTileButton> {/if} - <JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Start, force: false })}> + <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}> <Icon path={mdiSelectionSearch} size="24" /> {missingText} </JobTileButton> {/if} {#if !disabled && !multipleButtons && isIdle} - <JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Start, force: false })}> + <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}> <Icon path={mdiPlay} size="48" /> {$t('start').toUpperCase()} </JobTileButton> diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 67d672d398806..9b4f3ffdd646a 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -25,7 +25,11 @@ import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; - export let jobs: AllJobStatusResponseDto; + interface Props { + jobs: AllJobStatusResponseDto; + } + + let { jobs = $bindable() }: Props = $props(); interface JobDetails { title: string; @@ -56,8 +60,7 @@ await handleCommand(jobId, dto); }; - // svelte-ignore reactive_declaration_non_reactive_property - $: jobDetails = <Partial<Record<JobName, JobDetails>>>{ + let jobDetails: Partial<Record<JobName, JobDetails>> = { [JobName.ThumbnailGeneration]: { icon: mdiFileJpgBox, title: $getJobName(JobName.ThumbnailGeneration), @@ -142,7 +145,8 @@ missingText: $t('missing'), }, }; - $: jobList = Object.entries(jobDetails) as [JobName, JobDetails][]; + + let jobList = Object.entries(jobDetails) as [JobName, JobDetails][]; async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) { const title = jobDetails[jobId]?.title; diff --git a/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte b/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte index 8a74d2c5ad0be..b47df1daaec86 100644 --- a/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte +++ b/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte @@ -7,12 +7,13 @@ <FormatMessage key="admin.storage_template_migration_description" values={{ template: $t('admin.storage_template_settings') }} - let:message > - <a - href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}" - class="text-immich-primary dark:text-immich-dark-primary" - > - {message} - </a> + {#snippet children({ message })} + <a + href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}" + class="text-immich-primary dark:text-immich-dark-primary" + > + {message} + </a> + {/snippet} </FormatMessage> diff --git a/web/src/lib/components/admin-page/restore-dialogue.svelte b/web/src/lib/components/admin-page/restore-dialogue.svelte index 25afbc6d4b9b6..a72ada2ca5d88 100644 --- a/web/src/lib/components/admin-page/restore-dialogue.svelte +++ b/web/src/lib/components/admin-page/restore-dialogue.svelte @@ -5,10 +5,14 @@ import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; - export let user: UserResponseDto; - export let onSuccess: () => void; - export let onFail: () => void; - export let onCancel: () => void; + interface Props { + user: UserResponseDto; + onSuccess: () => void; + onFail: () => void; + onCancel: () => void; + } + + let { user, onSuccess, onFail, onCancel }: Props = $props(); const handleRestoreUser = async () => { try { @@ -32,11 +36,13 @@ onConfirm={handleRestoreUser} {onCancel} > - <svelte:fragment slot="prompt"> + {#snippet promptSnippet()} <p> - <FormatMessage key="admin.user_restore_description" values={{ user: user.name }} let:message> - <b>{message}</b> + <FormatMessage key="admin.user_restore_description" values={{ user: user.name }}> + {#snippet children({ message })} + <b>{message}</b> + {/snippet} </FormatMessage> </p> - </svelte:fragment> + {/snippet} </ConfirmDialog> diff --git a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte index 35afc0962d414..feab6a9c6d072 100644 --- a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte +++ b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte @@ -7,14 +7,20 @@ import StatsCard from './stats-card.svelte'; import { t } from 'svelte-i18n'; - export let stats: ServerStatsResponseDto = { - photos: 0, - videos: 0, - usage: 0, - usageByUser: [], - }; + interface Props { + stats?: ServerStatsResponseDto; + } + + let { + stats = { + photos: 0, + videos: 0, + usage: 0, + usageByUser: [], + }, + }: Props = $props(); - $: zeros = (value: number) => { + const zeros = (value: number) => { const maxLength = 13; const valueLength = value.toString().length; const zeroLength = maxLength - valueLength; @@ -23,7 +29,7 @@ }; const TiB = 1024 ** 4; - $: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0); + let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0)); </script> <div class="flex flex-col gap-5"> diff --git a/web/src/lib/components/admin-page/server-stats/stats-card.svelte b/web/src/lib/components/admin-page/server-stats/stats-card.svelte index 31baa0afdd780..14d32c055f23a 100644 --- a/web/src/lib/components/admin-page/server-stats/stats-card.svelte +++ b/web/src/lib/components/admin-page/server-stats/stats-card.svelte @@ -2,18 +2,22 @@ import Icon from '$lib/components/elements/icon.svelte'; import { ByteUnit } from '$lib/utils/byte-units'; - export let icon: string; - export let title: string; - export let value: number; - export let unit: ByteUnit | undefined = undefined; + interface Props { + icon: string; + title: string; + value: number; + unit?: ByteUnit | undefined; + } - $: zeros = () => { + let { icon, title, value, unit = undefined }: Props = $props(); + + const zeros = $derived(() => { const maxLength = 13; const valueLength = value.toString().length; const zeroLength = maxLength - valueLength; return '0'.repeat(zeroLength); - }; + }); </script> <div class="flex h-[140px] w-[250px] flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray"> diff --git a/web/src/lib/components/admin-page/settings/admin-settings.svelte b/web/src/lib/components/admin-page/settings/admin-settings.svelte index 19a8580d6bb90..199db0b57112a 100644 --- a/web/src/lib/components/admin-page/settings/admin-settings.svelte +++ b/web/src/lib/components/admin-page/settings/admin-settings.svelte @@ -1,5 +1,3 @@ -<svelte:options accessors /> - <script lang="ts"> import { NotificationType, @@ -13,12 +11,17 @@ import type { SettingsResetOptions } from './admin-settings'; import { t } from 'svelte-i18n'; - export let config: SystemConfigDto; + interface Props { + config: SystemConfigDto; + children: import('svelte').Snippet<[{ savedConfig: SystemConfigDto; defaultConfig: SystemConfigDto }]>; + } + + let { config = $bindable(), children }: Props = $props(); - let savedConfig: SystemConfigDto; - let defaultConfig: SystemConfigDto; + let savedConfig: SystemConfigDto | undefined = $state(); + let defaultConfig: SystemConfigDto | undefined = $state(); - const handleReset = async (options: SettingsResetOptions) => { + export const handleReset = async (options: SettingsResetOptions) => { await (options.default ? resetToDefault(options.configKeys) : reset(options.configKeys)); }; @@ -26,7 +29,8 @@ let systemConfigDto = { ...savedConfig, ...update, - }; + } as SystemConfigDto; + if (isEqual(systemConfigDto, savedConfig)) { return; } @@ -59,6 +63,10 @@ }; const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => { + if (!defaultConfig) { + return; + } + for (const key of configKeys) { config = { ...config, [key]: defaultConfig[key] }; } @@ -75,5 +83,5 @@ </script> {#if savedConfig && defaultConfig} - <slot {handleReset} {handleSave} {savedConfig} {defaultConfig} /> + {@render children({ savedConfig, defaultConfig })} {/if} diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index 9b0e4b32706b5..7f94dfa253a4d 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -2,9 +2,7 @@ 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, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { type SystemConfigDto } from '@immich/sdk'; import { isEqual } from 'lodash-es'; @@ -12,15 +10,20 @@ 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'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } - let isConfirmOpen = false; + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + let isConfirmOpen = $state(false); const handleToggleOverride = () => { // click runs before bind @@ -48,29 +51,31 @@ onCancel={() => (isConfirmOpen = false)} onConfirm={() => handleSave(true)} > - <svelte:fragment slot="prompt"> + {#snippet promptSnippet()} <div class="flex flex-col gap-4"> <p>{$t('admin.authentication_settings_disable_all')}</p> <p> - <FormatMessage key="admin.authentication_settings_reenable" let:message> - <a - href="https://immich.app/docs/administration/server-commands" - rel="noreferrer" - target="_blank" - class="underline" - > - {message} - </a> + <FormatMessage key="admin.authentication_settings_reenable"> + {#snippet children({ message })} + <a + href="https://immich.app/docs/administration/server-commands" + rel="noreferrer" + target="_blank" + class="underline" + > + {message} + </a> + {/snippet} </FormatMessage> </p> </div> - </svelte:fragment> + {/snippet} </ConfirmDialog> {/if} <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" onsubmit={(e) => e.preventDefault()}> <div class="ml-4 mt-4 flex flex-col"> <SettingAccordion key="oauth" @@ -79,15 +84,17 @@ > <div class="ml-4 mt-4 flex flex-col gap-4"> <p class="text-sm dark:text-immich-dark-fg"> - <FormatMessage key="admin.oauth_settings_more_details" let:message> - <a - href="https://immich.app/docs/administration/oauth" - class="underline" - target="_blank" - rel="noreferrer" - > - {message} - </a> + <FormatMessage key="admin.oauth_settings_more_details"> + {#snippet children({ message })} + <a + href="https://immich.app/docs/administration/oauth" + class="underline" + target="_blank" + rel="noreferrer" + > + {message} + </a> + {/snippet} </FormatMessage> </p> @@ -147,7 +154,7 @@ <SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('admin.oauth_profile_signing_algorithm').toUpperCase()} - desc={$t('admin.oauth_profile_signing_algorithm_description')} + description={$t('admin.oauth_profile_signing_algorithm_description')} bind:value={config.oauth.profileSigningAlgorithm} required={true} disabled={disabled || !config.oauth.enabled} @@ -157,7 +164,7 @@ <SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('admin.oauth_storage_label_claim').toUpperCase()} - desc={$t('admin.oauth_storage_label_claim_description')} + description={$t('admin.oauth_storage_label_claim_description')} bind:value={config.oauth.storageLabelClaim} required={true} disabled={disabled || !config.oauth.enabled} @@ -167,7 +174,7 @@ <SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('admin.oauth_storage_quota_claim').toUpperCase()} - desc={$t('admin.oauth_storage_quota_claim_description')} + description={$t('admin.oauth_storage_quota_claim_description')} bind:value={config.oauth.storageQuotaClaim} required={true} disabled={disabled || !config.oauth.enabled} @@ -177,7 +184,7 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('admin.oauth_storage_quota_default').toUpperCase()} - desc={$t('admin.oauth_storage_quota_default_description')} + description={$t('admin.oauth_storage_quota_default_description')} bind:value={config.oauth.defaultStorageQuota} required={true} disabled={disabled || !config.oauth.enabled} @@ -213,7 +220,7 @@ values: { callback: 'app.immich:///oauth-callback' }, })} disabled={disabled || !config.oauth.enabled} - on:click={() => handleToggleOverride()} + onToggle={() => handleToggleOverride()} bind:checked={config.oauth.mobileOverrideEnabled} /> diff --git a/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte b/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte index 05543f112465e..3ec477e29c645 100644 --- a/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte +++ b/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte @@ -3,33 +3,40 @@ import { isEqual } from 'lodash-es'; import { fade } from 'svelte/transition'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } - $: cronExpressionOptions = [ + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + let cronExpressionOptions = $derived([ { text: $t('interval.night_at_midnight'), value: '0 0 * * *' }, { text: $t('interval.night_at_twoam'), value: '0 02 * * *' }, { text: $t('interval.day_at_onepm'), value: '0 13 * * *' }, { text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' }, - ]; + ]); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingSwitch title={$t('admin.backup_database_enable_description')} @@ -53,21 +60,23 @@ bind:value={config.backup.database.cronExpression} isEdited={config.backup.database.cronExpression !== savedConfig.backup.database.cronExpression} > - <svelte:fragment slot="desc"> + {#snippet descriptionSnippet()} <p class="text-sm dark:text-immich-dark-fg"> - <FormatMessage key="admin.cron_expression_description" let:message> - <a - href="https://crontab.guru/#{config.backup.database.cronExpression.replaceAll(' ', '_')}" - class="underline" - target="_blank" - rel="noreferrer" - > - {message} - <br /> - </a> + <FormatMessage key="admin.cron_expression_description"> + {#snippet children({ message })} + <a + href="https://crontab.guru/#{config.backup.database.cronExpression.replaceAll(' ', '_')}" + class="underline" + target="_blank" + rel="noreferrer" + > + {message} + <br /> + </a> + {/snippet} </FormatMessage> </p> - </svelte:fragment> + {/snippet} </SettingInputField> <SettingInputField diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 8f5b587ae6bf2..702ec1c1710b9 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -15,44 +15,53 @@ import { fade } from 'svelte/transition'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.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 SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4 flex flex-col gap-4"> <p class="text-sm dark:text-immich-dark-fg"> <Icon path={mdiHelpCircleOutline} class="inline" size="15" /> - <FormatMessage key="admin.transcoding_codecs_learn_more" let:tag let:message> - {#if tag === 'h264-link'} - <a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer"> - {message} - </a> - {:else if tag === 'hevc-link'} - <a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer"> - {message} - </a> - {:else if tag === 'vp9-link'} - <a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer"> - {message} - </a> - {/if} + <FormatMessage key="admin.transcoding_codecs_learn_more"> + {#snippet children({ tag, message })} + {#if tag === 'h264-link'} + <a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer"> + {message} + </a> + {:else if tag === 'hevc-link'} + <a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer"> + {message} + </a> + {:else if tag === 'vp9-link'} + <a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer"> + {message} + </a> + {/if} + {/snippet} </FormatMessage> </p> @@ -60,7 +69,7 @@ inputType={SettingInputFieldType.NUMBER} {disabled} label={$t('admin.transcoding_constant_rate_factor')} - desc={$t('admin.transcoding_constant_rate_factor_description')} + description={$t('admin.transcoding_constant_rate_factor_description')} bind:value={config.ffmpeg.crf} required={true} isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf} @@ -186,7 +195,7 @@ inputType={SettingInputFieldType.TEXT} {disabled} label={$t('admin.transcoding_max_bitrate')} - desc={$t('admin.transcoding_max_bitrate_description')} + description={$t('admin.transcoding_max_bitrate_description')} bind:value={config.ffmpeg.maxBitrate} isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate} /> @@ -195,7 +204,7 @@ inputType={SettingInputFieldType.NUMBER} {disabled} label={$t('admin.transcoding_threads')} - desc={$t('admin.transcoding_threads_description')} + description={$t('admin.transcoding_threads_description')} bind:value={config.ffmpeg.threads} isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads} /> @@ -329,7 +338,7 @@ <SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('admin.transcoding_preferred_hardware_device')} - desc={$t('admin.transcoding_preferred_hardware_device_description')} + description={$t('admin.transcoding_preferred_hardware_device_description')} bind:value={config.ffmpeg.preferredHwDevice} isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice} {disabled} @@ -346,7 +355,7 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('admin.transcoding_max_b_frames')} - desc={$t('admin.transcoding_max_b_frames_description')} + description={$t('admin.transcoding_max_b_frames_description')} bind:value={config.ffmpeg.bframes} isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes} {disabled} @@ -355,7 +364,7 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('admin.transcoding_reference_frames')} - desc={$t('admin.transcoding_reference_frames_description')} + description={$t('admin.transcoding_reference_frames_description')} bind:value={config.ffmpeg.refs} isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs} {disabled} @@ -364,7 +373,7 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('admin.transcoding_max_keyframe_interval')} - desc={$t('admin.transcoding_max_keyframe_interval_description')} + description={$t('admin.transcoding_max_keyframe_interval_description')} bind:value={config.ffmpeg.gopSize} isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize} {disabled} diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index 50ae494570bfc..2f2bcbca64276 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -7,24 +7,39 @@ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { t } from 'svelte-i18n'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; - export let openByDefault = false; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + openByDefault?: boolean; + } + + let { + savedConfig, + defaultConfig, + config = $bindable(), + disabled = false, + onReset, + onSave, + openByDefault = false, + }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingAccordion key="thumbnail-settings" @@ -65,7 +80,7 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('admin.image_quality')} - desc={$t('admin.image_thumbnail_quality_description')} + description={$t('admin.image_thumbnail_quality_description')} bind:value={config.image.thumbnail.quality} isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality} {disabled} @@ -110,7 +125,7 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('admin.image_quality')} - desc={$t('admin.image_preview_quality_description')} + description={$t('admin.image_preview_quality_description')} bind:value={config.image.preview.quality} isEdited={config.image.preview.quality !== savedConfig.image.preview.quality} {disabled} diff --git a/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte b/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte index e09fde8baea31..356de6ae860c5 100644 --- a/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte +++ b/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte @@ -5,17 +5,20 @@ import { fade } from 'svelte/transition'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { t } from 'svelte-i18n'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); const jobNames = [ JobName.ThumbnailGeneration, @@ -34,11 +37,15 @@ function isSystemConfigJobDto(jobName: any): jobName is keyof SystemConfigJobDto { return jobName in config.job; } + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> {#each jobNames as jobName} <div class="ml-4 mt-4 flex flex-col gap-4"> {#if isSystemConfigJobDto(jobName)} @@ -46,7 +53,7 @@ inputType={SettingInputFieldType.NUMBER} {disabled} label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })} - desc="" + description="" bind:value={config.job[jobName].concurrency} required={true} isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)} @@ -55,7 +62,7 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })} - desc="" + description="" value="1" disabled={true} title={$t('admin.job_not_concurrency_safe')} diff --git a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte index b494dca53ffd5..b1012c028720e 100644 --- a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte +++ b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte @@ -4,34 +4,49 @@ import { fade } from 'svelte/transition'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; - export let openByDefault = false; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + openByDefault?: boolean; + } - $: cronExpressionOptions = [ + let { + savedConfig, + defaultConfig, + config = $bindable(), + disabled = false, + onReset, + onSave, + openByDefault = false, + }: Props = $props(); + + let cronExpressionOptions = $derived([ { text: $t('interval.night_at_midnight'), value: '0 0 * * *' }, { text: $t('interval.night_at_twoam'), value: '0 2 * * *' }, { text: $t('interval.day_at_onepm'), value: '0 13 * * *' }, { text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' }, - ]; + ]); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingAccordion key="library-watching" @@ -77,20 +92,22 @@ bind:value={config.library.scan.cronExpression} isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression} > - <svelte:fragment slot="desc"> + {#snippet descriptionSnippet()} <p class="text-sm dark:text-immich-dark-fg"> - <FormatMessage key="admin.cron_expression_description" let:message> - <a - href="https://crontab.guru/#{config.library.scan.cronExpression.replaceAll(' ', '_')}" - class="underline" - target="_blank" - rel="noreferrer" - > - {message} - </a> + <FormatMessage key="admin.cron_expression_description"> + {#snippet children({ message })} + <a + href="https://crontab.guru/#{config.library.scan.cronExpression.replaceAll(' ', '_')}" + class="underline" + target="_blank" + rel="noreferrer" + > + {message} + </a> + {/snippet} </FormatMessage> </p> - </svelte:fragment> + {/snippet} </SettingInputField> </div> </SettingAccordion> diff --git a/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte b/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte index 6e71ba926c79f..29a1c65162359 100644 --- a/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte +++ b/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte @@ -8,17 +8,25 @@ import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import { t } from 'svelte-i18n'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingSwitch title={$t('admin.logging_enable_description')} diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index aac8cd52123be..13678a31c1b63 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -5,26 +5,33 @@ import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; 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, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.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 { featureFlags } from '$lib/stores/server-config.store'; import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; - - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + import { SettingInputFieldType } from '$lib/constants'; + + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div class="mt-2"> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4"> + <form autocomplete="off" {onsubmit} class="mx-4 mt-4"> <div class="flex flex-col gap-4"> <SettingSwitch title={$t('admin.machine_learning_enabled')} @@ -38,7 +45,7 @@ <SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('url')} - desc={$t('admin.machine_learning_url_description')} + description={$t('admin.machine_learning_url_description')} bind:value={config.machineLearning.url} required={true} disabled={disabled || !config.machineLearning.enabled} @@ -69,11 +76,15 @@ disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled} isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName} > - <p slot="desc" class="immich-form-label pb-2 text-sm"> - <FormatMessage key="admin.machine_learning_clip_model_description" let:message> - <a href="https://huggingface.co/immich-app"><u>{message}</u></a> - </FormatMessage> - </p> + {#snippet descriptionSnippet()} + <p class="immich-form-label pb-2 text-sm"> + <FormatMessage key="admin.machine_learning_clip_model_description"> + {#snippet children({ message })} + <a href="https://huggingface.co/immich-app"><u>{message}</u></a> + {/snippet} + </FormatMessage> + </p> + {/snippet} </SettingInputField> </div> </SettingAccordion> @@ -100,7 +111,7 @@ step="0.0005" min={0.001} max={0.1} - desc={$t('admin.machine_learning_max_detection_distance_description')} + description={$t('admin.machine_learning_max_detection_distance_description')} disabled={disabled || !$featureFlags.duplicateDetection} isEdited={config.machineLearning.duplicateDetection.maxDistance !== savedConfig.machineLearning.duplicateDetection.maxDistance} @@ -142,7 +153,7 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('admin.machine_learning_min_detection_score')} - desc={$t('admin.machine_learning_min_detection_score_description')} + description={$t('admin.machine_learning_min_detection_score_description')} bind:value={config.machineLearning.facialRecognition.minScore} step="0.1" min={0.1} @@ -155,7 +166,7 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('admin.machine_learning_max_recognition_distance')} - desc={$t('admin.machine_learning_max_recognition_distance_description')} + description={$t('admin.machine_learning_max_recognition_distance_description')} bind:value={config.machineLearning.facialRecognition.maxDistance} step="0.1" min={0.1} @@ -168,7 +179,7 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('admin.machine_learning_min_recognized_faces')} - desc={$t('admin.machine_learning_min_recognized_faces_description')} + description={$t('admin.machine_learning_min_recognized_faces_description')} bind:value={config.machineLearning.facialRecognition.minFaces} step="1" min={1} diff --git a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte index 7c2c5c856aedc..4a4b23ded28a2 100644 --- a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte +++ b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte @@ -6,23 +6,30 @@ import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div class="mt-2"> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="flex flex-col gap-4"> <SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}> <div class="ml-4 mt-4 flex flex-col gap-4"> @@ -38,7 +45,7 @@ <SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('admin.map_light_style')} - desc={$t('admin.map_style_description')} + description={$t('admin.map_style_description')} bind:value={config.map.lightStyle} disabled={disabled || !config.map.enabled} isEdited={config.map.lightStyle !== savedConfig.map.lightStyle} @@ -46,7 +53,7 @@ <SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('admin.map_dark_style')} - desc={$t('admin.map_style_description')} + description={$t('admin.map_style_description')} bind:value={config.map.darkStyle} disabled={disabled || !config.map.enabled} isEdited={config.map.darkStyle !== savedConfig.map.darkStyle} @@ -55,20 +62,22 @@ > <SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}> - <svelte:fragment slot="subtitle"> + {#snippet subtitleSnippet()} <p class="text-sm dark:text-immich-dark-fg"> - <FormatMessage key="admin.map_manage_reverse_geocoding_settings" let:message> - <a - href="https://immich.app/docs/features/reverse-geocoding" - class="underline" - target="_blank" - rel="noreferrer" - > - {message} - </a> + <FormatMessage key="admin.map_manage_reverse_geocoding_settings"> + {#snippet children({ message })} + <a + href="https://immich.app/docs/features/reverse-geocoding" + class="underline" + target="_blank" + rel="noreferrer" + > + {message} + </a> + {/snippet} </FormatMessage> </p> - </svelte:fragment> + {/snippet} <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingSwitch title={$t('admin.map_reverse_geocoding_enable_description')} diff --git a/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte b/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte index c28050e0229cb..1ba82b2eb916b 100644 --- a/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte +++ b/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte @@ -7,17 +7,25 @@ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { t } from 'svelte-i18n'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div class="mt-2"> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4"> + <form autocomplete="off" {onsubmit} class="mx-4 mt-4"> <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingSwitch title={$t('admin.metadata_faces_import_setting')} diff --git a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte index 76c238df823ba..1a6f0ab8669bd 100644 --- a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte +++ b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte @@ -7,17 +7,25 @@ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { t } from 'svelte-i18n'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4"> <SettingSwitch title={$t('admin.version_check_enabled_description')} diff --git a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte index fcd26c684b4ba..28187978f9581 100644 --- a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte +++ b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte @@ -3,9 +3,7 @@ import { isEqual } from 'lodash-es'; import { fade } from 'svelte/transition'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; @@ -18,15 +16,20 @@ import { user } from '$lib/stores/user.store'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { handleError } from '$lib/utils/handle-error'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } - let isSending = false; + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + let isSending = $state(false); const handleSendTestEmail = async () => { if (isSending) { @@ -65,11 +68,15 @@ isSending = false; } }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault class="mt-4"> + <form autocomplete="off" {onsubmit} class="mt-4"> <div class="flex flex-col gap-4"> <SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}> <div class="ml-4 mt-4 flex flex-col gap-4"> @@ -85,7 +92,7 @@ inputType={SettingInputFieldType.TEXT} required label={$t('host')} - desc={$t('admin.notification_email_host_description')} + description={$t('admin.notification_email_host_description')} disabled={disabled || !config.notifications.smtp.enabled} bind:value={config.notifications.smtp.transport.host} isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host} @@ -95,7 +102,7 @@ inputType={SettingInputFieldType.NUMBER} required label={$t('port')} - desc={$t('admin.notification_email_port_description')} + description={$t('admin.notification_email_port_description')} disabled={disabled || !config.notifications.smtp.enabled} bind:value={config.notifications.smtp.transport.port} isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port} @@ -104,7 +111,7 @@ <SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('username')} - desc={$t('admin.notification_email_username_description')} + description={$t('admin.notification_email_username_description')} disabled={disabled || !config.notifications.smtp.enabled} bind:value={config.notifications.smtp.transport.username} isEdited={config.notifications.smtp.transport.username !== @@ -114,7 +121,7 @@ <SettingInputField inputType={SettingInputFieldType.PASSWORD} label={$t('password')} - desc={$t('admin.notification_email_password_description')} + description={$t('admin.notification_email_password_description')} disabled={disabled || !config.notifications.smtp.enabled} bind:value={config.notifications.smtp.transport.password} isEdited={config.notifications.smtp.transport.password !== @@ -134,14 +141,14 @@ inputType={SettingInputFieldType.TEXT} required label={$t('admin.notification_email_from_address')} - desc={$t('admin.notification_email_from_address_description')} + description={$t('admin.notification_email_from_address_description')} disabled={disabled || !config.notifications.smtp.enabled} bind:value={config.notifications.smtp.from} isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from} /> <div class="flex gap-2 place-items-center"> - <Button size="sm" disabled={!config.notifications.smtp.enabled} on:click={handleSendTestEmail}> + <Button size="sm" disabled={!config.notifications.smtp.enabled} onclick={handleSendTestEmail}> {#if disabled} {$t('admin.notification_email_test_email')} {:else} diff --git a/web/src/lib/components/admin-page/settings/server/server-settings.svelte b/web/src/lib/components/admin-page/settings/server/server-settings.svelte index f021c99f24a1c..14d5624c5ff73 100644 --- a/web/src/lib/components/admin-page/settings/server/server-settings.svelte +++ b/web/src/lib/components/admin-page/settings/server/server-settings.svelte @@ -3,28 +3,35 @@ import { isEqual } from 'lodash-es'; import { fade } from 'svelte/transition'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import { t } from 'svelte-i18n'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="mt-4 ml-4"> <SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('admin.server_external_domain_settings')} - desc={$t('admin.server_external_domain_settings_description')} + description={$t('admin.server_external_domain_settings_description')} bind:value={config.server.externalDomain} isEdited={config.server.externalDomain !== savedConfig.server.externalDomain} /> @@ -32,7 +39,7 @@ <SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('admin.server_welcome_message')} - desc={$t('admin.server_welcome_message_description')} + description={$t('admin.server_welcome_message_description')} bind:value={config.server.loginPageMessage} isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage} /> diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 4ebf4ed118d89..74d240a4a6a8a 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -1,6 +1,9 @@ <script lang="ts"> + import { createBubbler, preventDefault } from 'svelte/legacy'; + + const bubble = createBubbler(); import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; - import { AppRoute } from '$lib/constants'; + import { AppRoute, SettingInputFieldType } from '$lib/constants'; import { user } from '$lib/stores/user.store'; import { getStorageTemplateOptions, @@ -15,24 +18,38 @@ import SupportedDatetimePanel from './supported-datetime-panel.svelte'; import SupportedVariablesPanel from './supported-variables-panel.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; + import type { Snippet } from 'svelte'; + + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + minified?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + duration?: number; + children?: Snippet; + } - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let minified = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; - export let duration: number = 500; + let { + savedConfig, + defaultConfig, + config = $bindable(), + disabled = false, + minified = false, + onReset, + onSave, + duration = 500, + children, + }: Props = $props(); - let templateOptions: SystemConfigTemplateStorageOptionDto; - let selectedPreset = ''; + let templateOptions: SystemConfigTemplateStorageOptionDto | undefined = $state(); + let selectedPreset = $state(''); const getTemplateOptions = async () => { templateOptions = await getStorageTemplateOptions(); @@ -41,15 +58,11 @@ const getSupportDateTimeFormat = () => getStorageTemplateOptions(); - $: parsedTemplate = () => { - try { - return renderTemplate(config.storageTemplate.template); - } catch { - return 'error'; + const renderTemplate = (templateString: string) => { + if (!templateOptions) { + return ''; } - }; - const renderTemplate = (templateString: string) => { const template = handlebar.compile(templateString, { knownHelpers: undefined, }); @@ -85,31 +98,40 @@ const handlePresetSelection = () => { config.storageTemplate.template = selectedPreset; }; + let parsedTemplate = $derived(() => { + try { + return renderTemplate(config.storageTemplate.template); + } catch { + return 'error'; + } + }); </script> <section class="dark:text-immich-dark-fg mt-2"> <div in:fade={{ duration }} class="mx-4 flex flex-col gap-4 py-4"> <p class="text-sm dark:text-immich-dark-fg"> - <FormatMessage key="admin.storage_template_more_details" let:tag let:message> - {#if tag === 'template-link'} - <a - href="https://immich.app/docs/administration/storage-template" - class="underline" - target="_blank" - rel="noreferrer" - > - {message} - </a> - {:else if tag === 'implications-link'} - <a - href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations" - class="underline" - target="_blank" - rel="noreferrer" - > - {message} - </a> - {/if} + <FormatMessage key="admin.storage_template_more_details"> + {#snippet children({ tag, message })} + {#if tag === 'template-link'} + <a + href="https://immich.app/docs/administration/storage-template" + class="underline" + target="_blank" + rel="noreferrer" + > + {message} + </a> + {:else if tag === 'implications-link'} + <a + href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations" + class="underline" + target="_blank" + rel="noreferrer" + > + {message} + </a> + {/if} + {/snippet} </FormatMessage> </p> </div> @@ -164,19 +186,18 @@ <FormatMessage key="admin.storage_template_path_length" values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }} - let:message > - <span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span> + {#snippet children({ message })} + <span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span> + {/snippet} </FormatMessage> </p> <p class="text-sm"> - <FormatMessage - key="admin.storage_template_user_label" - values={{ label: $user.storageLabel || $user.id }} - let:message - > - <code class="text-immich-primary dark:text-immich-dark-primary">{message}</code> + <FormatMessage key="admin.storage_template_user_label" values={{ label: $user.storageLabel || $user.id }}> + {#snippet children({ message })} + <code class="text-immich-primary dark:text-immich-dark-primary">{message}</code> + {/snippet} </FormatMessage> </p> @@ -186,24 +207,30 @@ >/{parsedTemplate()}.jpg </p> - <form autocomplete="off" class="flex flex-col" on:submit|preventDefault> + <form autocomplete="off" class="flex flex-col" onsubmit={preventDefault(bubble('submit'))}> <div class="flex flex-col my-2"> - <label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="preset-select"> - {$t('preset')} - </label> - <select - class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600" - disabled={disabled || !config.storageTemplate.enabled} - name="presets" - id="preset-select" - bind:value={selectedPreset} - on:change={handlePresetSelection} - > - {#each templateOptions.presetOptions as preset} - <option value={preset}>{renderTemplate(preset)}</option> - {/each} - </select> + {#if templateOptions} + <label + class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" + for="preset-select" + > + {$t('preset')} + </label> + <select + class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600" + disabled={disabled || !config.storageTemplate.enabled} + name="presets" + id="preset-select" + bind:value={selectedPreset} + onchange={handlePresetSelection} + > + {#each templateOptions.presetOptions as preset} + <option value={preset}>{renderTemplate(preset)}</option> + {/each} + </select> + {/if} </div> + <div class="flex gap-2 align-bottom"> <SettingInputField label={$t('template')} @@ -232,11 +259,12 @@ <FormatMessage key="admin.storage_template_migration_info" values={{ job: $t('admin.storage_template_migration_job') }} - let:message > - <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"> - {message} - </a> + {#snippet children({ message })} + <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"> + {message} + </a> + {/snippet} </FormatMessage> </p> </section> @@ -247,7 +275,7 @@ {/if} {#if minified} - <slot /> + {@render children?.()} {:else} <SettingButtonsRow onReset={(options) => onReset({ ...options, configKeys: ['storageTemplate'] })} diff --git a/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte b/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte index 10f22c18057f3..379e366df60f1 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte @@ -4,7 +4,11 @@ import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; - export let options: SystemConfigTemplateStorageOptionDto; + interface Props { + options: SystemConfigTemplateStorageOptionDto; + } + + let { options }: Props = $props(); const getLuxonExample = (format: string) => { return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format); diff --git a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte index 84a12e05c9674..ca5b4c934b7bb 100644 --- a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte +++ b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte @@ -7,22 +7,30 @@ import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import { t } from 'svelte-i18n'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingTextarea {disabled} label={$t('admin.theme_custom_css_settings')} - desc={$t('admin.theme_custom_css_settings_description')} + description={$t('admin.theme_custom_css_settings_description')} bind:value={config.theme.customCss} required={true} isEdited={config.theme.customCss !== savedConfig.theme.customCss} diff --git a/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte b/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte index 8f287d48e04ed..05979bf9f037d 100644 --- a/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte +++ b/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte @@ -4,23 +4,30 @@ import { fade } from 'svelte/transition'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import { t } from 'svelte-i18n'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingSwitch title={$t('admin.trash_enabled_description')} {disabled} bind:checked={config.trash.enabled} /> @@ -29,7 +36,7 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('admin.trash_number_of_days')} - desc={$t('admin.trash_number_of_days_description')} + description={$t('admin.trash_number_of_days_description')} bind:value={config.trash.days} required={true} disabled={disabled || !config.trash.enabled} diff --git a/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte index 21453cbc7023c..f96c3808a8059 100644 --- a/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte +++ b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte @@ -5,28 +5,31 @@ import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { t } from 'svelte-i18n'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); </script> <div> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" onsubmit={(e) => e.preventDefault()}> <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingInputField inputType={SettingInputFieldType.NUMBER} min={1} label={$t('admin.user_delete_delay_settings')} - desc={$t('admin.user_delete_delay_settings_description')} + description={$t('admin.user_delete_delay_settings_description')} bind:value={config.user.deleteDelay} isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay} /> diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts index 8da9fbfd4591f..9e396bec3ecc8 100644 --- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts @@ -1,14 +1,15 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { albumFactory } from '@test-data/factories/album-factory'; import '@testing-library/jest-dom'; -import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte'; +import { render, waitFor, type RenderResult } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; import { init, register, waitLocale } from 'svelte-i18n'; import AlbumCard from '../album-card.svelte'; const onShowContextMenu = vi.fn(); describe('AlbumCard component', () => { - let sut: RenderResult<AlbumCard>; + let sut: RenderResult<typeof AlbumCard>; beforeAll(async () => { await init({ fallbackLocale: 'en-US' }); @@ -110,13 +111,9 @@ describe('AlbumCard component', () => { toJSON: () => ({}), }); - await fireEvent( - contextMenuButton, - new MouseEvent('click', { - clientX: 123, - clientY: 456, - }), - ); + const user = userEvent.setup(); + await user.click(contextMenuButton); + expect(onShowContextMenu).toHaveBeenCalledTimes(1); expect(onShowContextMenu).toHaveBeenCalledWith(expect.objectContaining({ x: 123, y: 456 })); }); diff --git a/web/src/lib/components/album-page/album-card-group.svelte b/web/src/lib/components/album-page/album-card-group.svelte index f899cebd8c430..ae2b27efac1da 100644 --- a/web/src/lib/components/album-page/album-card-group.svelte +++ b/web/src/lib/components/album-page/album-card-group.svelte @@ -11,28 +11,43 @@ import Icon from '$lib/components/elements/icon.svelte'; import { t } from 'svelte-i18n'; - export let albums: AlbumResponseDto[]; - export let group: AlbumGroup | undefined = undefined; - export let showOwner = false; - export let showDateRange = false; - export let showItemCount = false; - export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined = - undefined; + interface Props { + albums: AlbumResponseDto[]; + group?: AlbumGroup | undefined; + showOwner?: boolean; + showDateRange?: boolean; + showItemCount?: boolean; + onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined; + } - $: isCollapsed = !!group && isAlbumGroupCollapsed($albumViewSettings, group.id); + let { + albums, + group = undefined, + showOwner = false, + showDateRange = false, + showItemCount = false, + onShowContextMenu = undefined, + }: Props = $props(); + + let isCollapsed = $derived(!!group && isAlbumGroupCollapsed($albumViewSettings, group.id)); const showContextMenu = (position: ContextMenuPosition, album: AlbumResponseDto) => { onShowContextMenu?.(position, album); }; - $: iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'; + let iconRotation = $derived(isCollapsed ? 'rotate-0' : 'rotate-90'); + + const oncontextmenu = (event: MouseEvent, album: AlbumResponseDto) => { + event.preventDefault(); + showContextMenu({ x: event.x, y: event.y }, album); + }; </script> {#if group} <div class="grid"> <button type="button" - on:click={() => toggleAlbumGroupCollapsing(group.id)} + onclick={() => toggleAlbumGroupCollapsing(group.id)} class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg" aria-expanded={!isCollapsed} > @@ -56,7 +71,7 @@ data-sveltekit-preload-data="hover" href="{AppRoute.ALBUMS}/{album.id}" animate:flip={{ duration: 400 }} - on:contextmenu|preventDefault={(e) => showContextMenu({ x: e.x, y: e.y }, album)} + oncontextmenu={(event) => oncontextmenu(event, album)} > <AlbumCard {album} diff --git a/web/src/lib/components/album-page/album-card.svelte b/web/src/lib/components/album-page/album-card.svelte index f574c65f0b736..cec4919e4e9d5 100644 --- a/web/src/lib/components/album-page/album-card.svelte +++ b/web/src/lib/components/album-page/album-card.svelte @@ -8,12 +8,23 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; - export let album: AlbumResponseDto; - export let showOwner = false; - export let showDateRange = false; - export let showItemCount = false; - export let preload = false; - export let onShowContextMenu: ((position: ContextMenuPosition) => unknown) | undefined = undefined; + interface Props { + album: AlbumResponseDto; + showOwner?: boolean; + showDateRange?: boolean; + showItemCount?: boolean; + preload?: boolean; + onShowContextMenu?: ((position: ContextMenuPosition) => unknown) | undefined; + } + + let { + album, + showOwner = false, + showDateRange = false, + showItemCount = false, + preload = false, + onShowContextMenu = undefined, + }: Props = $props(); const showAlbumContextMenu = (e: MouseEvent) => { e.stopPropagation(); @@ -39,7 +50,7 @@ size="20" padding="2" class="icon-white-drop-shadow" - on:click={showAlbumContextMenu} + onclick={showAlbumContextMenu} /> </div> {/if} diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte index d0444f35990e2..3f71bbe632c9f 100644 --- a/web/src/lib/components/album-page/album-cover.svelte +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -5,13 +5,18 @@ import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte'; import { t } from 'svelte-i18n'; - export let album: AlbumResponseDto; - export let preload = false; - let className = ''; - export { className as class }; + interface Props { + album: AlbumResponseDto; + preload?: boolean; + class?: string; + } - $: alt = album.albumName || $t('unnamed_album'); - $: thumbnailUrl = album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null; + let { album, preload = false, class: className = '' }: Props = $props(); + + let alt = $derived(album.albumName || $t('unnamed_album')); + let thumbnailUrl = $derived( + album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null, + ); </script> {#if thumbnailUrl} diff --git a/web/src/lib/components/album-page/album-description.svelte b/web/src/lib/components/album-page/album-description.svelte index b3ad688a30512..46b424f93ae0d 100644 --- a/web/src/lib/components/album-page/album-description.svelte +++ b/web/src/lib/components/album-page/album-description.svelte @@ -4,9 +4,13 @@ import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte'; import { t } from 'svelte-i18n'; - export let id: string; - export let description: string; - export let isOwned: boolean; + interface Props { + id: string; + description: string; + isOwned: boolean; + } + + let { id, description = $bindable(), isOwned }: Props = $props(); const handleUpdateDescription = async (newDescription: string) => { try { diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte index 3ec1842757201..884de8c2a2b83 100644 --- a/web/src/lib/components/album-page/album-options.svelte +++ b/web/src/lib/components/album-page/album-options.svelte @@ -23,24 +23,38 @@ import { notificationController, NotificationType } from '../shared-components/notification/notification'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; - export let album: AlbumResponseDto; - export let order: AssetOrder | undefined; - export let user: UserResponseDto; // Declare user as a prop - export let onChangeOrder: (order: AssetOrder) => void; - export let onClose: () => void; - export let onToggleEnabledActivity: () => void; - export let onShowSelectSharedUser: () => void; - export let onRemove: (userId: string) => void; - export let onRefreshAlbum: () => void; + interface Props { + album: AlbumResponseDto; + order: AssetOrder | undefined; + user: UserResponseDto; + onChangeOrder: (order: AssetOrder) => void; + onClose: () => void; + onToggleEnabledActivity: () => void; + onShowSelectSharedUser: () => void; + onRemove: (userId: string) => void; + onRefreshAlbum: () => void; + } - let selectedRemoveUser: UserResponseDto | null = null; + let { + album, + order, + user, + onChangeOrder, + onClose, + onToggleEnabledActivity, + onShowSelectSharedUser, + onRemove, + onRefreshAlbum, + }: Props = $props(); + + let selectedRemoveUser: UserResponseDto | null = $state(null); const options: Record<AssetOrder, RenderedOption> = { [AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') }, [AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') }, }; - $: selectedOption = order ? options[order] : options[AssetOrder.Desc]; + let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]); const handleToggle = async (returnedOption: RenderedOption): Promise<void> => { if (selectedOption === returnedOption) { @@ -125,7 +139,7 @@ <div class="py-2"> <div class="text-gray text-sm mb-3">{$t('people').toUpperCase()}</div> <div class="p-2"> - <button type="button" class="flex items-center gap-2" on:click={onShowSelectSharedUser}> + <button type="button" class="flex items-center gap-2" onclick={onShowSelectSharedUser}> <div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center"> <div><Icon path={mdiPlus} size="25" /></div> </div> diff --git a/web/src/lib/components/album-page/album-summary.svelte b/web/src/lib/components/album-page/album-summary.svelte index 0277035d5c97d..f2cd23f616469 100644 --- a/web/src/lib/components/album-page/album-summary.svelte +++ b/web/src/lib/components/album-page/album-summary.svelte @@ -4,10 +4,11 @@ import type { AlbumResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; - export let album: AlbumResponseDto; + interface Props { + album: AlbumResponseDto; + } - $: startDate = formatDate(album.startDate); - $: endDate = formatDate(album.endDate); + let { album }: Props = $props(); const formatDate = (date?: string) => { return date ? new Date(date).toLocaleDateString($locale, dateFormats.album) : undefined; @@ -24,6 +25,8 @@ return ''; }; + let startDate = $derived(formatDate(album.startDate)); + let endDate = $derived(formatDate(album.endDate)); </script> <span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details"> diff --git a/web/src/lib/components/album-page/album-title.svelte b/web/src/lib/components/album-page/album-title.svelte index 1e69ecf1a3c2d..74786c1ea42f6 100644 --- a/web/src/lib/components/album-page/album-title.svelte +++ b/web/src/lib/components/album-page/album-title.svelte @@ -4,12 +4,20 @@ import { shortcut } from '$lib/actions/shortcut'; import { t } from 'svelte-i18n'; - export let id: string; - export let albumName: string; - export let isOwned: boolean; - export let onUpdate: (albumName: string) => void; + interface Props { + id: string; + albumName: string; + isOwned: boolean; + onUpdate: (albumName: string) => void; + } - $: newAlbumName = albumName; + let { id, albumName = $bindable(), isOwned, onUpdate }: Props = $props(); + + let newAlbumName = $state(albumName); + + $effect(() => { + newAlbumName = albumName; + }); const handleUpdateName = async () => { if (newAlbumName === albumName) { @@ -33,7 +41,7 @@ <input use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }} - on:blur={handleUpdateName} + onblur={handleUpdateName} class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned ? 'hover:border-gray-400' : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 87b3d8e2c50f1..1dc43c5b61188 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -21,11 +21,15 @@ import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; - export let sharedLink: SharedLinkResponseDto; - export let user: UserResponseDto | undefined = undefined; + interface Props { + sharedLink: SharedLinkResponseDto; + user?: UserResponseDto | undefined; + } + + let { sharedLink, user = undefined }: Props = $props(); const album = sharedLink.album as AlbumResponseDto; - let innerWidth: number; + let innerWidth: number = $state(0); let { isViewing: showAssetViewer } = assetViewingStore; @@ -70,15 +74,15 @@ </AssetSelectControlBar> {:else} <ControlAppBar showBackButton={false}> - <svelte:fragment slot="leading"> + {#snippet leading()} <ImmichLogoSmallLink width={innerWidth} /> - </svelte:fragment> + {/snippet} - <svelte:fragment slot="trailing"> + {#snippet trailing()} {#if sharedLink.allowUpload} <CircleIconButton title={$t('add_photos')} - on:click={() => openFileUploadDialog({ albumId: album.id })} + onclick={() => openFileUploadDialog({ albumId: album.id })} icon={mdiFileImagePlusOutline} /> {/if} @@ -86,13 +90,13 @@ {#if album.assetCount > 0 && sharedLink.allowDownload} <CircleIconButton title={$t('download')} - on:click={() => downloadAlbum(album)} + onclick={() => downloadAlbum(album)} icon={mdiFolderDownloadOutline} /> {/if} <ThemeButton /> - </svelte:fragment> + {/snippet} </ControlAppBar> {/if} </header> diff --git a/web/src/lib/components/album-page/albums-controls.svelte b/web/src/lib/components/album-page/albums-controls.svelte index 34563eddd3987..85a7260f40d39 100644 --- a/web/src/lib/components/album-page/albums-controls.svelte +++ b/web/src/lib/components/album-page/albums-controls.svelte @@ -38,8 +38,12 @@ import { fly } from 'svelte/transition'; import { t } from 'svelte-i18n'; - export let albumGroups: string[]; - export let searchQuery: string; + interface Props { + albumGroups: string[]; + searchQuery: string; + } + + let { albumGroups, searchQuery = $bindable() }: Props = $props(); const flipOrdering = (ordering: string) => { return ordering === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc; @@ -73,62 +77,38 @@ $albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover; }; - let selectedGroupOption: AlbumGroupOptionMetadata; - let groupIcon: string; - - $: selectedFilterOption = albumFilterNames[findFilterOption($albumViewSettings.filter)]; - - $: selectedSortOption = findSortOptionMetadata($albumViewSettings.sortBy); - - $: { - selectedGroupOption = findGroupOptionMetadata($albumViewSettings.groupBy); - if (selectedGroupOption.isDisabled()) { - selectedGroupOption = findGroupOptionMetadata(AlbumGroupBy.None); + let groupIcon = $derived.by(() => { + if (selectedGroupOption?.id === AlbumGroupBy.None) { + return mdiFolderRemoveOutline; } - } - - // svelte-ignore reactive_declaration_non_reactive_property - $: { - if (selectedGroupOption.id === AlbumGroupBy.None) { - groupIcon = mdiFolderRemoveOutline; - } else { - groupIcon = - $albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline; - } - } - - // svelte-ignore reactive_declaration_non_reactive_property - $: sortIcon = $albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin; - - // svelte-ignore reactive_declaration_non_reactive_property - $: albumFilterNames = ((): Record<AlbumFilter, string> => { - return { - [AlbumFilter.All]: $t('all'), - [AlbumFilter.Owned]: $t('owned'), - [AlbumFilter.Shared]: $t('shared'), - }; - })(); - - // svelte-ignore reactive_declaration_non_reactive_property - $: albumSortByNames = ((): Record<AlbumSortBy, string> => { - return { - [AlbumSortBy.Title]: $t('sort_title'), - [AlbumSortBy.ItemCount]: $t('sort_items'), - [AlbumSortBy.DateModified]: $t('sort_modified'), - [AlbumSortBy.DateCreated]: $t('sort_created'), - [AlbumSortBy.MostRecentPhoto]: $t('sort_recent'), - [AlbumSortBy.OldestPhoto]: $t('sort_oldest'), - }; - })(); - - // svelte-ignore reactive_declaration_non_reactive_property - $: albumGroupByNames = ((): Record<AlbumGroupBy, string> => { - return { - [AlbumGroupBy.None]: $t('group_no'), - [AlbumGroupBy.Owner]: $t('group_owner'), - [AlbumGroupBy.Year]: $t('group_year'), - }; - })(); + return $albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline; + }); + + let albumFilterNames: Record<AlbumFilter, string> = $derived({ + [AlbumFilter.All]: $t('all'), + [AlbumFilter.Owned]: $t('owned'), + [AlbumFilter.Shared]: $t('shared'), + }); + + let selectedFilterOption = $derived(albumFilterNames[findFilterOption($albumViewSettings.filter)]); + let selectedSortOption = $derived(findSortOptionMetadata($albumViewSettings.sortBy)); + let selectedGroupOption = $derived(findGroupOptionMetadata($albumViewSettings.groupBy)); + let sortIcon = $derived($albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin); + + let albumSortByNames: Record<AlbumSortBy, string> = $derived({ + [AlbumSortBy.Title]: $t('sort_title'), + [AlbumSortBy.ItemCount]: $t('sort_items'), + [AlbumSortBy.DateModified]: $t('sort_modified'), + [AlbumSortBy.DateCreated]: $t('sort_created'), + [AlbumSortBy.MostRecentPhoto]: $t('sort_recent'), + [AlbumSortBy.OldestPhoto]: $t('sort_oldest'), + }); + + let albumGroupByNames: Record<AlbumGroupBy, string> = $derived({ + [AlbumGroupBy.None]: $t('group_no'), + [AlbumGroupBy.Owner]: $t('group_owner'), + [AlbumGroupBy.Year]: $t('group_year'), + }); </script> <!-- Filter Albums by Sharing Status (All, Owned, Shared) --> @@ -147,7 +127,7 @@ </div> <!-- Create Album --> -<LinkButton on:click={() => createAlbumAndRedirect()}> +<LinkButton onclick={() => createAlbumAndRedirect()}> <div class="flex place-items-center gap-2 text-sm"> <Icon path={mdiPlusBoxOutline} size="18" /> <p class="hidden md:block">{$t('create_album')}</p> @@ -184,7 +164,7 @@ <!-- Expand Album Groups --> <div class="hidden xl:flex gap-0"> <div class="block"> - <LinkButton title={$t('expand_all')} on:click={() => expandAllAlbumGroups()}> + <LinkButton title={$t('expand_all')} onclick={() => expandAllAlbumGroups()}> <div class="flex place-items-center gap-2 text-sm"> <Icon path={mdiUnfoldMoreHorizontal} size="18" /> </div> @@ -193,7 +173,7 @@ <!-- Collapse Album Groups --> <div class="block"> - <LinkButton title={$t('collapse_all')} on:click={() => collapseAllAlbumGroups(albumGroups)}> + <LinkButton title={$t('collapse_all')} onclick={() => collapseAllAlbumGroups(albumGroups)}> <div class="flex place-items-center gap-2 text-sm"> <Icon path={mdiUnfoldLessHorizontal} size="18" /> </div> @@ -204,7 +184,7 @@ {/if} <!-- Cover/List Display Toggle --> -<LinkButton on:click={() => handleChangeListMode()}> +<LinkButton onclick={() => handleChangeListMode()}> <div class="flex place-items-center gap-2 text-sm"> {#if $albumViewSettings.view === AlbumViewMode.List} <Icon path={mdiViewGridOutline} size="18" /> diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index 3858dd23b7809..178190dc340b4 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { onMount } from 'svelte'; + import { onMount, type Snippet } from 'svelte'; import { groupBy } from 'lodash-es'; import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk'; import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js'; @@ -38,14 +38,29 @@ import { goto } from '$app/navigation'; import { AppRoute } from '$lib/constants'; import { t } from 'svelte-i18n'; + import { run } from 'svelte/legacy'; + + interface Props { + ownedAlbums?: AlbumResponseDto[]; + sharedAlbums?: AlbumResponseDto[]; + searchQuery?: string; + userSettings: AlbumViewSettings; + allowEdit?: boolean; + showOwner?: boolean; + albumGroupIds?: string[]; + empty?: Snippet; + } - export let ownedAlbums: AlbumResponseDto[] = []; - export let sharedAlbums: AlbumResponseDto[] = []; - export let searchQuery: string = ''; - export let userSettings: AlbumViewSettings; - export let allowEdit = false; - export let showOwner = false; - export let albumGroupIds: string[] = []; + let { + ownedAlbums = $bindable([]), + sharedAlbums = $bindable([]), + searchQuery = '', + userSettings, + allowEdit = false, + showOwner = false, + albumGroupIds = $bindable([]), + empty, + }: Props = $props(); interface AlbumGroupOption { [option: string]: (order: SortOrder, albums: AlbumResponseDto[]) => AlbumGroup[]; @@ -118,25 +133,24 @@ }, }; - let albums: AlbumResponseDto[] = []; - let filteredAlbums: AlbumResponseDto[] = []; - let groupedAlbums: AlbumGroup[] = []; + let albums: AlbumResponseDto[] = $state([]); + let filteredAlbums: AlbumResponseDto[] = $state([]); + let groupedAlbums: AlbumGroup[] = $state([]); - let albumGroupOption: string = AlbumGroupBy.None; + let albumGroupOption: string = $state(AlbumGroupBy.None); - let showShareByURLModal = false; + let showShareByURLModal = $state(false); - let albumToEdit: AlbumResponseDto | null = null; - let albumToShare: AlbumResponseDto | null = null; + let albumToEdit: AlbumResponseDto | null = $state(null); + let albumToShare: AlbumResponseDto | null = $state(null); let albumToDelete: AlbumResponseDto | null = null; - let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 }; - let contextMenuTargetAlbum: AlbumResponseDto | null = null; - let isOpen = false; + let contextMenuPosition: ContextMenuPosition = $state({ x: 0, y: 0 }); + let contextMenuTargetAlbum: AlbumResponseDto | undefined = $state(); + let isOpen = $state(false); // Step 1: Filter between Owned and Shared albums, or both. - // svelte-ignore reactive_declaration_non_reactive_property - $: { + run(() => { switch (userSettings.filter) { case AlbumFilter.Owned: { albums = ownedAlbums; @@ -152,10 +166,10 @@ albums = nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums; } } - } + }); // Step 2: Filter using the given search query. - $: { + run(() => { if (searchQuery) { const searchAlbumNormalized = normalizeSearchString(searchQuery); @@ -165,17 +179,17 @@ } else { filteredAlbums = albums; } - } + }); // Step 3: Group albums. - $: { + run(() => { albumGroupOption = getSelectedAlbumGroupOption(userSettings); const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None]; groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums); - } + }); // Step 4: Sort albums amongst each group. - $: { + run(() => { groupedAlbums = groupedAlbums.map((group) => ({ id: group.id, name: group.name, @@ -183,9 +197,11 @@ })); albumGroupIds = groupedAlbums.map(({ id }) => id); - } + }); - $: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id; + let showFullContextMenu = $derived( + allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id, + ); onMount(async () => { if (allowEdit) { @@ -320,6 +336,10 @@ }; const openShareModal = () => { + if (!contextMenuTargetAlbum) { + return; + } + albumToShare = contextMenuTargetAlbum; closeAlbumContextMenu(); }; @@ -359,7 +379,7 @@ {/if} {:else} <!-- Empty Message --> - <slot name="empty" /> + {@render empty?.()} {/if} <!-- Context Menu --> diff --git a/web/src/lib/components/album-page/albums-table-header.svelte b/web/src/lib/components/album-page/albums-table-header.svelte index 84e32b82f55e8..4c018f7454b3d 100644 --- a/web/src/lib/components/album-page/albums-table-header.svelte +++ b/web/src/lib/components/album-page/albums-table-header.svelte @@ -3,7 +3,11 @@ import type { AlbumSortOptionMetadata } from '$lib/utils/album-utils'; import { t } from 'svelte-i18n'; - export let option: AlbumSortOptionMetadata; + interface Props { + option: AlbumSortOptionMetadata; + } + + let { option }: Props = $props(); const handleSort = () => { if ($albumViewSettings.sortBy === option.id) { @@ -13,24 +17,22 @@ $albumViewSettings.sortOrder = option.defaultOrder; } }; - // svelte-ignore reactive_declaration_non_reactive_property - $: albumSortByNames = ((): Record<AlbumSortBy, string> => { - return { - [AlbumSortBy.Title]: $t('sort_title'), - [AlbumSortBy.ItemCount]: $t('sort_items'), - [AlbumSortBy.DateModified]: $t('sort_modified'), - [AlbumSortBy.DateCreated]: $t('sort_created'), - [AlbumSortBy.MostRecentPhoto]: $t('sort_recent'), - [AlbumSortBy.OldestPhoto]: $t('sort_oldest'), - }; - })(); + + let albumSortByNames: Record<AlbumSortBy, string> = $derived({ + [AlbumSortBy.Title]: $t('sort_title'), + [AlbumSortBy.ItemCount]: $t('sort_items'), + [AlbumSortBy.DateModified]: $t('sort_modified'), + [AlbumSortBy.DateCreated]: $t('sort_created'), + [AlbumSortBy.MostRecentPhoto]: $t('sort_recent'), + [AlbumSortBy.OldestPhoto]: $t('sort_oldest'), + }); </script> <th class="text-sm font-medium {option.columnStyle}"> <button type="button" class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50" - on:click={handleSort} + onclick={handleSort} > {#if $albumViewSettings.sortBy === option.id} {#if $albumViewSettings.sortOrder === SortOrder.Desc} diff --git a/web/src/lib/components/album-page/albums-table-row.svelte b/web/src/lib/components/album-page/albums-table-row.svelte index 3e9027de3dbfd..c900930f8abad 100644 --- a/web/src/lib/components/album-page/albums-table-row.svelte +++ b/web/src/lib/components/album-page/albums-table-row.svelte @@ -9,9 +9,12 @@ import Icon from '$lib/components/elements/icon.svelte'; import { t } from 'svelte-i18n'; - export let album: AlbumResponseDto; - export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined = - undefined; + interface Props { + album: AlbumResponseDto; + onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined; + } + + let { album, onShowContextMenu = undefined }: Props = $props(); const showContextMenu = (position: ContextMenuPosition) => { onShowContextMenu?.(position, album); @@ -20,12 +23,17 @@ const dateLocaleString = (dateString: string) => { return new Date(dateString).toLocaleDateString($locale, dateFormats.album); }; + + const oncontextmenu = (event: MouseEvent) => { + event.preventDefault(); + showContextMenu({ x: event.x, y: event.y }); + }; </script> <tr class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center 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" - on:click={() => goto(`${AppRoute.ALBUMS}/${album.id}`)} - on:contextmenu|preventDefault={(e) => showContextMenu({ x: e.x, y: e.y })} + onclick={() => goto(`${AppRoute.ALBUMS}/${album.id}`)} + {oncontextmenu} > <td class="text-md text-ellipsis text-left w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center"> {album.albumName} diff --git a/web/src/lib/components/album-page/albums-table.svelte b/web/src/lib/components/album-page/albums-table.svelte index d9ffe8595bf11..bd7c7fd7f5a46 100644 --- a/web/src/lib/components/album-page/albums-table.svelte +++ b/web/src/lib/components/album-page/albums-table.svelte @@ -15,10 +15,13 @@ } from '$lib/utils/album-utils'; import { t } from 'svelte-i18n'; - export let groupedAlbums: AlbumGroup[]; - export let albumGroupOption: string = AlbumGroupBy.None; - export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined = - undefined; + interface Props { + groupedAlbums: AlbumGroup[]; + albumGroupOption?: string; + onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined; + } + + let { groupedAlbums, albumGroupOption = AlbumGroupBy.None, onShowContextMenu }: Props = $props(); </script> <table class="mt-2 w-full text-left"> @@ -46,7 +49,7 @@ > <tr class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3" - on:click={() => toggleAlbumGroupCollapsing(albumGroup.id)} + onclick={() => toggleAlbumGroupCollapsing(albumGroup.id)} aria-expanded={!isCollapsed} > <td class="text-md text-left -mb-1"> diff --git a/web/src/lib/components/album-page/share-info-modal.svelte b/web/src/lib/components/album-page/share-info-modal.svelte index ee98d5a82186f..778943af3a592 100644 --- a/web/src/lib/components/album-page/share-info-modal.svelte +++ b/web/src/lib/components/album-page/share-info-modal.svelte @@ -18,15 +18,19 @@ import { t } from 'svelte-i18n'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; - export let album: AlbumResponseDto; - export let onClose: () => void; - export let onRemove: (userId: string) => void; - export let onRefreshAlbum: () => void; + interface Props { + album: AlbumResponseDto; + onClose: () => void; + onRemove: (userId: string) => void; + onRefreshAlbum: () => void; + } - let currentUser: UserResponseDto; - let selectedRemoveUser: UserResponseDto | null = null; + let { album, onClose, onRemove, onRefreshAlbum }: Props = $props(); - $: isOwned = currentUser?.id == album.ownerId; + let currentUser: UserResponseDto | undefined = $state(); + let selectedRemoveUser: UserResponseDto | null = $state(null); + + let isOwned = $derived(currentUser?.id == album.ownerId); onMount(async () => { try { @@ -123,7 +127,7 @@ {:else if user.id == currentUser?.id} <button type="button" - on:click={() => (selectedRemoveUser = user)} + onclick={() => (selectedRemoveUser = user)} class="text-sm font-medium text-immich-primary transition-colors hover:text-immich-primary/75 dark:text-immich-dark-primary" >{$t('leave')}</button > diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index ee0a5c7410ab2..fca244ac75425 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -18,13 +18,17 @@ import UserAvatar from '../shared-components/user-avatar.svelte'; import { t } from 'svelte-i18n'; - export let album: AlbumResponseDto; - export let onClose: () => void; - export let onSelect: (selectedUsers: AlbumUserAddDto[]) => void; - export let onShare: () => void; + interface Props { + album: AlbumResponseDto; + onClose: () => void; + onSelect: (selectedUsers: AlbumUserAddDto[]) => void; + onShare: () => void; + } - let users: UserResponseDto[] = []; - let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = {}; + let { album, onClose, onSelect, onShare }: Props = $props(); + + let users: UserResponseDto[] = $state([]); + let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({}); const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [ { title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil }, @@ -32,7 +36,7 @@ { title: $t('remove_user'), value: 'none' }, ]; - let sharedLinks: SharedLinkResponseDto[] = []; + let sharedLinks: SharedLinkResponseDto[] = $state([]); onMount(async () => { await getSharedLinks(); const data = await searchUsers(); @@ -121,11 +125,7 @@ {#each users as user} {#if !Object.keys(selectedUsers).includes(user.id)} <div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"> - <button - type="button" - on:click={() => handleToggle(user)} - class="flex w-full place-items-center gap-4 p-4" - > + <button type="button" onclick={() => handleToggle(user)} class="flex w-full place-items-center gap-4 p-4"> <UserAvatar {user} size="md" /> <div class="text-left flex-grow"> <p class="text-immich-fg dark:text-immich-dark-fg"> @@ -150,7 +150,7 @@ fullwidth rounded="full" disabled={Object.keys(selectedUsers).length === 0} - on:click={() => + onclick={() => onSelect(Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })))} >{$t('add')}</Button > @@ -163,7 +163,7 @@ <button type="button" class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer" - on:click={onShare} + onclick={onShare} > <Icon path={mdiLink} size={24} /> <p class="text-sm">{$t('create_link')}</p> diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte index cd4e8091afa0f..ab0da059d0395 100644 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte @@ -9,11 +9,15 @@ import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let asset: AssetResponseDto; - export let onAction: OnAction; - export let shared = false; + interface Props { + asset: AssetResponseDto; + onAction: OnAction; + shared?: boolean; + } - let showSelectionModal = false; + let { asset, onAction, shared = false }: Props = $props(); + + let showSelectionModal = $state(false); const handleAddToNewAlbum = async (albumName: string) => { showSelectionModal = false; diff --git a/web/src/lib/components/asset-viewer/actions/archive-action.svelte b/web/src/lib/components/asset-viewer/actions/archive-action.svelte index 3e2c453f392e1..6337b278924f7 100644 --- a/web/src/lib/components/asset-viewer/actions/archive-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/archive-action.svelte @@ -8,8 +8,12 @@ import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let asset: AssetResponseDto; - export let onAction: OnAction; + interface Props { + asset: AssetResponseDto; + onAction: OnAction; + } + + let { asset, onAction }: Props = $props(); const onArchive = async () => { const updatedAsset = await toggleArchive(asset); diff --git a/web/src/lib/components/asset-viewer/actions/close-action.svelte b/web/src/lib/components/asset-viewer/actions/close-action.svelte index 647ad61e4fb10..26cb81edd8061 100644 --- a/web/src/lib/components/asset-viewer/actions/close-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/close-action.svelte @@ -4,9 +4,13 @@ import { mdiArrowLeft } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let onClose: () => void; + interface Props { + onClose: () => void; + } + + let { onClose }: Props = $props(); </script> <svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} /> -<CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} on:click={onClose} /> +<CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} onclick={onClose} /> diff --git a/web/src/lib/components/asset-viewer/actions/delete-action.svelte b/web/src/lib/components/asset-viewer/actions/delete-action.svelte index ae5f83c456952..c0f163634a83b 100644 --- a/web/src/lib/components/asset-viewer/actions/delete-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/delete-action.svelte @@ -16,10 +16,14 @@ import { t } from 'svelte-i18n'; import type { OnAction } from './action'; - export let asset: AssetResponseDto; - export let onAction: OnAction; + interface Props { + asset: AssetResponseDto; + onAction: OnAction; + } - let showConfirmModal = false; + let { asset, onAction }: Props = $props(); + + let showConfirmModal = $state(false); const trashOrDelete = async (force = false) => { if (force || !$featureFlags.trash) { @@ -77,7 +81,7 @@ color="opaque" icon={asset.isTrashed ? mdiDeleteForeverOutline : mdiDeleteOutline} title={asset.isTrashed ? $t('permanently_delete') : $t('delete')} - on:click={() => trashOrDelete(asset.isTrashed)} + onclick={() => trashOrDelete(asset.isTrashed)} /> {#if showConfirmModal} diff --git a/web/src/lib/components/asset-viewer/actions/download-action.svelte b/web/src/lib/components/asset-viewer/actions/download-action.svelte index 88c0eeadf2869..d7f4f563527d7 100644 --- a/web/src/lib/components/asset-viewer/actions/download-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/download-action.svelte @@ -7,8 +7,12 @@ import { mdiFolderDownloadOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let asset: AssetResponseDto; - export let menuItem = false; + interface Props { + asset: AssetResponseDto; + menuItem?: boolean; + } + + let { asset, menuItem = false }: Props = $props(); const onDownloadFile = () => downloadFile(asset); </script> @@ -16,7 +20,7 @@ <svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} /> {#if !menuItem} - <CircleIconButton color="opaque" icon={mdiFolderDownloadOutline} title={$t('download')} on:click={onDownloadFile} /> + <CircleIconButton color="opaque" icon={mdiFolderDownloadOutline} title={$t('download')} onclick={onDownloadFile} /> {:else} <MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} onClick={onDownloadFile} /> {/if} diff --git a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte b/web/src/lib/components/asset-viewer/actions/favorite-action.svelte index 488ed7ecb2d89..0cc3188d518ce 100644 --- a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/favorite-action.svelte @@ -12,8 +12,12 @@ import { t } from 'svelte-i18n'; import type { OnAction } from './action'; - export let asset: AssetResponseDto; - export let onAction: OnAction; + interface Props { + asset: AssetResponseDto; + onAction: OnAction; + } + + let { asset, onAction }: Props = $props(); const toggleFavorite = async () => { try { @@ -24,7 +28,8 @@ }, }); - asset.isFavorite = data.isFavorite; + asset = { ...asset, isFavorite: data.isFavorite }; + onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset }); notificationController.show({ @@ -43,5 +48,5 @@ color="opaque" icon={asset.isFavorite ? mdiHeart : mdiHeartOutline} title={asset.isFavorite ? $t('unfavorite') : $t('to_favorite')} - on:click={toggleFavorite} + onclick={toggleFavorite} /> diff --git a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte b/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte index fd519a05d470d..f629a42db7b95 100644 --- a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte @@ -3,13 +3,17 @@ import { mdiMotionPauseOutline, mdiPlaySpeed } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let isPlaying: boolean; - export let onClick: (shouldPlay: boolean) => void; + interface Props { + isPlaying: boolean; + onClick: (shouldPlay: boolean) => void; + } + + let { isPlaying, onClick }: Props = $props(); </script> <CircleIconButton color="opaque" icon={isPlaying ? mdiMotionPauseOutline : mdiPlaySpeed} title={isPlaying ? $t('stop_motion_photo') : $t('play_motion_photo')} - on:click={() => onClick(!isPlaying)} + onclick={() => onClick(!isPlaying)} /> diff --git a/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte b/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte index cc074f3b6c2d9..355f816a6b9f2 100644 --- a/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte @@ -5,7 +5,11 @@ import { t } from 'svelte-i18n'; import NavigationArea from '../navigation-area.svelte'; - export let onNextAsset: () => void; + interface Props { + onNextAsset: () => void; + } + + let { onNextAsset }: Props = $props(); </script> <svelte:window diff --git a/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte b/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte index 9f8c638e1220a..1770bc673ab7b 100644 --- a/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte @@ -5,7 +5,11 @@ import { t } from 'svelte-i18n'; import NavigationArea from '../navigation-area.svelte'; - export let onPreviousAsset: () => void; + interface Props { + onPreviousAsset: () => void; + } + + let { onPreviousAsset }: Props = $props(); </script> <svelte:window diff --git a/web/src/lib/components/asset-viewer/actions/restore-action.svelte b/web/src/lib/components/asset-viewer/actions/restore-action.svelte index c000dad9a1cfe..abcae5c4c919d 100644 --- a/web/src/lib/components/asset-viewer/actions/restore-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/restore-action.svelte @@ -11,8 +11,12 @@ import { t } from 'svelte-i18n'; import type { OnAction } from './action'; - export let asset: AssetResponseDto; - export let onAction: OnAction; + interface Props { + asset: AssetResponseDto; + onAction: OnAction; + } + + let { asset = $bindable(), onAction }: Props = $props(); const handleRestoreAsset = async () => { try { diff --git a/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte b/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte index f20c4872bca47..c015c224ff654 100644 --- a/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte @@ -9,8 +9,12 @@ import { mdiImageOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let asset: AssetResponseDto; - export let album: AlbumResponseDto; + interface Props { + asset: AssetResponseDto; + album: AlbumResponseDto; + } + + let { asset, album }: Props = $props(); const handleUpdateThumbnail = async () => { try { diff --git a/web/src/lib/components/asset-viewer/actions/set-profile-picture-action.svelte b/web/src/lib/components/asset-viewer/actions/set-profile-picture-action.svelte index 23c147815c1f3..a35ff11c48079 100644 --- a/web/src/lib/components/asset-viewer/actions/set-profile-picture-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/set-profile-picture-action.svelte @@ -6,9 +6,13 @@ import { mdiAccountCircleOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let asset: AssetResponseDto; + interface Props { + asset: AssetResponseDto; + } - let showProfileImageCrop = false; + let { asset }: Props = $props(); + + let showProfileImageCrop = $state(false); </script> <MenuOption diff --git a/web/src/lib/components/asset-viewer/actions/share-action.svelte b/web/src/lib/components/asset-viewer/actions/share-action.svelte index f0b2177128471..6fd5aa456eeff 100644 --- a/web/src/lib/components/asset-viewer/actions/share-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/share-action.svelte @@ -6,17 +6,16 @@ import { mdiShareVariantOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let asset: AssetResponseDto; + interface Props { + asset: AssetResponseDto; + } - let showModal = false; + let { asset }: Props = $props(); + + let showModal = $state(false); </script> -<CircleIconButton - color="opaque" - icon={mdiShareVariantOutline} - on:click={() => (showModal = true)} - title={$t('share')} -/> +<CircleIconButton color="opaque" icon={mdiShareVariantOutline} onclick={() => (showModal = true)} title={$t('share')} /> {#if showModal} <Portal target="body"> diff --git a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte b/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte index 66e5d0e10ff8e..5613114cad3a5 100644 --- a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte @@ -4,9 +4,13 @@ import { mdiInformationOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let onShowDetail: () => void; + interface Props { + onShowDetail: () => void; + } + + let { onShowDetail }: Props = $props(); </script> <svelte:window use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} /> -<CircleIconButton color="opaque" icon={mdiInformationOutline} on:click={onShowDetail} title={$t('info')} /> +<CircleIconButton color="opaque" icon={mdiInformationOutline} onclick={onShowDetail} title={$t('info')} /> diff --git a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte index bd18e0e8bffde..f2a50cce1351e 100644 --- a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte @@ -7,8 +7,12 @@ import { t } from 'svelte-i18n'; import type { OnAction } from './action'; - export let stack: StackResponseDto; - export let onAction: OnAction; + interface Props { + stack: StackResponseDto; + onAction: OnAction; + } + + let { stack, onAction }: Props = $props(); const handleUnstack = async () => { const unstackedAssets = await deleteStack([stack.id]); diff --git a/web/src/lib/components/asset-viewer/activity-status.svelte b/web/src/lib/components/asset-viewer/activity-status.svelte index fe6ee793630d4..494c6fcbf7de9 100644 --- a/web/src/lib/components/asset-viewer/activity-status.svelte +++ b/web/src/lib/components/asset-viewer/activity-status.svelte @@ -4,20 +4,24 @@ import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js'; import Icon from '../elements/icon.svelte'; - export let isLiked: ActivityResponseDto | null; - export let numberOfComments: number | undefined; - export let disabled: boolean; - export let onOpenActivityTab: () => void; - export let onFavorite: () => void; + interface Props { + isLiked: ActivityResponseDto | null; + numberOfComments: number | undefined; + disabled: boolean; + onOpenActivityTab: () => void; + onFavorite: () => void; + } + + let { isLiked, numberOfComments, disabled, onOpenActivityTab, onFavorite }: Props = $props(); </script> <div class="w-full flex p-4 text-white items-center justify-center rounded-full gap-5 bg-immich-dark-bg bg-opacity-60"> - <button type="button" class={disabled ? 'cursor-not-allowed' : ''} on:click={onFavorite} {disabled}> + <button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}> <div class="items-center justify-center"> <Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} /> </div> </button> - <button type="button" on:click={onOpenActivityTab}> + <button type="button" onclick={onOpenActivityTab}> <div class="flex gap-2 items-center justify-center"> <Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} /> {#if numberOfComments} diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 4f4fdb264961a..34940aee56c9d 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -47,40 +47,45 @@ return relativeFormatter.format(Math.trunc(diff.as(unit)), unit); }; - export let reactions: ActivityResponseDto[]; - export let user: UserResponseDto; - export let assetId: string | undefined = undefined; - export let albumId: string; - export let assetType: AssetTypeEnum | undefined = undefined; - export let albumOwnerId: string; - export let disabled: boolean; - export let isLiked: ActivityResponseDto | null; - export let onDeleteComment: () => void; - export let onDeleteLike: () => void; - export let onAddComment: () => void; - export let onClose: () => void; + 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 textArea: HTMLTextAreaElement; - let innerHeight: number; - let activityHeight: number; - let chatHeight: number; - let divHeight: number; - let previousAssetId: string | undefined = assetId; - let message = ''; - let isSendingMessage = false; + let { + reactions = $bindable(), + user, + assetId = undefined, + albumId, + assetType = undefined, + albumOwnerId, + disabled, + isLiked, + onDeleteComment, + onDeleteLike, + onAddComment, + onClose, + }: Props = $props(); - $: { - if (innerHeight && activityHeight) { - divHeight = innerHeight - activityHeight; - } - } + let textArea: HTMLTextAreaElement | undefined = $state(); + let innerHeight: number = $state(0); + let activityHeight: number = $state(0); + let chatHeight: number = $state(0); + let divHeight: number = $state(0); + let previousAssetId: string | undefined = $state(assetId); + let message = $state(''); + let isSendingMessage = $state(false); - $: { - if (assetId && previousAssetId != assetId) { - handlePromiseError(getReactions()); - previousAssetId = assetId; - } - } onMount(async () => { await getReactions(); }); @@ -136,7 +141,11 @@ activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message }, }); reactions.push(data); - textArea.style.height = '18px'; + + if (textArea) { + textArea.style.height = '18px'; + } + message = ''; onAddComment(); // Re-render the activity feed @@ -148,6 +157,22 @@ } isSendingMessage = false; }; + $effect(() => { + if (innerHeight && activityHeight) { + divHeight = innerHeight - activityHeight; + } + }); + $effect(() => { + if (assetId && previousAssetId != assetId) { + handlePromiseError(getReactions()); + previousAssetId = assetId; + } + }); + + const onsubmit = async (event: Event) => { + event.preventDefault(); + await handleSendComment(); + }; </script> <div class="overflow-y-hidden relative h-full" bind:offsetHeight={innerHeight}> @@ -157,7 +182,7 @@ bind:clientHeight={activityHeight} > <div class="flex place-items-center gap-2"> - <CircleIconButton on:click={onClose} icon={mdiClose} title={$t('close')} /> + <CircleIconButton onclick={onClose} icon={mdiClose} title={$t('close')} /> <p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('activity')}</p> </div> @@ -277,7 +302,7 @@ <div> <UserAvatar {user} size="md" showTitle={false} /> </div> - <form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}> + <form class="flex w-full max-h-56 gap-1" {onsubmit}> <div class="flex w-full items-center gap-4"> <textarea {disabled} @@ -285,7 +310,7 @@ bind:value={message} use:autoGrowHeight={'5px'} placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')} - on:input={() => autoGrowHeight(textArea, '5px')} + oninput={() => autoGrowHeight(textArea, '5px')} use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: () => handleSendComment(), @@ -308,7 +333,7 @@ size="15" icon={mdiSend} class="dark:text-immich-dark-gray" - on:click={() => handleSendComment()} + onclick={() => handleSendComment()} /> </div> {/if} diff --git a/web/src/lib/components/asset-viewer/album-list-item-details.svelte b/web/src/lib/components/asset-viewer/album-list-item-details.svelte index ecc38b7c24b82..08dd105ca1f6e 100644 --- a/web/src/lib/components/asset-viewer/album-list-item-details.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item-details.svelte @@ -2,7 +2,11 @@ import type { AlbumResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; - export let album: AlbumResponseDto; + interface Props { + album: AlbumResponseDto; + } + + let { album }: Props = $props(); </script> <span>{$t('items_count', { values: { count: album.assetCount } })}</span> diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index 8e9f6f6b5a736..43352a4904b6f 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -4,15 +4,19 @@ import { normalizeSearchString } from '$lib/utils/string-utils.js'; import AlbumListItemDetails from './album-list-item-details.svelte'; - export let album: AlbumResponseDto; - export let searchQuery = ''; - export let onAlbumClick: () => void; + interface Props { + album: AlbumResponseDto; + searchQuery?: string; + onAlbumClick: () => void; + } + + let { album, searchQuery = '', onAlbumClick }: Props = $props(); - let albumNameArray: string[] = ['', '', '']; + let albumNameArray: string[] = $state(['', '', '']); // This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query // It is used to highlight the search query in the album name - $: { + $effect(() => { let { albumName } = album; let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery)); let findLength = searchQuery.length; @@ -21,12 +25,12 @@ albumName.slice(findIndex, findIndex + findLength), albumName.slice(findIndex + findLength), ]; - } + }); </script> <button type="button" - on:click={onAlbumClick} + onclick={onAlbumClick} class="flex w-full gap-4 px-6 py-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl" > <span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300"> diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 1a12a938a9681..8bea15e2a77c1 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -45,25 +45,44 @@ } from '@mdi/js'; import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { t } from 'svelte-i18n'; + import type { Snippet } from 'svelte'; - export let asset: AssetResponseDto; - export let album: AlbumResponseDto | null = null; - export let stack: StackResponseDto | null = null; - export let showDetailButton: boolean; - export let showSlideshow = false; - export let onZoomImage: () => void; - export let onCopyImage: () => void; - export let onAction: OnAction; - export let onRunJob: (name: AssetJobName) => void; - export let onPlaySlideshow: () => void; - export let onShowDetail: () => void; - // export let showEditorHandler: () => void; - export let onClose: () => void; + interface Props { + asset: AssetResponseDto; + album?: AlbumResponseDto | null; + stack?: StackResponseDto | null; + showDetailButton: boolean; + showSlideshow?: boolean; + onZoomImage: () => void; + onCopyImage?: () => Promise<void>; + onAction: OnAction; + onRunJob: (name: AssetJobName) => void; + onPlaySlideshow: () => void; + onShowDetail: () => void; + // export let showEditorHandler: () => void; + onClose: () => void; + motionPhoto?: Snippet; + } + + let { + asset, + album = null, + stack = null, + showDetailButton, + showSlideshow = false, + onZoomImage, + onCopyImage, + onAction, + onRunJob, + onPlaySlideshow, + onShowDetail, + onClose, + motionPhoto, + }: Props = $props(); const sharedLink = getSharedLink(); - $: isOwner = $user && asset.ownerId === $user?.id; - // svelte-ignore reactive_declaration_non_reactive_property - $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; + let isOwner = $derived($user && asset.ownerId === $user?.id); + let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline); // $: showEditorButton = // isOwner && // asset.type === AssetTypeEnum.Image && @@ -89,10 +108,10 @@ <ShareAction {asset} /> {/if} {#if asset.isOffline} - <CircleIconButton color="alert" icon={mdiAlertOutline} on:click={onShowDetail} title={$t('asset_offline')} /> + <CircleIconButton color="alert" icon={mdiAlertOutline} onclick={onShowDetail} title={$t('asset_offline')} /> {/if} {#if asset.livePhotoVideoId} - <slot name="motion-photo" /> + {@render motionPhoto?.()} {/if} {#if asset.type === AssetTypeEnum.Image} <CircleIconButton @@ -100,11 +119,11 @@ hideMobile={true} icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline} title={$t('zoom_image')} - on:click={onZoomImage} + onclick={onZoomImage} /> {/if} {#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image} - <CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} /> + <CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} onclick={() => onCopyImage?.()} /> {/if} {#if !isOwner && showDownloadButton} @@ -123,7 +142,7 @@ color="opaque" hideMobile={true} icon={mdiImageEditOutline} - on:click={showEditorHandler} + onclick={showEditorHandler} title={$t('editor')} /> {/if} --> diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index e4c2b3aaecb84..e27c7e94c481c 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -48,18 +48,37 @@ import SlideshowBar from './slideshow-bar.svelte'; import VideoViewer from './video-wrapper-viewer.svelte'; - export let assetStore: AssetStore | null = null; - export let asset: AssetResponseDto; - export let preloadAssets: AssetResponseDto[] = []; - export let showNavigation = true; - export let withStacked = false; - export let isShared = false; - export let album: AlbumResponseDto | null = null; - export let onAction: OnAction | undefined = undefined; - export let reactions: ActivityResponseDto[] = []; - export let onClose: (dto: { asset: AssetResponseDto }) => void; - export let onNext: () => void; - export let onPrevious: () => void; + interface Props { + assetStore?: AssetStore | null; + asset: AssetResponseDto; + preloadAssets?: AssetResponseDto[]; + showNavigation?: boolean; + withStacked?: boolean; + isShared?: boolean; + album?: AlbumResponseDto | null; + onAction?: OnAction | undefined; + reactions?: ActivityResponseDto[]; + onClose: (dto: { asset: AssetResponseDto }) => void; + onNext: () => void; + onPrevious: () => void; + copyImage?: () => Promise<void>; + } + + let { + assetStore = null, + asset = $bindable(), + preloadAssets = $bindable([]), + showNavigation = true, + withStacked = false, + isShared = false, + album = null, + onAction = undefined, + reactions = $bindable([]), + onClose, + onNext, + onPrevious, + copyImage = $bindable(), + }: Props = $props(); const { setAsset } = assetViewingStore; const { @@ -70,26 +89,23 @@ slideshowTransition, } = slideshowStore; - let appearsInAlbums: AlbumResponseDto[] = []; - let shouldPlayMotionPhoto = false; + let appearsInAlbums: AlbumResponseDto[] = $state([]); + let shouldPlayMotionPhoto = $state(false); let sharedLink = getSharedLink(); let enableDetailPanel = asset.hasMetadata; let slideshowStateUnsubscribe: () => void; let shuffleSlideshowUnsubscribe: () => void; - let previewStackedAsset: AssetResponseDto | undefined; - let isShowActivity = false; - let isShowEditor = false; - let isLiked: ActivityResponseDto | null = null; - let numberOfComments: number; - let fullscreenElement: Element; + 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 = ''; - let stack: StackResponseDto | null = null; - - let zoomToggle = () => void 0; - let copyImage: () => Promise<void>; + let selectedEditType: string = $state(''); + let stack: StackResponseDto | null = $state(null); - $: isFullScreen = fullscreenElement !== null; + let zoomToggle = $state(() => void 0); const refreshStack = async () => { if (isSharedLink()) { @@ -109,16 +125,6 @@ } }; - $: if (asset) { - handlePromiseError(refreshStack()); - } - - $: { - if (album && !album.isActivityEnabled && numberOfComments === 0) { - isShowActivity = false; - } - } - const handleAddComment = () => { numberOfComments++; updateNumberOfComments(1); @@ -184,13 +190,6 @@ } }; - $: { - if (isShared && asset.id) { - handlePromiseError(getFavorite()); - handlePromiseError(getNumberOfComments()); - } - } - onMount(async () => { unsubscribes.push( websocketEvents.on('on_upload_success', onAssetUpdate), @@ -233,12 +232,6 @@ } }); - $: { - if (asset.id && !sharedLink) { - handlePromiseError(handleGetAllAlbums()); - } - } - const handleGetAllAlbums = async () => { if (isSharedLink()) { return; @@ -337,7 +330,7 @@ * Slide show mode */ - let assetViewerHtmlElement: HTMLElement; + let assetViewerHtmlElement = $state<HTMLElement>(); const slideshowHistory = new SlideshowHistory((asset) => { setAsset(asset); @@ -352,7 +345,7 @@ const handlePlaySlideshow = async () => { try { - await assetViewerHtmlElement.requestFullscreen?.(); + await assetViewerHtmlElement?.requestFullscreen?.(); } catch (error) { handleError(error, $t('errors.unable_to_enter_fullscreen')); $slideshowState = SlideshowState.StopSlideshow; @@ -396,6 +389,28 @@ const handleUpdateSelectedEditType = (type: string) => { selectedEditType = type; }; + let isFullScreen = $derived(fullscreenElement !== null); + $effect(() => { + if (asset) { + handlePromiseError(refreshStack()); + } + }); + $effect(() => { + if (album && !album.isActivityEnabled && numberOfComments === 0) { + isShowActivity = false; + } + }); + $effect(() => { + if (isShared && asset.id) { + handlePromiseError(getFavorite()); + handlePromiseError(getNumberOfComments()); + } + }); + $effect(() => { + if (asset.id && !sharedLink) { + handlePromiseError(handleGetAllAlbums()); + } + }); </script> <svelte:document bind:fullscreenElement /> @@ -422,11 +437,12 @@ onShowDetail={toggleDetailPanel} onClose={closeViewer} > - <MotionPhotoAction - slot="motion-photo" - isPlaying={shouldPlayMotionPhoto} - onClick={(shouldPlay) => (shouldPlayMotionPhoto = shouldPlay)} - /> + {#snippet motionPhoto()} + <MotionPhotoAction + isPlaying={shouldPlayMotionPhoto} + onClick={(shouldPlay) => (shouldPlayMotionPhoto = shouldPlay)} + /> + {/snippet} </AssetViewerNavBar> </div> {/if} @@ -443,7 +459,7 @@ <div class="z-[1000] absolute w-full flex"> <SlideshowBar {isFullScreen} - onSetToFullScreen={() => assetViewerHtmlElement.requestFullscreen?.()} + onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()} onPrevious={() => navigateAsset('previous')} onNext={() => navigateAsset('next')} onClose={() => ($slideshowState = SlideshowState.StopSlideshow)} @@ -461,7 +477,7 @@ {preloadAssets} onPreviousAsset={() => navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} - on:close={closeViewer} + onClose={closeViewer} haveFadeTransition={false} {sharedLink} /> @@ -473,9 +489,9 @@ loopVideo={true} onPreviousAsset={() => navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} - on:close={closeViewer} - on:onVideoEnded={() => navigateAsset()} - on:onVideoStarted={handleVideoStarted} + onClose={closeViewer} + onVideoEnded={() => navigateAsset()} + onVideoStarted={handleVideoStarted} /> {/if} {/key} @@ -490,8 +506,7 @@ loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} onPreviousAsset={() => navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} - on:close={closeViewer} - on:onVideoEnded={() => (shouldPlayMotionPhoto = false)} + onVideoEnded={() => (shouldPlayMotionPhoto = false)} /> {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath .toLowerCase() @@ -507,7 +522,7 @@ {preloadAssets} onPreviousAsset={() => navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} - on:close={closeViewer} + onClose={closeViewer} {sharedLink} haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition} /> @@ -520,9 +535,9 @@ loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} onPreviousAsset={() => navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} - on:close={closeViewer} - on:onVideoEnded={() => navigateAsset()} - on:onVideoStarted={handleVideoStarted} + onClose={closeViewer} + onVideoEnded={() => navigateAsset()} + onVideoStarted={handleVideoStarted} /> {/if} {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)} @@ -575,7 +590,7 @@ class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar" > <div class="relative w-full whitespace-nowrap transition-all"> - {#each stackedAssets as stackedAsset, index (stackedAsset.id)} + {#each stackedAssets as stackedAsset (stackedAsset.id)} <div class="{stackedAsset.id == asset.id ? '-translate-y-[1px]' @@ -588,7 +603,6 @@ asset={stackedAsset} onClick={(stackedAsset) => { asset = stackedAsset; - preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]]; }} onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} disableMouseOver diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.svelte b/web/src/lib/components/asset-viewer/detail-panel-description.svelte index b916733476618..0eba78b0c0682 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-description.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte @@ -8,14 +8,21 @@ import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte'; import { t } from 'svelte-i18n'; - export let asset: AssetResponseDto; - export let isOwner: boolean; + interface Props { + asset: AssetResponseDto; + isOwner: boolean; + } - $: description = asset.exifInfo?.description || ''; + let { asset, isOwner }: Props = $props(); + + let description = $derived(asset.exifInfo?.description || ''); const handleFocusOut = async (newDescription: string) => { try { await updateAsset({ id: asset.id, updateAssetDto: { description: newDescription } }); + + asset.exifInfo = { ...asset.exifInfo, description: newDescription }; + notificationController.show({ type: NotificationType.Info, message: $t('asset_description_updated'), @@ -23,7 +30,6 @@ } catch (error) { handleError(error, $t('cannot_update_the_description')); } - description = newDescription; }; </script> diff --git a/web/src/lib/components/asset-viewer/detail-panel-location.svelte b/web/src/lib/components/asset-viewer/detail-panel-location.svelte index 7d5d86b4435dc..9e59243aa11b5 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-location.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-location.svelte @@ -7,10 +7,14 @@ import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let isOwner: boolean; - export let asset: AssetResponseDto; + interface Props { + isOwner: boolean; + asset: AssetResponseDto; + } + + let { isOwner, asset = $bindable() }: Props = $props(); - let isShowChangeLocation = false; + let isShowChangeLocation = $state(false); async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) { isShowChangeLocation = false; @@ -30,7 +34,7 @@ <button type="button" class="flex w-full text-left justify-between place-items-start gap-4 py-4" - on:click={() => (isOwner ? (isShowChangeLocation = true) : null)} + onclick={() => (isOwner ? (isShowChangeLocation = true) : null)} title={isOwner ? $t('edit_location') : ''} class:hover:dark:text-immich-dark-primary={isOwner} class:hover:text-immich-primary={isOwner} @@ -65,7 +69,7 @@ <button type="button" class="flex w-full text-left justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary" - on:click={() => (isShowChangeLocation = true)} + onclick={() => (isShowChangeLocation = true)} title={$t('add_location')} > <div class="flex gap-4"> diff --git a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte index b73fe7171619e..4c5bfd71a839f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte @@ -6,10 +6,14 @@ import { handlePromiseError, isSharedLink } from '$lib/utils'; import { preferences } from '$lib/stores/user.store'; - export let asset: AssetResponseDto; - export let isOwner: boolean; + interface Props { + asset: AssetResponseDto; + isOwner: boolean; + } - $: rating = asset.exifInfo?.rating || 0; + let { asset, isOwner }: Props = $props(); + + let rating = $derived(asset.exifInfo?.rating || 0); const handleChangeRating = async (rating: number) => { try { diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte index 449f61183fb33..c1175f5eb4a6d 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte @@ -9,12 +9,16 @@ import { mdiClose, mdiPlus } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let asset: AssetResponseDto; - export let isOwner: boolean; + interface Props { + asset: AssetResponseDto; + isOwner: boolean; + } - $: tags = asset.tags || []; + let { asset = $bindable(), isOwner }: Props = $props(); - let isOpen = false; + let tags = $derived(asset.tags || []); + + let isOpen = $state(false); const handleAdd = () => (isOpen = true); @@ -58,7 +62,7 @@ type="button" class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all" title="Remove tag" - on:click={() => handleRemove(tag.id)} + onclick={() => handleRemove(tag.id)} > <Icon path={mdiClose} /> </button> @@ -68,7 +72,7 @@ type="button" class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1" title="Add tag" - on:click={handleAdd} + onclick={handleAdd} > <span class="text-sm px-1 flex place-items-center place-content-center gap-1"><Icon path={mdiPlus} />Add</span> </button> diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index ab84896b7b019..9908630233b01 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -46,10 +46,14 @@ import AlbumListItemDetails from './album-list-item-details.svelte'; import Portal from '$lib/components/shared-components/portal/portal.svelte'; - export let asset: AssetResponseDto; - export let albums: AlbumResponseDto[] = []; - export let currentAlbum: AlbumResponseDto | null = null; - export let onClose: () => void; + interface Props { + asset: AssetResponseDto; + albums?: AlbumResponseDto[]; + currentAlbum?: AlbumResponseDto | null; + onClose: () => void; + } + + let { asset, albums = [], currentAlbum = null, onClose }: Props = $props(); const getDimensions = (exifInfo: ExifResponseDto) => { const { exifImageWidth: width, exifImageHeight: height } = exifInfo; @@ -60,11 +64,11 @@ return { width, height }; }; - let showAssetPath = false; - let showEditFaces = false; - let previousId: string; + let showAssetPath = $state(false); + let showEditFaces = $state(false); + let previousId: string | undefined = $state(); - $: { + $effect(() => { if (!previousId) { previousId = asset.id; } @@ -72,9 +76,9 @@ showEditFaces = false; previousId = asset.id; } - } + }); - $: isOwner = $user?.id === asset.ownerId; + let isOwner = $derived($user?.id === asset.ownerId); const handleNewAsset = async (newAsset: AssetResponseDto) => { // TODO: check if reloading asset data is necessary @@ -85,27 +89,30 @@ } }; - $: handlePromiseError(handleNewAsset(asset)); - - $: latlng = (() => { - const lat = asset.exifInfo?.latitude; - const lng = asset.exifInfo?.longitude; - - if (lat && lng) { - return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) }; - } - })(); - - $: people = asset.people || []; - $: showingHiddenPeople = false; - - $: unassignedFaces = asset.unassignedFaces || []; - - $: timeZone = asset.exifInfo?.timeZone; - $: dateTime = + $effect(() => { + handlePromiseError(handleNewAsset(asset)); + }); + + let latlng = $derived( + (() => { + const lat = asset.exifInfo?.latitude; + const lng = asset.exifInfo?.longitude; + + if (lat && lng) { + return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) }; + } + })(), + ); + + let people = $state(asset.people || []); + let unassignedFaces = $state(asset.unassignedFaces || []); + let showingHiddenPeople = $state(false); + let timeZone = $derived(asset.exifInfo?.timeZone); + let dateTime = $derived( timeZone && asset.exifInfo?.dateTimeOriginal ? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone) - : fromLocalDateTime(asset.localDateTime); + : fromLocalDateTime(asset.localDateTime), + ); const getMegapixel = (width: number, height: number): number | undefined => { const megapixel = Math.round((height * width) / 1_000_000); @@ -127,7 +134,7 @@ const toggleAssetPath = () => (showAssetPath = !showAssetPath); - let isShowChangeDate = false; + let isShowChangeDate = $state(false); async function handleConfirmChangeDate(dateTimeOriginal: string) { isShowChangeDate = false; @@ -141,7 +148,7 @@ <section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"> <div class="flex place-items-center gap-2"> - <CircleIconButton icon={mdiClose} title={$t('close')} on:click={onClose} /> + <CircleIconButton icon={mdiClose} title={$t('close')} onclick={onClose} /> <p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p> </div> @@ -190,7 +197,7 @@ icon={showingHiddenPeople ? mdiEyeOff : mdiEye} padding="1" buttonSize="32" - on:click={() => (showingHiddenPeople = !showingHiddenPeople)} + onclick={() => (showingHiddenPeople = !showingHiddenPeople)} /> {/if} <CircleIconButton @@ -199,7 +206,7 @@ padding="1" size="20" buttonSize="32" - on:click={() => (showEditFaces = true)} + onclick={() => (showEditFaces = true)} /> </div> </div> @@ -212,10 +219,10 @@ href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id ? `${AppRoute.ALBUMS}/${currentAlbum?.id}` : AppRoute.PHOTOS}" - on:focus={() => ($boundingBoxesArray = people[index].faces)} - on:blur={() => ($boundingBoxesArray = [])} - on:mouseover={() => ($boundingBoxesArray = people[index].faces)} - on:mouseleave={() => ($boundingBoxesArray = [])} + onfocus={() => ($boundingBoxesArray = people[index].faces)} + onblur={() => ($boundingBoxesArray = [])} + onmouseover={() => ($boundingBoxesArray = people[index].faces)} + onmouseleave={() => ($boundingBoxesArray = [])} > <div class="relative"> <ImageThumbnail @@ -278,7 +285,7 @@ <button type="button" class="flex w-full text-left justify-between place-items-start gap-4 py-4" - on:click={() => (isOwner ? (isShowChangeDate = true) : null)} + onclick={() => (isOwner ? (isShowChangeDate = true) : null)} title={isOwner ? $t('edit_date') : ''} class:hover:dark:text-immich-dark-primary={isOwner} class:hover:text-immich-primary={isOwner} @@ -357,7 +364,7 @@ title={$t('show_file_location')} size="16" padding="2" - on:click={toggleAssetPath} + onclick={toggleAssetPath} /> {/if} </p> @@ -428,8 +435,7 @@ </div> {/await} {:then component} - <svelte:component - this={component.default} + <component.default mapMarkers={[ { lat: latlng.lat, @@ -446,7 +452,7 @@ useLocationPin onOpenInMapView={() => goto(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`)} > - <svelte:fragment slot="popup" let:marker> + {#snippet popup({ marker })} {@const { lat, lon } = marker} <div class="flex flex-col items-center gap-1"> <p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p> @@ -458,8 +464,8 @@ {$t('open_in_openstreetmap')} </a> </div> - </svelte:fragment> - </svelte:component> + {/snippet} + </component.default> {/await} </div> {/if} diff --git a/web/src/lib/components/asset-viewer/download-panel.svelte b/web/src/lib/components/asset-viewer/download-panel.svelte index db46e1eff0bb8..17f5e7e6a8a8b 100644 --- a/web/src/lib/components/asset-viewer/download-panel.svelte +++ b/web/src/lib/components/asset-viewer/download-panel.svelte @@ -44,7 +44,7 @@ <div class="absolute right-2"> <CircleIconButton title={$t('close')} - on:click={() => abort(downloadKey, download)} + onclick={() => abort(downloadKey, download)} size="20" icon={mdiClose} class="dark:text-immich-dark-gray" diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte index c35fd915197aa..2b7153ed4e51e 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { onMount, afterUpdate, onDestroy, tick } from 'svelte'; + import { onMount, onDestroy, tick } from 'svelte'; import { t } from 'svelte-i18n'; import { getAssetOriginalUrl } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; @@ -17,11 +17,23 @@ resetGlobalCropStore, rotateDegrees, } from '$lib/stores/asset-editor.store'; + import type { AssetResponseDto } from '@immich/sdk'; - export let asset; - let img: HTMLImageElement; + interface Props { + asset: AssetResponseDto; + } + + let { asset }: Props = $props(); - $: imgElement.set(img); + let img = $state<HTMLImageElement>(); + + $effect(() => { + if (!img) { + return; + } + + imgElement.set(img); + }); cropAspectRatio.subscribe((value) => { if (!img || !$cropAreaEl) { @@ -54,7 +66,7 @@ resetGlobalCropStore(); }); - afterUpdate(() => { + $effect(() => { resizeCanvas(); }); </script> @@ -64,8 +76,8 @@ class={`crop-area ${$changedOriention ? 'changedOriention' : ''}`} style={`rotate:${$rotateDegrees}deg`} bind:this={$cropAreaEl} - on:mousedown={handleMouseDown} - on:mouseup={handleMouseUp} + onmousedown={handleMouseDown} + onmouseup={handleMouseUp} aria-label="Crop area" type="button" > diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte index 667191274faaa..eb788b2d16da1 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte @@ -3,37 +3,41 @@ import Icon from '$lib/components/elements/icon.svelte'; import type { CropAspectRatio } from '$lib/stores/asset-editor.store'; - export let size: { - icon: string; - name: CropAspectRatio; - viewBox: string; - rotate?: boolean; - }; - export let selectedSize: CropAspectRatio; - export let rotateHorizontal: boolean; - export let selectType: (size: CropAspectRatio) => void; + interface Props { + size: { + icon: string; + name: CropAspectRatio; + viewBox: string; + rotate?: boolean; + }; + selectedSize: CropAspectRatio; + rotateHorizontal: boolean; + selectType: (size: CropAspectRatio) => void; + } - $: isSelected = selectedSize === size.name; - $: buttonColor = (isSelected ? 'primary' : 'transparent-gray') as Color; + let { size, selectedSize, rotateHorizontal, selectType }: Props = $props(); - $: rotatedTitle = (title: string, toRotate: boolean) => { + let isSelected = $derived(selectedSize === size.name); + let buttonColor = $derived((isSelected ? 'primary' : 'transparent-gray') as Color); + + let rotatedTitle = $derived((title: string, toRotate: boolean) => { let sides = title.split(':'); if (toRotate) { sides.reverse(); } return sides.join(':'); - }; + }); - $: toRotate = (def: boolean | undefined) => { + let toRotate = $derived((def: boolean | undefined) => { if (def === false) { return false; } return (def && !rotateHorizontal) || (!def && rotateHorizontal); - }; + }); </script> <li> - <Button color={buttonColor} class="flex-col gap-1" size="sm" rounded="lg" on:click={() => selectType(size.name)}> + <Button color={buttonColor} class="flex-col gap-1" size="sm" rounded="lg" onclick={() => selectType(size.name)}> <Icon size="1.75em" path={size.icon} viewBox={size.viewBox} class={toRotate(size.rotate) ? 'rotate-90' : ''} /> <span>{rotatedTitle(size.name, rotateHorizontal)}</span> </Button> diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte index dba3be5d671ff..363bec7c1f71c 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte @@ -16,7 +16,7 @@ import { tick } from 'svelte'; import CropPreset from './crop-preset.svelte'; - $: rotateHorizontal = [90, 270].includes($normaizedRorateDegrees); + let rotateHorizontal = $derived([90, 270].includes($normaizedRorateDegrees)); const icon_16_9 = `M200-280q-33 0-56.5-23.5T120-360v-240q0-33 23.5-56.5T200-680h560q33 0 56.5 23.5T840-600v240q0 33-23.5 56.5T760-280H200Zm0-80h560v-240H200v240Zm0 0v-240 240Z`; const icon_4_3 = `M19 5H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H5V7h14v10z`; const icon_3_2 = `M200-240q-33 0-56.5-23.5T120-320v-320q0-33 23.5-56.5T200-720h560q33 0 56.5 23.5T840-640v320q0 33-23.5 56.5T760-240H200Zm0-80h560v-320H200v320Zm0 0v-320 320Z`; @@ -92,14 +92,17 @@ }, ]; - let selectedSize: CropAspectRatio = 'free'; - $cropAspectRatio = selectedSize; + let selectedSize: CropAspectRatio = $state('free'); - $: sizesRows = [ + $effect(() => { + $cropAspectRatio = selectedSize; + }); + + let sizesRows = $derived([ sizes.filter((s) => s.rotate === false), sizes.filter((s) => s.rotate === undefined), sizes.filter((s) => s.rotate === true), - ]; + ]); async function rotate(clock: boolean) { rotateDegrees.update((v) => { @@ -145,7 +148,7 @@ <h2>{$t('editor_crop_tool_h2_rotation').toUpperCase()}</h2> </div> <ul class="flex-wrap flex-row flex gap-x-6 gap-y-4 justify-center"> - <li><CircleIconButton title={$t('anti_clockwise')} on:click={() => rotate(false)} icon={mdiRotateLeft} /></li> - <li><CircleIconButton title={$t('clockwise')} on:click={() => rotate(true)} icon={mdiRotateRight} /></li> + <li><CircleIconButton title={$t('anti_clockwise')} onclick={() => rotate(false)} icon={mdiRotateLeft} /></li> + <li><CircleIconButton title={$t('clockwise')} onclick={() => rotate(true)} icon={mdiRotateRight} /></li> </ul> </div> diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte index 78d5ca26e0ec5..133d9c9021a4f 100644 --- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -9,8 +9,6 @@ import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import { shortcut } from '$lib/actions/shortcut'; - export let asset: AssetResponseDto; - onMount(() => { return websocketEvents.on('on_asset_update', (assetUpdate) => { if (assetUpdate.id === asset.id) { @@ -19,12 +17,16 @@ }); }); - export let onUpdateSelectedType: (type: string) => void; - export let onClose: () => void; + interface Props { + asset: AssetResponseDto; + onUpdateSelectedType: (type: string) => void; + onClose: () => void; + } + + let { asset = $bindable(), onUpdateSelectedType, onClose }: Props = $props(); - let selectedType: string = editTypes[0].name; - // svelte-ignore reactive_declaration_non_reactive_property - $: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0]; + let selectedType: string = $state(editTypes[0].name); + let selectedTypeObj = $derived(editTypes.find((t) => t.name === selectedType) || editTypes[0]); setTimeout(() => { onUpdateSelectedType(selectedType); @@ -39,7 +41,7 @@ <section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"> <div class="flex place-items-center gap-2"> - <CircleIconButton icon={mdiClose} title={$t('close')} on:click={onClose} /> + <CircleIconButton icon={mdiClose} title={$t('close')} onclick={onClose} /> <p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p> </div> <section class="px-4 py-4"> @@ -50,14 +52,14 @@ color={etype.name === selectedType ? 'primary' : 'opaque'} icon={etype.icon} title={etype.name} - on:click={() => selectType(etype.name)} + onclick={() => selectType(etype.name)} /> </li> {/each} </ul> </section> <section> - <svelte:component this={selectedTypeObj.component} /> + <selectedTypeObj.component /> </section> </section> diff --git a/web/src/lib/components/asset-viewer/navigation-area.svelte b/web/src/lib/components/asset-viewer/navigation-area.svelte index e69d93b6b6339..88f0baf0bc1a8 100644 --- a/web/src/lib/components/asset-viewer/navigation-area.svelte +++ b/web/src/lib/components/asset-viewer/navigation-area.svelte @@ -1,13 +1,20 @@ <script lang="ts"> - export let onClick: (e: MouseEvent) => void; - export let label: string; + import type { Snippet } from 'svelte'; + + interface Props { + onClick: (e: MouseEvent) => void; + label: string; + children?: Snippet; + } + + let { onClick, label, children }: Props = $props(); </script> <button type="button" class="my-auto mx-4 rounded-full p-3 text-gray-500 transition hover:bg-gray-500 hover:text-white" aria-label={label} - on:click={onClick} + onclick={onClick} > - <slot /> + {@render children?.()} </button> diff --git a/web/src/lib/components/asset-viewer/panorama-viewer.svelte b/web/src/lib/components/asset-viewer/panorama-viewer.svelte index 396685e351eaa..b17f9fdea77d2 100644 --- a/web/src/lib/components/asset-viewer/panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/panorama-viewer.svelte @@ -8,7 +8,11 @@ import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; - export let asset: { id: string; type: AssetTypeEnum.Video } | AssetResponseDto; + interface Props { + asset: { id: string; type: AssetTypeEnum.Video } | AssetResponseDto; + } + + let { asset }: Props = $props(); const photoSphereConfigs = asset.type === AssetTypeEnum.Video @@ -43,14 +47,7 @@ {#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])} <LoadingSpinner /> {:then [data, module, adapter, plugins, navbar]} - <svelte:component - this={module.default} - panorama={data} - plugins={plugins ?? undefined} - {navbar} - {adapter} - {originalImageUrl} - /> + <module.default panorama={data} plugins={plugins ?? undefined} {navbar} {adapter} {originalImageUrl} /> {:catch} {$t('errors.failed_to_load_asset')} {/await} diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index 1745cd66b66eb..c18e6bd14bd1e 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -10,16 +10,24 @@ import '@photo-sphere-viewer/core/index.css'; import { onDestroy, onMount } from 'svelte'; - export let panorama: string | { source: string }; - export let originalImageUrl: string | null; - export let adapter: AdapterConstructor | [AdapterConstructor, unknown] = EquirectangularAdapter; - export let plugins: (PluginConstructor | [PluginConstructor, unknown])[] = []; - export let navbar = false; + interface Props { + panorama: string | { source: string }; + originalImageUrl: string | null; + adapter?: AdapterConstructor | [AdapterConstructor, unknown]; + plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; + navbar?: boolean; + } - let container: HTMLDivElement; + let { panorama, originalImageUrl, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); + + let container: HTMLDivElement | undefined = $state(); let viewer: Viewer; onMount(() => { + if (!container) { + return; + } + viewer = new Viewer({ adapter, plugins, diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index d7595f6b7e849..e24751b3c83fd 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -20,33 +20,38 @@ import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { handleError } from '$lib/utils/handle-error'; - export let asset: AssetResponseDto; - export let preloadAssets: AssetResponseDto[] | undefined = undefined; - export let element: HTMLDivElement | undefined = undefined; - export let haveFadeTransition = true; - export let sharedLink: SharedLinkResponseDto | undefined = undefined; - export let onPreviousAsset: (() => void) | null = null; - export let onNextAsset: (() => void) | null = null; - export let copyImage: (() => Promise<void>) | null = null; - export let zoomToggle: (() => void) | null = null; + interface Props { + asset: AssetResponseDto; + preloadAssets?: AssetResponseDto[] | undefined; + element?: HTMLDivElement | undefined; + haveFadeTransition?: boolean; + sharedLink?: SharedLinkResponseDto | undefined; + onPreviousAsset?: (() => void) | null; + onNextAsset?: (() => void) | null; + copyImage?: () => Promise<void>; + zoomToggle?: (() => void) | null; + onClose?: () => void; + } + + let { + asset, + preloadAssets = undefined, + element = $bindable(), + haveFadeTransition = true, + sharedLink = undefined, + onPreviousAsset = null, + onNextAsset = null, + copyImage = $bindable(), + zoomToggle = $bindable(), + }: Props = $props(); const { slideshowState, slideshowLook } = slideshowStore; - let assetFileUrl: string = ''; - let imageLoaded: boolean = false; - let imageError: boolean = false; - let forceUseOriginal: boolean = false; - let loader: HTMLImageElement; + let assetFileUrl: string = $state(''); + let imageLoaded: boolean = $state(false); + let imageError: boolean = $state(false); - $: isWebCompatible = isWebCompatibleImage(asset); - $: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile; - $: useOriginalImage = useOriginalByDefault || forceUseOriginal; - // when true, will force loading of the original image - $: forceUseOriginal = - forceUseOriginal || asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible); - - $: preload(useOriginalImage, preloadAssets); - $: imageLoaderUrl = getAssetUrl(asset.id, useOriginalImage, asset.checksum); + let loader = $state<HTMLImageElement>(); photoZoomState.set({ currentRotation: 0, @@ -129,16 +134,31 @@ const onerror = () => { imageError = imageLoaded = true; }; - if (loader.complete) { + if (loader?.complete) { onload(); } - loader.addEventListener('load', onload); - loader.addEventListener('error', onerror); + loader?.addEventListener('load', onload); + loader?.addEventListener('error', onerror); return () => { loader?.removeEventListener('load', onload); loader?.removeEventListener('error', onerror); }; }); + let isWebCompatible = $derived(isWebCompatibleImage(asset)); + let useOriginalByDefault = $derived(isWebCompatible && $alwaysLoadOriginalFile); + // when true, will force loading of the original image + + let forceUseOriginal: boolean = $derived( + asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible), + ); + + let useOriginalImage = $derived(useOriginalByDefault || forceUseOriginal); + + $effect(() => { + preload(useOriginalImage, preloadAssets); + }); + + let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.checksum)); </script> <svelte:window @@ -150,15 +170,15 @@ {#if imageError} <BrokenAsset class="text-xl" /> {/if} -<!-- svelte-ignore a11y-missing-attribute --> +<!-- svelte-ignore a11y_missing_attribute --> <img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" /> <div bind:this={element} class="relative h-full select-none"> <img style="display:none" src={imageLoaderUrl} alt={$getAltText(asset)} - on:load={() => ((imageLoaded = true), (assetFileUrl = imageLoaderUrl))} - on:error={() => (imageError = imageLoaded = true)} + onload={() => ((imageLoaded = true), (assetFileUrl = imageLoaderUrl))} + onerror={() => (imageError = imageLoaded = true)} /> {#if !imageLoaded} <div id="spinner" class="flex h-full items-center justify-center"> @@ -168,7 +188,7 @@ <div use:zoomImageAction use:swipe - on:swipe={onSwipe} + onswipe={onSwipe} class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} > diff --git a/web/src/lib/components/asset-viewer/slideshow-bar.svelte b/web/src/lib/components/asset-viewer/slideshow-bar.svelte index 1acc06f21bb2c..95e08cb310cf8 100644 --- a/web/src/lib/components/asset-viewer/slideshow-bar.svelte +++ b/web/src/lib/components/asset-viewer/slideshow-bar.svelte @@ -9,20 +9,30 @@ import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; - export let isFullScreen: boolean; - export let onNext = () => {}; - export let onPrevious = () => {}; - export let onClose = () => {}; - export let onSetToFullScreen = () => {}; + interface Props { + isFullScreen: boolean; + onNext?: () => void; + onPrevious?: () => void; + onClose?: () => void; + onSetToFullScreen?: () => void; + } + + let { + isFullScreen, + onNext = () => {}, + onPrevious = () => {}, + onClose = () => {}, + onSetToFullScreen = () => {}, + }: Props = $props(); const { restartProgress, stopProgress, slideshowDelay, showProgressBar, slideshowNavigation } = slideshowStore; - let progressBarStatus: ProgressBarStatus; - let progressBar: ProgressBar; - let showSettings = false; - let showControls = true; + let progressBarStatus: ProgressBarStatus | undefined = $state(); + let progressBar = $state<ReturnType<typeof ProgressBar>>(); + let showSettings = $state(false); + let showControls = $state(true); let timer: NodeJS.Timeout; - let isOverControls = false; + let isOverControls = $state(false); let unsubscribeRestart: () => void; let unsubscribeStop: () => void; @@ -55,13 +65,13 @@ hideControlsAfterDelay(); unsubscribeRestart = restartProgress.subscribe((value) => { if (value) { - progressBar.restart(value); + progressBar?.restart(value); } }); unsubscribeStop = stopProgress.subscribe((value) => { if (value) { - progressBar.restart(false); + progressBar?.restart(false); stopControlsHideTimer(); } }); @@ -77,7 +87,9 @@ } }); - const handleDone = () => { + const handleDone = async () => { + await progressBar?.reset(); + if ($slideshowNavigation === SlideshowNavigation.AscendingOrder) { onPrevious(); return; @@ -87,7 +99,7 @@ </script> <svelte:window - on:mousemove={showControlBar} + onmousemove={showControlBar} use:shortcuts={[ { shortcut: { key: 'Escape' }, onShortcut: onClose }, { shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious }, @@ -98,32 +110,32 @@ {#if showControls} <div class="m-4 flex gap-2" - on:mouseenter={() => (isOverControls = true)} - on:mouseleave={() => (isOverControls = false)} + onmouseenter={() => (isOverControls = true)} + onmouseleave={() => (isOverControls = false)} transition:fly={{ duration: 150 }} role="navigation" > - <CircleIconButton buttonSize="50" icon={mdiClose} on:click={onClose} title={$t('exit_slideshow')} /> + <CircleIconButton buttonSize="50" icon={mdiClose} onclick={onClose} title={$t('exit_slideshow')} /> <CircleIconButton buttonSize="50" icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause} - on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())} + onclick={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar?.play() : progressBar?.pause())} title={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')} /> - <CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={onPrevious} title={$t('previous')} /> - <CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={onNext} title={$t('next')} /> + <CircleIconButton buttonSize="50" icon={mdiChevronLeft} onclick={onPrevious} title={$t('previous')} /> + <CircleIconButton buttonSize="50" icon={mdiChevronRight} onclick={onNext} title={$t('next')} /> <CircleIconButton buttonSize="50" icon={mdiCog} - on:click={() => (showSettings = !showSettings)} + onclick={() => (showSettings = !showSettings)} title={$t('slideshow_settings')} /> {#if !isFullScreen} <CircleIconButton buttonSize="50" icon={mdiFullscreen} - on:click={onSetToFullScreen} + onclick={onSetToFullScreen} title={$t('set_slideshow_to_fullscreen')} /> {/if} diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 58012ccfce5e5..d019ef273f677 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -4,30 +4,52 @@ import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { AssetMediaSize } from '@immich/sdk'; - import { tick } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import { swipe } from 'svelte-gestures'; import type { SwipeCustomEvent } from 'svelte-gestures'; import { fade } from 'svelte/transition'; import { t } from 'svelte-i18n'; - export let assetId: string; - export let loopVideo: boolean; - export let checksum: string; - export let onPreviousAsset: () => void = () => {}; - export let onNextAsset: () => void = () => {}; - export let onVideoEnded: () => void = () => {}; - export let onVideoStarted: () => void = () => {}; + interface Props { + assetId: string; + loopVideo: boolean; + checksum: string; + onPreviousAsset?: () => void; + onNextAsset?: () => void; + onVideoEnded?: () => void; + onVideoStarted?: () => void; + onClose?: () => void; + } - let element: HTMLVideoElement | undefined = undefined; - let isVideoLoading = true; - let assetFileUrl: string; - let forceMuted = false; + let { + assetId, + loopVideo, + checksum, + onPreviousAsset = () => {}, + onNextAsset = () => {}, + onVideoEnded = () => {}, + onVideoStarted = () => {}, + onClose = () => {}, + }: Props = $props(); - $: if (element) { - assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum }); - forceMuted = false; - element.load(); - } + let videoPlayer: HTMLVideoElement | undefined = $state(); + let isLoading = $state(true); + let assetFileUrl = $state(''); + let forceMuted = $state(false); + + onMount(() => { + if (videoPlayer) { + assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum }); + forceMuted = false; + videoPlayer.load(); + } + }); + + onDestroy(() => { + if (videoPlayer) { + videoPlayer.src = ''; + } + }); const handleCanPlay = async (video: HTMLVideoElement) => { try { @@ -38,16 +60,16 @@ await tryForceMutedPlay(video); return; } + handleError(error, $t('errors.unable_to_play_video')); } finally { - isVideoLoading = false; + isLoading = false; } }; const tryForceMutedPlay = async (video: HTMLVideoElement) => { try { - forceMuted = true; - await tick(); + video.muted = true; await handleCanPlay(video); } catch (error) { handleError(error, $t('errors.unable_to_play_video')); @@ -66,21 +88,22 @@ <div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center"> <video - bind:this={element} + bind:this={videoPlayer} loop={$loopVideoPreference && loopVideo} autoplay playsinline controls class="h-full object-contain" use:swipe - on:swipe={onSwipe} - on:canplay={(e) => handleCanPlay(e.currentTarget)} - on:ended={onVideoEnded} - on:volumechange={(e) => { + onswipe={onSwipe} + oncanplay={(e) => handleCanPlay(e.currentTarget)} + onended={onVideoEnded} + onvolumechange={(e) => { if (!forceMuted) { $videoViewerMuted = e.currentTarget.muted; } }} + onclose={() => onClose()} muted={forceMuted || $videoViewerMuted} bind:volume={$videoViewerVolume} poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })} @@ -88,7 +111,7 @@ > </video> - {#if isVideoLoading} + {#if isLoading} <div class="absolute flex place-content-center place-items-center"> <LoadingSpinner /> </div> diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index 5f03784c42258..3ee4791b073db 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -4,12 +4,29 @@ import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte'; import PanoramaViewer from '$lib/components/asset-viewer/panorama-viewer.svelte'; - export let assetId: string; - export let projectionType: string | null | undefined; - export let checksum: string; - export let loopVideo: boolean; - export let onPreviousAsset: () => void; - export let onNextAsset: () => void; + interface Props { + assetId: string; + projectionType: string | null | undefined; + checksum: string; + loopVideo: boolean; + onClose?: () => void; + onPreviousAsset?: () => void; + onNextAsset?: () => void; + onVideoEnded?: () => void; + onVideoStarted?: () => void; + } + + let { + assetId, + projectionType, + checksum, + loopVideo, + onPreviousAsset, + onClose, + onNextAsset, + onVideoEnded, + onVideoStarted, + }: Props = $props(); </script> {#if projectionType === ProjectionType.EQUIRECTANGULAR} @@ -21,7 +38,8 @@ {assetId} {onPreviousAsset} {onNextAsset} - on:onVideoEnded - on:onVideoStarted + {onVideoEnded} + {onVideoStarted} + {onClose} /> {/if} diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte index dd54afba01ee9..31acb832e5d26 100644 --- a/web/src/lib/components/assets/broken-asset.svelte +++ b/web/src/lib/components/assets/broken-asset.svelte @@ -3,11 +3,14 @@ import { mdiImageBrokenVariant } from '@mdi/js'; import { t } from 'svelte-i18n'; - let className = ''; - export { className as class }; - export let hideMessage = false; - export let width: string | undefined = undefined; - export let height: string | undefined = undefined; + interface Props { + class?: string; + hideMessage?: boolean; + width?: string | undefined; + height?: string | undefined; + } + + let { class: className = '', hideMessage = false, width = undefined, height = undefined }: Props = $props(); </script> <div diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 282d152e90ae2..9d69bdeeb2704 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -7,29 +7,49 @@ import { onMount } from 'svelte'; import { fade } from 'svelte/transition'; - export let url: string; - export let altText: string | undefined; - export let title: string | null = null; - export let heightStyle: string | undefined = undefined; - export let widthStyle: string; - export let base64ThumbHash: string | null = null; - export let curve = false; - export let shadow = false; - export let circle = false; - export let hidden = false; - export let border = false; - export let preload = true; - export let hiddenIconClass = 'text-white'; - export let onComplete: (() => void) | undefined = undefined; + interface Props { + url: string; + altText: string | undefined; + title?: string | null; + heightStyle?: string | undefined; + widthStyle: string; + base64ThumbHash?: string | null; + curve?: boolean; + shadow?: boolean; + circle?: boolean; + hidden?: boolean; + border?: boolean; + preload?: boolean; + hiddenIconClass?: string; + onComplete?: (() => void) | undefined; + onClick?: (() => void) | undefined; + } + + let { + url, + altText, + title = null, + heightStyle = undefined, + widthStyle, + base64ThumbHash = null, + curve = false, + shadow = false, + circle = false, + hidden = false, + border = false, + preload = true, + hiddenIconClass = 'text-white', + onComplete = undefined, + }: Props = $props(); let { IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, } = TUNABLES; - let loaded = false; - let errored = false; + let loaded = $state(false); + let errored = $state(false); - let img: HTMLImageElement; + let img = $state<HTMLImageElement>(); const setLoaded = () => { loaded = true; @@ -40,20 +60,22 @@ onComplete?.(); }; onMount(() => { - if (img.complete) { + if (img?.complete) { setLoaded(); } }); - $: optionalClasses = [ - curve && 'rounded-xl', - circle && 'rounded-full', - shadow && 'shadow-lg', - (circle || !heightStyle) && 'aspect-square', - border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary', - ] - .filter(Boolean) - .join(' '); + let optionalClasses = $derived( + [ + curve && 'rounded-xl', + circle && 'rounded-full', + shadow && 'shadow-lg', + (circle || !heightStyle) && 'aspect-square', + border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary', + ] + .filter(Boolean) + .join(' '), + ); </script> {#if errored} @@ -61,8 +83,8 @@ {:else} <img bind:this={img} - on:load={setLoaded} - on:error={setErrored} + onload={setLoaded} + onerror={setErrored} loading={preload ? 'eager' : 'lazy'} style:width={widthStyle} style:height={heightStyle} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 4c2cf7451821d..536ea90163973 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -31,62 +31,89 @@ import { TUNABLES } from '$lib/utils/tunables'; import { thumbhash } from '$lib/actions/thumbhash'; - export let asset: AssetResponseDto; - export let dateGroup: DateGroup | undefined = undefined; - export let assetStore: AssetStore | undefined = undefined; - export let groupIndex = 0; - export let thumbnailSize: number | undefined = undefined; - export let thumbnailWidth: number | undefined = undefined; - export let thumbnailHeight: number | undefined = undefined; - export let selected = false; - export let selectionCandidate = false; - export let disabled = false; - export let readonly = false; - export let showArchiveIcon = false; - export let showStackedIcon = true; - export let disableMouseOver = false; - export let intersectionConfig: { - root?: HTMLElement; - bottom?: string; - top?: string; - left?: string; - priority?: number; + interface Props { + asset: AssetResponseDto; + dateGroup?: DateGroup | undefined; + assetStore?: AssetStore | undefined; + groupIndex?: number; + thumbnailSize?: number | undefined; + thumbnailWidth?: number | undefined; + thumbnailHeight?: number | undefined; + selected?: boolean; + selectionCandidate?: boolean; disabled?: boolean; - } = {}; - - export let retrieveElement: boolean = false; - export let onIntersected: (() => void) | undefined = undefined; - export let onClick: ((asset: AssetResponseDto) => void) | undefined = undefined; - export let onRetrieveElement: ((elment: HTMLElement) => void) | undefined = undefined; - export let onSelect: ((asset: AssetResponseDto) => void) | undefined = undefined; - export let onMouseEvent: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined = - undefined; + readonly?: boolean; + showArchiveIcon?: boolean; + showStackedIcon?: boolean; + disableMouseOver?: boolean; + intersectionConfig?: { + root?: HTMLElement; + bottom?: string; + top?: string; + left?: string; + priority?: number; + disabled?: boolean; + }; + retrieveElement?: boolean; + onIntersected?: (() => void) | undefined; + onClick?: ((asset: AssetResponseDto) => void) | undefined; + onRetrieveElement?: ((elment: HTMLElement) => void) | undefined; + onSelect?: ((asset: AssetResponseDto) => void) | undefined; + onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined; + class?: string; + } - let className = ''; - export { className as class }; + let { + asset, + dateGroup = undefined, + assetStore = undefined, + groupIndex = 0, + thumbnailSize = undefined, + thumbnailWidth = undefined, + thumbnailHeight = undefined, + selected = false, + selectionCandidate = false, + disabled = false, + readonly = false, + showArchiveIcon = false, + showStackedIcon = true, + disableMouseOver = false, + intersectionConfig = {}, + retrieveElement = false, + onIntersected = undefined, + onClick = undefined, + onRetrieveElement = undefined, + onSelect = undefined, + onMouseEvent = undefined, + class: className = '', + }: Props = $props(); let { IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, } = TUNABLES; const componentId = generateId(); - let element: HTMLElement | undefined; - let mouseOver = false; - let intersecting = false; - let lastRetrievedElement: HTMLElement | undefined; - let loaded = false; + let element: HTMLElement | undefined = $state(); + let mouseOver = $state(false); + let intersecting = $state(false); + let lastRetrievedElement: HTMLElement | undefined = $state(); + let loaded = $state(false); - $: if (!retrieveElement) { - lastRetrievedElement = undefined; - } - $: if (retrieveElement && element && lastRetrievedElement !== element) { - lastRetrievedElement = element; - onRetrieveElement?.(element); - } + $effect(() => { + if (!retrieveElement) { + lastRetrievedElement = undefined; + } + }); + $effect(() => { + if (retrieveElement && element && lastRetrievedElement !== element) { + lastRetrievedElement = element; + onRetrieveElement?.(element); + } + }); - $: width = thumbnailSize || thumbnailWidth || 235; - $: height = thumbnailSize || thumbnailHeight || 235; - $: display = intersecting; + let width = $derived(thumbnailSize || thumbnailWidth || 235); + let height = $derived(thumbnailSize || thumbnailHeight || 235); + let display = $derived(intersecting); const onIconClickedHandler = (e?: MouseEvent) => { e?.stopPropagation(); @@ -197,15 +224,15 @@ class="group" class:cursor-not-allowed={disabled} class:cursor-pointer={!disabled} - on:mouseenter={onMouseEnter} - on:mouseleave={onMouseLeave} - on:keypress={(evt) => { + onmouseenter={onMouseEnter} + onmouseleave={onMouseLeave} + onkeypress={(evt) => { if (evt.key === 'Enter') { callClickHandlers(); } }} tabindex={0} - on:click={handleClick} + onclick={handleClick} role="link" > {#if mouseOver && !disableMouseOver} @@ -216,7 +243,7 @@ style:width="{width}px" style:height="{height}px" href={currentUrlReplaceAssetId(asset.id)} - on:click={(evt) => evt.preventDefault()} + onclick={(evt) => evt.preventDefault()} tabindex={0} aria-label="Thumbnail URL" > @@ -227,7 +254,7 @@ {#if !readonly && (mouseOver || selected || selectionCandidate)} <button type="button" - on:click={onIconClickedHandler} + onclick={onIconClickedHandler} class="absolute p-2 focus:outline-none" class:cursor-not-allowed={disabled} role="checkbox" diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index 14f99ac3314e5..9188ab9a4f649 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -7,31 +7,47 @@ import { generateId } from '$lib/utils/generate-id'; import { onDestroy } from 'svelte'; - export let assetStore: AssetStore | undefined = undefined; - export let url: string; - export let durationInSeconds = 0; - export let enablePlayback = false; - export let playbackOnIconHover = false; - export let showTime = true; - export let curve = false; - export let playIcon = mdiPlayCircleOutline; - export let pauseIcon = mdiPauseCircleOutline; + interface Props { + assetStore?: AssetStore | undefined; + url: string; + durationInSeconds?: number; + enablePlayback?: boolean; + playbackOnIconHover?: boolean; + showTime?: boolean; + curve?: boolean; + playIcon?: string; + pauseIcon?: string; + } + + let { + assetStore = undefined, + url, + durationInSeconds = 0, + enablePlayback = $bindable(false), + playbackOnIconHover = false, + showTime = true, + curve = false, + playIcon = mdiPlayCircleOutline, + pauseIcon = mdiPauseCircleOutline, + }: Props = $props(); const componentId = generateId(); - let remainingSeconds = durationInSeconds; - let loading = true; - let error = false; - let player: HTMLVideoElement; + let remainingSeconds = $state(durationInSeconds); + let loading = $state(true); + let error = $state(false); + let player: HTMLVideoElement | undefined = $state(); - $: if (!enablePlayback) { - // Reset remaining time when playback is disabled. - remainingSeconds = durationInSeconds; + $effect(() => { + if (!enablePlayback) { + // Reset remaining time when playback is disabled. + remainingSeconds = durationInSeconds; - if (player) { - // Cancel video buffering. - player.src = ''; + if (player) { + // Cancel video buffering. + player.src = ''; + } } - } + }); const onMouseEnter = () => { if (assetStore) { assetStore.taskManager.queueScrollSensitiveTask({ @@ -78,8 +94,8 @@ </span> {/if} - <!-- svelte-ignore a11y-no-static-element-interactions --> - <span class="pr-2 pt-2" on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <span class="pr-2 pt-2" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}> {#if enablePlayback} {#if loading} <LoadingSpinner /> @@ -103,15 +119,19 @@ autoplay loop src={url} - on:play={() => { + onplay={() => { loading = false; error = false; }} - on:error={() => { + onerror={() => { + if (!player?.src) { + // Do not show error when the URL is empty. + return; + } error = true; loading = false; }} - on:timeupdate={({ currentTarget }) => { + ontimeupdate={({ currentTarget }) => { const remaining = currentTarget.duration - currentTarget.currentTime; remainingSeconds = Math.min( Math.ceil(Number.isNaN(remaining) ? Number.POSITIVE_INFINITY : remaining), diff --git a/web/src/lib/components/elements/badge.svelte b/web/src/lib/components/elements/badge.svelte index da305e40f9349..0db6e3fa407f1 100644 --- a/web/src/lib/components/elements/badge.svelte +++ b/web/src/lib/components/elements/badge.svelte @@ -1,11 +1,18 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export type Color = 'primary' | 'secondary'; export type Rounded = false | true | 'full'; </script> <script lang="ts"> - export let color: Color = 'primary'; - export let rounded: Rounded = true; + import type { Snippet } from 'svelte'; + + interface Props { + color?: Color; + rounded?: Rounded; + children?: Snippet; + } + + let { color = 'primary', rounded = true, children }: Props = $props(); const colorClasses: Record<Color, string> = { primary: 'text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary', @@ -20,5 +27,5 @@ class:rounded-md={rounded === true} class:rounded-full={rounded === 'full'} > - <slot /> + {@render children?.()} </span> diff --git a/web/src/lib/components/elements/buttons/button.svelte b/web/src/lib/components/elements/buttons/button.svelte index cdd7463445b48..7e8418e2f5da0 100644 --- a/web/src/lib/components/elements/buttons/button.svelte +++ b/web/src/lib/components/elements/buttons/button.svelte @@ -1,6 +1,4 @@ -<script lang="ts" context="module"> - import type { HTMLButtonAttributes, HTMLLinkAttributes } from 'svelte/elements'; - +<script lang="ts" module> export type Color = | 'primary' | 'primary-inversed' @@ -17,44 +15,47 @@ export type Size = 'tiny' | 'icon' | 'link' | 'sm' | 'base' | 'lg'; export type Rounded = 'lg' | '3xl' | 'full' | 'none'; export type Shadow = 'md' | false; +</script> - type BaseProps = { - class?: string; +<script lang="ts"> + import type { Snippet } from 'svelte'; + + interface Props { + type?: string; + href?: string; color?: Color; size?: Size; rounded?: Rounded; shadow?: Shadow; fullwidth?: boolean; border?: boolean; - }; - - export type ButtonProps = HTMLButtonAttributes & - BaseProps & { - href?: never; - }; - - export type LinkProps = HTMLLinkAttributes & - BaseProps & { - type?: never; - }; - - export type Props = ButtonProps | LinkProps; -</script> - -<script lang="ts"> - type $$Props = Props; - - export let type: $$Props['type'] = 'button'; - export let href: $$Props['href'] = undefined; - export let color: Color = 'primary'; - export let size: Size = 'base'; - export let rounded: Rounded = '3xl'; - export let shadow: Shadow = 'md'; - export let fullwidth = false; - export let border = false; + class?: string; + children?: Snippet; + onclick?: (event: MouseEvent) => void; + onfocus?: () => void; + onblur?: () => void; + form?: string; + disabled?: boolean; + title?: string; + 'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | undefined | null; + } - let className = ''; - export { className as class }; + let { + type = 'button', + href = undefined, + color = 'primary', + size = 'base', + rounded = '3xl', + shadow = 'md', + fullwidth = false, + border = false, + class: className = '', + children, + onclick, + onfocus, + onblur, + ...rest + }: Props = $props(); const colorClasses: Record<Color, string> = { primary: @@ -93,29 +94,31 @@ full: 'rounded-full', }; - $: computedClass = [ - className, - colorClasses[color], - sizeClasses[size], - roundedClasses[rounded], - shadow === 'md' && 'shadow-md', - fullwidth && 'w-full', - border && 'border', - ] - .filter(Boolean) - .join(' '); + let computedClass = $derived( + [ + className, + colorClasses[color], + sizeClasses[size], + roundedClasses[rounded], + shadow === 'md' && 'shadow-md', + fullwidth && 'w-full', + border && 'border', + ] + .filter(Boolean) + .join(' '), + ); </script> -<!-- svelte-ignore a11y-no-static-element-interactions --> +<!-- svelte-ignore a11y_no_static_element_interactions --> <svelte:element this={href ? 'a' : 'button'} type={href ? undefined : type} {href} - on:click - on:focus - on:blur + {onclick} + {onfocus} + {onblur} class="inline-flex items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 disabled:pointer-events-none {computedClass}" - {...$$restProps} + {...rest} > - <slot /> + {@render children?.()} </svelte:element> diff --git a/web/src/lib/components/elements/buttons/circle-icon-button.svelte b/web/src/lib/components/elements/buttons/circle-icon-button.svelte index 8af3f75ade296..4b984154f3b9d 100644 --- a/web/src/lib/components/elements/buttons/circle-icon-button.svelte +++ b/web/src/lib/components/elements/buttons/circle-icon-button.svelte @@ -1,64 +1,64 @@ -<script lang="ts" context="module"> - import type { HTMLButtonAttributes, HTMLLinkAttributes } from 'svelte/elements'; - +<script lang="ts" module> export type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque' | 'alert'; export type Padding = '1' | '2' | '3'; - - type BaseProps = { - icon: string; - title: string; - class?: string; - color?: Color; - padding?: Padding; - size?: string; - hideMobile?: true; - buttonSize?: string; - viewBox?: string; - }; - - export type ButtonProps = HTMLButtonAttributes & - BaseProps & { - href?: never; - }; - - export type LinkProps = HTMLLinkAttributes & - BaseProps & { - type?: never; - }; - - export type Props = ButtonProps | LinkProps; </script> <script lang="ts"> import Icon from '$lib/components/elements/icon.svelte'; - type $$Props = Props; - - export let type: $$Props['type'] = 'button'; - export let href: $$Props['href'] = undefined; - export let icon: string; - export let color: Color = 'transparent'; - export let title: string; - /** - * The padding of the button, used by the `p-{padding}` Tailwind CSS class. - */ - export let padding: Padding = '3'; - /** - * Size of the button, used for a CSS value. - */ - export let size = '24'; - export let hideMobile = false; - export let buttonSize: string | undefined = undefined; - /** - * viewBox attribute for the SVG icon. - */ - export let viewBox: string | undefined = undefined; - /** * Override the default styling of the button for specific use cases, such as the icon color. */ - let className = ''; - export { className as class }; + interface Props { + id?: string; + type?: string; + href?: string; + icon: string; + color?: Color; + title: string; + /** + * The padding of the button, used by the `p-{padding}` Tailwind CSS class. + */ + padding?: Padding; + /** + * Size of the button, used for a CSS value. + */ + size?: string; + hideMobile?: boolean; + buttonSize?: string | undefined; + /** + * viewBox attribute for the SVG icon. + */ + viewBox?: string | undefined; + class?: string; + + 'aria-hidden'?: boolean | undefined | null; + 'aria-checked'?: 'true' | 'false' | undefined | null; + 'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' | undefined | null; + 'aria-controls'?: string | undefined | null; + 'aria-expanded'?: boolean; + 'aria-haspopup'?: boolean; + tabindex?: number | undefined | null; + role?: string | undefined | null; + onclick: (e: MouseEvent) => void; + disabled?: boolean; + } + + let { + type = 'button', + href = undefined, + icon, + color = 'transparent', + title, + padding = '3', + size = '24', + hideMobile = false, + buttonSize = undefined, + viewBox = undefined, + class: className = '', + onclick, + ...rest + }: Props = $props(); const colorClasses: Record<Color, string> = { transparent: 'bg-transparent hover:bg-[#d3d3d3] dark:text-immich-dark-fg', @@ -77,12 +77,12 @@ '3': 'p-3', }; - $: colorClass = colorClasses[color]; - $: mobileClass = hideMobile ? 'hidden sm:flex' : ''; - $: paddingClass = paddingClasses[padding]; + let colorClass = $derived(colorClasses[color]); + let mobileClass = $derived(hideMobile ? 'hidden sm:flex' : ''); + let paddingClass = $derived(paddingClasses[padding]); </script> -<!-- svelte-ignore a11y-no-static-element-interactions --> +<!-- svelte-ignore a11y_no_static_element_interactions --> <svelte:element this={href ? 'a' : 'button'} type={href ? undefined : type} @@ -91,8 +91,8 @@ style:width={buttonSize ? buttonSize + 'px' : ''} style:height={buttonSize ? buttonSize + 'px' : ''} class="flex place-content-center place-items-center rounded-full {colorClass} {paddingClass} transition-all disabled:cursor-default hover:dark:text-immich-dark-gray {className} {mobileClass}" - on:click - {...$$restProps} + {onclick} + {...rest} > <Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" /> </svelte:element> diff --git a/web/src/lib/components/elements/buttons/link-button.svelte b/web/src/lib/components/elements/buttons/link-button.svelte index b8e81f4469627..a39e2608cfbe0 100644 --- a/web/src/lib/components/elements/buttons/link-button.svelte +++ b/web/src/lib/components/elements/buttons/link-button.svelte @@ -1,22 +1,25 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export type Color = 'transparent-primary' | 'transparent-gray'; - - type BaseProps = { - color?: Color; - }; - - export type Props = (LinkProps & BaseProps) | (ButtonProps & BaseProps); </script> <script lang="ts"> - import Button, { type ButtonProps, type LinkProps } from '$lib/components/elements/buttons/button.svelte'; + import Button from '$lib/components/elements/buttons/button.svelte'; + import type { Snippet } from 'svelte'; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - type $$Props = Props; + interface Props { + href?: string; + color?: Color; + children?: Snippet; + onclick?: (e: MouseEvent) => void; + title?: string; + disabled?: boolean; + fullwidth?: boolean; + class?: string; + } - export let color: Color = 'transparent-gray'; + let { color = 'transparent-gray', children, ...rest }: Props = $props(); </script> -<Button size="link" {color} shadow={false} rounded="lg" on:click {...$$restProps}> - <slot /> +<Button size="link" {color} shadow={false} rounded="lg" {...rest}> + {@render children?.()} </Button> diff --git a/web/src/lib/components/elements/buttons/skip-link.svelte b/web/src/lib/components/elements/buttons/skip-link.svelte index d1ad667379bfc..858d296c30a8c 100644 --- a/web/src/lib/components/elements/buttons/skip-link.svelte +++ b/web/src/lib/components/elements/buttons/skip-link.svelte @@ -2,13 +2,17 @@ import { t } from 'svelte-i18n'; import Button from './button.svelte'; - /** - * Target for the skip link to move focus to. - */ - export let target: string = 'main'; - export let text: string = $t('skip_to_content'); + interface Props { + /** + * Target for the skip link to move focus to. + */ + target?: string; + text?: string; + } - let isFocused = false; + let { target = 'main', text = $t('skip_to_content') }: Props = $props(); + + let isFocused = $state(false); const moveFocus = () => { const targetEl = document.querySelector<HTMLElement>(target); @@ -20,9 +24,9 @@ <Button size={'sm'} rounded="none" - on:click={moveFocus} - on:focus={() => (isFocused = true)} - on:blur={() => (isFocused = false)} + onclick={moveFocus} + onfocus={() => (isFocused = true)} + onblur={() => (isFocused = false)} > {text} </Button> diff --git a/web/src/lib/components/elements/checkbox.svelte b/web/src/lib/components/elements/checkbox.svelte index 3407262551c08..4595c06bfb313 100644 --- a/web/src/lib/components/elements/checkbox.svelte +++ b/web/src/lib/components/elements/checkbox.svelte @@ -1,11 +1,25 @@ <script lang="ts"> - export let id: string; - export let label: string; - export let checked: boolean | undefined = undefined; - export let disabled: boolean = false; - export let labelClass: string | undefined = undefined; - export let name: string | undefined = undefined; - export let value: string | undefined = undefined; + interface Props { + id: string; + label: string; + checked?: boolean | undefined; + disabled?: boolean; + labelClass?: string | undefined; + name?: string | undefined; + value?: string | undefined; + onchange?: () => void; + } + + let { + id, + label, + checked = $bindable(), + disabled = false, + labelClass = undefined, + name = undefined, + value = undefined, + onchange = () => {}, + }: Props = $props(); </script> <div class="flex items-center space-x-2"> @@ -17,7 +31,7 @@ {disabled} class="size-5 flex-shrink-0 focus-visible:ring" bind:checked - on:change + {onchange} /> <label class={labelClass} for={id}>{label}</label> </div> diff --git a/web/src/lib/components/elements/date-input.svelte b/web/src/lib/components/elements/date-input.svelte index f42fff43596ed..687e9442e7664 100644 --- a/web/src/lib/components/elements/date-input.svelte +++ b/web/src/lib/components/elements/date-input.svelte @@ -1,29 +1,35 @@ <script lang="ts"> - import type { HTMLInputAttributes } from 'svelte/elements'; - - interface $$Props extends HTMLInputAttributes { + interface Props { type: 'date' | 'datetime-local'; + value?: string; + min?: string; + max?: string; + class?: string; + id?: string; + name?: string; + placeholder?: string; } - export let type: $$Props['type']; - export let value: $$Props['value'] = undefined; - export let max: $$Props['max'] = undefined; + let { type, value = $bindable(), max = undefined, ...rest }: Props = $props(); - $: fallbackMax = type === 'date' ? '9999-12-31' : '9999-12-31T23:59'; + let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59'); // Updating `value` directly causes the date input to reset itself or // interfere with user changes. - $: updatedValue = value; + let updatedValue = $state<string>(); + $effect(() => { + updatedValue = value; + }); </script> <input - {...$$restProps} + {...rest} {type} {value} max={max || fallbackMax} - on:input={(e) => (updatedValue = e.currentTarget.value)} - on:blur={() => (value = updatedValue)} - on:keydown={(e) => { + oninput={(e) => (updatedValue = e.currentTarget.value)} + onblur={() => (value = updatedValue)} + onkeydown={(e) => { if (e.key === 'Enter') { value = updatedValue; } diff --git a/web/src/lib/components/elements/dropdown.svelte b/web/src/lib/components/elements/dropdown.svelte index 80689ef1fe33b..b146f347dc836 100644 --- a/web/src/lib/components/elements/dropdown.svelte +++ b/web/src/lib/components/elements/dropdown.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> // Necessary for eslint /* eslint-disable @typescript-eslint/no-explicit-any */ type T = any; @@ -20,19 +20,31 @@ import { clickOutside } from '$lib/actions/click-outside'; import { fly } from 'svelte/transition'; - let className = ''; - export { className as class }; + interface Props { + class?: string; + options: T[]; + selectedOption?: any; + showMenu?: boolean; + controlable?: boolean; + hideTextOnSmallScreen?: boolean; + title?: string | undefined; + onSelect: (option: T) => void; + onClickOutside?: () => void; + render?: (item: T) => string | RenderedOption; + } - export let options: T[]; - export let selectedOption = options[0]; - export let showMenu = false; - export let controlable = false; - export let hideTextOnSmallScreen = true; - export let title: string | undefined = undefined; - export let onSelect: (option: T) => void; - export let onClickOutside: () => void = () => {}; - - export let render: (item: T) => string | RenderedOption = String; + let { + class: className = '', + options, + selectedOption = $bindable(options[0]), + showMenu = $bindable(false), + controlable = false, + hideTextOnSmallScreen = true, + title = undefined, + onSelect, + onClickOutside = () => {}, + render = String, + }: Props = $props(); const handleClickOutside = () => { if (!controlable) { @@ -65,12 +77,12 @@ } }; - $: renderedSelectedOption = renderOption(selectedOption); + let renderedSelectedOption = $derived(renderOption(selectedOption)); </script> <div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }}> <!-- BUTTON TITLE --> - <LinkButton on:click={() => (showMenu = true)} fullwidth {title}> + <LinkButton onclick={() => (showMenu = true)} fullwidth {title}> <div class="flex place-items-center gap-2 text-sm"> {#if renderedSelectedOption?.icon} <Icon path={renderedSelectedOption.icon} size="18" /> @@ -92,7 +104,7 @@ type="button" class="grid grid-cols-[36px,1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}" disabled={renderedOption.disabled} - on:click={() => !renderedOption.disabled && handleSelectOption(option)} + onclick={() => !renderedOption.disabled && handleSelectOption(option)} > {#if isEqual(selectedOption, option)} <div class="text-immich-primary dark:text-immich-dark-primary"> diff --git a/web/src/lib/components/elements/group-tab.svelte b/web/src/lib/components/elements/group-tab.svelte index f5e2f79350ba2..021d5ca96fa80 100644 --- a/web/src/lib/components/elements/group-tab.svelte +++ b/web/src/lib/components/elements/group-tab.svelte @@ -1,10 +1,14 @@ <script lang="ts"> import { generateId } from '$lib/utils/generate-id'; - export let filters: string[]; - export let selected: string; - export let label: string; - export let onSelect: (selected: string) => void; + interface Props { + filters: string[]; + selected: string; + label: string; + onSelect: (selected: string) => void; + } + + let { filters, selected, label, onSelect }: Props = $props(); const id = `group-tab-${generateId()}`; </script> @@ -22,7 +26,7 @@ class="peer sr-only" value={filter} checked={filter === selected} - on:change={() => onSelect(filter)} + onchange={() => onSelect(filter)} /> <label for="{id}-{index}" diff --git a/web/src/lib/components/elements/icon.svelte b/web/src/lib/components/elements/icon.svelte index 5965928718276..4bc55b3247628 100644 --- a/web/src/lib/components/elements/icon.svelte +++ b/web/src/lib/components/elements/icon.svelte @@ -1,22 +1,41 @@ <script lang="ts"> import type { AriaRole } from 'svelte/elements'; - export let size: string | number = '1em'; - export let color = 'currentColor'; - export let path: string; - export let title: string | null = null; - export let desc = ''; - export let flipped = false; - let className = ''; - export { className as class }; - export let viewBox = '0 0 24 24'; - export let role: AriaRole = 'img'; - export let ariaHidden: boolean | undefined = undefined; - export let ariaLabel: string | undefined = undefined; - export let ariaLabelledby: string | undefined = undefined; - export let strokeWidth: number = 0; - export let strokeColor: string = 'currentColor'; - export let spin = false; + interface Props { + size?: string | number; + color?: string; + path: string; + title?: string | null; + desc?: string; + flipped?: boolean; + class?: string; + viewBox?: string; + role?: AriaRole; + ariaHidden?: boolean | undefined; + ariaLabel?: string | undefined; + ariaLabelledby?: string | undefined; + strokeWidth?: number; + strokeColor?: string; + spin?: boolean; + } + + let { + size = '1em', + color = 'currentColor', + path, + title = null, + desc = '', + flipped = false, + class: className = '', + viewBox = '0 0 24 24', + role = 'img', + ariaHidden = undefined, + ariaLabel = undefined, + ariaLabelledby = undefined, + strokeWidth = 0, + strokeColor = 'currentColor', + spin = false, + }: Props = $props(); </script> <svg diff --git a/web/src/lib/components/elements/radio-button.svelte b/web/src/lib/components/elements/radio-button.svelte index a3c47e5fbc376..1d110ff644d81 100644 --- a/web/src/lib/components/elements/radio-button.svelte +++ b/web/src/lib/components/elements/radio-button.svelte @@ -1,9 +1,13 @@ <script lang="ts"> - export let id: string; - export let label: string; - export let name: string; - export let value: string; - export let group: string | undefined = undefined; + interface Props { + id: string; + label: string; + name: string; + value: string; + group?: string | undefined; + } + + let { id, label, name, value, group = $bindable(undefined) }: Props = $props(); </script> <div class="flex items-center space-x-2"> diff --git a/web/src/lib/components/elements/search-bar.svelte b/web/src/lib/components/elements/search-bar.svelte index 7668152d35460..c852be3b68bbe 100644 --- a/web/src/lib/components/elements/search-bar.svelte +++ b/web/src/lib/components/elements/search-bar.svelte @@ -5,14 +5,25 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; - export let name: string; - export let roundedBottom = true; - export let showLoadingSpinner: boolean; - export let placeholder: string; - export let onSearch: (options: SearchOptions) => void = () => {}; - export let onReset: () => void = () => {}; + interface Props { + name: string; + roundedBottom?: boolean; + showLoadingSpinner: boolean; + placeholder: string; + onSearch?: (options: SearchOptions) => void; + onReset?: () => void; + } - let inputRef: HTMLElement; + let { + name = $bindable(), + roundedBottom = true, + showLoadingSpinner, + placeholder, + onSearch = () => {}, + onReset = () => {}, + }: Props = $props(); + + let inputRef = $state<HTMLElement>(); const resetSearch = () => { name = ''; @@ -37,7 +48,7 @@ title={$t('search')} size="16" padding="2" - on:click={() => onSearch({ force: true })} + onclick={() => onSearch({ force: true })} /> <input class="w-full gap-2 bg-gray-200 dark:bg-immich-dark-gray dark:text-white" @@ -45,8 +56,8 @@ {placeholder} bind:value={name} bind:this={inputRef} - on:keydown={handleSearch} - on:input={() => onSearch({ force: false })} + onkeydown={handleSearch} + oninput={() => onSearch({ force: false })} /> {#if showLoadingSpinner} <div class="flex place-items-center"> @@ -54,6 +65,6 @@ </div> {/if} {#if name} - <CircleIconButton icon={mdiClose} title={$t('clear_value')} size="16" padding="2" on:click={resetSearch} /> + <CircleIconButton icon={mdiClose} title={$t('clear_value')} size="16" padding="2" onclick={resetSearch} /> {/if} </div> diff --git a/web/src/lib/components/elements/slider.svelte b/web/src/lib/components/elements/slider.svelte index 4c19696372084..5c80eb2a9e11a 100644 --- a/web/src/lib/components/elements/slider.svelte +++ b/web/src/lib/components/elements/slider.svelte @@ -1,15 +1,25 @@ <script lang="ts"> - /** - * Unique identifier for the checkbox element, used to associate labels with the input element. - */ - export let id: string; - /** - * Optional aria-describedby attribute to associate the checkbox with a description. - */ - export let ariaDescribedBy: string | undefined = undefined; - export let checked = false; - export let disabled = false; - export let onToggle: ((checked: boolean) => void) | undefined = undefined; + interface Props { + /** + * Unique identifier for the checkbox element, used to associate labels with the input element. + */ + id: string; + /** + * Optional aria-describedby attribute to associate the checkbox with a description. + */ + ariaDescribedBy?: string | undefined; + checked?: boolean; + disabled?: boolean; + onToggle?: ((checked: boolean) => void) | undefined; + } + + let { + id, + ariaDescribedBy = undefined, + checked = $bindable(false), + disabled = false, + onToggle = undefined, + }: Props = $props(); const handleToggle = (event: Event) => onToggle?.((event.target as HTMLInputElement).checked); </script> @@ -20,7 +30,7 @@ class="disabled::cursor-not-allowed h-0 w-0 opacity-0 peer" type="checkbox" bind:checked - on:click={handleToggle} + onclick={handleToggle} {disabled} aria-describedby={ariaDescribedBy} /> diff --git a/web/src/lib/components/error.svelte b/web/src/lib/components/error.svelte index cbc8c26bd8521..54466b5a55047 100644 --- a/web/src/lib/components/error.svelte +++ b/web/src/lib/components/error.svelte @@ -6,7 +6,11 @@ import { mdiCodeTags, mdiContentCopy, mdiMessage, mdiPartyPopper } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let error: { message: string; code?: string | number; stack?: string } | undefined | null = undefined; + interface Props { + error?: { message: string; code?: string | number; stack?: string } | undefined | null; + } + + let { error = undefined }: Props = $props(); const handleCopy = async () => { if (!error) { @@ -41,7 +45,7 @@ color="primary" icon={mdiContentCopy} title={$t('copy_error')} - on:click={() => handleCopy()} + onclick={() => handleCopy()} /> </div> </div> diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index ce184321e3767..b6c9beb43a140 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -14,24 +14,28 @@ import { zoomImageToBase64 } from '$lib/utils/people-utils'; import { t } from 'svelte-i18n'; - export let allPeople: PersonResponseDto[]; - export let editedFace: AssetFaceResponseDto; - export let assetId: string; - export let assetType: AssetTypeEnum; - export let onClose: () => void; - export let onCreatePerson: (featurePhoto: string | null) => void; - export let onReassign: (person: PersonResponseDto) => void; + interface Props { + allPeople: PersonResponseDto[]; + editedFace: AssetFaceResponseDto; + assetId: string; + assetType: AssetTypeEnum; + onClose: () => void; + onCreatePerson: (featurePhoto: string | null) => void; + onReassign: (person: PersonResponseDto) => void; + } + + let { allPeople, editedFace, assetId, assetType, onClose, onCreatePerson, onReassign }: Props = $props(); // loading spinners - let isShowLoadingNewPerson = false; - let isShowLoadingSearch = false; + let isShowLoadingNewPerson = $state(false); + let isShowLoadingSearch = $state(false); // search people - let searchedPeople: PersonResponseDto[] = []; - let searchFaces = false; - let searchName = ''; + let searchedPeople: PersonResponseDto[] = $state([]); + let searchFaces = $state(false); + let searchName = $state(''); - $: showPeople = searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden); + let showPeople = $derived(searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden)); const handleCreatePerson = async () => { const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner); @@ -53,19 +57,19 @@ <div class="flex place-items-center justify-between gap-2"> {#if !searchFaces} <div class="flex items-center gap-2"> - <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={onClose} /> + <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} onclick={onClose} /> <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">{$t('select_face')}</p> </div> <div class="flex justify-end gap-2"> <CircleIconButton icon={mdiMagnify} title={$t('search_for_existing_person')} - on:click={() => { + onclick={() => { searchFaces = true; }} /> {#if !isShowLoadingNewPerson} - <CircleIconButton icon={mdiPlus} title={$t('create_new_person')} on:click={handleCreatePerson} /> + <CircleIconButton icon={mdiPlus} title={$t('create_new_person')} onclick={handleCreatePerson} /> {:else} <div class="flex place-content-center place-items-center"> <LoadingSpinner /> @@ -73,7 +77,7 @@ {/if} </div> {:else} - <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={onClose} /> + <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} onclick={onClose} /> <div class="w-full flex"> <SearchPeople type="input" @@ -87,7 +91,7 @@ </div> {/if} </div> - <CircleIconButton icon={mdiClose} title={$t('cancel_search')} on:click={() => (searchFaces = false)} /> + <CircleIconButton icon={mdiClose} title={$t('cancel_search')} onclick={() => (searchFaces = false)} /> {/if} </div> <div class="px-4 py-4 text-sm"> @@ -96,7 +100,7 @@ {#each showPeople as person (person.id)} {#if !editedFace.person || person.id !== editedFace.person.id} <div class="w-fit"> - <button type="button" class="w-[90px]" on:click={() => onReassign(person)}> + <button type="button" class="w-[90px]" onclick={() => onReassign(person)}> <div class="relative"> <ImageThumbnail curve diff --git a/web/src/lib/components/faces-page/edit-name-input.svelte b/web/src/lib/components/faces-page/edit-name-input.svelte index d9e961c13f5f6..ebb44c4008d79 100644 --- a/web/src/lib/components/faces-page/edit-name-input.svelte +++ b/web/src/lib/components/faces-page/edit-name-input.svelte @@ -5,25 +5,37 @@ import SearchPeople from '$lib/components/faces-page/people-search.svelte'; import { t } from 'svelte-i18n'; - export let person: PersonResponseDto; - export let name: string; - export let suggestedPeople: PersonResponseDto[]; - export let thumbnailData: string; - export let isSearchingPeople: boolean; - export let onChange: (name: string) => void; + interface Props { + person: PersonResponseDto; + name: string; + suggestedPeople: PersonResponseDto[]; + thumbnailData: string; + isSearchingPeople: boolean; + onChange: (name: string) => void; + } + + let { + person, + name = $bindable(), + suggestedPeople = $bindable(), + thumbnailData, + isSearchingPeople = $bindable(), + onChange, + }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + onChange(name); + }; </script> <div class="flex w-full h-14 place-items-center {suggestedPeople.length > 0 ? 'rounded-t-lg dark:border-immich-dark-gray' - : 'rounded-lg'} bg-gray-100 p-2 dark:bg-gray-700" + : 'rounded-lg'} bg-gray-100 p-2 dark:bg-gray-700 border border-gray-200 dark:border-immich-dark-gray" > <ImageThumbnail circle shadow url={thumbnailData} altText={person.name} widthStyle="2rem" heightStyle="2rem" /> - <form - class="ml-4 flex w-full justify-between gap-16" - autocomplete="off" - on:submit|preventDefault={() => onChange(name)} - > + <form class="ml-4 flex w-full justify-between gap-16" autocomplete="off" {onsubmit}> <SearchPeople bind:searchName={name} bind:searchedPeopleLocal={suggestedPeople} diff --git a/web/src/lib/components/faces-page/face-thumbnail.svelte b/web/src/lib/components/faces-page/face-thumbnail.svelte index cce91b466931a..cc3fffe5d7e81 100644 --- a/web/src/lib/components/faces-page/face-thumbnail.svelte +++ b/web/src/lib/components/faces-page/face-thumbnail.svelte @@ -3,19 +3,31 @@ import { type PersonResponseDto } from '@immich/sdk'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; - export let person: PersonResponseDto; - export let selectable = false; - export let selected = false; - export let thumbnailSize: number | null = null; - export let circle = false; - export let border = false; - export let onClick: (person: PersonResponseDto) => void = () => {}; + interface Props { + person: PersonResponseDto; + selectable?: boolean; + selected?: boolean; + thumbnailSize?: number | null; + circle?: boolean; + border?: boolean; + onClick?: (person: PersonResponseDto) => void; + } + + let { + person, + selectable = false, + selected = false, + thumbnailSize = null, + circle = false, + border = false, + onClick = () => {}, + }: Props = $props(); </script> <button type="button" class="relative rounded-lg transition-all" - on:click={() => onClick(person)} + onclick={() => onClick(person)} disabled={!selectable} style:width={thumbnailSize ? thumbnailSize + 'px' : '100%'} style:height={thumbnailSize ? thumbnailSize + 'px' : '100%'} diff --git a/web/src/lib/components/faces-page/manage-people-visibility.svelte b/web/src/lib/components/faces-page/manage-people-visibility.svelte index 90e20a1e5b429..295f629736d7e 100644 --- a/web/src/lib/components/faces-page/manage-people-visibility.svelte +++ b/web/src/lib/components/faces-page/manage-people-visibility.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> const enum ToggleVisibility { HIDE_ALL = 'hide-all', HIDE_UNNANEMD = 'hide-unnamed', @@ -24,17 +24,18 @@ import { t } from 'svelte-i18n'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; - export let people: PersonResponseDto[]; - export let totalPeopleCount: number; - export let titleId: string | undefined = undefined; - export let onClose: () => void; - export let loadNextPage: () => void; + interface Props { + people: PersonResponseDto[]; + totalPeopleCount: number; + titleId?: string | undefined; + onClose: () => void; + loadNextPage: () => void; + } - let toggleVisibility = ToggleVisibility.SHOW_ALL; - let showLoadingSpinner = false; + let { people = $bindable(), totalPeopleCount, titleId = undefined, onClose, loadNextPage }: Props = $props(); - $: personIsHidden = getPersonIsHidden(people); - $: toggleButton = toggleButtonOptions[getNextVisibility(toggleVisibility)]; + let toggleVisibility = $state(ToggleVisibility.SHOW_ALL); + let showLoadingSpinner = $state(false); const getPersonIsHidden = (people: PersonResponseDto[]) => { const personIsHidden: Record<string, boolean> = {}; @@ -44,16 +45,6 @@ return personIsHidden; }; - // svelte-ignore reactive_declaration_non_reactive_property - // svelte-ignore reactive_declaration_module_script_dependency - $: toggleButtonOptions = ((): Record<ToggleVisibility, { icon: string; label: string }> => { - return { - [ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') }, - [ToggleVisibility.HIDE_UNNANEMD]: { icon: mdiEyeSettings, label: $t('hide_unnamed_people') }, - [ToggleVisibility.SHOW_ALL]: { icon: mdiEye, label: $t('show_all_people') }, - }; - })(); - const getNextVisibility = (toggleVisibility: ToggleVisibility) => { if (toggleVisibility === ToggleVisibility.SHOW_ALL) { return ToggleVisibility.HIDE_UNNANEMD; @@ -115,6 +106,15 @@ showLoadingSpinner = false; } }; + + let personIsHidden = $state(getPersonIsHidden(people)); + + let toggleButtonOptions: Record<ToggleVisibility, { icon: string; label: string }> = $derived({ + [ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') }, + [ToggleVisibility.HIDE_UNNANEMD]: { icon: mdiEyeSettings, label: $t('hide_unnamed_people') }, + [ToggleVisibility.SHOW_ALL]: { icon: mdiEye, label: $t('show_all_people') }, + }); + let toggleButton = $derived(toggleButtonOptions[getNextVisibility(toggleVisibility)]); </script> <svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} /> @@ -123,7 +123,7 @@ class="fixed top-0 z-10 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8" > <div class="flex items-center"> - <CircleIconButton title={$t('close')} icon={mdiClose} on:click={onClose} /> + <CircleIconButton title={$t('close')} icon={mdiClose} onclick={onClose} /> <div class="flex gap-2 items-center"> <p id={titleId} class="ml-2">{$t('show_and_hide_people')}</p> <p class="text-sm text-gray-400 dark:text-gray-600">({totalPeopleCount.toLocaleString($locale)})</p> @@ -131,11 +131,11 @@ </div> <div class="flex items-center justify-end"> <div class="flex items-center md:mr-4"> - <CircleIconButton title={$t('reset_people_visibility')} icon={mdiRestart} on:click={handleResetVisibility} /> - <CircleIconButton title={toggleButton.label} icon={toggleButton.icon} on:click={handleToggleVisibility} /> + <CircleIconButton title={$t('reset_people_visibility')} icon={mdiRestart} onclick={handleResetVisibility} /> + <CircleIconButton title={toggleButton.label} icon={toggleButton.icon} onclick={handleToggleVisibility} /> </div> {#if !showLoadingSpinner} - <Button on:click={handleSaveVisibility} size="sm" rounded="lg">{$t('done')}</Button> + <Button onclick={handleSaveVisibility} size="sm" rounded="lg">{$t('done')}</Button> {:else} <LoadingSpinner /> {/if} @@ -143,29 +143,31 @@ </div> <div class="flex flex-wrap gap-1 bg-immich-bg p-2 pb-8 dark:bg-immich-dark-bg md:px-8 mt-16"> - <PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage} let:person let:index> - {@const hidden = personIsHidden[person.id]} - <button - type="button" - class="group relative w-full h-full" - on:click={() => (personIsHidden[person.id] = !hidden)} - aria-pressed={hidden} - aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')} - > - <ImageThumbnail - preload={index < 20} - {hidden} - shadow - url={getPeopleThumbnailUrl(person)} - altText={person.name} - widthStyle="100%" - hiddenIconClass="text-white group-hover:text-black transition-colors" - /> - {#if person.name} - <span class="absolute bottom-2 left-0 w-full select-text px-1 text-center font-medium text-white"> - {person.name} - </span> - {/if} - </button> + <PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}> + {#snippet children({ person, index })} + {@const hidden = personIsHidden[person.id]} + <button + type="button" + class="group relative w-full h-full" + onclick={() => (personIsHidden[person.id] = !hidden)} + aria-pressed={hidden} + aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')} + > + <ImageThumbnail + preload={index < 20} + {hidden} + shadow + url={getPeopleThumbnailUrl(person)} + altText={person.name} + widthStyle="100%" + hiddenIconClass="text-white group-hover:text-black transition-colors" + /> + {#if person.name} + <span class="absolute bottom-2 left-0 w-full select-text px-1 text-center font-medium text-white"> + {person.name} + </span> + {/if} + </button> + {/snippet} </PeopleInfiniteScroll> </div> diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte index 52daa36a99354..c638691080e16 100644 --- a/web/src/lib/components/faces-page/merge-face-selector.svelte +++ b/web/src/lib/components/faces-page/merge-face-selector.svelte @@ -19,16 +19,20 @@ import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; - export let person: PersonResponseDto; - export let onBack: () => void; - export let onMerge: (mergedPerson: PersonResponseDto) => void; + interface Props { + person: PersonResponseDto; + onBack: () => void; + onMerge: (mergedPerson: PersonResponseDto) => void; + } - let people: PersonResponseDto[] = []; - let selectedPeople: PersonResponseDto[] = []; - let screenHeight: number; + let { person = $bindable(), onBack, onMerge }: Props = $props(); - $: hasSelection = selectedPeople.length > 0; - $: peopleToNotShow = [...selectedPeople, person]; + let people: PersonResponseDto[] = $state([]); + let selectedPeople: PersonResponseDto[] = $state([]); + let screenHeight: number = $state(0); + + let hasSelection = $derived(selectedPeople.length > 0); + let peopleToNotShow = $derived([...selectedPeople, person]); onMount(async () => { const data = await getAllPeople({ withHidden: false }); @@ -96,20 +100,20 @@ class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg" > <ControlAppBar onClose={onBack}> - <svelte:fragment slot="leading"> + {#snippet leading()} {#if hasSelection} {$t('selected_count', { values: { count: selectedPeople.length } })} {:else} {$t('merge_people')} {/if} <div></div> - </svelte:fragment> - <svelte:fragment slot="trailing"> - <Button size={'sm'} disabled={!hasSelection} on:click={handleMerge}> + {/snippet} + {#snippet trailing()} + <Button size={'sm'} disabled={!hasSelection} onclick={handleMerge}> <Icon path={mdiMerge} size={18} /> <span class="ml-2">{$t('merge')}</span></Button > - </svelte:fragment> + {/snippet} </ControlAppBar> <section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg"> <section id="merge-face-selector relative"> @@ -135,7 +139,7 @@ title={$t('swap_merge_direction')} icon={mdiSwapHorizontal} size="24" - on:click={handleSwapPeople} + onclick={handleSwapPeople} /> </div> {/if} diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte index f869790ebab0b..a4ac76f19813d 100644 --- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte +++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte @@ -9,14 +9,25 @@ import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; - export let personMerge1: PersonResponseDto; - export let personMerge2: PersonResponseDto; - export let potentialMergePeople: PersonResponseDto[]; - export let onReject: () => void; - export let onConfirm: ([personMerge1, personMerge2]: [PersonResponseDto, PersonResponseDto]) => void; - export let onClose: () => void; + interface Props { + personMerge1: PersonResponseDto; + personMerge2: PersonResponseDto; + potentialMergePeople: PersonResponseDto[]; + onReject: () => void; + onConfirm: ([personMerge1, personMerge2]: [PersonResponseDto, PersonResponseDto]) => void; + onClose: () => void; + } - let choosePersonToMerge = false; + let { + personMerge1 = $bindable(), + personMerge2 = $bindable(), + potentialMergePeople = $bindable(), + onReject, + onConfirm, + onClose, + }: Props = $props(); + + let choosePersonToMerge = $state(false); const title = personMerge2.name; @@ -43,7 +54,7 @@ <CircleIconButton title={$t('swap_merge_direction')} icon={mdiMerge} - on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])} + onclick={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])} /> </div> @@ -51,7 +62,7 @@ type="button" disabled={potentialMergePeople.length === 0} class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2" - on:click={() => { + onclick={() => { if (potentialMergePeople.length > 0) { choosePersonToMerge = !choosePersonToMerge; } @@ -69,13 +80,13 @@ {:else} <div class="grid w-full grid-cols-1 gap-2"> <div class="px-2"> - <button type="button" on:click={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button> + <button type="button" onclick={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button> </div> <div class="flex items-center justify-center"> <div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}"> {#each potentialMergePeople as person (person.id)} <div class="h-24 w-24 md:h-28 md:w-28"> - <button type="button" class="p-2 w-full" on:click={() => changePersonToMerge(person)}> + <button type="button" class="p-2 w-full" onclick={() => changePersonToMerge(person)}> <ImageThumbnail border={true} circle @@ -83,7 +94,7 @@ url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" - on:click={() => changePersonToMerge(person)} + onClick={() => changePersonToMerge(person)} /> </button> </div> @@ -100,8 +111,9 @@ <div class="flex px-4 pt-2"> <p class="text-sm text-gray-500 dark:text-gray-300">{$t('they_will_be_merged_together')}</p> </div> - <svelte:fragment slot="sticky-bottom"> - <Button fullwidth color="gray" on:click={onReject}>{$t('no')}</Button> - <Button fullwidth on:click={() => onConfirm([personMerge1, personMerge2])}>{$t('yes')}</Button> - </svelte:fragment> + + {#snippet stickyBottom()} + <Button fullwidth color="gray" onclick={onReject}>{$t('no')}</Button> + <Button fullwidth onclick={() => onConfirm([personMerge1, personMerge2])}>{$t('yes')}</Button> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 6791a26232e48..a83d1180f954e 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -15,28 +15,32 @@ import { focusOutside } from '$lib/actions/focus-outside'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; - export let person: PersonResponseDto; - export let preload = false; - export let onChangeName: () => void; - export let onSetBirthDate: () => void; - export let onMergePeople: () => void; - export let onHidePerson: () => void; + interface Props { + person: PersonResponseDto; + preload?: boolean; + onChangeName: () => void; + onSetBirthDate: () => void; + onMergePeople: () => void; + onHidePerson: () => void; + } - let showVerticalDots = false; + let { person, preload = false, onChangeName, onSetBirthDate, onMergePeople, onHidePerson }: Props = $props(); + + let showVerticalDots = $state(false); </script> <div id="people-card" class="relative" - on:mouseenter={() => (showVerticalDots = true)} - on:mouseleave={() => (showVerticalDots = false)} + onmouseenter={() => (showVerticalDots = true)} + onmouseleave={() => (showVerticalDots = false)} role="group" use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }} > <a href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}" draggable="false" - on:focus={() => (showVerticalDots = true)} + onfocus={() => (showVerticalDots = true)} > <div class="w-full h-full rounded-xl brightness-95 filter"> <ImageThumbnail diff --git a/web/src/lib/components/faces-page/people-infinite-scroll.svelte b/web/src/lib/components/faces-page/people-infinite-scroll.svelte index aefd6fe9578bc..0de084c4b2608 100644 --- a/web/src/lib/components/faces-page/people-infinite-scroll.svelte +++ b/web/src/lib/components/faces-page/people-infinite-scroll.svelte @@ -1,11 +1,16 @@ <script lang="ts"> import type { PersonResponseDto } from '@immich/sdk'; - export let people: PersonResponseDto[]; - export let hasNextPage: boolean | undefined = undefined; - export let loadNextPage: () => void; + interface Props { + people: PersonResponseDto[]; + hasNextPage?: boolean | undefined; + loadNextPage: () => void; + children?: import('svelte').Snippet<[{ person: PersonResponseDto; index: number }]>; + } + + let { people, hasNextPage = undefined, loadNextPage, children }: Props = $props(); - let lastPersonContainer: HTMLElement | undefined; + let lastPersonContainer: HTMLElement | undefined = $state(); const intersectionObserver = new IntersectionObserver((entries) => { const entry = entries.find((entry) => entry.target === lastPersonContainer); @@ -14,20 +19,22 @@ } }); - $: if (lastPersonContainer) { - intersectionObserver.disconnect(); - intersectionObserver.observe(lastPersonContainer); - } + $effect(() => { + if (lastPersonContainer) { + intersectionObserver.disconnect(); + intersectionObserver.observe(lastPersonContainer); + } + }); </script> <div class="w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-9 gap-1"> {#each people as person, index (person.id)} {#if hasNextPage && index === people.length - 1} <div bind:this={lastPersonContainer}> - <slot {person} {index} /> + {@render children?.({ person, index })} </div> {:else} - <slot {person} {index} /> + {@render children?.({ person, index })} {/if} {/each} </div> diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte index 10626a6a93888..511792e536c35 100644 --- a/web/src/lib/components/faces-page/people-list.svelte +++ b/web/src/lib/components/faces-page/people-list.svelte @@ -4,22 +4,24 @@ import SearchPeople from '$lib/components/faces-page/people-search.svelte'; import { t } from 'svelte-i18n'; - export let screenHeight: number; - export let people: PersonResponseDto[]; - export let peopleToNotShow: PersonResponseDto[]; - export let onSelect: (person: PersonResponseDto) => void; + interface Props { + screenHeight: number; + people: PersonResponseDto[]; + peopleToNotShow: PersonResponseDto[]; + onSelect: (person: PersonResponseDto) => void; + } + + let { screenHeight, people, peopleToNotShow, onSelect }: Props = $props(); - let searchedPeopleLocal: PersonResponseDto[] = []; + let searchedPeopleLocal: PersonResponseDto[] = $state([]); - let name = ''; - let showPeople: PersonResponseDto[]; + let name = $state(''); - $: { - showPeople = name ? searchedPeopleLocal : people; - showPeople = showPeople.filter( + const showPeople = $derived( + (name ? searchedPeopleLocal : people).filter( (person) => !peopleToNotShow.some((unselectedPerson) => unselectedPerson.id === person.id), - ); - } + ), + ); </script> <div class=" w-40 sm:w-48 md:w-96 h-14 mb-8"> diff --git a/web/src/lib/components/faces-page/people-search.svelte b/web/src/lib/components/faces-page/people-search.svelte index 2a952b8145b25..835f4188c47e3 100644 --- a/web/src/lib/components/faces-page/people-search.svelte +++ b/web/src/lib/components/faces-page/people-search.svelte @@ -7,16 +7,6 @@ import { searchPerson, type PersonResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; - export let searchName: string; - export let searchedPeopleLocal: PersonResponseDto[]; - export let type: 'searchBar' | 'input'; - export let numberPeopleToSearch: number = maximumLengthSearchPeople; - export let inputClass: string = 'w-full gap-2 bg-immich-bg dark:bg-immich-dark-bg'; - export let showLoadingSpinner: boolean = false; - export let placeholder: string = $t('name_or_nickname'); - export let onReset = () => {}; - export let onSearch = () => {}; - let searchedPeople: PersonResponseDto[] = []; let searchWord: string; let abortController: AbortController | null = null; @@ -43,7 +33,36 @@ } }; - export let handleSearch = async (force?: boolean, name?: string) => { + interface Props { + searchName: string; + searchedPeopleLocal: PersonResponseDto[]; + type: 'searchBar' | 'input'; + numberPeopleToSearch?: number; + inputClass?: string; + showLoadingSpinner?: boolean; + placeholder?: string; + onReset?: () => void; + onSearch?: () => void; + } + + let { + searchName = $bindable(), + searchedPeopleLocal = $bindable(), + type, + numberPeopleToSearch = maximumLengthSearchPeople, + inputClass = 'w-full gap-2 bg-immich-bg dark:bg-immich-dark-bg', + showLoadingSpinner = $bindable(false), + placeholder = $t('name_or_nickname'), + onReset = () => {}, + onSearch = () => {}, + }: Props = $props(); + + const handleReset = () => { + reset(); + onReset(); + }; + + export async function searchPeople(force?: boolean, name?: string) { searchName = name ?? searchName; onSearch(); if (searchName === '') { @@ -70,12 +89,7 @@ showLoadingSpinner = false; search(); } - }; - - const handleReset = () => { - reset(); - onReset(); - }; + } </script> {#if type === 'searchBar'} @@ -84,7 +98,7 @@ {showLoadingSpinner} {placeholder} onReset={handleReset} - onSearch={({ force }) => handleSearch(force ?? false)} + onSearch={({ force }) => searchPeople(force ?? false)} /> {:else} <input @@ -92,7 +106,7 @@ type="text" {placeholder} bind:value={searchName} - on:input={() => handleSearch(false)} + oninput={() => searchPeople(false)} use:initInput /> {/if} diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 13f356dfc043c..8bbfaaafcf5f1 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -28,28 +28,32 @@ import { photoViewer } from '$lib/stores/assets.store'; import { t } from 'svelte-i18n'; - export let assetId: string; - export let assetType: AssetTypeEnum; - export let onClose: () => void; - export let onRefresh: () => void; + interface Props { + assetId: string; + assetType: AssetTypeEnum; + onClose: () => void; + onRefresh: () => void; + } + + let { assetId, assetType, onClose, onRefresh }: Props = $props(); // keep track of the changes let peopleToCreate: string[] = []; let assetFaceGenerated: string[] = []; // faces - let peopleWithFaces: AssetFaceResponseDto[] = []; - let selectedPersonToReassign: Record<string, PersonResponseDto> = {}; - let selectedPersonToCreate: Record<string, string> = {}; - let editedFace: AssetFaceResponseDto; + let peopleWithFaces: AssetFaceResponseDto[] = $state([]); + let selectedPersonToReassign: Record<string, PersonResponseDto> = $state({}); + let selectedPersonToCreate: Record<string, string> = $state({}); + let editedFace: AssetFaceResponseDto | undefined = $state(); // loading spinners - let isShowLoadingDone = false; - let isShowLoadingPeople = false; + let isShowLoadingDone = $state(false); + let isShowLoadingPeople = $state(false); // search people - let showSelectedFaces = false; - let allPeople: PersonResponseDto[] = []; + let showSelectedFaces = $state(false); + let allPeople: PersonResponseDto[] = $state([]); // timers let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>; @@ -152,14 +156,14 @@ }; const handleCreatePerson = (newFeaturePhoto: string | null) => { - if (newFeaturePhoto) { + if (newFeaturePhoto && editedFace) { selectedPersonToCreate[editedFace.id] = newFeaturePhoto; } showSelectedFaces = false; }; const handleReassignFace = (person: PersonResponseDto | null) => { - if (person) { + if (person && editedFace) { selectedPersonToReassign[editedFace.id] = person; } showSelectedFaces = false; @@ -177,14 +181,14 @@ > <div class="flex place-items-center justify-between gap-2"> <div class="flex items-center gap-2"> - <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={onClose} /> + <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} onclick={onClose} /> <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">{$t('edit_faces')}</p> </div> {#if !isShowLoadingDone} <button type="button" class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50" - on:click={() => handleEditFaces()} + onclick={() => handleEditFaces()} > {$t('done')} </button> @@ -207,9 +211,9 @@ role="button" tabindex={index} class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default" - on:focus={() => ($boundingBoxesArray = [peopleWithFaces[index]])} - on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} - on:mouseleave={() => ($boundingBoxesArray = [])} + onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])} + onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} + onmouseleave={() => ($boundingBoxesArray = [])} > <div class="relative"> {#if selectedPersonToCreate[face.id]} @@ -291,7 +295,7 @@ size="18" padding="1" class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform" - on:click={() => handleReset(face.id)} + onclick={() => handleReset(face.id)} /> {:else} <CircleIconButton @@ -301,7 +305,7 @@ size="18" padding="1" class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform" - on:click={() => handleFacePicker(face)} + onclick={() => handleFacePicker(face)} /> {/if} </div> @@ -322,7 +326,7 @@ </div> </section> -{#if showSelectedFaces} +{#if showSelectedFaces && editedFace} <AssignFaceSidePanel {allPeople} {editedFace} diff --git a/web/src/lib/components/faces-page/set-birth-date-modal.svelte b/web/src/lib/components/faces-page/set-birth-date-modal.svelte index d38c519911dee..f5ecbfabf0af2 100644 --- a/web/src/lib/components/faces-page/set-birth-date-modal.svelte +++ b/web/src/lib/components/faces-page/set-birth-date-modal.svelte @@ -5,11 +5,20 @@ import DateInput from '../elements/date-input.svelte'; import { t } from 'svelte-i18n'; - export let birthDate: string; - export let onClose: () => void; - export let onUpdate: (birthDate: string) => void; + interface Props { + birthDate: string; + onClose: () => void; + onUpdate: (birthDate: string) => void; + } + + let { birthDate = $bindable(), onClose, onUpdate }: Props = $props(); const todayFormatted = new Date().toISOString().split('T')[0]; + + const onSubmit = (event: Event) => { + event.preventDefault(); + onUpdate(birthDate); + }; </script> <FullScreenModal title={$t('set_date_of_birth')} icon={mdiCake} {onClose}> @@ -19,7 +28,7 @@ </p> </div> - <form on:submit|preventDefault={() => onUpdate(birthDate)} autocomplete="off" id="set-birth-date-form"> + <form onsubmit={(e) => onSubmit(e)} autocomplete="off" id="set-birth-date-form"> <div class="my-4 flex flex-col gap-2"> <DateInput class="immich-form-input" @@ -31,8 +40,9 @@ /> </div> </form> - <svelte:fragment slot="sticky-bottom"> - <Button color="gray" fullwidth on:click={onClose}>{$t('cancel')}</Button> + + {#snippet stickyBottom()} + <Button color="gray" fullwidth onclick={onClose}>{$t('cancel')}</Button> <Button type="submit" fullwidth form="set-birth-date-form">{$t('set')}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte index 70a360bea0b0a..06c53f3618031 100644 --- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -10,7 +10,7 @@ type PersonResponseDto, } from '@immich/sdk'; import { mdiMerge, mdiPlus } from '@mdi/js'; - import { onMount } from 'svelte'; + import { onMount, type Snippet } from 'svelte'; import { quintOut } from 'svelte/easing'; import { fly } from 'svelte/transition'; import Button from '../elements/buttons/button.svelte'; @@ -21,20 +21,26 @@ import PeopleList from './people-list.svelte'; import { t } from 'svelte-i18n'; - export let assetIds: string[]; - export let personAssets: PersonResponseDto; - export let onConfirm: () => void; - export let onClose: () => void; + interface Props { + assetIds: string[]; + personAssets: PersonResponseDto; + onConfirm: () => void; + onClose: () => void; + header?: Snippet; + merge?: Snippet; + } + + let { assetIds, personAssets, onConfirm, onClose, header, merge }: Props = $props(); - let people: PersonResponseDto[] = []; - let selectedPerson: PersonResponseDto | null = null; - let disableButtons = false; - let showLoadingSpinnerCreate = false; - let showLoadingSpinnerReassign = false; - let hasSelection = false; - let screenHeight: number; + let people: PersonResponseDto[] = $state([]); + let selectedPerson: PersonResponseDto | null = $state(null); + let disableButtons = $state(false); + let showLoadingSpinnerCreate = $state(false); + let showLoadingSpinnerReassign = $state(false); + let hasSelection = $state(false); + let screenHeight: number = $state(0); - $: peopleToNotShow = selectedPerson ? [personAssets, selectedPerson] : [personAssets]; + let peopleToNotShow = $derived(selectedPerson ? [personAssets, selectedPerson] : [personAssets]); const selectedPeople: AssetFaceUpdateItem[] = []; @@ -117,17 +123,17 @@ class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg" > <ControlAppBar {onClose}> - <svelte:fragment slot="leading"> - <slot name="header" /> + {#snippet leading()} + {@render header?.()} <div></div> - </svelte:fragment> - <svelte:fragment slot="trailing"> + {/snippet} + {#snippet trailing()} <div class="flex gap-4"> <Button title={$t('create_new_person_hint')} size={'sm'} disabled={disableButtons || hasSelection} - on:click={handleCreate} + onclick={handleCreate} > {#if !showLoadingSpinnerCreate} <Icon path={mdiPlus} size={18} /> @@ -140,7 +146,7 @@ size={'sm'} title={$t('reassing_hint')} disabled={disableButtons || !hasSelection} - on:click={handleReassign} + onclick={handleReassign} > {#if !showLoadingSpinnerReassign} <div> @@ -152,9 +158,9 @@ <span class="ml-2"> {$t('reassign')}</span></Button > </div> - </svelte:fragment> + {/snippet} </ControlAppBar> - <slot name="merge" /> + {@render merge?.()} <section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg"> <section id="merge-face-selector relative"> {#if selectedPerson !== null} diff --git a/web/src/lib/components/forms/admin-registration-form.svelte b/web/src/lib/components/forms/admin-registration-form.svelte index d49ab554397c1..b4ecd56283195 100644 --- a/web/src/lib/components/forms/admin-registration-form.svelte +++ b/web/src/lib/components/forms/admin-registration-form.svelte @@ -8,15 +8,15 @@ import { t } from 'svelte-i18n'; import { retrieveServerConfig } from '$lib/stores/server-config.store'; - let email = ''; - let password = ''; - let confirmPassword = ''; - let name = ''; + let email = $state(''); + let password = $state(''); + let confirmPassword = $state(''); + let name = $state(''); - let errorMessage: string; - let canRegister = false; + let errorMessage: string = $state(''); + let canRegister = $state(false); - $: { + $effect(() => { if (password !== confirmPassword && confirmPassword.length > 0) { errorMessage = $t('password_does_not_match'); canRegister = false; @@ -24,7 +24,7 @@ errorMessage = ''; canRegister = true; } - } + }); async function registerAdmin() { if (canRegister) { @@ -40,9 +40,14 @@ } } } + + const onsubmit = async (event: Event) => { + event.preventDefault(); + await registerAdmin(); + }; </script> -<form on:submit|preventDefault={registerAdmin} method="post" class="mt-5 flex flex-col gap-5"> +<form {onsubmit} method="post" class="mt-5 flex flex-col gap-5"> <div class="flex flex-col gap-2"> <label class="immich-form-label" for="email">{$t('admin_email')}</label> <input class="immich-form-input" id="email" bind:value={email} type="email" autocomplete="email" required /> diff --git a/web/src/lib/components/forms/api-key-form.svelte b/web/src/lib/components/forms/api-key-form.svelte index 5b1341db44add..086d7708c3c58 100644 --- a/web/src/lib/components/forms/api-key-form.svelte +++ b/web/src/lib/components/forms/api-key-form.svelte @@ -5,13 +5,23 @@ import FullScreenModal from '../shared-components/full-screen-modal.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; - export let apiKey: { name: string }; - export let title: string; - export let cancelText = $t('cancel'); - export let submitText = $t('save'); + interface Props { + apiKey: { name: string }; + title: string; + cancelText?: string; + submitText?: string; + onSubmit: (apiKey: { name: string }) => void; + onCancel: () => void; + } - export let onSubmit: (apiKey: { name: string }) => void; - export let onCancel: () => void; + let { + apiKey = $bindable(), + title, + cancelText = $t('cancel'), + submitText = $t('save'), + onSubmit, + onCancel, + }: Props = $props(); const handleSubmit = () => { if (apiKey.name) { @@ -23,17 +33,23 @@ }); } }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + handleSubmit(); + }; </script> <FullScreenModal {title} icon={mdiKeyVariant} onClose={() => onCancel()}> - <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="api-key-form"> + <form {onsubmit} autocomplete="off" id="api-key-form"> <div class="mb-4 flex flex-col gap-2"> <label class="immich-form-label" for="name">{$t('name')}</label> <input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} /> </div> </form> - <svelte:fragment slot="sticky-bottom"> - <Button color="gray" fullwidth on:click={() => onCancel()}>{cancelText}</Button> + + {#snippet stickyBottom()} + <Button color="gray" fullwidth onclick={() => onCancel()}>{cancelText}</Button> <Button type="submit" fullwidth form="api-key-form">{submitText}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/forms/api-key-secret.svelte b/web/src/lib/components/forms/api-key-secret.svelte index dbbefe0d7184c..fd0503e8506e7 100644 --- a/web/src/lib/components/forms/api-key-secret.svelte +++ b/web/src/lib/components/forms/api-key-secret.svelte @@ -5,8 +5,12 @@ import FullScreenModal from '../shared-components/full-screen-modal.svelte'; import { t } from 'svelte-i18n'; - export let secret = ''; - export let onDone: () => void; + interface Props { + secret?: string; + onDone: () => void; + } + + let { secret = '', onDone }: Props = $props(); </script> <FullScreenModal title={$t('api_key')} icon={mdiKeyVariant} onClose={onDone}> @@ -21,8 +25,8 @@ <textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret}></textarea> </div> - <svelte:fragment slot="sticky-bottom"> - <Button on:click={() => copyToClipboard(secret)} fullwidth>{$t('copy_to_clipboard')}</Button> - <Button on:click={onDone} fullwidth>{$t('done')}</Button> - </svelte:fragment> + {#snippet stickyBottom()} + <Button onclick={() => copyToClipboard(secret)} fullwidth>{$t('copy_to_clipboard')}</Button> + <Button onclick={onDone} fullwidth>{$t('done')}</Button> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/forms/change-password-form.svelte b/web/src/lib/components/forms/change-password-form.svelte index cbf2ff07f0f00..94dbb5841f07a 100644 --- a/web/src/lib/components/forms/change-password-form.svelte +++ b/web/src/lib/components/forms/change-password-form.svelte @@ -4,17 +4,21 @@ import { updateMyUser } from '@immich/sdk'; import { t } from 'svelte-i18n'; - export let onSuccess: () => void; + interface Props { + onSuccess: () => void; + } + + let { onSuccess }: Props = $props(); - let errorMessage: string; + let errorMessage: string = $state(''); let success: string; - let password = ''; - let passwordConfirm = ''; + let password = $state(''); + let passwordConfirm = $state(''); - let valid = false; + let valid = $state(false); - $: { + $effect(() => { if (password !== passwordConfirm && passwordConfirm.length > 0) { errorMessage = $t('password_does_not_match'); valid = false; @@ -22,7 +26,7 @@ errorMessage = ''; valid = true; } - } + }); async function changePassword() { if (valid) { @@ -33,9 +37,14 @@ onSuccess(); } } + + const onsubmit = async (event: Event) => { + event.preventDefault(); + await changePassword(); + }; </script> -<form on:submit|preventDefault={changePassword} method="post" class="mt-5 flex flex-col gap-5"> +<form {onsubmit} method="post" class="mt-5 flex flex-col gap-5"> <div class="flex flex-col gap-2"> <label class="immich-form-label" for="password">{$t('new_password')}</label> <PasswordField id="password" bind:password autocomplete="new-password" /> diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte index 0687912542067..b1599a24b2099 100644 --- a/web/src/lib/components/forms/create-user-form.svelte +++ b/web/src/lib/components/forms/create-user-form.svelte @@ -10,29 +10,33 @@ import Slider from '../elements/slider.svelte'; import PasswordField from '../shared-components/password-field.svelte'; - export let onClose: () => void; - export let onSubmit: () => void; - export let onCancel: () => void; - export let oauthEnabled = false; + interface Props { + onClose: () => void; + onSubmit: () => void; + onCancel: () => void; + oauthEnabled?: boolean; + } + + let { onClose, onSubmit, onCancel, oauthEnabled = false }: Props = $props(); - let error: string; - let success: string; + let error = $state(''); + let success = $state(''); - let email = ''; - let password = ''; - let confirmPassword = ''; - let name = ''; - let shouldChangePassword = true; - let notify = true; + let email = $state(''); + let password = $state(''); + let confirmPassword = $state(''); + let name = $state(''); + let shouldChangePassword = $state(true); + let notify = $state(true); - let canCreateUser = false; - let quotaSize: number | undefined; - let isCreatingUser = false; + let canCreateUser = $state(false); + let quotaSize: number | undefined = $state(); + let isCreatingUser = $state(false); - $: quotaSizeInBytes = quotaSize ? convertToBytes(quotaSize, ByteUnit.GiB) : null; - $: quotaSizeWarning = quotaSizeInBytes && quotaSizeInBytes > $serverInfo.diskSizeRaw; + let quotaSizeInBytes = $derived(quotaSize ? convertToBytes(quotaSize, ByteUnit.GiB) : null); + let quotaSizeWarning = $derived(quotaSizeInBytes && quotaSizeInBytes > $serverInfo.diskSizeRaw); - $: { + $effect(() => { if (password !== confirmPassword && confirmPassword.length > 0) { error = $t('password_does_not_match'); canCreateUser = false; @@ -40,7 +44,7 @@ error = ''; canCreateUser = true; } - } + }); async function registerUser() { if (canCreateUser && !isCreatingUser) { @@ -71,10 +75,15 @@ } } } + + const onsubmit = async (event: Event) => { + event.preventDefault(); + await registerUser(); + }; </script> <FullScreenModal title={$t('create_new_user')} showLogo {onClose}> - <form on:submit|preventDefault={registerUser} autocomplete="off" id="create-new-user-form"> + <form {onsubmit} autocomplete="off" id="create-new-user-form"> <div class="my-4 flex flex-col gap-2"> <label class="immich-form-label" for="email">{$t('email')}</label> <input class="immich-form-input" id="email" bind:value={email} type="email" required /> @@ -134,8 +143,9 @@ <p class="text-sm text-immich-primary">{success}</p> {/if} </form> - <svelte:fragment slot="sticky-bottom"> - <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button> + + {#snippet stickyBottom()} + <Button color="gray" fullwidth onclick={onCancel}>{$t('cancel')}</Button> <Button type="submit" disabled={isCreatingUser} fullwidth form="create-new-user-form">{$t('create')}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/forms/edit-album-form.svelte b/web/src/lib/components/forms/edit-album-form.svelte index e33774245b192..bd61cd20684c5 100644 --- a/web/src/lib/components/forms/edit-album-form.svelte +++ b/web/src/lib/components/forms/edit-album-form.svelte @@ -6,15 +6,19 @@ import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import { t } from 'svelte-i18n'; - export let album: AlbumResponseDto; - export let onEditSuccess: ((album: AlbumResponseDto) => unknown) | undefined = undefined; - export let onCancel: (() => unknown) | undefined = undefined; - export let onClose: () => void; + interface Props { + album: AlbumResponseDto; + onEditSuccess?: ((album: AlbumResponseDto) => unknown) | undefined; + onCancel?: (() => unknown) | undefined; + onClose: () => void; + } - let albumName = album.albumName; - let description = album.description; + let { album = $bindable(), onEditSuccess = undefined, onCancel = undefined, onClose }: Props = $props(); - let isSubmitting = false; + let albumName = $state(album.albumName); + let description = $state(album.description); + + let isSubmitting = $state(false); const handleUpdateAlbumInfo = async () => { isSubmitting = true; @@ -35,10 +39,15 @@ isSubmitting = false; } }; + + const onsubmit = async (event: Event) => { + event.preventDefault(); + await handleUpdateAlbumInfo(); + }; </script> <FullScreenModal title={$t('edit_album')} width="wide" {onClose}> - <form on:submit|preventDefault={handleUpdateAlbumInfo} autocomplete="off" id="edit-album-form"> + <form {onsubmit} autocomplete="off" id="edit-album-form"> <div class="flex items-center"> <div class="hidden sm:flex"> <AlbumCover {album} class="h-[200px] w-[200px] m-4 shadow-lg" /> @@ -57,8 +66,9 @@ </div> </div> </form> - <svelte:fragment slot="sticky-bottom"> - <Button color="gray" fullwidth on:click={() => onCancel?.()}>{$t('cancel')}</Button> + + {#snippet stickyBottom()} + <Button color="gray" fullwidth onclick={() => onCancel?.()}>{$t('cancel')}</Button> <Button type="submit" fullwidth disabled={isSubmitting} form="edit-album-form">{$t('ok')}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte index 0079a695bc3f7..e95ed641355b4 100644 --- a/web/src/lib/components/forms/edit-user-form.svelte +++ b/web/src/lib/components/forms/edit-user-form.svelte @@ -10,23 +10,35 @@ import { t } from 'svelte-i18n'; import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; - export let user: UserAdminResponseDto; - export let canResetPassword = true; - export let newPassword: string; - export let onClose: () => void; - export let onResetPasswordSuccess: () => void; - export let onEditSuccess: () => void; + interface Props { + user: UserAdminResponseDto; + canResetPassword?: boolean; + newPassword: string; + onClose: () => void; + onResetPasswordSuccess: () => void; + onEditSuccess: () => void; + } + + let { + user, + canResetPassword = true, + newPassword = $bindable(), + onClose, + onResetPasswordSuccess, + onEditSuccess, + }: Props = $props(); let error: string; let success: string; - let quotaSize = user.quotaSizeInBytes ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : null; + let quotaSize = $state(user.quotaSizeInBytes ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : null); const previousQutoa = user.quotaSizeInBytes; - $: quotaSizeWarning = + let quotaSizeWarning = $derived( previousQutoa !== convertToBytes(Number(quotaSize), ByteUnit.GiB) && - !!quotaSize && - convertToBytes(Number(quotaSize), ByteUnit.GiB) > $serverInfo.diskSizeRaw; + !!quotaSize && + convertToBytes(Number(quotaSize), ByteUnit.GiB) > $serverInfo.diskSizeRaw, + ); const editUser = async () => { try { @@ -89,10 +101,15 @@ return generatedPassword; } + + const onSubmit = async (event: Event) => { + event.preventDefault(); + await editUser(); + }; </script> <FullScreenModal title={$t('edit_user')} icon={mdiAccountEditOutline} {onClose}> - <form on:submit|preventDefault={editUser} autocomplete="off" id="edit-user-form"> + <form onsubmit={onSubmit} autocomplete="off" id="edit-user-form"> <div class="my-4 flex flex-col gap-2"> <label class="immich-form-label" for="email">{$t('email')}</label> <input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} /> @@ -140,10 +157,11 @@ <p class="ml-4 text-sm text-immich-primary">{success}</p> {/if} </form> - <svelte:fragment slot="sticky-bottom"> + + {#snippet stickyBottom()} {#if canResetPassword} - <Button color="light-red" fullwidth on:click={resetPassword}>{$t('reset_password')}</Button> + <Button color="light-red" fullwidth onclick={resetPassword}>{$t('reset_password')}</Button> {/if} <Button type="submit" fullwidth form="edit-user-form">{$t('confirm')}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte index 05d47c0a0fbee..e79b60d2659bb 100644 --- a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte +++ b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte @@ -5,13 +5,25 @@ import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; - export let exclusionPattern: string; - export let exclusionPatterns: string[] = []; - export let isEditing = false; - export let submitText = $t('submit'); - export let onCancel: () => void; - export let onSubmit: (exclusionPattern: string) => void; - export let onDelete: () => void = () => {}; + interface Props { + exclusionPattern: string; + exclusionPatterns?: string[]; + isEditing?: boolean; + submitText?: string; + onCancel: () => void; + onSubmit: (exclusionPattern: string) => void; + onDelete?: () => void; + } + + let { + exclusionPattern = $bindable(), + exclusionPatterns = $bindable([]), + isEditing = false, + submitText = $t('submit'), + onCancel, + onSubmit, + onDelete, + }: Props = $props(); onMount(() => { if (isEditing) { @@ -19,12 +31,19 @@ } }); - $: isDuplicate = exclusionPattern !== null && exclusionPatterns.includes(exclusionPattern); - $: canSubmit = exclusionPattern && !exclusionPatterns.includes(exclusionPattern); + let isDuplicate = $derived(exclusionPattern !== null && exclusionPatterns.includes(exclusionPattern)); + let canSubmit = $derived(exclusionPattern && !exclusionPatterns.includes(exclusionPattern)); + + const onsubmit = (event: Event) => { + event.preventDefault(); + if (canSubmit) { + onSubmit(exclusionPattern); + } + }; </script> <FullScreenModal title={$t('add_exclusion_pattern')} icon={mdiFolderRemove} onClose={onCancel}> - <form on:submit|preventDefault={() => onSubmit(exclusionPattern)} autocomplete="off" id="add-exclusion-pattern-form"> + <form {onsubmit} autocomplete="off" id="add-exclusion-pattern-form"> <p class="py-5 text-sm"> {$t('admin.exclusion_pattern_description')} <br /><br /> @@ -46,11 +65,12 @@ {/if} </div> </form> - <svelte:fragment slot="sticky-bottom"> - <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button> + + {#snippet stickyBottom()} + <Button color="gray" fullwidth onclick={onCancel}>{$t('cancel')}</Button> {#if isEditing} - <Button color="red" fullwidth on:click={onDelete}>{$t('delete')}</Button> + <Button color="red" fullwidth onclick={onDelete}>{$t('delete')}</Button> {/if} <Button type="submit" disabled={!canSubmit} fullwidth form="add-exclusion-pattern-form">{submitText}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/forms/library-import-path-form.svelte b/web/src/lib/components/forms/library-import-path-form.svelte index 8bfca80aecb32..33e763f0f0fc3 100644 --- a/web/src/lib/components/forms/library-import-path-form.svelte +++ b/web/src/lib/components/forms/library-import-path-form.svelte @@ -5,15 +5,29 @@ import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; - export let importPath: string | null; - export let importPaths: string[] = []; - export let title = $t('import_path'); - export let cancelText = $t('cancel'); - export let submitText = $t('save'); - export let isEditing = false; - export let onCancel: () => void; - export let onSubmit: (importPath: string | null) => void; - export let onDelete: () => void = () => {}; + interface Props { + importPath: string | null; + importPaths?: string[]; + title?: string; + cancelText?: string; + submitText?: string; + isEditing?: boolean; + onCancel: () => void; + onSubmit: (importPath: string | null) => void; + onDelete?: () => void; + } + + let { + importPath = $bindable(), + importPaths = $bindable([]), + title = $t('import_path'), + cancelText = $t('cancel'), + submitText = $t('save'), + isEditing = false, + onCancel, + onSubmit, + onDelete, + }: Props = $props(); onMount(() => { if (isEditing) { @@ -21,12 +35,19 @@ } }); - $: isDuplicate = importPath !== null && importPaths.includes(importPath); - $: canSubmit = importPath !== '' && importPath !== null && !importPaths.includes(importPath); + let isDuplicate = $derived(importPath !== null && importPaths.includes(importPath)); + let canSubmit = $derived(importPath !== '' && importPath !== null && !importPaths.includes(importPath)); + + const onsubmit = (event: Event) => { + event.preventDefault(); + if (canSubmit) { + onSubmit(importPath); + } + }; </script> <FullScreenModal {title} icon={mdiFolderSync} onClose={onCancel}> - <form on:submit|preventDefault={() => onSubmit(importPath)} autocomplete="off" id="library-import-path-form"> + <form {onsubmit} autocomplete="off" id="library-import-path-form"> <p class="py-5 text-sm">{$t('admin.library_import_path_description')}</p> <div class="my-4 flex flex-col gap-2"> @@ -40,11 +61,12 @@ {/if} </div> </form> - <svelte:fragment slot="sticky-bottom"> - <Button color="gray" fullwidth on:click={onCancel}>{cancelText}</Button> + + {#snippet stickyBottom()} + <Button color="gray" fullwidth onclick={onCancel}>{cancelText}</Button> {#if isEditing} - <Button color="red" fullwidth on:click={onDelete}>{$t('delete')}</Button> + <Button color="red" fullwidth onclick={onDelete}>{$t('delete')}</Button> {/if} <Button type="submit" disabled={!canSubmit} fullwidth form="library-import-path-form">{submitText}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/forms/library-import-paths-form.svelte b/web/src/lib/components/forms/library-import-paths-form.svelte index 9e7ae11a63b48..3acd46520f314 100644 --- a/web/src/lib/components/forms/library-import-paths-form.svelte +++ b/web/src/lib/components/forms/library-import-paths-form.svelte @@ -11,19 +11,23 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; - export let library: LibraryResponseDto; - export let onCancel: () => void; - export let onSubmit: (library: LibraryResponseDto) => void; + interface Props { + library: LibraryResponseDto; + onCancel: () => void; + onSubmit: (library: LibraryResponseDto) => void; + } - let addImportPath = false; - let editImportPath: number | null = null; + let { library = $bindable(), onCancel, onSubmit }: Props = $props(); - let importPathToAdd: string | null = null; - let editedImportPath: string; + let addImportPath = $state(false); + let editImportPath: number | null = $state(null); - let validatedPaths: ValidateLibraryImportPathResponseDto[] = []; + let importPathToAdd: string | null = $state(null); + let editedImportPath: string = $state(''); - $: importPaths = validatedPaths.map((validatedPath) => validatedPath.importPath); + let validatedPaths: ValidateLibraryImportPathResponseDto[] = $state([]); + + let importPaths = $derived(validatedPaths.map((validatedPath) => validatedPath.importPath)); onMount(async () => { if (library.importPaths) { @@ -134,6 +138,11 @@ editImportPath = null; } }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + onSubmit({ ...library }); + }; </script> {#if addImportPath} @@ -163,7 +172,7 @@ /> {/if} -<form on:submit|preventDefault={() => onSubmit({ ...library })} autocomplete="off" class="m-4 flex flex-col gap-4"> +<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4"> <table class="text-left"> <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray"> {#each validatedPaths as validatedPath, listIndex} @@ -199,7 +208,7 @@ icon={mdiPencilOutline} title={$t('edit_import_path')} size="16" - on:click={() => { + onclick={() => { editImportPath = listIndex; editedImportPath = validatedPath.importPath; }} @@ -223,7 +232,7 @@ ><Button type="button" size="sm" - on:click={() => { + onclick={() => { addImportPath = true; }}>{$t('add_path')}</Button ></td @@ -233,12 +242,12 @@ </table> <div class="flex justify-between w-full"> <div class="justify-end gap-2"> - <Button size="sm" color="gray" on:click={() => revalidate()} + <Button size="sm" color="gray" onclick={() => revalidate()} ><Icon path={mdiRefresh} size={20} />{$t('validate')}</Button > </div> <div class="justify-end gap-2"> - <Button size="sm" color="gray" on:click={onCancel}>{$t('cancel')}</Button> + <Button size="sm" color="gray" onclick={onCancel}>{$t('cancel')}</Button> <Button size="sm" type="submit">{$t('save')}</Button> </div> </div> diff --git a/web/src/lib/components/forms/library-rename-form.svelte b/web/src/lib/components/forms/library-rename-form.svelte index 1f93fb028bdea..3f20709474057 100644 --- a/web/src/lib/components/forms/library-rename-form.svelte +++ b/web/src/lib/components/forms/library-rename-form.svelte @@ -3,18 +3,27 @@ import Button from '../elements/buttons/button.svelte'; import { t } from 'svelte-i18n'; - export let library: Partial<LibraryResponseDto>; - export let onCancel: () => void; - export let onSubmit: (library: Partial<LibraryResponseDto>) => void; + interface Props { + library: Partial<LibraryResponseDto>; + onCancel: () => void; + onSubmit: (library: Partial<LibraryResponseDto>) => void; + } + + let { library = $bindable(), onCancel, onSubmit }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + onSubmit({ ...library }); + }; </script> -<form on:submit|preventDefault={() => onSubmit({ ...library })} autocomplete="off" class="m-4 flex flex-col gap-2"> +<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-2"> <div class="flex flex-col gap-2"> <label class="immich-form-label" for="path">{$t('name')}</label> <input class="immich-form-input" id="name" name="name" type="text" bind:value={library.name} /> </div> <div class="flex w-full justify-end gap-2 pt-2"> - <Button size="sm" color="gray" on:click={onCancel}>{$t('cancel')}</Button> + <Button size="sm" color="gray" onclick={onCancel}>{$t('cancel')}</Button> <Button size="sm" type="submit">{$t('save')}</Button> </div> </form> diff --git a/web/src/lib/components/forms/library-scan-settings-form.svelte b/web/src/lib/components/forms/library-scan-settings-form.svelte index a9a42c31f7b24..68e99641e8f36 100644 --- a/web/src/lib/components/forms/library-scan-settings-form.svelte +++ b/web/src/lib/components/forms/library-scan-settings-form.svelte @@ -8,17 +8,21 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; - export let library: Partial<LibraryResponseDto>; - export let onCancel: () => void; - export let onSubmit: (library: Partial<LibraryResponseDto>) => void; + interface Props { + library: Partial<LibraryResponseDto>; + onCancel: () => void; + onSubmit: (library: Partial<LibraryResponseDto>) => void; + } - let addExclusionPattern = false; - let editExclusionPattern: number | null = null; + let { library = $bindable(), onCancel, onSubmit }: Props = $props(); - let exclusionPatternToAdd: string; - let editedExclusionPattern: string; + let addExclusionPattern = $state(false); + let editExclusionPattern: number | null = $state(null); - let exclusionPatterns: string[] = []; + let exclusionPatternToAdd: string = $state(''); + let editedExclusionPattern: string = $state(''); + + let exclusionPatterns: string[] = $state([]); onMount(() => { if (library.exclusionPatterns) { @@ -89,6 +93,11 @@ editExclusionPattern = null; } }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + onSubmit(library); + }; </script> {#if addExclusionPattern} @@ -113,7 +122,7 @@ /> {/if} -<form on:submit|preventDefault={() => onSubmit(library)} autocomplete="off" class="m-4 flex flex-col gap-4"> +<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4"> <table class="w-full text-left"> <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray"> {#each exclusionPatterns as exclusionPattern, listIndex} @@ -131,7 +140,7 @@ icon={mdiPencilOutline} title={$t('edit_exclusion_pattern')} size="16" - on:click={() => { + onclick={() => { editExclusionPattern = listIndex; editedExclusionPattern = exclusionPattern; }} @@ -154,7 +163,7 @@ <td class="w-1/4 text-ellipsis px-4 text-sm" ><Button size="sm" - on:click={() => { + onclick={() => { addExclusionPattern = true; }}>{$t('add_exclusion_pattern')}</Button ></td @@ -164,7 +173,7 @@ </table> <div class="flex w-full justify-end gap-4"> - <Button size="sm" color="gray" on:click={onCancel}>{$t('cancel')}</Button> + <Button size="sm" color="gray" onclick={onCancel}>{$t('cancel')}</Button> <Button size="sm" type="submit">{$t('save')}</Button> </div> </form> diff --git a/web/src/lib/components/forms/library-user-picker-form.svelte b/web/src/lib/components/forms/library-user-picker-form.svelte index e5334ff9e9026..137a49921a707 100644 --- a/web/src/lib/components/forms/library-user-picker-form.svelte +++ b/web/src/lib/components/forms/library-user-picker-form.svelte @@ -8,27 +8,37 @@ import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import { t } from 'svelte-i18n'; - export let onCancel: () => void; - export let onSubmit: (ownerId: string) => void; + interface Props { + onCancel: () => void; + onSubmit: (ownerId: string) => void; + } - let ownerId: string = $user.id; + let { onCancel, onSubmit }: Props = $props(); - let userOptions: { value: string; text: string }[] = []; + let ownerId: string = $state($user.id); + + let userOptions: { value: string; text: string }[] = $state([]); onMount(async () => { const users = await searchUsersAdmin({}); userOptions = users.map((user) => ({ value: user.id, text: user.name })); }); + + const onsubmit = (event: Event) => { + event.preventDefault(); + onSubmit(ownerId); + }; </script> <FullScreenModal title={$t('select_library_owner')} icon={mdiFolderSync} onClose={onCancel}> - <form on:submit|preventDefault={() => onSubmit(ownerId)} autocomplete="off" id="select-library-owner-form"> + <form {onsubmit} autocomplete="off" id="select-library-owner-form"> <p class="p-5 text-sm">{$t('admin.note_cannot_be_changed_later')}</p> <SettingSelect bind:value={ownerId} options={userOptions} name="user" /> </form> - <svelte:fragment slot="sticky-bottom"> - <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button> + + {#snippet stickyBottom()} + <Button color="gray" fullwidth onclick={onCancel}>{$t('cancel')}</Button> <Button type="submit" fullwidth form="select-library-owner-form">{$t('create')}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/forms/login-form.svelte b/web/src/lib/components/forms/login-form.svelte index b1af7a01f4b1a..be40210e17e39 100644 --- a/web/src/lib/components/forms/login-form.svelte +++ b/web/src/lib/components/forms/login-form.svelte @@ -12,16 +12,20 @@ import PasswordField from '../shared-components/password-field.svelte'; import { t } from 'svelte-i18n'; - export let onSuccess: () => unknown | Promise<unknown>; - export let onFirstLogin: () => unknown | Promise<unknown>; - export let onOnboarding: () => unknown | Promise<unknown>; + interface Props { + onSuccess: () => unknown | Promise<unknown>; + onFirstLogin: () => unknown | Promise<unknown>; + onOnboarding: () => unknown | Promise<unknown>; + } - let errorMessage: string; - let email = ''; - let password = ''; - let oauthError = ''; - let loading = false; - let oauthLoading = true; + let { onSuccess, onFirstLogin, onOnboarding }: Props = $props(); + + let errorMessage: string = $state(''); + let email = $state(''); + let password = $state(''); + let oauthError = $state(''); + let loading = $state(false); + let oauthLoading = $state(true); onMount(async () => { if (!$featureFlags.oauth) { @@ -87,10 +91,15 @@ oauthError = $t('errors.unable_to_login_with_oauth'); } }; + + const onsubmit = async (event: Event) => { + event.preventDefault(); + await handleLogin(); + }; </script> {#if !oauthLoading && $featureFlags.passwordLogin} - <form on:submit|preventDefault={handleLogin} class="mt-5 flex flex-col gap-5"> + <form {onsubmit} class="mt-5 flex flex-col gap-5"> {#if errorMessage} <p class="text-red-400" transition:fade> {errorMessage} @@ -150,7 +159,7 @@ size="lg" fullwidth color={$featureFlags.passwordLogin ? 'secondary' : 'primary'} - on:click={handleOAuthLogin} + onclick={handleOAuthLogin} > {#if oauthLoading} <span class="h-6"> diff --git a/web/src/lib/components/forms/tag-asset-form.svelte b/web/src/lib/components/forms/tag-asset-form.svelte index b5e358ec9664e..84a8c1a40942e 100644 --- a/web/src/lib/components/forms/tag-asset-form.svelte +++ b/web/src/lib/components/forms/tag-asset-form.svelte @@ -9,14 +9,19 @@ import Icon from '$lib/components/elements/icon.svelte'; import { AppRoute } from '$lib/constants'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; + import { SvelteSet } from 'svelte/reactivity'; - export let onTag: (tagIds: string[]) => void; - export let onCancel: () => void; + interface Props { + onTag: (tagIds: string[]) => void; + onCancel: () => void; + } - let allTags: TagResponseDto[] = []; - $: tagMap = Object.fromEntries(allTags.map((tag) => [tag.id, tag])); - let selectedIds = new Set<string>(); - $: disabled = selectedIds.size === 0; + let { onTag, onCancel }: Props = $props(); + + let allTags: TagResponseDto[] = $state([]); + let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag]))); + let selectedIds = $state(new SvelteSet<string>()); + let disabled = $derived(selectedIds.size === 0); onMount(async () => { allTags = await getAllTags(); @@ -37,19 +42,26 @@ selectedIds.delete(tag); selectedIds = selectedIds; }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + handleSubmit(); + }; </script> <FullScreenModal title={$t('tag_assets')} icon={mdiTag} onClose={onCancel}> <div class="text-sm"> <p> - <FormatMessage key="tag_not_found_question" let:message> - <a href={AppRoute.TAGS} class="text-immich-primary dark:text-immich-dark-primary underline"> - {message} - </a> + <FormatMessage key="tag_not_found_question"> + {#snippet children({ message })} + <a href={AppRoute.TAGS} class="text-immich-primary dark:text-immich-dark-primary underline"> + {message} + </a> + {/snippet} </FormatMessage> </p> </div> - <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="create-tag-form"> + <form {onsubmit} autocomplete="off" id="create-tag-form"> <div class="my-4 flex flex-col gap-2"> <Combobox onSelect={handleSelect} @@ -77,7 +89,7 @@ type="button" class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all" title="Remove tag" - on:click={() => handleRemove(tagId)} + onclick={() => handleRemove(tagId)} > <Icon path={mdiClose} /> </button> @@ -86,8 +98,8 @@ {/each} </section> - <svelte:fragment slot="sticky-bottom"> - <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button> + {#snippet stickyBottom()} + <Button color="gray" fullwidth onclick={onCancel}>{$t('cancel')}</Button> <Button type="submit" fullwidth form="create-tag-form" {disabled}>{$t('tag_assets')}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/i18n/__test__/format-tag-b.svelte b/web/src/lib/components/i18n/__test__/format-tag-b.svelte index 122358c6b7a58..6e8b2412e10e2 100644 --- a/web/src/lib/components/i18n/__test__/format-tag-b.svelte +++ b/web/src/lib/components/i18n/__test__/format-tag-b.svelte @@ -3,12 +3,18 @@ import FormatMessage from '../format-message.svelte'; import type { ComponentProps } from 'svelte'; - export let key: Translations; - export let values: ComponentProps<FormatMessage>['values']; + interface Props { + key: Translations; + values: ComponentProps<typeof FormatMessage>['values']; + } + + let { key, values }: Props = $props(); </script> -<FormatMessage {key} {values} let:tag let:message> - {#if tag === 'b'} - <strong>{message}</strong> - {/if} +<FormatMessage {key} {values}> + {#snippet children({ tag, message })} + {#if tag === 'b'} + <strong>{message}</strong> + {/if} + {/snippet} </FormatMessage> diff --git a/web/src/lib/components/i18n/format-bold-message.svelte b/web/src/lib/components/i18n/format-bold-message.svelte index 052b220edc615..0381ec5e98881 100644 --- a/web/src/lib/components/i18n/format-bold-message.svelte +++ b/web/src/lib/components/i18n/format-bold-message.svelte @@ -3,12 +3,18 @@ import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte'; import type { Translations } from 'svelte-i18n'; - export let key: Translations; - export let values: InterpolationValues = {}; + interface Props { + key: Translations; + values?: InterpolationValues; + } + + let { key, values = {} }: Props = $props(); </script> -<FormatMessage {key} {values} let:message let:tag> - {#if tag === 'b'} - <b>{message}</b> - {/if} +<FormatMessage {key} {values}> + {#snippet children({ message, tag })} + {#if tag === 'b'} + <b>{message}</b> + {/if} + {/snippet} </FormatMessage> diff --git a/web/src/lib/components/i18n/format-message.svelte b/web/src/lib/components/i18n/format-message.svelte index 48c59478c6154..b8909e34de2a2 100644 --- a/web/src/lib/components/i18n/format-message.svelte +++ b/web/src/lib/components/i18n/format-message.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> import type { FormatXMLElementFn, PrimitiveType } from 'intl-messageformat'; export type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>; </script> @@ -18,8 +18,13 @@ tag?: string; }; - export let key: Translations; - export let values: InterpolationValues = {}; + interface Props { + key: Translations; + values?: InterpolationValues; + children?: import('svelte').Snippet<[{ tag?: string; message?: string }]>; + } + + let { key, values = {}, children }: Props = $props(); const getLocale = (locale?: string | null) => { if (locale == null) { @@ -96,9 +101,9 @@ } }; - $: message = ($json(key) as string) || key; - $: locale = getLocale($i18nLocale); - $: parts = getParts(message, locale); + let message = $derived(($json(key) as string) || key); + let locale = $derived(getLocale($i18nLocale)); + let parts = $derived(getParts(message, locale)); </script> <!-- @@ -130,7 +135,7 @@ Result: Visit <a href="">docs</a> <strong>now</strong> --> {#each parts as { tag, message }} {#if tag} - <slot {tag} {message}>{message}</slot> + {#if children}{@render children({ tag, message })}{:else}{message}{/if} {:else} {message} {/if} diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index ed232b80cda28..9be2db2691e28 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export const headerId = 'user-page-header'; </script> @@ -7,16 +7,36 @@ import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte'; import SideBar from '../shared-components/side-bar/side-bar.svelte'; import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte'; + import type { Snippet } from 'svelte'; - export let hideNavbar = false; - export let showUploadButton = false; - export let title: string | undefined = undefined; - export let description: string | undefined = undefined; - export let scrollbar = true; - export let admin = false; + interface Props { + hideNavbar?: boolean; + showUploadButton?: boolean; + title?: string | undefined; + description?: string | undefined; + scrollbar?: boolean; + admin?: boolean; + header?: Snippet; + sidebar?: Snippet; + buttons?: Snippet; + children?: Snippet; + } - $: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden'; - $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full'; + let { + hideNavbar = false, + showUploadButton = false, + title = undefined, + description = undefined, + scrollbar = true, + admin = false, + header, + sidebar, + buttons, + children, + }: Props = $props(); + + let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden'); + let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full'); </script> <header> @@ -24,22 +44,20 @@ <NavigationBar {showUploadButton} onUploadClick={() => openFileUploadDialog()} /> {/if} - <slot name="header" /> + {@render header?.()} </header> <main tabindex="-1" class="relative grid h-screen grid-cols-[theme(spacing.18)_auto] overflow-hidden bg-immich-bg pt-[var(--navbar-height)] dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]" > - <slot name="sidebar"> - {#if admin} - <AdminSideBar /> - {:else} - <SideBar /> - {/if} - </slot> + {#if sidebar}{@render sidebar()}{:else if admin} + <AdminSideBar /> + {:else} + <SideBar /> + {/if} <section class="relative"> - {#if title || $$slots.buttons} + {#if title || buttons} <div class="absolute flex h-16 w-full place-items-center justify-between border-b p-4 dark:border-immich-dark-gray dark:text-immich-dark-fg" > @@ -51,12 +69,12 @@ <p class="text-sm text-gray-400 dark:text-gray-600">{description}</p> {/if} </div> - <slot name="buttons" /> + {@render buttons?.()} </div> {/if} <div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto"> - <slot /> + {@render children?.()} </div> </section> </main> diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte index 35df9f2285f1a..270978e120ad5 100644 --- a/web/src/lib/components/map-page/map-settings-modal.svelte +++ b/web/src/lib/components/map-page/map-settings-modal.svelte @@ -10,19 +10,24 @@ import LinkButton from '../elements/buttons/link-button.svelte'; import DateInput from '../elements/date-input.svelte'; - export let settings: MapSettings; - export let onClose: () => void; - export let onSave: (settings: MapSettings) => void; + interface Props { + settings: MapSettings; + onClose: () => void; + onSave: (settings: MapSettings) => void; + } - let customDateRange = !!settings.dateAfter || !!settings.dateBefore; + let { settings = $bindable(), onClose, onSave }: Props = $props(); + + let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore); + + const onsubmit = (event: Event) => { + event.preventDefault(); + onSave(settings); + }; </script> <FullScreenModal title={$t('map_settings')} {onClose}> - <form - on:submit|preventDefault={() => onSave(settings)} - class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary" - id="map-settings-form" - > + <form {onsubmit} class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary" id="map-settings-form"> <SettingSwitch title={$t('allow_dark_mode')} bind:checked={settings.allowDarkMode} /> <SettingSwitch title={$t('only_favorites')} bind:checked={settings.onlyFavorites} /> <SettingSwitch title={$t('include_archived')} bind:checked={settings.includeArchived} /> @@ -46,7 +51,7 @@ </div> <div class="flex justify-center text-xs"> <LinkButton - on:click={() => { + onclick={() => { customDateRange = false; settings.dateAfter = ''; settings.dateBefore = ''; @@ -91,7 +96,7 @@ /> <div class="text-xs"> <LinkButton - on:click={() => { + onclick={() => { customDateRange = true; settings.relativeDate = ''; }} @@ -102,8 +107,9 @@ </div> {/if} </form> - <svelte:fragment slot="sticky-bottom"> - <Button color="gray" size="sm" fullwidth on:click={onClose}>{$t('cancel')}</Button> + + {#snippet stickyBottom()} + <Button color="gray" size="sm" fullwidth onclick={onClose}>{$t('cancel')}</Button> <Button type="submit" size="sm" fullwidth form="map-settings-form">{$t('save')}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index f4715ce57c15f..bca3b2024d6bc 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -42,8 +42,9 @@ import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { tweened } from 'svelte/motion'; - import { derived } from 'svelte/store'; + import { derived as storeDerived } from 'svelte/store'; import { fade } from 'svelte/transition'; + import { SvelteSet } from 'svelte/reactivity'; type MemoryIndex = { memoryIndex: number; @@ -59,19 +60,21 @@ nextMemory?: MemoryLaneResponseDto; }; - let memoryGallery: HTMLElement; - let memoryWrapper: HTMLElement; - let galleryInView = false; - let paused = false; - let selectedAssets: Set<AssetResponseDto> = new Set(); - let current: MemoryAsset | undefined = undefined; + let memoryGallery: HTMLElement | undefined = $state(); + let memoryWrapper: HTMLElement | undefined = $state(); + let galleryInView = $state(false); + let paused = $state(false); + let selectedAssets: SvelteSet<AssetResponseDto> = $state(new SvelteSet()); + let current: MemoryAsset | undefined = $state(undefined); // let memories: MemoryAsset[] = []; - let resetPromise = Promise.resolve(); + let resetPromise = $state(Promise.resolve()); const { isViewing } = assetViewingStore; - const viewport: Viewport = { width: 0, height: 0 }; - const progress = tweened<number>(0, { duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0) }); - const memories = derived(memoryStore, (memories) => { + const viewport: Viewport = $state({ width: 0, height: 0 }); + const progressBarController = tweened<number>(0, { + duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0), + }); + const memories = storeDerived(memoryStore, (memories) => { memories = memories ?? []; const memoryAssets: MemoryAsset[] = []; let previous: MemoryAsset | undefined; @@ -100,13 +103,6 @@ return memoryAssets; }); - $: isMultiSelectionMode = selectedAssets.size > 0; - $: isAllArchived = [...selectedAssets].every((asset) => asset.isArchived); - $: isAllFavorite = [...selectedAssets].every((asset) => asset.isFavorite); - $: selectedAssets = galleryInView ? selectedAssets : new Set(); - $: handlePromiseError(handleProgress($progress)); - $: handlePromiseError(handleAction(galleryInView ? 'pause' : 'play')); - const loadFromParams = (memories: MemoryAsset[], page: typeof $page | NavigationTarget | null) => { const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined; handlePromiseError(handleAction($isViewing ? 'pause' : 'reset')); @@ -130,24 +126,24 @@ const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); const handleEscape = async () => goto(AppRoute.PHOTOS); - const handleSelectAll = () => (selectedAssets = new Set(current?.memory.assets || [])); + const handleSelectAll = () => (selectedAssets = new SvelteSet(current?.memory.assets || [])); const handleAction = async (action: 'reset' | 'pause' | 'play') => { switch (action) { case 'play': { paused = false; - await progress.set(1); + await progressBarController.set(1); break; } case 'pause': { paused = true; - await progress.set($progress); + await progressBarController.set($progressBarController); break; } case 'reset': { paused = false; - resetPromise = progress.set(0); + resetPromise = progressBarController.set(0); break; } } @@ -159,6 +155,7 @@ } if (progress === 1) { + await progressBarController.set(0); await (current?.next ? handleNextAsset() : handleAction('pause')); } }; @@ -210,6 +207,21 @@ current = loadFromParams($memories, target); }); + $effect(() => { + selectedAssets = galleryInView ? selectedAssets : new SvelteSet(); + }); + + let isMultiSelectionMode = $derived(selectedAssets.size > 0); + let isAllArchived = $derived([...selectedAssets].every((asset) => asset.isArchived)); + let isAllFavorite = $derived([...selectedAssets].every((asset) => asset.isFavorite)); + + $effect(() => { + handlePromiseError(handleProgress($progressBarController)); + }); + + $effect(() => { + handlePromiseError(handleAction(galleryInView ? 'pause' : 'play')); + }); </script> <svelte:window @@ -226,9 +238,9 @@ {#if isMultiSelectionMode} <div class="sticky top-0 z-[90]"> - <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}> + <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new SvelteSet())}> <CreateSharedLink /> - <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} /> + <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} /> <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}> <AddToAlbum /> @@ -251,17 +263,19 @@ <section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}> {#if current && current.memory.assets.length > 0} <ControlAppBar onClose={() => goto(AppRoute.PHOTOS)} forceDark> - <svelte:fragment slot="leading"> - <p class="text-lg"> - {$memoryLaneTitle(current.memory.yearsAgo)} - </p> - </svelte:fragment> + {#snippet leading()} + {#if current} + <p class="text-lg"> + {$memoryLaneTitle(current.memory.yearsAgo)} + </p> + {/if} + {/snippet} <div class="flex place-content-center place-items-center gap-2 overflow-hidden"> <CircleIconButton title={paused ? $t('play_memories') : $t('pause_memories')} icon={paused ? mdiPlay : mdiPause} - on:click={() => handleAction(paused ? 'play' : 'pause')} + onclick={() => handleAction(paused ? 'play' : 'pause')} class="hover:text-black" /> @@ -274,7 +288,7 @@ {:then} <span class="absolute left-0 h-[2px] bg-white" - style:width={`${index < current.assetIndex ? 100 : index > current.assetIndex ? 0 : $progress * 100}%`} + style:width={`${index < current.assetIndex ? 100 : index > current.assetIndex ? 0 : $progressBarController * 100}%`} ></span> {/await} </a> @@ -296,10 +310,10 @@ > <button type="button" - on:click={() => memoryWrapper.scrollIntoView({ behavior: 'smooth' })} + onclick={() => memoryWrapper?.scrollIntoView({ behavior: 'smooth' })} disabled={!galleryInView} > - <CircleIconButton title={$t('hide_gallery')} icon={mdiChevronUp} color="light" /> + <CircleIconButton title={$t('hide_gallery')} icon={mdiChevronUp} color="light" onclick={() => {}} /> </button> </div> {/if} @@ -314,7 +328,7 @@ type="button" class="relative h-full w-full rounded-2xl" disabled={!current.previousMemory} - on:click={handlePreviousMemory} + onclick={handlePreviousMemory} > {#if current.previousMemory && current.previousMemory.assets.length > 0} <img @@ -367,6 +381,7 @@ icon={mdiImageSearch} title={$t('view_in_timeline')} color="light" + onclick={() => {}} /> </div> <!-- CONTROL BUTTONS --> @@ -376,7 +391,7 @@ title={$t('previous_memory')} icon={mdiChevronLeft} color="dark" - on:click={handlePreviousAsset} + onclick={handlePreviousAsset} /> </div> {/if} @@ -387,7 +402,7 @@ title={$t('next_memory')} icon={mdiChevronRight} color="dark" - on:click={handleNextAsset} + onclick={handleNextAsset} /> </div> {/if} @@ -409,7 +424,7 @@ <button type="button" class="relative h-full w-full rounded-2xl" - on:click={handleNextMemory} + onclick={handleNextMemory} disabled={!current.nextMemory} > {#if current.nextMemory && current.nextMemory.assets.length > 0} @@ -451,7 +466,7 @@ title={$t('show_gallery')} icon={mdiChevronDown} color="light" - on:click={() => memoryGallery.scrollIntoView({ behavior: 'smooth' })} + onclick={() => memoryGallery?.scrollIntoView({ behavior: 'smooth' })} /> </div> diff --git a/web/src/lib/components/onboarding-page/onboarding-card.svelte b/web/src/lib/components/onboarding-page/onboarding-card.svelte index 9b2378ccd8465..54951dfa09455 100644 --- a/web/src/lib/components/onboarding-page/onboarding-card.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-card.svelte @@ -1,9 +1,15 @@ <script lang="ts"> import Icon from '$lib/components/elements/icon.svelte'; + import type { Snippet } from 'svelte'; import { fade } from 'svelte/transition'; - export let title: string | undefined = undefined; - export let icon: string | undefined = undefined; + interface Props { + title?: string | undefined; + icon?: string | undefined; + children?: Snippet; + } + + let { title = undefined, icon = undefined, children }: Props = $props(); </script> <div @@ -23,5 +29,5 @@ {/if} </div> {/if} - <slot /> + {@render children?.()} </div> diff --git a/web/src/lib/components/onboarding-page/onboarding-hello.svelte b/web/src/lib/components/onboarding-page/onboarding-hello.svelte index 466e1d29f702f..102465f0196d2 100644 --- a/web/src/lib/components/onboarding-page/onboarding-hello.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-hello.svelte @@ -7,7 +7,11 @@ import { user } from '$lib/stores/user.store'; import { t } from 'svelte-i18n'; - export let onDone: () => void; + interface Props { + onDone: () => void; + } + + let { onDone }: Props = $props(); </script> <OnboardingCard> @@ -18,7 +22,7 @@ <p class="text-3xl pb-6 font-light">{$t('onboarding_welcome_description')}</p> <div class="w-full flex place-content-end"> - <Button class="flex gap-2 place-content-center" on:click={() => onDone()}> + <Button class="flex gap-2 place-content-center" onclick={() => onDone()}> <p>{$t('theme')}</p> <Icon path={mdiArrowRight} size="18" /> </Button> diff --git a/web/src/lib/components/onboarding-page/onboarding-privacy.svelte b/web/src/lib/components/onboarding-page/onboarding-privacy.svelte index da36f741f1619..8ff8a9200dac7 100644 --- a/web/src/lib/components/onboarding-page/onboarding-privacy.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-privacy.svelte @@ -10,10 +10,15 @@ import { t } from 'svelte-i18n'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; - export let onDone: () => void; - export let onPrevious: () => void; + interface Props { + onDone: () => void; + onPrevious: () => void; + } - let config: SystemConfigDto | null = null; + let { onDone, onPrevious }: Props = $props(); + + let config: SystemConfigDto | null = $state(null); + let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>(); onMount(async () => { config = await getConfig(); @@ -26,38 +31,42 @@ </p> {#if config && $user} - <AdminSettings bind:config let:handleSave> - <SettingSwitch - title={$t('admin.map_settings')} - subtitle={$t('admin.map_implications')} - bind:checked={config.map.enabled} - /> - <SettingSwitch - title={$t('admin.version_check_settings')} - subtitle={$t('admin.version_check_implications')} - bind:checked={config.newVersionCheck.enabled} - /> - <div class="flex pt-4"> - <div class="w-full flex place-content-start"> - <Button class="flex gap-2 place-content-center" on:click={() => onPrevious()}> - <Icon path={mdiArrowLeft} size="18" /> - <p>{$t('theme')}</p> - </Button> - </div> - <div class="flex w-full place-content-end"> - <Button - on:click={() => { - handleSave({ map: config?.map, newVersionCheck: config?.newVersionCheck }); - onDone(); - }} - > - <span class="flex place-content-center place-items-center gap-2"> - {$t('admin.storage_template_settings')} - <Icon path={mdiArrowRight} size="18" /> - </span> - </Button> - </div> - </div> + <AdminSettings bind:config bind:this={adminSettingsComponent}> + {#snippet children()} + {#if config} + <SettingSwitch + title={$t('admin.map_settings')} + subtitle={$t('admin.map_implications')} + bind:checked={config.map.enabled} + /> + <SettingSwitch + title={$t('admin.version_check_settings')} + subtitle={$t('admin.version_check_implications')} + bind:checked={config.newVersionCheck.enabled} + /> + <div class="flex pt-4"> + <div class="w-full flex place-content-start"> + <Button class="flex gap-2 place-content-center" onclick={() => onPrevious()}> + <Icon path={mdiArrowLeft} size="18" /> + <p>{$t('theme')}</p> + </Button> + </div> + <div class="flex w-full place-content-end"> + <Button + onclick={() => { + adminSettingsComponent?.handleSave({ map: config?.map, newVersionCheck: config?.newVersionCheck }); + onDone(); + }} + > + <span class="flex place-content-center place-items-center gap-2"> + {$t('admin.storage_template_settings')} + <Icon path={mdiArrowRight} size="18" /> + </span> + </Button> + </div> + </div> + {/if} + {/snippet} </AdminSettings> {/if} </OnboardingCard> diff --git a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte index 69809dd39d1c9..b692a6f2de03e 100644 --- a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte @@ -12,10 +12,15 @@ import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; - export let onDone: () => void; - export let onPrevious: () => void; + interface Props { + onDone: () => void; + onPrevious: () => void; + } - let config: SystemConfigDto | null = null; + let { onDone, onPrevious }: Props = $props(); + + let config: SystemConfigDto | undefined = $state(); + let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>(); onMount(async () => { config = await getConfig(); @@ -24,45 +29,51 @@ <OnboardingCard title={$t('admin.storage_template_settings')} icon={mdiHarddisk}> <p> - <FormatMessage key="admin.storage_template_onboarding_description" let:message> - <a class="underline" href="https://immich.app/docs/administration/storage-template">{message}</a> + <FormatMessage key="admin.storage_template_onboarding_description"> + {#snippet children({ message })} + <a class="underline" href="https://immich.app/docs/administration/storage-template">{message}</a> + {/snippet} </FormatMessage> </p> {#if config && $user} - <AdminSettings bind:config let:defaultConfig let:savedConfig let:handleSave let:handleReset> - <StorageTemplateSettings - minified - disabled={$featureFlags.configFile} - {config} - {defaultConfig} - {savedConfig} - onSave={(config) => handleSave(config)} - onReset={(options) => handleReset(options)} - duration={0} - > - <div class="flex pt-4"> - <div class="w-full flex place-content-start"> - <Button class="flex gap-2 place-content-center" on:click={() => onPrevious()}> - <Icon path={mdiArrowLeft} size="18" /> - <p>{$t('theme')}</p> - </Button> - </div> - <div class="flex w-full place-content-end"> - <Button - on:click={() => { - handleSave({ storageTemplate: config?.storageTemplate }); - onDone(); - }} - > - <span class="flex place-content-center place-items-center gap-2"> - {$t('done')} - <Icon path={mdiCheck} size="18" /> - </span> - </Button> - </div> - </div> - </StorageTemplateSettings> + <AdminSettings bind:config bind:this={adminSettingsComponent}> + {#snippet children({ defaultConfig, savedConfig })} + {#if config} + <StorageTemplateSettings + minified + disabled={$featureFlags.configFile} + {config} + {defaultConfig} + {savedConfig} + onSave={(config) => adminSettingsComponent?.handleSave(config)} + onReset={(options) => adminSettingsComponent?.handleReset(options)} + duration={0} + > + <div class="flex pt-4"> + <div class="w-full flex place-content-start"> + <Button class="flex gap-2 place-content-center" onclick={() => onPrevious()}> + <Icon path={mdiArrowLeft} size="18" /> + <p>{$t('theme')}</p> + </Button> + </div> + <div class="flex w-full place-content-end"> + <Button + onclick={() => { + adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate }); + onDone(); + }} + > + <span class="flex place-content-center place-items-center gap-2"> + {$t('done')} + <Icon path={mdiCheck} size="18" /> + </span> + </Button> + </div> + </div> + </StorageTemplateSettings> + {/if} + {/snippet} </AdminSettings> {/if} </OnboardingCard> diff --git a/web/src/lib/components/onboarding-page/onboarding-theme.svelte b/web/src/lib/components/onboarding-page/onboarding-theme.svelte index 975dbd1ec32b7..4229cf9f678c9 100644 --- a/web/src/lib/components/onboarding-page/onboarding-theme.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-theme.svelte @@ -8,7 +8,11 @@ import { Theme } from '$lib/constants'; import { t } from 'svelte-i18n'; - export let onDone: () => void; + interface Props { + onDone: () => void; + } + + let { onDone }: Props = $props(); </script> <OnboardingCard icon={mdiThemeLightDark} title={$t('color_theme')}> @@ -20,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" - on:click={() => ($colorTheme.value = Theme.LIGHT)} + onclick={() => ($colorTheme.value = Theme.LIGHT)} > <div class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary" @@ -32,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" - on:click={() => ($colorTheme.value = Theme.DARK)} + onclick={() => ($colorTheme.value = Theme.DARK)} > <div class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary" @@ -45,7 +49,7 @@ <div class="flex"> <div class="w-full flex place-content-end"> - <Button class="flex gap-2 place-content-center" on:click={() => onDone()}> + <Button class="flex gap-2 place-content-center" onclick={() => onDone()}> <p>{$t('privacy')}</p> <Icon path={mdiArrowRight} size="18" /> </Button> diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte index 8c467644088b3..10917a1d90f39 100644 --- a/web/src/lib/components/photos-page/actions/add-to-album.svelte +++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte @@ -8,10 +8,14 @@ import { t } from 'svelte-i18n'; import type { OnAddToAlbum } from '$lib/utils/actions'; - export let shared = false; - export let onAddToAlbum: OnAddToAlbum = () => {}; + interface Props { + shared?: boolean; + onAddToAlbum?: OnAddToAlbum; + } - let showAlbumPicker = false; + let { shared = false, onAddToAlbum = () => {} }: Props = $props(); + + let showAlbumPicker = $state(false); const { getAssets } = getAssetControlContext(); diff --git a/web/src/lib/components/photos-page/actions/archive-action.svelte b/web/src/lib/components/photos-page/actions/archive-action.svelte index 792b80b7026dc..868a5ddd6da7f 100644 --- a/web/src/lib/components/photos-page/actions/archive-action.svelte +++ b/web/src/lib/components/photos-page/actions/archive-action.svelte @@ -7,15 +7,18 @@ import { archiveAssets } from '$lib/utils/asset-utils'; import { t } from 'svelte-i18n'; - export let onArchive: OnArchive; + interface Props { + onArchive: OnArchive; + menuItem?: boolean; + unarchive?: boolean; + } - export let menuItem = false; - export let unarchive = false; + let { onArchive, menuItem = false, unarchive = false }: Props = $props(); - $: text = unarchive ? $t('unarchive') : $t('to_archive'); - $: icon = unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline; + let text = $derived(unarchive ? $t('unarchive') : $t('to_archive')); + let icon = $derived(unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline); - let loading = false; + let loading = $state(false); const { clearSelect, getOwnedAssets } = getAssetControlContext(); @@ -38,8 +41,8 @@ {#if !menuItem} {#if loading} - <CircleIconButton title={$t('loading')} icon={mdiTimerSand} /> + <CircleIconButton title={$t('loading')} icon={mdiTimerSand} onclick={() => {}} /> {:else} - <CircleIconButton title={text} {icon} on:click={handleArchive} /> + <CircleIconButton title={text} {icon} onclick={handleArchive} /> {/if} {/if} diff --git a/web/src/lib/components/photos-page/actions/asset-job-actions.svelte b/web/src/lib/components/photos-page/actions/asset-job-actions.svelte index 13b51638f4a52..b383729ecdec7 100644 --- a/web/src/lib/components/photos-page/actions/asset-job-actions.svelte +++ b/web/src/lib/components/photos-page/actions/asset-job-actions.svelte @@ -10,16 +10,16 @@ import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { t } from 'svelte-i18n'; - export let jobs: AssetJobName[] = [ - AssetJobName.RegenerateThumbnail, - AssetJobName.RefreshMetadata, - AssetJobName.TranscodeVideo, - ]; + interface Props { + jobs?: AssetJobName[]; + } + + let { jobs = [AssetJobName.RegenerateThumbnail, AssetJobName.RefreshMetadata, AssetJobName.TranscodeVideo] }: Props = + $props(); const { clearSelect, getOwnedAssets } = getAssetControlContext(); - // svelte-ignore reactive_declaration_non_reactive_property - $: isAllVideos = [...getOwnedAssets()].every((asset) => asset.type === AssetTypeEnum.Video); + let isAllVideos = $derived([...getOwnedAssets()].every((asset) => asset.type === AssetTypeEnum.Video)); const handleRunJob = async (name: AssetJobName) => { try { diff --git a/web/src/lib/components/photos-page/actions/change-date-action.svelte b/web/src/lib/components/photos-page/actions/change-date-action.svelte index 114315348d203..3232cbd2b400d 100644 --- a/web/src/lib/components/photos-page/actions/change-date-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-date-action.svelte @@ -9,10 +9,14 @@ import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { mdiCalendarEditOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let menuItem = false; + interface Props { + menuItem?: boolean; + } + + let { menuItem = false }: Props = $props(); const { clearSelect, getOwnedAssets } = getAssetControlContext(); - let isShowChangeDate = false; + let isShowChangeDate = $state(false); const handleConfirm = async (dateTimeOriginal: string) => { isShowChangeDate = false; diff --git a/web/src/lib/components/photos-page/actions/change-location-action.svelte b/web/src/lib/components/photos-page/actions/change-location-action.svelte index 3fe1db4327ae0..0ad93e5d81a40 100644 --- a/web/src/lib/components/photos-page/actions/change-location-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-location-action.svelte @@ -9,10 +9,14 @@ import { mdiMapMarkerMultipleOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let menuItem = false; + interface Props { + menuItem?: boolean; + } + + let { menuItem = false }: Props = $props(); const { clearSelect, getOwnedAssets } = getAssetControlContext(); - let isShowChangeLocation = false; + let isShowChangeLocation = $state(false); async function handleConfirm(point: { lng: number; lat: number }) { isShowChangeLocation = false; diff --git a/web/src/lib/components/photos-page/actions/create-shared-link.svelte b/web/src/lib/components/photos-page/actions/create-shared-link.svelte index 7436ff2177514..1b99627ea9952 100644 --- a/web/src/lib/components/photos-page/actions/create-shared-link.svelte +++ b/web/src/lib/components/photos-page/actions/create-shared-link.svelte @@ -5,11 +5,11 @@ import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { t } from 'svelte-i18n'; - let showModal = false; + let showModal = $state(false); const { getAssets } = getAssetControlContext(); </script> -<CircleIconButton title={$t('share')} icon={mdiShareVariantOutline} on:click={() => (showModal = true)} /> +<CircleIconButton title={$t('share')} icon={mdiShareVariantOutline} onclick={() => (showModal = true)} /> {#if showModal} <CreateSharedLinkModal assetIds={[...getAssets()].map(({ id }) => id)} onClose={() => (showModal = false)} /> diff --git a/web/src/lib/components/photos-page/actions/delete-assets.svelte b/web/src/lib/components/photos-page/actions/delete-assets.svelte index 6d3275c74d594..bdd442e50c3ff 100644 --- a/web/src/lib/components/photos-page/actions/delete-assets.svelte +++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte @@ -8,16 +8,20 @@ import DeleteAssetDialog from '../delete-asset-dialog.svelte'; import { t } from 'svelte-i18n'; - export let onAssetDelete: OnDelete; - export let menuItem = false; - export let force = !$featureFlags.trash; + interface Props { + onAssetDelete: OnDelete; + menuItem?: boolean; + force?: boolean; + } + + let { onAssetDelete, menuItem = false, force = !$featureFlags.trash }: Props = $props(); const { clearSelect, getOwnedAssets } = getAssetControlContext(); - let isShowConfirmation = false; - let loading = false; + let isShowConfirmation = $state(false); + let loading = $state(false); - $: label = force ? $t('permanently_delete') : $t('delete'); + let label = $derived(force ? $t('permanently_delete') : $t('delete')); const handleTrash = async () => { if (force) { @@ -41,9 +45,9 @@ {#if menuItem} <MenuOption text={label} icon={mdiDeleteOutline} onClick={handleTrash} /> {:else if loading} - <CircleIconButton title={$t('loading')} icon={mdiTimerSand} /> + <CircleIconButton title={$t('loading')} icon={mdiTimerSand} onclick={() => {}} /> {:else} - <CircleIconButton title={label} icon={mdiDeleteForeverOutline} on:click={handleTrash} /> + <CircleIconButton title={label} icon={mdiDeleteForeverOutline} onclick={handleTrash} /> {/if} {#if isShowConfirmation} diff --git a/web/src/lib/components/photos-page/actions/download-action.svelte b/web/src/lib/components/photos-page/actions/download-action.svelte index 7716fbe36d53d..89eca9c6a83f6 100644 --- a/web/src/lib/components/photos-page/actions/download-action.svelte +++ b/web/src/lib/components/photos-page/actions/download-action.svelte @@ -7,8 +7,12 @@ import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let filename = 'immich.zip'; - export let menuItem = false; + interface Props { + filename?: string; + menuItem?: boolean; + } + + let { filename = 'immich.zip', menuItem = false }: Props = $props(); const { getAssets, clearSelect } = getAssetControlContext(); @@ -24,7 +28,7 @@ await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) }); }; - $: menuItemIcon = getAssets().size === 1 ? mdiFileDownloadOutline : mdiFolderDownloadOutline; + let menuItemIcon = $derived(getAssets().size === 1 ? mdiFileDownloadOutline : mdiFolderDownloadOutline); </script> <svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: handleDownloadFiles }} /> @@ -32,5 +36,5 @@ {#if menuItem} <MenuOption text={$t('download')} icon={menuItemIcon} onClick={handleDownloadFiles} /> {:else} - <CircleIconButton title={$t('download')} icon={mdiCloudDownloadOutline} on:click={handleDownloadFiles} /> + <CircleIconButton title={$t('download')} icon={mdiCloudDownloadOutline} onclick={handleDownloadFiles} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/favorite-action.svelte b/web/src/lib/components/photos-page/actions/favorite-action.svelte index 1d723b1a9dc8c..1bc676415735a 100644 --- a/web/src/lib/components/photos-page/actions/favorite-action.svelte +++ b/web/src/lib/components/photos-page/actions/favorite-action.svelte @@ -12,15 +12,18 @@ import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { t } from 'svelte-i18n'; - export let onFavorite: OnFavorite; + interface Props { + onFavorite: OnFavorite; + menuItem?: boolean; + removeFavorite: boolean; + } - export let menuItem = false; - export let removeFavorite: boolean; + let { onFavorite, menuItem = false, removeFavorite }: Props = $props(); - $: text = removeFavorite ? $t('remove_from_favorites') : $t('to_favorite'); - $: icon = removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline; + let text = $derived(removeFavorite ? $t('remove_from_favorites') : $t('to_favorite')); + let icon = $derived(removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline); - let loading = false; + let loading = $state(false); const { clearSelect, getOwnedAssets } = getAssetControlContext(); @@ -65,8 +68,8 @@ {#if !menuItem} {#if loading} - <CircleIconButton title={$t('loading')} icon={mdiTimerSand} /> + <CircleIconButton title={$t('loading')} icon={mdiTimerSand} onclick={() => {}} /> {:else} - <CircleIconButton title={text} {icon} on:click={handleFavorite} /> + <CircleIconButton title={text} {icon} onclick={handleFavorite} /> {/if} {/if} diff --git a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte index 24107b9f88c2e..27ac6cf04287a 100644 --- a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte +++ b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte @@ -8,15 +8,19 @@ import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte'; - export let onLink: OnLink; - export let onUnlink: OnUnlink; - export let menuItem = false; - export let unlink = false; + interface Props { + onLink: OnLink; + onUnlink: OnUnlink; + menuItem?: boolean; + unlink?: boolean; + } - let loading = false; + let { onLink, onUnlink, menuItem = false, unlink = false }: Props = $props(); - $: text = unlink ? $t('unlink_motion_video') : $t('link_motion_video'); - $: icon = unlink ? mdiLinkOff : mdiMotionPlayOutline; + let loading = $state(false); + + let text = $derived(unlink ? $t('unlink_motion_video') : $t('link_motion_video')); + let icon = $derived(unlink ? mdiLinkOff : mdiMotionPlayOutline); const { clearSelect, getOwnedAssets } = getAssetControlContext(); @@ -68,8 +72,8 @@ {#if !menuItem} {#if loading} - <CircleIconButton title={$t('loading')} icon={mdiTimerSand} /> + <CircleIconButton title={$t('loading')} icon={mdiTimerSand} onclick={() => {}} /> {:else} - <CircleIconButton title={text} {icon} on:click={onClick} /> + <CircleIconButton title={text} {icon} onclick={onClick} /> {/if} {/if} diff --git a/web/src/lib/components/photos-page/actions/remove-from-album.svelte b/web/src/lib/components/photos-page/actions/remove-from-album.svelte index 2384f95d2e0a1..19c1e54cfa64b 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-album.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-album.svelte @@ -11,9 +11,13 @@ import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; - export let album: AlbumResponseDto; - export let onRemove: ((assetIds: string[]) => void) | undefined; - export let menuItem = false; + interface Props { + album: AlbumResponseDto; + onRemove: ((assetIds: string[]) => void) | undefined; + menuItem?: boolean; + } + + let { album = $bindable(), onRemove, menuItem = false }: Props = $props(); const { getAssets, clearSelect } = getAssetControlContext(); @@ -57,5 +61,5 @@ {#if menuItem} <MenuOption text={$t('remove_from_album')} icon={mdiImageRemoveOutline} onClick={removeFromAlbum} /> {:else} - <CircleIconButton title={$t('remove_from_album')} icon={mdiDeleteOutline} on:click={removeFromAlbum} /> + <CircleIconButton title={$t('remove_from_album')} icon={mdiDeleteOutline} onclick={removeFromAlbum} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte index e838f0813d461..e884a929a3874 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte @@ -9,7 +9,11 @@ import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; - export let sharedLink: SharedLinkResponseDto; + interface Props { + sharedLink: SharedLinkResponseDto; + } + + let { sharedLink = $bindable() }: Props = $props(); const { getAssets, clearSelect } = getAssetControlContext(); @@ -55,4 +59,4 @@ }; </script> -<CircleIconButton title={$t('remove_from_shared_link')} on:click={handleRemove} icon={mdiDeleteOutline} /> +<CircleIconButton title={$t('remove_from_shared_link')} onclick={handleRemove} icon={mdiDeleteOutline} /> diff --git a/web/src/lib/components/photos-page/actions/restore-assets.svelte b/web/src/lib/components/photos-page/actions/restore-assets.svelte index 19e1c206fdba9..037e3239ef8ca 100644 --- a/web/src/lib/components/photos-page/actions/restore-assets.svelte +++ b/web/src/lib/components/photos-page/actions/restore-assets.svelte @@ -12,11 +12,15 @@ import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { t } from 'svelte-i18n'; - export let onRestore: OnRestore | undefined; + interface Props { + onRestore: OnRestore | undefined; + } + + let { onRestore }: Props = $props(); const { getAssets, clearSelect } = getAssetControlContext(); - let loading = false; + let loading = $state(false); const handleRestore = async () => { loading = true; @@ -40,7 +44,7 @@ }; </script> -<Button disabled={loading} size="sm" color="transparent-gray" shadow={false} rounded="lg" on:click={handleRestore}> +<Button disabled={loading} size="sm" color="transparent-gray" shadow={false} rounded="lg" onclick={handleRestore}> <Icon path={mdiHistory} size="24" /> <span class="ml-2">{$t('restore')}</span> </Button> diff --git a/web/src/lib/components/photos-page/actions/select-all-assets.svelte b/web/src/lib/components/photos-page/actions/select-all-assets.svelte index 93df51a6a01cb..cc27f3ebbeb5b 100644 --- a/web/src/lib/components/photos-page/actions/select-all-assets.svelte +++ b/web/src/lib/components/photos-page/actions/select-all-assets.svelte @@ -6,8 +6,12 @@ import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils'; import { t } from 'svelte-i18n'; - export let assetStore: AssetStore; - export let assetInteractionStore: AssetInteractionStore; + interface Props { + assetStore: AssetStore; + assetInteractionStore: AssetInteractionStore; + } + + let { assetStore, assetInteractionStore }: Props = $props(); const handleSelectAll = async () => { await selectAllAssets(assetStore, assetInteractionStore); @@ -19,7 +23,7 @@ </script> {#if $isSelectingAllAssets} - <CircleIconButton title={$t('unselect_all')} icon={mdiSelectRemove} on:click={handleCancel} /> + <CircleIconButton title={$t('unselect_all')} icon={mdiSelectRemove} onclick={handleCancel} /> {:else} - <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} /> + <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/stack-action.svelte b/web/src/lib/components/photos-page/actions/stack-action.svelte index c1f2bf212ff50..fe4f066a0e320 100644 --- a/web/src/lib/components/photos-page/actions/stack-action.svelte +++ b/web/src/lib/components/photos-page/actions/stack-action.svelte @@ -6,9 +6,13 @@ import type { OnStack, OnUnstack } from '$lib/utils/actions'; import { t } from 'svelte-i18n'; - export let unstack = false; - export let onStack: OnStack | undefined; - export let onUnstack: OnUnstack | undefined; + interface Props { + unstack?: boolean; + onStack: OnStack | undefined; + onUnstack: OnUnstack | undefined; + } + + let { unstack = false, onStack, onUnstack }: Props = $props(); const { clearSelect, getOwnedAssets } = getAssetControlContext(); diff --git a/web/src/lib/components/photos-page/actions/tag-action.svelte b/web/src/lib/components/photos-page/actions/tag-action.svelte index 77e91d7235aa4..32cdaec16a0c7 100644 --- a/web/src/lib/components/photos-page/actions/tag-action.svelte +++ b/web/src/lib/components/photos-page/actions/tag-action.svelte @@ -7,13 +7,17 @@ import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte'; - export let menuItem = false; + interface Props { + menuItem?: boolean; + } + + let { menuItem = false }: Props = $props(); const text = $t('tag'); const icon = mdiTagMultipleOutline; - let loading = false; - let isOpen = false; + let loading = $state(false); + let isOpen = $state(false); const { clearSelect, getOwnedAssets } = getAssetControlContext(); @@ -36,9 +40,9 @@ {#if !menuItem} {#if loading} - <CircleIconButton title={$t('loading')} icon={mdiTimerSand} /> + <CircleIconButton title={$t('loading')} icon={mdiTimerSand} onclick={() => {}} /> {:else} - <CircleIconButton title={text} {icon} on:click={handleOpen} /> + <CircleIconButton title={text} {icon} onclick={handleOpen} /> {/if} {/if} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 6c534e5116698..775b2a9282667 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -22,7 +22,7 @@ import { TUNABLES } from '$lib/utils/tunables'; import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; import { throttle } from 'lodash-es'; - import { onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount, type Snippet } from 'svelte'; import Portal from '../shared-components/portal/portal.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; @@ -38,80 +38,70 @@ import { generateId } from '$lib/utils/generate-id'; import { isTimelineScrolling } from '$lib/stores/timeline.store'; - export let isSelectionMode = false; - export let singleSelect = false; - - /** `true` if this asset grid is responds to navigation events; if `true`, then look at the + interface Props { + isSelectionMode?: boolean; + singleSelect?: boolean; + /** `true` if this asset grid is responds to navigation events; if `true`, then look at the `AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and additionally, update the page location/url with the asset as the asset-grid is scrolled */ - export let enableRouting: boolean; - - export let assetStore: AssetStore; - export let assetInteractionStore: AssetInteractionStore; - export let removeAction: - | AssetAction.UNARCHIVE - | AssetAction.ARCHIVE - | AssetAction.FAVORITE - | AssetAction.UNFAVORITE - | null = null; - export let withStacked = false; - export let showArchiveIcon = false; - export let isShared = false; - export let album: AlbumResponseDto | null = null; - export let isShowDeleteConfirmation = false; - export let onSelect: (asset: AssetResponseDto) => void = () => {}; - export let onEscape: () => void = () => {}; + enableRouting: boolean; + assetStore: AssetStore; + assetInteractionStore: AssetInteractionStore; + removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null; + withStacked?: boolean; + showArchiveIcon?: boolean; + isShared?: boolean; + album?: AlbumResponseDto | null; + isShowDeleteConfirmation?: boolean; + onSelect?: (asset: AssetResponseDto) => void; + onEscape?: () => void; + children?: Snippet; + empty?: Snippet; + } + + let { + isSelectionMode = false, + singleSelect = false, + enableRouting, + assetStore = $bindable(), + assetInteractionStore, + removeAction = null, + withStacked = false, + showArchiveIcon = false, + isShared = false, + album = null, + isShowDeleteConfirmation = $bindable(false), + onSelect = () => {}, + onEscape = () => {}, + children, + empty, + }: Props = $props(); let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } = assetInteractionStore; - const viewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 }; - const safeViewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 }; + const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); + const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); const componentId = generateId(); - let element: HTMLElement; - let timelineElement: HTMLElement; - let showShortcuts = false; - let showSkeleton = true; + let element: HTMLElement | undefined = $state(); + let timelineElement: HTMLElement | undefined = $state(); + let showShortcuts = $state(false); + let showSkeleton = $state(true); let internalScroll = false; let navigating = false; - let preMeasure: AssetBucket[] = []; + let preMeasure: AssetBucket[] = $state([]); let lastIntersectedBucketDate: string | undefined; - let scrubBucketPercent = 0; - let scrubBucket: { bucketDate: string | undefined } | undefined; - let scrubOverallPercent: number = 0; - let topSectionHeight = 0; - let topSectionOffset = 0; + let scrubBucketPercent = $state(0); + let scrubBucket: { bucketDate: string | undefined } | undefined = $state(); + let scrubOverallPercent: number = $state(0); + let topSectionHeight = $state(0); + let topSectionOffset = $state(0); // 60 is the bottom spacer element at 60px let bottomSectionHeight = 60; - let leadout = false; + let leadout = $state(false); - $: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash; - $: isEmpty = $assetStore.initialized && $assetStore.buckets.length === 0; - $: idsSelectedAssets = [...$selectedAssets].map(({ id }) => id); - $: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived); - $: { - if (isEmpty) { - assetInteractionStore.clearMultiselect(); - } - } - $: { - if (element && isViewportOrigin()) { - const rect = element.getBoundingClientRect(); - viewport.height = rect.height; - viewport.width = rect.width; - viewport.x = rect.x; - viewport.y = rect.y; - } - if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) { - safeViewport.height = viewport.height; - safeViewport.width = viewport.width; - safeViewport.x = viewport.x; - safeViewport.y = viewport.y; - updateViewport(); - } - } const { ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW }, BUCKET: { @@ -141,11 +131,11 @@ if ($gridScrollTarget?.at) { void $assetStore.scheduleScrollToAssetId($gridScrollTarget, () => { - element.scrollTo({ top: 0 }); + element?.scrollTo({ top: 0 }); showSkeleton = false; }); } else { - element.scrollTo({ top: 0 }); + element?.scrollTo({ top: 0 }); showSkeleton = false; } }; @@ -185,7 +175,7 @@ { replaceState: true, forceNavigate: true }, ); } else { - element.scrollTo({ top: 0 }); + element?.scrollTo({ top: 0 }); showSkeleton = false; } }, 500); @@ -276,14 +266,24 @@ ($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) / ($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight); - const getMaxScroll = () => - topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight); + const getMaxScroll = () => { + if (!element || !timelineElement) { + return 0; + } + + return topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight); + }; const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => { const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset; const maxScrollPercent = getMaxScrollPercent(); const delta = bucket.bucketHeight * bucketScrollPercent; const scrollTop = (topOffset + delta) * maxScrollPercent; + + if (!element) { + return; + } + element.scrollTop = scrollTop; }; @@ -297,6 +297,11 @@ const maxScroll = getMaxScroll(); const offset = maxScroll * scrollPercent; + + if (!element) { + return; + } + element.scrollTop = offset; } else { const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate); @@ -344,6 +349,11 @@ }, 1000); leadout = false; + + if (!element) { + return; + } + if ($assetStore.timelineHeight < safeViewport.height * 2) { // edge case - scroll limited due to size of content, must adjust - use the overall percent instead const maxScroll = getMaxScroll(); @@ -409,7 +419,7 @@ : () => void 0; const onScrollTarget: ScrollTargetListener = ({ bucket, offset }) => { - element.scrollTo({ top: offset }); + element?.scrollTo({ top: offset }); if (!bucket.measured) { preMeasure.push(bucket); } @@ -466,37 +476,10 @@ const focusElement = () => { if (document.activeElement === document.body) { - element.focus(); + element?.focus(); } }; - $: shortcutList = (() => { - if ($isSearchEnabled || $showAssetViewer) { - return []; - } - - const shortcuts: ShortcutOptions[] = [ - { shortcut: { key: 'Escape' }, onShortcut: onEscape }, - { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, - { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, - { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) }, - { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement }, - { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement }, - ]; - - if ($isMultiSelectState) { - shortcuts.push( - { shortcut: { key: 'Delete' }, onShortcut: onDelete }, - { shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete }, - { shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() }, - { shortcut: { key: 's' }, onShortcut: () => onStackAssets() }, - { shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive }, - ); - } - - return shortcuts; - })(); - const handleSelectAsset = (asset: AssetResponseDto) => { if (!$assetStore.albumAssets.has(asset.id)) { assetInteractionStore.selectAsset(asset); @@ -585,13 +568,9 @@ } }; - let lastAssetMouseEvent: AssetResponseDto | null = null; - - $: if (!lastAssetMouseEvent) { - assetInteractionStore.clearAssetSelectionCandidates(); - } + let lastAssetMouseEvent: AssetResponseDto | null = $state(null); - let shiftKeyIsDown = false; + let shiftKeyIsDown = $state(false); const deselectAllAssets = () => { cancelMultiselect(assetInteractionStore); @@ -619,14 +598,6 @@ } }; - $: if (!shiftKeyIsDown) { - assetInteractionStore.clearAssetSelectionCandidates(); - } - - $: if (shiftKeyIsDown && lastAssetMouseEvent) { - selectAssetCandidates(lastAssetMouseEvent); - } - const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => { if (asset) { selectAssetCandidates(asset); @@ -655,7 +626,7 @@ onSelect(asset); - if (singleSelect) { + if (singleSelect && element) { element.scrollTop = 0; return; } @@ -723,18 +694,18 @@ assetInteractionStore.setAssetSelectionStart(deselect ? null : asset); }; - const selectAssetCandidates = (asset: AssetResponseDto) => { + const selectAssetCandidates = (endAsset: AssetResponseDto) => { if (!shiftKeyIsDown) { return; } - const rangeStart = $assetSelectionStart; - if (!rangeStart) { + const startAsset = $assetSelectionStart; + if (!startAsset) { return; } - let start = $assetStore.assets.indexOf(rangeStart); - let end = $assetStore.assets.indexOf(asset); + let start = $assetStore.assets.findIndex((a) => a.id === startAsset.id); + let end = $assetStore.assets.findIndex((a) => a.id === endAsset.id); if (start > end) { [start, end] = [end, start]; @@ -751,9 +722,83 @@ onDestroy(() => { assetStore.taskManager.removeAllTasksForComponent(componentId); }); + let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); + let isEmpty = $derived($assetStore.initialized && $assetStore.buckets.length === 0); + let idsSelectedAssets = $derived([...$selectedAssets].map(({ id }) => id)); + let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); + + $effect(() => { + if (isEmpty) { + assetInteractionStore.clearMultiselect(); + } + }); + + $effect(() => { + if (element && isViewportOrigin()) { + const rect = element.getBoundingClientRect(); + viewport.height = rect.height; + viewport.width = rect.width; + viewport.x = rect.x; + viewport.y = rect.y; + } + if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) { + safeViewport.height = viewport.height; + safeViewport.width = viewport.width; + safeViewport.x = viewport.x; + safeViewport.y = viewport.y; + updateViewport(); + } + }); + + let shortcutList = $derived( + (() => { + if ($isSearchEnabled || $showAssetViewer) { + return []; + } + + const shortcuts: ShortcutOptions[] = [ + { shortcut: { key: 'Escape' }, onShortcut: onEscape }, + { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, + { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, + { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) }, + { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement }, + { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement }, + ]; + + if ($isMultiSelectState) { + shortcuts.push( + { shortcut: { key: 'Delete' }, onShortcut: onDelete }, + { shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete }, + { shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() }, + { shortcut: { key: 's' }, onShortcut: () => onStackAssets() }, + { shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive }, + ); + } + + return shortcuts; + })(), + ); + + $effect(() => { + if (!lastAssetMouseEvent) { + assetInteractionStore.clearAssetSelectionCandidates(); + } + }); + + $effect(() => { + if (!shiftKeyIsDown) { + assetInteractionStore.clearAssetSelectionCandidates(); + } + }); + + $effect(() => { + if (shiftKeyIsDown && lastAssetMouseEvent) { + selectAssetCandidates(lastAssetMouseEvent); + } + }); </script> -<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} use:shortcuts={shortcutList} /> +<svelte:window onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} /> {#if isShowDeleteConfirmation} <DeleteAssetDialog @@ -789,16 +834,16 @@ tabindex="-1" use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))} bind:this={element} - on:scroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())} + onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())} > <section use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))} class:invisible={showSkeleton} > - <slot /> + {@render children?.()} {#if isEmpty} <!-- (optional) empty placeholder --> - <slot name="empty" /> + {@render empty?.()} {/if} </section> diff --git a/web/src/lib/components/photos-page/asset-select-control-bar.svelte b/web/src/lib/components/photos-page/asset-select-control-bar.svelte index 79a0ea75e6736..2ab8f1e9c2e45 100644 --- a/web/src/lib/components/photos-page/asset-select-control-bar.svelte +++ b/web/src/lib/components/photos-page/asset-select-control-bar.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> import { createContext } from '$lib/utils/context'; import { t } from 'svelte-i18n'; @@ -17,10 +17,16 @@ import type { AssetResponseDto } from '@immich/sdk'; import { mdiClose } from '@mdi/js'; import ControlAppBar from '../shared-components/control-app-bar.svelte'; + import type { Snippet } from 'svelte'; - export let assets: Set<AssetResponseDto>; - export let clearSelect: () => void; - export let ownerId: string | undefined = undefined; + interface Props { + assets: Set<AssetResponseDto>; + clearSelect: () => void; + ownerId?: string | undefined; + children?: Snippet; + } + + let { assets, clearSelect, ownerId = undefined, children }: Props = $props(); setContext({ getAssets: () => assets, @@ -31,9 +37,13 @@ </script> <ControlAppBar onClose={clearSelect} backIcon={mdiClose} tailwindClasses="bg-white shadow-md"> - <div class="font-medium text-immich-primary dark:text-immich-dark-primary" slot="leading"> - <p class="block sm:hidden">{assets.size}</p> - <p class="hidden sm:block">{$t('selected_count', { values: { count: assets.size } })}</p> - </div> - <slot slot="trailing" /> + {#snippet leading()} + <div class="font-medium text-immich-primary dark:text-immich-dark-primary"> + <p class="block sm:hidden">{assets.size}</p> + <p class="hidden sm:block">{$t('selected_count', { values: { count: assets.size } })}</p> + </div> + {/snippet} + {#snippet trailing()} + {@render children?.()} + {/snippet} </ControlAppBar> diff --git a/web/src/lib/components/photos-page/delete-asset-dialog.svelte b/web/src/lib/components/photos-page/delete-asset-dialog.svelte index 3eff428a7bb5e..3053600a4718d 100644 --- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte +++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte @@ -5,11 +5,15 @@ import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; - export let size: number; - export let onConfirm: () => void; - export let onCancel: () => void; + interface Props { + size: number; + onConfirm: () => void; + onCancel: () => void; + } - let checked = false; + let { size, onConfirm, onCancel }: Props = $props(); + + let checked = $state(false); const handleConfirm = () => { if (checked) { @@ -25,10 +29,12 @@ onConfirm={handleConfirm} {onCancel} > - <svelte:fragment slot="prompt"> + {#snippet promptSnippet()} <p> - <FormatMessage key="permanently_delete_assets_prompt" values={{ count: size }} let:message> - <b>{message}</b> + <FormatMessage key="permanently_delete_assets_prompt" values={{ count: size }}> + {#snippet children({ message })} + <b>{message}</b> + {/snippet} </FormatMessage> </p> <p><b>{$t('cannot_undo_this_action')}</b></p> @@ -36,5 +42,5 @@ <div class="pt-4 flex justify-center items-center"> <Checkbox id="confirm-deletion-input" label={$t('do_not_show_again')} bind:checked /> </div> - </svelte:fragment> + {/snippet} </ConfirmDialog> diff --git a/web/src/lib/components/photos-page/measure-date-group.svelte b/web/src/lib/components/photos-page/measure-date-group.svelte index f458fe40dd84b..80ad7640fb938 100644 --- a/web/src/lib/components/photos-page/measure-date-group.svelte +++ b/web/src/lib/components/photos-page/measure-date-group.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> const recentTimes: number[] = []; // TODO: track average time to measure, and use this to populate TUNABLES.ASSETS_STORE.CHECK_INTERVAL_MS // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -20,9 +20,13 @@ import { resizeObserver } from '$lib/actions/resize-observer'; import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets.store'; - export let assetStore: AssetStore; - export let bucket: AssetBucket; - export let onMeasured: () => void; + interface Props { + assetStore: AssetStore; + bucket: AssetBucket; + onMeasured: () => void; + } + + let { assetStore, bucket, onMeasured }: Props = $props(); async function _measure(element: Element) { try { diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index ed6ef4f3a73d4..3a6ac7e8cf0b1 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -11,27 +11,29 @@ import { fade } from 'svelte/transition'; import { t } from 'svelte-i18n'; - $: shouldRender = $memoryStore?.length > 0; + let shouldRender = $derived($memoryStore?.length > 0); onMount(async () => { const localTime = new Date(); $memoryStore = await getMemoryLane({ month: localTime.getMonth() + 1, day: localTime.getDate() }); }); - let memoryLaneElement: HTMLElement; - let offsetWidth = 0; - let innerWidth = 0; + let memoryLaneElement: HTMLElement | undefined = $state(); + let offsetWidth = $state(0); + let innerWidth = $state(0); - let scrollLeftPosition = 0; + let scrollLeftPosition = $state(0); - const onScroll = () => (scrollLeftPosition = memoryLaneElement?.scrollLeft); + const onScroll = () => { + scrollLeftPosition = memoryLaneElement?.scrollLeft ?? 0; + }; - $: canScrollLeft = scrollLeftPosition > 0; - $: canScrollRight = Math.ceil(scrollLeftPosition) < innerWidth - offsetWidth; + let canScrollLeft = $derived(scrollLeftPosition > 0); + let canScrollRight = $derived(Math.ceil(scrollLeftPosition) < innerWidth - offsetWidth); const scrollBy = 400; - const scrollLeft = () => memoryLaneElement.scrollBy({ left: -scrollBy, behavior: 'smooth' }); - const scrollRight = () => memoryLaneElement.scrollBy({ left: scrollBy, behavior: 'smooth' }); + const scrollLeft = () => memoryLaneElement?.scrollBy({ left: -scrollBy, behavior: 'smooth' }); + const scrollRight = () => memoryLaneElement?.scrollBy({ left: scrollBy, behavior: 'smooth' }); </script> {#if shouldRender} @@ -40,7 +42,7 @@ bind:this={memoryLaneElement} class="relative mt-5 overflow-x-hidden whitespace-nowrap transition-all" use:resizeObserver={({ width }) => (offsetWidth = width)} - on:scroll={onScroll} + onscroll={onScroll} > {#if canScrollLeft || canScrollRight} <div class="sticky left-0 z-20"> @@ -49,7 +51,7 @@ <button type="button" class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100" - on:click={scrollLeft} + onclick={scrollLeft} > <Icon path={mdiChevronLeft} size="36" /></button > @@ -60,7 +62,7 @@ <button type="button" class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100" - on:click={scrollRight} + onclick={scrollRight} > <Icon path={mdiChevronRight} size="36" /></button > diff --git a/web/src/lib/components/photos-page/skeleton.svelte b/web/src/lib/components/photos-page/skeleton.svelte index 07836eb4dbed2..601a40cce2115 100644 --- a/web/src/lib/components/photos-page/skeleton.svelte +++ b/web/src/lib/components/photos-page/skeleton.svelte @@ -1,6 +1,10 @@ <script lang="ts"> - export let title: string | null = null; - export let height: string | null = null; + interface Props { + title?: string | null; + height?: string | null; + } + + let { title = null, height = null }: Props = $props(); </script> <div class="overflow-clip" style={`height: ${height}`}> diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 1b5368b1336e1..245a90f9f3195 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -19,15 +19,19 @@ import type { Viewport } from '$lib/stores/assets.store'; import { t } from 'svelte-i18n'; - export let sharedLink: SharedLinkResponseDto; - export let isOwned: boolean; + interface Props { + sharedLink: SharedLinkResponseDto; + isOwned: boolean; + } - const viewport: Viewport = { width: 0, height: 0 }; - let selectedAssets: Set<AssetResponseDto> = new Set(); - let innerWidth: number; + let { sharedLink = $bindable(), isOwned }: Props = $props(); - $: assets = sharedLink.assets; - $: isMultiSelectionMode = selectedAssets.size > 0; + const viewport: Viewport = $state({ width: 0, height: 0 }); + let selectedAssets: Set<AssetResponseDto> = $state(new Set()); + let innerWidth: number = $state(0); + + let assets = $derived(sharedLink.assets); + let isMultiSelectionMode = $derived(selectedAssets.size > 0); dragAndDropFilesStore.subscribe((value) => { if (value.isDragging && value.files.length > 0) { @@ -75,7 +79,7 @@ <section class="bg-immich-bg dark:bg-immich-dark-bg"> {#if isMultiSelectionMode} <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}> - <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} /> + <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} /> {#if sharedLink?.allowDownload} <DownloadAction filename="immich-shared.zip" /> {/if} @@ -85,23 +89,23 @@ </AssetSelectControlBar> {:else} <ControlAppBar onClose={() => goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}> - <svelte:fragment slot="leading"> + {#snippet leading()} <ImmichLogoSmallLink width={innerWidth} /> - </svelte:fragment> + {/snippet} - <svelte:fragment slot="trailing"> + {#snippet trailing()} {#if sharedLink?.allowUpload} <CircleIconButton title={$t('add_photos')} - on:click={() => handleUploadAssets()} + onclick={() => handleUploadAssets()} icon={mdiFileImagePlusOutline} /> {/if} {#if sharedLink?.allowDownload} - <CircleIconButton title={$t('download')} on:click={downloadAssets} icon={mdiFolderDownloadOutline} /> + <CircleIconButton title={$t('download')} onclick={downloadAssets} icon={mdiFolderDownloadOutline} /> {/if} - </svelte:fragment> + {/snippet} </ControlAppBar> {/if} <section class="my-[160px] mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}> diff --git a/web/src/lib/components/shared-components/album-selection-modal.svelte b/web/src/lib/components/shared-components/album-selection-modal.svelte index 65f39ccb162f7..3400864efd61f 100644 --- a/web/src/lib/components/shared-components/album-selection-modal.svelte +++ b/web/src/lib/components/shared-components/album-selection-modal.svelte @@ -11,17 +11,19 @@ import { sortAlbums } from '$lib/utils/album-utils'; import { albumViewSettings } from '$lib/stores/preferences.store'; - export let onNewAlbum: (search: string) => void; - export let onAlbumClick: (album: AlbumResponseDto) => void; + let albums: AlbumResponseDto[] = $state([]); + let recentAlbums: AlbumResponseDto[] = $state([]); + let loading = $state(true); + let search = $state(''); - let albums: AlbumResponseDto[] = []; - let recentAlbums: AlbumResponseDto[] = []; - let filteredAlbums: AlbumResponseDto[] = []; - let loading = true; - let search = ''; + interface Props { + onNewAlbum: (search: string) => void; + onAlbumClick: (album: AlbumResponseDto) => void; + shared: boolean; + onClose: () => void; + } - export let shared: boolean; - export let onClose: () => void; + let { onNewAlbum, onAlbumClick, shared, onClose }: Props = $props(); onMount(async () => { albums = await getAllAlbums({ shared: shared || undefined }); @@ -29,13 +31,15 @@ loading = false; }); - $: filteredAlbums = sortAlbums( - search.length > 0 && albums.length > 0 - ? albums.filter((album) => { - return normalizeSearchString(album.albumName).includes(normalizeSearchString(search)); - }) - : albums, - { sortBy: $albumViewSettings.sortBy, orderBy: $albumViewSettings.sortOrder }, + let filteredAlbums = $derived( + sortAlbums( + search.length > 0 && albums.length > 0 + ? albums.filter((album) => { + return normalizeSearchString(album.albumName).includes(normalizeSearchString(search)); + }) + : albums, + { sortBy: $albumViewSettings.sortBy, orderBy: $albumViewSettings.sortOrder }, + ), ); const getTitle = () => { @@ -71,7 +75,7 @@ <div class="immich-scrollbar overflow-y-auto"> <button type="button" - on:click={() => onNewAlbum(search)} + onclick={() => onNewAlbum(search)} class="flex w-full items-center gap-4 px-6 py-2 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl" > <div class="flex h-12 w-12 items-center justify-center"> diff --git a/web/src/lib/components/shared-components/autogrow-textarea.svelte b/web/src/lib/components/shared-components/autogrow-textarea.svelte index efbcf218e6c09..5bb4637e05e62 100644 --- a/web/src/lib/components/shared-components/autogrow-textarea.svelte +++ b/web/src/lib/components/shared-components/autogrow-textarea.svelte @@ -3,23 +3,23 @@ import { shortcut } from '$lib/actions/shortcut'; import { tick } from 'svelte'; - export let content: string = ''; - let className: string = ''; - export { className as class }; - export let onContentUpdate: (newContent: string) => void = () => null; - export let placeholder: string = ''; + interface Props { + content?: string; + class?: string; + onContentUpdate?: (newContent: string) => void; + placeholder?: string; + } + + let { content = '', class: className = '', onContentUpdate = () => null, placeholder = '' }: Props = $props(); - let textarea: HTMLTextAreaElement; - $: newContent = content; + let textarea: HTMLTextAreaElement | undefined = $state(); + let newContent = $state(content); - $: { - // re-visit with svelte 5. runes will make this better. - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - newContent; + $effect(() => { if (textarea && newContent.length > 0) { void tick().then(() => autoGrowHeight(textarea)); } - } + }); const updateContent = () => { if (content === newContent) { @@ -32,8 +32,8 @@ <textarea bind:this={textarea} class="resize-none {className}" - on:focusout={updateContent} - on:input={(e) => (newContent = e.currentTarget.value)} + onfocusout={updateContent} + oninput={(e) => (newContent = e.currentTarget.value)} {placeholder} use:shortcut={{ shortcut: { key: 'Enter', ctrl: true }, diff --git a/web/src/lib/components/shared-components/change-date.spec.ts b/web/src/lib/components/shared-components/change-date.spec.ts index 112e900c02890..815acac5abcc8 100644 --- a/web/src/lib/components/shared-components/change-date.spec.ts +++ b/web/src/lib/components/shared-components/change-date.spec.ts @@ -16,6 +16,16 @@ describe('ChangeDate component', () => { beforeEach(() => { vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); + + vi.stubGlobal('visualViewport', { + height: window.innerHeight, + width: window.innerWidth, + scale: 1, + offsetLeft: 0, + offsetTop: 0, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); }); afterEach(() => { diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index 8ceda5f1d6cee..13b2752f0c1d5 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -1,14 +1,18 @@ <script lang="ts"> import { DateTime } from 'luxon'; import ConfirmDialog from './dialog/confirm-dialog.svelte'; - import Combobox from './combobox.svelte'; + import Combobox, { type ComboBoxOption } from './combobox.svelte'; import DateInput from '../elements/date-input.svelte'; import { t } from 'svelte-i18n'; - export let initialDate: DateTime = DateTime.now(); - export let initialTimeZone: string = ''; - export let onCancel: () => void; - export let onConfirm: (date: string) => void; + interface Props { + initialDate?: DateTime; + initialTimeZone?: string; + onCancel: () => void; + onConfirm: (date: string) => void; + } + + let { initialDate = DateTime.now(), initialTimeZone = '', onCancel, onConfirm }: Props = $props(); type ZoneOption = { /** @@ -49,21 +53,15 @@ const knownTimezones = Intl.supportedValuesOf('timeZone'); - let timezones: ZoneOption[]; - $: timezones = knownTimezones + const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm")); + let timezones: ZoneOption[] = knownTimezones .map((zone) => zoneOptionForDate(zone, selectedDate)) .filter((zone) => zone.valid) .sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB)); - - const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; // the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list - let selectedOption: ZoneOption | undefined; - $: selectedOption = getPreferredTimeZone(initialDate, userTimeZone, timezones, selectedOption); - - let selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm"); - - // when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it) - $: date = DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }); + let selectedOption: ZoneOption | undefined = $state(getPreferredTimeZone(initialDate, userTimeZone, timezones)); function zoneOptionForDate(zone: string, date: string) { const dateAtZone: DateTime = DateTime.fromISO(date, { zone }); @@ -125,6 +123,14 @@ onConfirm(value); } }; + + const handleOnSelect = (option?: ComboBoxOption) => { + if (option) { + selectedOption = getPreferredTimeZone(initialDate, userTimeZone, timezones, option as ZoneOption); + } + }; + // when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it) + let date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true })); </script> <ConfirmDialog @@ -135,13 +141,23 @@ onConfirm={handleConfirm} {onCancel} > - <div class="flex flex-col text-left gap-2" slot="prompt"> - <div class="flex flex-col"> - <label for="datetime">{$t('date_and_time')}</label> - <DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} /> - </div> - <div> - <Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} /> + <!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component --> + <!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component --> + {#snippet promptSnippet()} + <div class="flex flex-col text-left gap-2"> + <div class="flex flex-col"> + <label for="datetime">{$t('date_and_time')}</label> + <DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} /> + </div> + <div> + <Combobox + bind:selectedOption + label={$t('timezone')} + options={timezones} + placeholder={$t('search_timezone')} + onSelect={(option) => handleOnSelect(option)} + /> + </div> </div> - </div> + {/snippet} </ConfirmDialog> diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 573c9ab38b5d1..fa050d39c2dcb 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -12,39 +12,44 @@ import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte'; + import Map from '$lib/components/shared-components/map/map.svelte'; interface Point { lng: number; lat: number; } - export let asset: AssetResponseDto | undefined = undefined; - export let onCancel: () => void; - export let onConfirm: (point: Point) => void; + interface Props { + asset?: AssetResponseDto | undefined; + onCancel: () => void; + onConfirm: (point: Point) => void; + } + + let { asset = undefined, onCancel, onConfirm }: Props = $props(); - let places: PlacesResponseDto[] = []; - let suggestedPlaces: PlacesResponseDto[] = []; - let searchWord: string; + let places: PlacesResponseDto[] = $state([]); + let suggestedPlaces: PlacesResponseDto[] = $state([]); + let searchWord: string = $state(''); let latestSearchTimeout: number; - let showLoadingSpinner = false; - let suggestionContainer: HTMLDivElement; - let hideSuggestion = false; - let addClipMapMarker: (long: number, lat: number) => void; + let showLoadingSpinner = $state(false); + let suggestionContainer: HTMLDivElement | undefined = $state(); + let hideSuggestion = $state(false); + let mapElement = $state<ReturnType<typeof Map>>(); - $: lat = asset?.exifInfo?.latitude ?? undefined; - $: lng = asset?.exifInfo?.longitude ?? undefined; - $: zoom = lat !== undefined && lng !== undefined ? 12.5 : 1; + let lat = $derived(asset?.exifInfo?.latitude ?? undefined); + let lng = $derived(asset?.exifInfo?.longitude ?? undefined); + let zoom = $derived(lat !== undefined && lng !== undefined ? 12.5 : 1); - $: { + $effect(() => { if (places) { suggestedPlaces = places.slice(0, 5); } if (searchWord === '') { suggestedPlaces = []; } - } + }); - let point: Point | null = null; + let point: Point | null = $state(null); const handleConfirm = () => { if (point) { @@ -94,88 +99,95 @@ const handleUseSuggested = (latitude: number, longitude: number) => { hideSuggestion = true; point = { lng: longitude, lat: latitude }; - addClipMapMarker(longitude, latitude); + mapElement?.addClipMapMarker(longitude, latitude); }; </script> <ConfirmDialog confirmColor="primary" title={$t('change_location')} width="wide" onConfirm={handleConfirm} {onCancel}> - <div slot="prompt" class="flex flex-col w-full h-full gap-2"> - <div - class="relative w-64 sm:w-96" - use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }} - use:listNavigation={suggestionContainer} - > - <button type="button" class="w-full" on:click={() => (hideSuggestion = false)}> - <SearchBar - placeholder={$t('search_places')} - bind:name={searchWord} - {showLoadingSpinner} - onReset={() => (suggestedPlaces = [])} - onSearch={handleSearchPlaces} - roundedBottom={suggestedPlaces.length === 0 || hideSuggestion} - /> - </button> - <div class="absolute z-[99] w-full" id="suggestion" bind:this={suggestionContainer}> - {#if !hideSuggestion} - {#each suggestedPlaces as place, index} - <button - type="button" - class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index === - suggestedPlaces.length - 1 - ? 'rounded-b-lg border-b' - : ''}" - on:click={() => handleUseSuggested(place.latitude, place.longitude)} - > - <p class="ml-4 text-sm text-gray-700 dark:text-gray-100 truncate"> - {getLocation(place.name, place.admin1name, place.admin2name)} - </p> + {#snippet promptSnippet()} + <div class="flex flex-col w-full h-full gap-2"> + <div class="relative w-64 sm:w-96"> + {#if suggestionContainer} + <div + use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }} + use:listNavigation={suggestionContainer} + > + <button type="button" class="w-full" onclick={() => (hideSuggestion = false)}> + <SearchBar + placeholder={$t('search_places')} + bind:name={searchWord} + {showLoadingSpinner} + onReset={() => (suggestedPlaces = [])} + onSearch={handleSearchPlaces} + roundedBottom={suggestedPlaces.length === 0 || hideSuggestion} + /> </button> - {/each} + </div> {/if} + + <div class="absolute z-[99] w-full" id="suggestion" bind:this={suggestionContainer}> + {#if !hideSuggestion} + {#each suggestedPlaces as place, index} + <button + type="button" + class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index === + suggestedPlaces.length - 1 + ? 'rounded-b-lg border-b' + : ''}" + onclick={() => handleUseSuggested(place.latitude, place.longitude)} + > + <p class="ml-4 text-sm text-gray-700 dark:text-gray-100 truncate"> + {getLocation(place.name, place.admin1name, place.admin2name)} + </p> + </button> + {/each} + {/if} + </div> </div> - </div> - <span>{$t('pick_a_location')}</span> - <div class="h-[500px] min-h-[300px] w-full"> - {#await import('../shared-components/map/map.svelte')} - {#await delay(timeToLoadTheMap) then} - <!-- show the loading spinner only if loading the map takes too much time --> - <div class="flex items-center justify-center h-full w-full"> - <LoadingSpinner /> - </div> + + <span>{$t('pick_a_location')}</span> + <div class="h-[500px] min-h-[300px] w-full"> + {#await import('../shared-components/map/map.svelte')} + {#await delay(timeToLoadTheMap) then} + <!-- show the loading spinner only if loading the map takes too much time --> + <div class="flex items-center justify-center h-full w-full"> + <LoadingSpinner /> + </div> + {/await} + {:then { default: Map }} + <Map + bind:this={mapElement} + mapMarkers={lat !== undefined && lng !== undefined && asset + ? [ + { + id: asset.id, + lat, + lon: lng, + city: asset.exifInfo?.city ?? null, + state: asset.exifInfo?.state ?? null, + country: asset.exifInfo?.country ?? null, + }, + ] + : []} + {zoom} + center={lat && lng ? { lat, lng } : undefined} + simplified={true} + clickable={true} + onClickPoint={(selected) => (point = selected)} + /> {/await} - {:then { default: Map }} - <Map - mapMarkers={lat !== undefined && lng !== undefined && asset - ? [ - { - id: asset.id, - lat, - lon: lng, - city: asset.exifInfo?.city ?? null, - state: asset.exifInfo?.state ?? null, - country: asset.exifInfo?.country ?? null, - }, - ] - : []} - {zoom} - bind:addClipMapMarker - center={lat && lng ? { lat, lng } : undefined} - simplified={true} - clickable={true} - onClickPoint={(selected) => (point = selected)} - /> - {/await} - </div> + </div> - <div class="grid sm:grid-cols-2 gap-4 text-sm text-left mt-4"> - <CoordinatesInput - lat={point ? point.lat : lat} - lng={point ? point.lng : lng} - onUpdate={(lat, lng) => { - point = { lat, lng }; - addClipMapMarker(lng, lat); - }} - /> + <div class="grid sm:grid-cols-2 gap-4 text-sm text-left mt-4"> + <CoordinatesInput + lat={point ? point.lat : lat} + lng={point ? point.lng : lng} + onUpdate={(lat, lng) => { + point = { lat, lng }; + mapElement?.addClipMapMarker(lng, lat); + }} + /> + </div> </div> - </div> + {/snippet} </ConfirmDialog> diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index c89d0d34f2d90..b17644f137360 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export type ComboBoxOption = { id?: string; label: string; @@ -30,12 +30,23 @@ import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; - export let label: string; - export let hideLabel = false; - export let options: ComboBoxOption[] = []; - export let selectedOption: ComboBoxOption | undefined = undefined; - export let placeholder = ''; - export let onSelect: (option: ComboBoxOption | undefined) => void = () => {}; + interface Props { + label: string; + hideLabel?: boolean; + options?: ComboBoxOption[]; + selectedOption?: ComboBoxOption | undefined; + placeholder?: string; + onSelect?: (option: ComboBoxOption | undefined) => void; + } + + let { + label, + hideLabel = false, + options = [], + selectedOption = $bindable(), + placeholder = '', + onSelect = () => {}, + }: Props = $props(); /** * Unique identifier for the combobox. @@ -44,17 +55,16 @@ /** * Indicates whether or not the dropdown autocomplete list should be visible. */ - let isOpen = false; + let isOpen = $state(false); /** * Keeps track of whether the combobox is actively being used. */ - let isActive = false; - let searchQuery = selectedOption?.label || ''; - let selectedIndex: number | undefined; - let optionRefs: HTMLElement[] = []; - let input: HTMLInputElement; - let bounds: DOMRect | undefined; - let dropdownDirection: 'bottom' | 'top' = 'bottom'; + let isActive = $state(false); + let searchQuery = $state(selectedOption?.label || ''); + let selectedIndex: number | undefined = $state(); + let optionRefs: HTMLElement[] = $state([]); + let input = $state<HTMLInputElement>(); + let bounds: DOMRect | undefined = $state(); const inputId = `combobox-${id}`; const listboxId = `listbox-${id}`; @@ -76,17 +86,12 @@ { threshold: 0.5 }, ); - $: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); - - $: { - searchQuery = selectedOption ? selectedOption.label : ''; - } - - $: position = calculatePosition(bounds); - onMount(() => { + if (!input) { + return; + } observer.observe(input); - const scrollableAncestor = input.closest('.overflow-y-auto, .overflow-y-scroll'); + const scrollableAncestor = input?.closest('.overflow-y-auto, .overflow-y-scroll'); scrollableAncestor?.addEventListener('scroll', onPositionChange); window.visualViewport?.addEventListener('resize', onPositionChange); window.visualViewport?.addEventListener('scroll', onPositionChange); @@ -157,7 +162,6 @@ const calculatePosition = (boundary: DOMRect | undefined) => { const visualViewport = window.visualViewport; - dropdownDirection = getComboboxDirection(boundary, visualViewport); if (!boundary) { return; @@ -212,9 +216,19 @@ }; const getInputPosition = () => input?.getBoundingClientRect(); + + $effect(() => { + // searchQuery = selectedOption ? selectedOption.label : ''; + }); + + let filteredOptions = $derived( + options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())), + ); + let position = $derived(calculatePosition(bounds)); + let dropdownDirection: 'bottom' | 'top' = $derived(getComboboxDirection(bounds, visualViewport)); </script> -<svelte:window on:resize={onPositionChange} /> +<svelte:window onresize={onPositionChange} /> <label class="immich-form-label" class:sr-only={hideLabel} for={inputId}>{label}</label> <div class="relative w-full dark:text-gray-300 text-gray-700 text-base" @@ -252,9 +266,9 @@ class:cursor-pointer={!isActive} class="immich-form-input text-sm text-left w-full !pr-12 transition-all" id={inputId} - on:click={activate} - on:focus={activate} - on:input={onInput} + onclick={activate} + onfocus={activate} + oninput={onInput} role="combobox" type="text" value={searchQuery} @@ -304,7 +318,7 @@ class:pointer-events-none={!selectedOption} > {#if selectedOption} - <CircleIconButton on:click={onClear} title={$t('clear_value')} icon={mdiClose} size="16" padding="2" /> + <CircleIconButton onclick={onClear} title={$t('clear_value')} icon={mdiClose} size="16" padding="2" /> {:else if !isOpen} <Icon path={mdiUnfoldMoreHorizontal} ariaHidden={true} /> {/if} @@ -329,26 +343,26 @@ > {#if isOpen} {#if filteredOptions.length === 0} - <!-- svelte-ignore a11y-click-events-have-key-events --> + <!-- svelte-ignore a11y_click_events_have_key_events --> <li role="option" aria-selected={selectedIndex === 0} aria-disabled={true} class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700" id={`${listboxId}-${0}`} - on:click={() => closeDropdown()} + onclick={() => closeDropdown()} > {$t('no_results')} </li> {/if} {#each filteredOptions as option, index (option.id || option.label)} - <!-- svelte-ignore a11y-click-events-have-key-events --> + <!-- svelte-ignore a11y_click_events_have_key_events --> <li aria-selected={index === selectedIndex} bind:this={optionRefs[index]} class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words" id={`${listboxId}-${index}`} - on:click={() => handleSelect(option)} + onclick={() => handleSelect(option)} role="option" > {option.label} diff --git a/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte b/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte index f1ee93cc50726..46dc17b9ad6b3 100644 --- a/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte @@ -14,41 +14,52 @@ import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store'; import { clickOutside } from '$lib/actions/click-outside'; import { shortcuts } from '$lib/actions/shortcut'; + import type { Snippet } from 'svelte'; - export let icon: string; - export let title: string; - /** - * The alignment of the context menu relative to the button. - */ - export let align: Align = 'top-left'; - /** - * The direction in which the context menu should open. - */ - export let direction: 'left' | 'right' = 'right'; - export let color: Color = 'transparent'; - export let size: string | undefined = undefined; - export let padding: Padding | undefined = undefined; - /** - * Additional classes to apply to the button. - */ - export let buttonClass: string | undefined = undefined; - export let hideContent = false; + interface Props { + icon: string; + title: string; + /** + * The alignment of the context menu relative to the button. + */ + align?: Align; + /** + * The direction in which the context menu should open. + */ + direction?: 'left' | 'right'; + color?: Color; + size?: string | undefined; + padding?: Padding | undefined; + /** + * Additional classes to apply to the button. + */ + buttonClass?: string | undefined; + hideContent?: boolean; + children?: Snippet; + } + + let { + icon, + title, + align = 'top-left', + direction = 'right', + color = 'transparent', + size = undefined, + padding = undefined, + buttonClass = undefined, + hideContent = false, + children, + }: Props = $props(); - let isOpen = false; - let contextMenuPosition = { x: 0, y: 0 }; - let menuContainer: HTMLUListElement; - let buttonContainer: HTMLDivElement; + let isOpen = $state(false); + let contextMenuPosition = $state({ x: 0, y: 0 }); + let menuContainer: HTMLUListElement | undefined = $state(); + let buttonContainer: HTMLDivElement | undefined = $state(); const id = generateId(); const buttonId = `context-menu-button-${id}`; const menuId = `context-menu-${id}`; - $: { - if (isOpen) { - $optionClickCallbackStore = handleOptionClick; - } - } - const openDropdown = (event: KeyboardEvent | MouseEvent) => { contextMenuPosition = getContextMenuPositionFromEvent(event, align); isOpen = true; @@ -72,9 +83,10 @@ }; const onResize = () => { - if (!isOpen) { + if (!isOpen || !buttonContainer) { return; } + contextMenuPosition = getContextMenuPositionFromBoundingRect(buttonContainer.getBoundingClientRect(), align); }; @@ -92,12 +104,19 @@ }; const focusButton = () => { - const button: HTMLButtonElement | null = buttonContainer.querySelector(`#${buttonId}`); + const button = buttonContainer?.querySelector(`#${buttonId}`) as HTMLButtonElement | null; button?.focus(); }; + + $effect(() => { + if (isOpen) { + $optionClickCallbackStore = handleOptionClick; + } + }); </script> -<svelte:window on:resize={onResize} /> +<svelte:window onresize={onResize} /> + <div use:contextMenuNavigation={{ closeDropdown, @@ -109,7 +128,7 @@ selectionChanged: (id) => ($selectedIdStore = id), }} use:clickOutside={{ onOutclick: closeDropdown }} - on:resize={onResize} + onresize={onResize} > <div bind:this={buttonContainer}> <CircleIconButton @@ -123,7 +142,7 @@ aria-haspopup={true} class={buttonClass} id={buttonId} - on:click={handleClick} + onclick={handleClick} /> </div> {#if isOpen || !hideContent} @@ -150,7 +169,7 @@ id={menuId} isVisible={isOpen} > - <slot /> + {@render children?.()} </ContextMenu> </div> {/if} diff --git a/web/src/lib/components/shared-components/context-menu/context-menu.svelte b/web/src/lib/components/shared-components/context-menu/context-menu.svelte index 8f5ebfa2cfedc..aff583d1fc575 100644 --- a/web/src/lib/components/shared-components/context-menu/context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/context-menu.svelte @@ -2,27 +2,44 @@ import { quintOut } from 'svelte/easing'; import { slide } from 'svelte/transition'; import { clickOutside } from '$lib/actions/click-outside'; + import type { Snippet } from 'svelte'; - export let isVisible: boolean = false; - export let direction: 'left' | 'right' = 'right'; - export let x = 0; - export let y = 0; - export let id: string | undefined = undefined; - export let ariaLabel: string | undefined = undefined; - export let ariaLabelledBy: string | undefined = undefined; - export let ariaActiveDescendant: string | undefined = undefined; + interface Props { + isVisible?: boolean; + direction?: 'left' | 'right'; + x?: number; + y?: number; + id?: string | undefined; + ariaLabel?: string | undefined; + ariaLabelledBy?: string | undefined; + ariaActiveDescendant?: string | undefined; + menuElement?: HTMLUListElement | undefined; + onClose?: (() => void) | undefined; + children?: Snippet; + } - export let menuElement: HTMLUListElement | undefined = undefined; - export let onClose: (() => void) | undefined = undefined; + let { + isVisible = false, + direction = 'right', + x = 0, + y = 0, + id = undefined, + ariaLabel = undefined, + ariaLabelledBy = undefined, + ariaActiveDescendant = undefined, + menuElement = $bindable(), + onClose = undefined, + children, + }: Props = $props(); - let left: number; - let top: number; + let left: number = $state(0); + let top: number = $state(0); // We need to bind clientHeight since the bounding box may return a height // of zero when starting the 'slide' animation. - let height: number; + let height: number = $state(0); - $: { + $effect(() => { if (menuElement) { const rect = menuElement.getBoundingClientRect(); const directionWidth = direction === 'left' ? rect.width : 0; @@ -31,7 +48,7 @@ left = Math.min(window.innerWidth - rect.width, x - directionWidth); top = Math.min(window.innerHeight - menuHeight, y); } - } + }); </script> <div @@ -54,6 +71,6 @@ role="menu" tabindex="-1" > - <slot /> + {@render children?.()} </ul> </div> diff --git a/web/src/lib/components/shared-components/context-menu/menu-option.svelte b/web/src/lib/components/shared-components/context-menu/menu-option.svelte index e7ff4c626ee60..5d3c29dc3ce41 100644 --- a/web/src/lib/components/shared-components/context-menu/menu-option.svelte +++ b/web/src/lib/components/shared-components/context-menu/menu-option.svelte @@ -3,16 +3,27 @@ import { generateId } from '$lib/utils/generate-id'; import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store'; - export let text: string; - export let subtitle = ''; - export let icon = ''; - export let activeColor = 'bg-slate-300'; - export let textColor = 'text-immich-fg dark:text-immich-dark-bg'; - export let onClick: () => void; + interface Props { + text: string; + subtitle?: string; + icon?: string; + activeColor?: string; + textColor?: string; + onClick: () => void; + } + + let { + text, + subtitle = '', + icon = '', + activeColor = 'bg-slate-300', + textColor = 'text-immich-fg dark:text-immich-dark-bg', + onClick, + }: Props = $props(); let id: string = generateId(); - $: isActive = $selectedIdStore === id; + let isActive = $derived($selectedIdStore === id); const handleClick = () => { $optionClickCallbackStore?.(); @@ -20,13 +31,13 @@ }; </script> -<!-- svelte-ignore a11y-click-events-have-key-events --> -<!-- svelte-ignore a11y-mouse-events-have-key-events --> +<!-- svelte-ignore a11y_click_events_have_key_events --> +<!-- svelte-ignore a11y_mouse_events_have_key_events --> <li {id} - on:click={handleClick} - on:mouseover={() => ($selectedIdStore = id)} - on:mouseleave={() => ($selectedIdStore = undefined)} + onclick={handleClick} + onmouseover={() => ($selectedIdStore = id)} + onmouseleave={() => ($selectedIdStore = undefined)} class="w-full p-4 text-left text-sm font-medium {textColor} focus:outline-none focus:ring-2 focus:ring-inset cursor-pointer border-gray-200 flex gap-2 items-center {isActive ? activeColor : 'bg-slate-100'}" diff --git a/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte b/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte index f0b0408ff9950..f0d8f0213a70e 100644 --- a/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte @@ -1,33 +1,30 @@ <script lang="ts"> - import { tick } from 'svelte'; + import { tick, type Snippet } from 'svelte'; import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte'; import { shortcuts } from '$lib/actions/shortcut'; import { generateId } from '$lib/utils/generate-id'; import { contextMenuNavigation } from '$lib/actions/context-menu-navigation'; import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store'; - export let title: string; - export let direction: 'left' | 'right' = 'right'; - export let x = 0; - export let y = 0; - export let isOpen = false; - export let onClose: (() => unknown) | undefined; + interface Props { + title: string; + direction?: 'left' | 'right'; + x?: number; + y?: number; + isOpen?: boolean; + onClose: (() => unknown) | undefined; + children?: Snippet; + } + + let { title, direction = 'right', x = 0, y = 0, isOpen = false, onClose, children }: Props = $props(); - let uniqueKey = {}; - let menuContainer: HTMLUListElement; - let triggerElement: HTMLElement | undefined = undefined; + let uniqueKey = $state({}); + let menuContainer: HTMLUListElement | undefined = $state(); + let triggerElement: HTMLElement | undefined = $state(undefined); const id = generateId(); const menuId = `context-menu-${id}`; - $: { - if (isOpen && menuContainer) { - triggerElement = document.activeElement as HTMLElement; - menuContainer.focus(); - $optionClickCallbackStore = closeContextMenu; - } - } - const reopenContextMenu = async (event: MouseEvent) => { const contextMenuEvent = new MouseEvent('contextmenu', { bubbles: true, @@ -39,7 +36,7 @@ const elements = document.elementsFromPoint(event.x, event.y); - if (elements.includes(menuContainer)) { + if (menuContainer && elements.includes(menuContainer)) { // User right-clicked on the context menu itself, we keep the context // menu as is return; @@ -58,6 +55,18 @@ triggerElement?.focus(); onClose?.(); }; + $effect(() => { + if (isOpen && menuContainer) { + triggerElement = document.activeElement as HTMLElement; + menuContainer.focus(); + $optionClickCallbackStore = closeContextMenu; + } + }); + + const oncontextmenu = async (event: MouseEvent) => { + event.preventDefault(); + await reopenContextMenu(event); + }; </script> {#key uniqueKey} @@ -81,11 +90,7 @@ }, ]} > - <section - class="fixed left-0 top-0 z-10 flex h-screen w-screen" - on:contextmenu|preventDefault={reopenContextMenu} - role="presentation" - > + <section class="fixed left-0 top-0 z-10 flex h-screen w-screen" {oncontextmenu} role="presentation"> <ContextMenu {direction} {x} @@ -97,7 +102,7 @@ isVisible onClose={closeContextMenu} > - <slot /> + {@render children?.()} </ContextMenu> </section> </div> diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index 228cd88a86e75..c78edaa601060 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -1,23 +1,39 @@ <script lang="ts"> import { browser } from '$app/environment'; - import { onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount, type Snippet } from 'svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import { fly } from 'svelte/transition'; import { mdiClose } from '@mdi/js'; import { isSelectingAllAssets } from '$lib/stores/assets.store'; import { t } from 'svelte-i18n'; - export let showBackButton = true; - export let backIcon = mdiClose; - export let tailwindClasses = ''; - export let forceDark = false; - export let onClose: () => void = () => {}; + interface Props { + showBackButton?: boolean; + backIcon?: string; + tailwindClasses?: string; + forceDark?: boolean; + onClose?: () => void; + leading?: Snippet; + children?: Snippet; + trailing?: Snippet; + } - let appBarBorder = 'bg-immich-bg border border-transparent'; + let { + showBackButton = true, + backIcon = mdiClose, + tailwindClasses = '', + forceDark = false, + onClose = () => {}, + leading, + children, + trailing, + }: Props = $props(); + + let appBarBorder = $state('bg-immich-bg border border-transparent'); const onScroll = () => { - if (window.pageYOffset > 80) { + if (window.scrollY > 80) { appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600'; if (forceDark) { @@ -45,7 +61,7 @@ } }); - $: buttonClass = forceDark ? 'hover:text-immich-dark-gray' : undefined; + let buttonClass = $derived(forceDark ? 'hover:text-immich-dark-gray' : undefined); </script> <div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent"> @@ -57,17 +73,17 @@ > <div class="flex place-items-center sm:gap-6 justify-self-start dark:text-immich-dark-fg"> {#if showBackButton} - <CircleIconButton title={$t('close')} on:click={handleClose} icon={backIcon} size={'24'} class={buttonClass} /> + <CircleIconButton title={$t('close')} onclick={handleClose} icon={backIcon} size={'24'} class={buttonClass} /> {/if} - <slot name="leading" /> + {@render leading?.()} </div> <div class="w-full"> - <slot /> + {@render children?.()} </div> <div class="mr-4 flex place-items-center gap-1 justify-self-end"> - <slot name="trailing" /> + {@render trailing?.()} </div> </div> </div> diff --git a/web/src/lib/components/shared-components/coordinates-input.svelte b/web/src/lib/components/shared-components/coordinates-input.svelte index f5ad120a7bec3..d39cea2fd1bd1 100644 --- a/web/src/lib/components/shared-components/coordinates-input.svelte +++ b/web/src/lib/components/shared-components/coordinates-input.svelte @@ -3,9 +3,13 @@ import { generateId } from '$lib/utils/generate-id'; import { t } from 'svelte-i18n'; - export let lat: number | null | undefined = undefined; - export let lng: number | null | undefined = undefined; - export let onUpdate: (lat: number, lng: number) => void; + interface Props { + lat?: number; + lng?: number; + onUpdate: (lat: number, lng: number) => void; + } + + let { lat = $bindable(), lng = $bindable(), onUpdate }: Props = $props(); const id = generateId(); diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte index ea5f801e29829..443e8f06b1d47 100644 --- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte +++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte @@ -8,29 +8,40 @@ import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk'; import { mdiContentCopy, mdiLink } from '@mdi/js'; import { NotificationType, notificationController } from '../notification/notification'; - import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte'; + import SettingInputField from '../settings/setting-input-field.svelte'; import SettingSwitch from '../settings/setting-switch.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import { t } from 'svelte-i18n'; import { locale } from '$lib/stores/preferences.store'; import { DateTime, Duration } from 'luxon'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; + import { SettingInputFieldType } from '$lib/constants'; - export let onClose: () => void; - export let albumId: string | undefined = undefined; - export let assetIds: string[] = []; - export let editingLink: SharedLinkResponseDto | undefined = undefined; - export let onCreated: () => void = () => {}; - - let sharedLink: string | null = null; - let description = ''; - let allowDownload = true; - let allowUpload = false; - let showMetadata = true; - let expirationOption: number = 0; - let password = ''; - let shouldChangeExpirationTime = false; - let enablePassword = false; + interface Props { + onClose: () => void; + albumId?: string | undefined; + assetIds?: string[]; + editingLink?: SharedLinkResponseDto | undefined; + onCreated?: () => void; + } + + let { + onClose, + albumId = $bindable(undefined), + assetIds = $bindable([]), + editingLink = undefined, + onCreated = () => {}, + }: Props = $props(); + + let sharedLink: string | null = $state(null); + let description = $state(''); + let allowDownload = $state(true); + let allowUpload = $state(false); + let showMetadata = $state(true); + let expirationOption: number = $state(0); + let password = $state(''); + let shouldChangeExpirationTime = $state(false); + let enablePassword = $state(false); const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [ [30, 'minutes'], @@ -43,22 +54,23 @@ [1, 'year'], ]; - $: relativeTime = new Intl.RelativeTimeFormat($locale); - $: expiredDateOptions = [ + let relativeTime = $derived(new Intl.RelativeTimeFormat($locale)); + let expiredDateOptions = $derived([ { text: $t('never'), value: 0 }, ...expirationOptions.map(([value, unit]) => ({ text: relativeTime.format(value, unit), value: Duration.fromObject({ [unit]: value }).toMillis(), })), - ]; + ]); - // svelte-ignore reactive_declaration_non_reactive_property - $: shareType = albumId ? SharedLinkType.Album : SharedLinkType.Individual; - $: { + let shareType = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual); + + $effect(() => { if (!showMetadata) { allowDownload = false; } - } + }); + if (editingLink) { if (editingLink.description) { description = editingLink.description; @@ -223,22 +235,22 @@ </div> </section> - <svelte:fragment slot="sticky-bottom"> + {#snippet stickyBottom()} {#if !sharedLink} {#if editingLink} - <Button size="sm" fullwidth on:click={handleEditLink}>{$t('confirm')}</Button> + <Button size="sm" fullwidth onclick={handleEditLink}>{$t('confirm')}</Button> {:else} - <Button size="sm" fullwidth on:click={handleCreateSharedLink}>{$t('create_link')}</Button> + <Button size="sm" fullwidth onclick={handleCreateSharedLink}>{$t('create_link')}</Button> {/if} {:else} <div class="flex w-full gap-2"> <input class="immich-form-input w-full" bind:value={sharedLink} disabled /> - <LinkButton on:click={() => (sharedLink ? copyToClipboard(sharedLink) : '')}> + <LinkButton onclick={() => (sharedLink ? copyToClipboard(sharedLink) : '')}> <div class="flex place-items-center gap-2 text-sm"> <Icon path={mdiContentCopy} ariaLabel={$t('copy_link_to_clipboard')} size="18" /> </div> </LinkButton> </div> {/if} - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte index 50d5fe56ce31f..3efc56dc4166b 100644 --- a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte +++ b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte @@ -3,18 +3,37 @@ import Button from '../../elements/buttons/button.svelte'; import type { Color } from '$lib/components/elements/buttons/button.svelte'; import { t } from 'svelte-i18n'; + import type { Snippet } from 'svelte'; - export let title = $t('confirm'); - export let prompt = $t('are_you_sure_to_do_this'); - export let confirmText = $t('confirm'); - export let confirmColor: Color = 'red'; - export let cancelText = $t('cancel'); - export let cancelColor: Color = 'secondary'; - export let hideCancelButton = false; - export let disabled = false; - export let width: 'wide' | 'narrow' = 'narrow'; - export let onCancel: () => void; - export let onConfirm: () => void; + interface Props { + title?: string; + prompt?: string; + confirmText?: string; + confirmColor?: Color; + cancelText?: string; + cancelColor?: Color; + hideCancelButton?: boolean; + disabled?: boolean; + width?: 'wide' | 'narrow'; + onCancel: () => void; + onConfirm: () => void; + promptSnippet?: Snippet; + } + + let { + title = $t('confirm'), + prompt = $t('are_you_sure_to_do_this'), + confirmText = $t('confirm'), + confirmColor = 'red', + cancelText = $t('cancel'), + cancelColor = 'secondary', + hideCancelButton = false, + disabled = false, + width = 'narrow', + onCancel, + onConfirm, + promptSnippet, + }: Props = $props(); const handleConfirm = () => { onConfirm(); @@ -23,19 +42,19 @@ <FullScreenModal {title} onClose={onCancel} {width}> <div class="text-md py-5 text-center"> - <slot name="prompt"> + {#if promptSnippet}{@render promptSnippet()}{:else} <p>{prompt}</p> - </slot> + {/if} </div> - <svelte:fragment slot="sticky-bottom"> + {#snippet stickyBottom()} {#if !hideCancelButton} - <Button color={cancelColor} fullwidth on:click={onCancel}> + <Button color={cancelColor} fullwidth onclick={onCancel}> {cancelText} </Button> {/if} - <Button color={confirmColor} fullwidth on:click={handleConfirm} {disabled}> + <Button color={confirmColor} fullwidth onclick={handleConfirm} {disabled}> {confirmText} </Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte index 6f92d81886485..620064ca1e86c 100644 --- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte +++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte @@ -8,10 +8,10 @@ import { fade } from 'svelte/transition'; import ImmichLogo from './immich-logo.svelte'; - $: albumId = isAlbumsRoute($page.route?.id) ? $page.params.albumId : undefined; - $: isShare = isSharedLinkRoute($page.route?.id); + let albumId = $derived(isAlbumsRoute($page.route?.id) ? $page.params.albumId : undefined); + let isShare = $derived(isSharedLinkRoute($page.route?.id)); - let dragStartTarget: EventTarget | null = null; + let dragStartTarget: EventTarget | null = $state(null); const onDragEnter = (e: DragEvent) => { if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { @@ -117,26 +117,41 @@ await fileUploadHandler(filesArray, albumId); } }; + + const ondragenter = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDragEnter(e); + }; + + const ondragleave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDragLeave(e); + }; + + const ondrop = async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + await onDrop(e); + }; + + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; </script> -<svelte:window on:paste={onPaste} /> +<svelte:window onpaste={onPaste} /> -<svelte:body - on:dragenter|stopPropagation|preventDefault={onDragEnter} - on:dragleave|stopPropagation|preventDefault={onDragLeave} - on:drop|stopPropagation|preventDefault={onDrop} -/> +<svelte:body {ondragenter} {ondragleave} {ondrop} /> {#if dragStartTarget} - <!-- svelte-ignore a11y-no-static-element-interactions --> + <!-- svelte-ignore a11y_no_static_element_interactions --> <div class="fixed inset-0 z-[1000] flex h-full w-full flex-col items-center justify-center bg-gray-100/90 text-immich-dark-gray dark:bg-immich-dark-bg/90 dark:text-immich-gray" transition:fade={{ duration: 250 }} - on:dragover={(e) => { - // Prevent browser from opening the dropped file. - e.stopPropagation(); - e.preventDefault(); - }} + ondragover={onDragOver} > <ImmichLogo noText class="m-16 w-48 animate-bounce" /> <div class="text-2xl">{$t('drop_files_to_upload')}</div> diff --git a/web/src/lib/components/shared-components/empty-placeholder.svelte b/web/src/lib/components/shared-components/empty-placeholder.svelte index 781f7821f1f96..922d7ad92fd01 100644 --- a/web/src/lib/components/shared-components/empty-placeholder.svelte +++ b/web/src/lib/components/shared-components/empty-placeholder.svelte @@ -1,22 +1,26 @@ <script lang="ts"> import empty1Url from '$lib/assets/empty-1.svg'; - export let onClick: undefined | (() => unknown) = undefined; - export let text: string; - export let fullWidth = false; - export let src = empty1Url; + interface Props { + onClick?: undefined | (() => unknown); + text: string; + fullWidth?: boolean; + src?: string; + } - $: width = fullWidth ? 'w-full' : 'w-1/2'; + let { onClick = undefined, text, fullWidth = false, src = empty1Url }: Props = $props(); + + let width = $derived(fullWidth ? 'w-full' : 'w-1/2'); const hoverClasses = onClick ? `border dark:border-immich-dark-gray hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25` : ''; </script> -<!-- svelte-ignore a11y-no-static-element-interactions --> +<!-- svelte-ignore a11y_no_static_element_interactions --> <svelte:element this={onClick ? 'button' : 'div'} - on:click={onClick} + onclick={onClick} class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}" > <img {src} alt="" width="500" draggable="false" /> diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index ab851552762a2..1263aed03bfba 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -4,36 +4,52 @@ import { fade } from 'svelte/transition'; import ModalHeader from '$lib/components/shared-components/modal-header.svelte'; import { generateId } from '$lib/utils/generate-id'; + import type { Snippet } from 'svelte'; - export let onClose: () => void; - export let title: string; - /** - * If true, the logo will be displayed next to the modal title. - */ - export let showLogo = false; - /** - * Optional icon to display next to the modal title, if `showLogo` is false. - */ - export let icon: string | undefined = undefined; - /** - * Sets the width of the modal. - * - * - `wide`: 48rem - * - `narrow`: 28rem - * - `auto`: fits the width of the modal content, up to a maximum of 32rem - */ - export let width: 'extra-wide' | 'wide' | 'narrow' | 'auto' = 'narrow'; + interface Props { + onClose: () => void; + title: string; + /** + * If true, the logo will be displayed next to the modal title. + */ + showLogo?: boolean; + /** + * Optional icon to display next to the modal title, if `showLogo` is false. + */ + icon?: string | undefined; + /** + * Sets the width of the modal. + * + * - `wide`: 48rem + * - `narrow`: 28rem + * - `auto`: fits the width of the modal content, up to a maximum of 32rem + */ + width?: 'extra-wide' | 'wide' | 'narrow' | 'auto'; + stickyBottom?: Snippet; + children?: Snippet; + } + + let { + onClose, + title, + showLogo = false, + icon = undefined, + width = 'narrow', + stickyBottom, + children, + }: Props = $props(); /** * Unique identifier for the modal. */ let id: string = generateId(); - $: titleId = `${id}-title`; - $: isStickyBottom = !!$$slots['sticky-bottom']; + let titleId = $derived(`${id}-title`); + let isStickyBottom = $derived(!!stickyBottom); - let modalWidth: string; - $: { + let modalWidth = $state<string>(); + + $effect(() => { switch (width) { case 'extra-wide': { modalWidth = 'w-[56rem]'; @@ -54,7 +70,7 @@ modalWidth = 'sm:max-w-4xl'; } } - } + }); </script> <section @@ -62,7 +78,7 @@ in:fade={{ duration: 100 }} out:fade={{ duration: 100 }} class="fixed left-0 top-0 z-[9999] flex h-dvh w-screen place-content-center place-items-center bg-black/40" - on:keydown={(event) => { + onkeydown={(event) => { event.stopPropagation(); }} use:focusTrap @@ -77,14 +93,14 @@ <div class="immich-scrollbar overflow-y-auto pt-1" class:pb-4={isStickyBottom}> <ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} /> <div class="px-5 pt-0 mb-5"> - <slot /> + {@render children?.()} </div> </div> {#if isStickyBottom} <div class="flex flex-col sm:flex-row justify-end w-full gap-2 sm:gap-4 sticky pt-4 px-5 bg-immich-bg dark:bg-immich-dark-gray border-t border-gray-200 dark:border-gray-500" > - <slot name="sticky-bottom" /> + {@render stickyBottom?.()} </div> {/if} </div> diff --git a/web/src/lib/components/shared-components/fullscreen-container.svelte b/web/src/lib/components/shared-components/fullscreen-container.svelte index 6d577f60bd30a..64ee41a2255cc 100644 --- a/web/src/lib/components/shared-components/fullscreen-container.svelte +++ b/web/src/lib/components/shared-components/fullscreen-container.svelte @@ -1,8 +1,15 @@ <script lang="ts"> + import type { Snippet } from 'svelte'; import ImmichLogo from './immich-logo.svelte'; - export let title: string; - export let showMessage = $$slots.message; + interface Props { + title: string; + message?: Snippet; + showMessage?: boolean; + children?: Snippet; + } + + let { title, message, showMessage = message != undefined, children }: Props = $props(); </script> <section class="min-w-screen flex min-h-screen place-content-center place-items-center p-4"> @@ -20,10 +27,10 @@ <div class="w-full rounded-xl border-2 border-immich-primary bg-immich-primary/5 p-4 text-sm font-medium text-immich-primary dark:border-immich-dark-bg dark:text-immich-dark-primary" > - <slot name="message" /> + {@render message?.()} </div> {/if} - <slot /> + {@render children?.()} </div> </section> diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index b595a6bb620d5..aa84bd69f0846 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -17,20 +17,34 @@ import Portal from '../portal/portal.svelte'; import { handlePromiseError } from '$lib/utils'; - export let assets: AssetResponseDto[]; - export let selectedAssets: Set<AssetResponseDto> = new Set(); - export let disableAssetSelect = false; - export let showArchiveIcon = false; - export let viewport: Viewport; - export let onIntersected: (() => void) | undefined = undefined; - export let showAssetName = false; - export let onPrevious: (() => Promise<AssetResponseDto | undefined>) | undefined = undefined; - export let onNext: (() => Promise<AssetResponseDto | undefined>) | undefined = undefined; + interface Props { + assets: AssetResponseDto[]; + selectedAssets?: Set<AssetResponseDto>; + disableAssetSelect?: boolean; + showArchiveIcon?: boolean; + viewport: Viewport; + onIntersected?: (() => void) | undefined; + showAssetName?: boolean; + onPrevious?: (() => Promise<AssetResponseDto | undefined>) | undefined; + onNext?: (() => Promise<AssetResponseDto | undefined>) | undefined; + } + + let { + assets = $bindable(), + selectedAssets = $bindable(new Set()), + disableAssetSelect = false, + showArchiveIcon = false, + viewport, + onIntersected = undefined, + showAssetName = false, + onPrevious = undefined, + onNext = undefined, + }: Props = $props(); let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; let currentViewAssetIndex = 0; - $: isMultiSelectionMode = selectedAssets.size > 0; + let isMultiSelectionMode = $derived(selectedAssets.size > 0); const viewAssetHandler = async (asset: AssetResponseDto) => { currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id); @@ -100,23 +114,25 @@ $isViewerOpen = false; }); - $: geometry = (() => { - const justifiedLayoutResult = justifiedLayout( - assets.map((asset) => getAssetRatio(asset)), - { - boxSpacing: 2, - containerWidth: Math.floor(viewport.width), - containerPadding: 0, - targetRowHeightTolerance: 0.15, - targetRowHeight: 235, - }, - ); - - return { - ...justifiedLayoutResult, - containerWidth: calculateWidth(justifiedLayoutResult.boxes), - }; - })(); + let geometry = $derived( + (() => { + const justifiedLayoutResult = justifiedLayout( + assets.map((asset) => getAssetRatio(asset)), + { + boxSpacing: 2, + containerWidth: Math.floor(viewport.width), + containerPadding: 0, + targetRowHeightTolerance: 0.15, + targetRowHeight: 235, + }, + ); + + return { + ...justifiedLayoutResult, + containerWidth: calculateWidth(justifiedLayoutResult.boxes), + }; + })(), + ); </script> {#if assets.length > 0} diff --git a/web/src/lib/components/shared-components/help-and-feedback-modal.svelte b/web/src/lib/components/shared-components/help-and-feedback-modal.svelte index 19e12a51f9eea..c122e0f23e8a2 100644 --- a/web/src/lib/components/shared-components/help-and-feedback-modal.svelte +++ b/web/src/lib/components/shared-components/help-and-feedback-modal.svelte @@ -7,9 +7,12 @@ import { mdiBugOutline, mdiFaceAgent, mdiGit, mdiGithub, mdiInformationOutline } from '@mdi/js'; import { discordPath } from '$lib/assets/svg-paths'; - export let onClose: () => void; + interface Props { + onClose: () => void; + info: ServerAboutResponseDto; + } - export let info: ServerAboutResponseDto; + let { onClose, info }: Props = $props(); </script> <Portal> diff --git a/web/src/lib/components/shared-components/immich-logo-small-link.svelte b/web/src/lib/components/shared-components/immich-logo-small-link.svelte index 9f1dd9714e2d4..cd3149e6de124 100644 --- a/web/src/lib/components/shared-components/immich-logo-small-link.svelte +++ b/web/src/lib/components/shared-components/immich-logo-small-link.svelte @@ -1,7 +1,11 @@ <script lang="ts"> import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte'; - export let width: number; + interface Props { + width: number; + } + + let { width }: Props = $props(); </script> <a data-sveltekit-preload-data="hover" class="ml-4" href="/"> diff --git a/web/src/lib/components/shared-components/immich-logo.svelte b/web/src/lib/components/shared-components/immich-logo.svelte index 952960ef3fb9e..7046ea689ee4b 100644 --- a/web/src/lib/components/shared-components/immich-logo.svelte +++ b/web/src/lib/components/shared-components/immich-logo.svelte @@ -9,14 +9,12 @@ import type { HTMLImgAttributes } from 'svelte/elements'; import { t } from 'svelte-i18n'; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface $$Props extends HTMLImgAttributes { + interface Props extends HTMLImgAttributes { noText?: boolean; draggable?: boolean; } - export let noText = false; - export let draggable = false; + let { noText = false, draggable = false, ...rest }: Props = $props(); const today = DateTime.now().toLocal(); </script> @@ -28,6 +26,6 @@ src={noText ? logoNoText : $colorTheme.value == Theme.LIGHT ? logoLightUrl : logoDarkUrl} alt={$t('immich_logo')} {draggable} - {...$$restProps} + {...rest} /> {/if} diff --git a/web/src/lib/components/shared-components/loading-spinner.svelte b/web/src/lib/components/shared-components/loading-spinner.svelte index 48626a50f485a..e81d2225b74d8 100644 --- a/web/src/lib/components/shared-components/loading-spinner.svelte +++ b/web/src/lib/components/shared-components/loading-spinner.svelte @@ -1,5 +1,9 @@ <script lang="ts"> - export let size: string = '24'; + interface Props { + size?: string; + } + + let { size = '24' }: Props = $props(); </script> <div> diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index 85d927d166e63..7644064d9dd1e 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> void maplibregl.setRTLTextPlugin(mapboxRtlUrl, true); </script> @@ -6,12 +6,13 @@ import Icon from '$lib/components/elements/icon.svelte'; import { Theme } from '$lib/constants'; import { colorTheme, mapSettings } from '$lib/stores/preferences.store'; + import { serverConfig } from '$lib/stores/server-config.store'; import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; - import { getServerConfig, type MapMarkerResponseDto } from '@immich/sdk'; + 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, LngLatLike, StyleSpecification } from 'maplibre-gl'; + import type { GeoJSONSource, LngLatLike } from 'maplibre-gl'; import maplibregl from 'maplibre-gl'; import { t } from 'svelte-i18n'; import { @@ -30,14 +31,43 @@ type Map, } from 'svelte-maplibre'; - export let mapMarkers: MapMarkerResponseDto[]; - export let showSettingsModal: boolean | undefined = undefined; - export let zoom: number | undefined = undefined; - export let center: LngLatLike | undefined = undefined; - export let hash = false; - export let simplified = false; - export let clickable = false; - export let useLocationPin = false; + interface Props { + mapMarkers: MapMarkerResponseDto[]; + showSettingsModal?: boolean | undefined; + zoom?: number | undefined; + center?: LngLatLike | undefined; + hash?: boolean; + simplified?: boolean; + clickable?: boolean; + useLocationPin?: boolean; + onOpenInMapView?: (() => Promise<void> | void) | undefined; + onSelect?: (assetIds: string[]) => void; + onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void; + popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>; + } + + let { + mapMarkers = $bindable(), + showSettingsModal = $bindable(undefined), + zoom = undefined, + center = $bindable(undefined), + hash = false, + simplified = false, + clickable = false, + useLocationPin = false, + onOpenInMapView = undefined, + onSelect = () => {}, + onClickPoint = () => {}, + popup, + }: Props = $props(); + + let map: maplibregl.Map | undefined = $state(); + let marker: maplibregl.Marker | null = null; + + const theme = $derived($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT); + const styleUrl = $derived(theme === Theme.DARK ? $serverConfig.mapDarkStyleUrl : $serverConfig.mapLightStyleUrl); + const style = $derived(fetch(styleUrl).then((response) => response.json())); + export function addClipMapMarker(lng: number, lat: number) { if (map) { if (marker) { @@ -46,26 +76,9 @@ center = { lng, lat }; marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map); - map.setZoom(15); } } - export let onOpenInMapView: (() => Promise<void> | void) | undefined = undefined; - export let onSelect: (assetIds: string[]) => void = () => {}; - export let onClickPoint: ({ lat, lng }: { lat: number; lng: number }) => void = () => {}; - - let map: maplibregl.Map; - let marker: maplibregl.Marker | null = null; - - // svelte-ignore reactive_declaration_non_reactive_property - $: style = (async () => { - const config = await getServerConfig(); - const theme = $mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT; - const styleUrl = theme === Theme.DARK ? config.mapDarkStyleUrl : config.mapLightStyleUrl; - const style = await fetch(styleUrl).then((response) => response.json()); - return style as StyleSpecification; - })(); - function handleAssetClick(assetId: string, map: Map | null) { if (!map) { return; @@ -93,7 +106,9 @@ marker.remove(); } - marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map); + if (map) { + marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map); + } } } @@ -135,92 +150,96 @@ {zoom} attributionControl={false} diffStyleUpdates={true} - let:map on:load={(event) => event.detail.setMaxZoom(18)} on:load={(event) => event.detail.on('click', handleMapClick)} bind:map > - <NavigationControl position="top-left" showCompass={!simplified} /> - - {#if !simplified} - <GeolocateControl position="top-left" /> - <FullscreenControl position="top-left" /> - <ScaleControl /> - <AttributionControl compact={false} /> - {/if} - - {#if showSettingsModal !== undefined} - <Control> - <ControlGroup> - <ControlButton on:click={() => (showSettingsModal = true)}><Icon path={mdiCog} size="100%" /></ControlButton> - </ControlGroup> - </Control> - {/if} - - {#if onOpenInMapView} - <Control position="top-right"> - <ControlGroup> - <ControlButton on:click={() => onOpenInMapView()}> - <Icon title={$t('open_in_map_view')} path={mdiMap} size="100%" /> - </ControlButton> - </ControlGroup> - </Control> - {/if} - - <GeoJSON - data={{ - type: 'FeatureCollection', - features: mapMarkers.map((marker) => asFeature(marker)), - }} - id="geojson" - cluster={{ radius: 500, maxZoom: 24 }} - > - <MarkerLayer - applyToClusters - asButton - let:feature - on:click={(event) => handlePromiseError(handleClusterClick(event.detail.feature.properties?.cluster_id, map))} - > - <div - class="rounded-full w-[40px] h-[40px] bg-immich-primary text-immich-gray flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90" - > - {feature.properties?.point_count} - </div> - </MarkerLayer> - <MarkerLayer - applyToClusters={false} - asButton - let:feature - on:click={(event) => { - if (!$$slots.popup) { - handleAssetClick(event.detail.feature.properties?.id, map); - } + {#snippet children({ map }: { map: maplibregl.Map })} + <NavigationControl position="top-left" showCompass={!simplified} /> + + {#if !simplified} + <GeolocateControl position="top-left" /> + <FullscreenControl position="top-left" /> + <ScaleControl /> + <AttributionControl compact={false} /> + {/if} + + {#if showSettingsModal !== undefined} + <Control> + <ControlGroup> + <ControlButton on:click={() => (showSettingsModal = true)}><Icon path={mdiCog} size="100%" /></ControlButton + > + </ControlGroup> + </Control> + {/if} + + {#if onOpenInMapView} + <Control position="top-right"> + <ControlGroup> + <ControlButton on:click={() => onOpenInMapView()}> + <Icon title={$t('open_in_map_view')} path={mdiMap} size="100%" /> + </ControlButton> + </ControlGroup> + </Control> + {/if} + + <GeoJSON + data={{ + type: 'FeatureCollection', + features: mapMarkers.map((marker) => asFeature(marker)), }} + id="geojson" + cluster={{ radius: 500, maxZoom: 24 }} > - {#if useLocationPin} - <Icon - path={mdiMapMarker} - size="50px" - class="location-pin dark:text-immich-dark-primary text-immich-primary" - /> - {:else} - <img - src={getAssetThumbnailUrl(feature.properties?.id)} - class="rounded-full w-[60px] h-[60px] border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary" - alt={feature.properties?.city && feature.properties.country - ? $t('map_marker_for_images', { - values: { city: feature.properties.city, country: feature.properties.country }, - }) - : $t('map_marker_with_image')} - /> - {/if} - {#if $$slots.popup} - <Popup offset={[0, -30]} openOn="click" closeOnClickOutside> - <slot name="popup" marker={asMarker(feature)} /> - </Popup> - {/if} - </MarkerLayer> - </GeoJSON> + <MarkerLayer + applyToClusters + asButton + on:click={(event) => handlePromiseError(handleClusterClick(event.detail.feature.properties?.cluster_id, map))} + > + {#snippet children({ feature }: { feature: maplibregl.Feature })} + <div + class="rounded-full w-[40px] h-[40px] bg-immich-primary text-immich-gray flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90" + > + {feature.properties?.point_count} + </div> + {/snippet} + </MarkerLayer> + <MarkerLayer + applyToClusters={false} + asButton + on:click={(event) => { + if (!popup) { + handleAssetClick(event.detail.feature.properties?.id, map); + } + }} + > + {#snippet children({ feature }: { feature: Feature<Geometry, GeoJsonProperties> })} + {#if useLocationPin} + <Icon + path={mdiMapMarker} + size="50px" + class="location-pin dark:text-immich-dark-primary text-immich-primary" + /> + {:else} + <img + src={getAssetThumbnailUrl(feature.properties?.id)} + class="rounded-full w-[60px] h-[60px] border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary" + alt={feature.properties?.city && feature.properties.country + ? $t('map_marker_for_images', { + values: { city: feature.properties.city, country: feature.properties.country }, + }) + : $t('map_marker_with_image')} + /> + {/if} + {#if popup} + <Popup offset={[0, -30]} openOn="click" closeOnClickOutside> + {@render popup?.({ marker: asMarker(feature) })} + </Popup> + {/if} + {/snippet} + </MarkerLayer> + </GeoJSON> + {/snippet} </MapLibre> <style> .location-pin { diff --git a/web/src/lib/components/shared-components/modal-header.svelte b/web/src/lib/components/shared-components/modal-header.svelte index efd87b476cb68..53f3fbdabbb15 100644 --- a/web/src/lib/components/shared-components/modal-header.svelte +++ b/web/src/lib/components/shared-components/modal-header.svelte @@ -5,20 +5,24 @@ import { mdiClose } from '@mdi/js'; import { t } from 'svelte-i18n'; - /** - * Unique identifier for the header text. - */ - export let id: string; - export let title: string; - export let onClose: () => void; - /** - * If true, the logo will be displayed next to the modal title. - */ - export let showLogo = false; - /** - * Optional icon to display next to the modal title, if `showLogo` is false. - */ - export let icon: string | undefined = undefined; + interface Props { + /** + * Unique identifier for the header text. + */ + id: string; + title: string; + onClose: () => void; + /** + * If true, the logo will be displayed next to the modal title. + */ + showLogo?: boolean; + /** + * Optional icon to display next to the modal title, if `showLogo` is false. + */ + icon?: string; + } + + let { id, title, onClose, showLogo = false, icon = undefined }: Props = $props(); </script> <div class="flex place-items-center justify-between px-5 pb-3"> @@ -33,5 +37,5 @@ </h1> </div> - <CircleIconButton on:click={onClose} icon={mdiClose} size={'20'} title={$t('close')} /> + <CircleIconButton onclick={onClose} icon={mdiClose} size={'20'} title={$t('close')} /> </div> diff --git a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte index bf0ca26d6124a..478b43b19055b 100644 --- a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte @@ -15,10 +15,14 @@ import UserAvatar from '../user-avatar.svelte'; import AvatarSelector from './avatar-selector.svelte'; - export let onLogout: () => void; - export let onClose: () => void = () => {}; + interface Props { + onLogout: () => void; + onClose?: () => void; + } - let isShowSelectAvatar = false; + let { onLogout, onClose = () => {} }: Props = $props(); + + let isShowSelectAvatar = $state(false); const handleSaveProfile = async (color: UserAvatarColor) => { try { @@ -60,7 +64,7 @@ class="border" size="12" padding="2" - on:click={() => (isShowSelectAvatar = true)} + onclick={() => (isShowSelectAvatar = true)} /> </div> </div> @@ -72,7 +76,7 @@ </div> <div class="flex flex-col gap-1"> - <Button href={AppRoute.USER_SETTINGS} on:click={onClose} color="dark-gray" size="sm" shadow={false} border> + <Button href={AppRoute.USER_SETTINGS} onclick={onClose} color="dark-gray" size="sm" shadow={false} border> <div class="flex place-content-center place-items-center text-center gap-2 px-2"> <Icon path={mdiCog} size="18" ariaHidden /> {$t('account_settings')} @@ -81,7 +85,7 @@ {#if $user.isAdmin} <Button href={AppRoute.ADMIN_USER_MANAGEMENT} - on:click={onClose} + onclick={onClose} color="dark-gray" size="sm" shadow={false} @@ -101,7 +105,7 @@ <button type="button" class="flex w-full place-content-center place-items-center gap-2 py-3 font-medium text-gray-500 hover:bg-immich-primary/10 dark:text-gray-300" - on:click={onLogout} + onclick={onLogout} > <Icon path={mdiLogout} size={24} /> {$t('sign_out')}</button diff --git a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte index 77a6e3a2d03bc..d762c7ba88971 100644 --- a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte @@ -4,9 +4,13 @@ import FullScreenModal from '../full-screen-modal.svelte'; import UserAvatar from '../user-avatar.svelte'; - export let user: UserResponseDto; - export let onClose: () => void; - export let onChoose: (color: UserAvatarColor) => void; + interface Props { + user: UserResponseDto; + onClose: () => void; + onChoose: (color: UserAvatarColor) => void; + } + + let { user, onClose, onChoose }: Props = $props(); const colors: UserAvatarColor[] = Object.values(UserAvatarColor); </script> @@ -15,7 +19,7 @@ <div class="flex items-center justify-center mt-4"> <div class="grid grid-cols-2 md:grid-cols-5 gap-4"> {#each colors as color} - <button type="button" on:click={() => onChoose(color)}> + <button type="button" onclick={() => onChoose(color)}> <UserAvatar label={color} {user} {color} size="xl" showProfileImage={false} /> </button> {/each} diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 2f8d0e2574aa0..1bbf34316c8d5 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -21,20 +21,24 @@ import HelpAndFeedbackModal from '$lib/components/shared-components/help-and-feedback-modal.svelte'; import { onMount } from 'svelte'; - export let showUploadButton = true; - export let onUploadClick: () => void; + interface Props { + showUploadButton?: boolean; + onUploadClick: () => void; + } - let shouldShowAccountInfo = false; - let shouldShowAccountInfoPanel = false; - let shouldShowHelpPanel = false; - let innerWidth: number; + let { showUploadButton = true, onUploadClick }: Props = $props(); + + let shouldShowAccountInfo = $state(false); + let shouldShowAccountInfoPanel = $state(false); + let shouldShowHelpPanel = $state(false); + let innerWidth: number = $state(0); const onLogout = async () => { const { redirectUri } = await logout(); await handleLogout(redirectUri); }; - let aboutInfo: ServerAboutResponseDto; + let aboutInfo: ServerAboutResponseDto | undefined = $state(); onMount(async () => { aboutInfo = await getAboutInfo(); @@ -43,7 +47,7 @@ <svelte:window bind:innerWidth /> -{#if shouldShowHelpPanel} +{#if shouldShowHelpPanel && aboutInfo} <HelpAndFeedbackModal onClose={() => (shouldShowHelpPanel = false)} info={aboutInfo} /> {/if} @@ -71,6 +75,7 @@ title={$t('go_to_search')} icon={mdiMagnify} padding="2" + onclick={() => {}} /> {/if} @@ -85,20 +90,20 @@ id="support-feedback-button" title={$t('support_and_feedback')} icon={mdiHelpCircleOutline} - on:click={() => (shouldShowHelpPanel = !shouldShowHelpPanel)} + onclick={() => (shouldShowHelpPanel = !shouldShowHelpPanel)} padding="1" /> </div> {#if !$page.url.pathname.includes('/admin') && showUploadButton} - <LinkButton on:click={onUploadClick} class="hidden lg:block"> + <LinkButton onclick={onUploadClick} class="hidden lg:block"> <div class="flex gap-2"> <Icon path={mdiTrayArrowUp} size="1.5em" /> <span>{$t('upload')}</span> </div> </LinkButton> <CircleIconButton - on:click={onUploadClick} + onclick={onUploadClick} title={$t('upload')} icon={mdiTrayArrowUp} class="lg:hidden" @@ -115,11 +120,11 @@ <button type="button" class="flex pl-2" - on:mouseover={() => (shouldShowAccountInfo = true)} - on:focus={() => (shouldShowAccountInfo = true)} - on:blur={() => (shouldShowAccountInfo = false)} - on:mouseleave={() => (shouldShowAccountInfo = false)} - on:click={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)} + onmouseover={() => (shouldShowAccountInfo = true)} + onfocus={() => (shouldShowAccountInfo = true)} + onblur={() => (shouldShowAccountInfo = false)} + onmouseleave={() => (shouldShowAccountInfo = false)} + onclick={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)} > {#key $user} <UserAvatar user={$user} size="md" showTitle={false} interactive /> diff --git a/web/src/lib/components/shared-components/navigation-loading-bar.svelte b/web/src/lib/components/shared-components/navigation-loading-bar.svelte index b6913ae025282..f5879cf0a1e05 100644 --- a/web/src/lib/components/shared-components/navigation-loading-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-loading-bar.svelte @@ -3,7 +3,7 @@ import { cubicOut } from 'svelte/easing'; import { tweened } from 'svelte/motion'; - let showing = false; + let showing = $state(false); // delay showing any progress for a little bit so very fast loads // do not cause flicker diff --git a/web/src/lib/components/shared-components/notification/__tests__/notification-component-test.svelte b/web/src/lib/components/shared-components/notification/__tests__/notification-component-test.svelte index dfa305a19db18..4dea3709522dc 100644 --- a/web/src/lib/components/shared-components/notification/__tests__/notification-component-test.svelte +++ b/web/src/lib/components/shared-components/notification/__tests__/notification-component-test.svelte @@ -1,5 +1,9 @@ <script lang="ts"> - export let href: string; + interface Props { + href: string; + } + + let { href }: Props = $props(); </script> Notification <b>message</b> with <a {href}>link</a> diff --git a/web/src/lib/components/shared-components/notification/notification-card.svelte b/web/src/lib/components/shared-components/notification/notification-card.svelte index f5e70d856aca3..5054c1869528a 100644 --- a/web/src/lib/components/shared-components/notification/notification-card.svelte +++ b/web/src/lib/components/shared-components/notification/notification-card.svelte @@ -13,11 +13,14 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; - export let notification: Notification | ComponentNotification; + interface Props { + notification: Notification | ComponentNotification; + } - // svelte-ignore reactive_declaration_non_reactive_property - $: icon = notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline; - $: hoverStyle = notification.action.type === 'discard' ? 'hover:cursor-pointer' : ''; + let { notification }: Props = $props(); + + let icon = $derived(notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline); + let hoverStyle = $derived(notification.action.type === 'discard' ? 'hover:cursor-pointer' : ''); const backgroundColor: Record<NotificationType, string> = { [NotificationType.Info]: '#E0E2F0', @@ -67,14 +70,14 @@ }; </script> -<!-- svelte-ignore a11y-no-static-element-interactions --> +<!-- svelte-ignore a11y_no_static_element_interactions --> <div transition:fade={{ duration: 250 }} style:background-color={backgroundColor[notification.type]} style:border-color={borderColor[notification.type]} class="border z-[999999] mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md {hoverStyle}" - on:click={handleClick} - on:keydown={handleClick} + onclick={handleClick} + onkeydown={handleClick} > <div class="flex justify-between"> <div class="flex place-items-center gap-2"> @@ -91,15 +94,15 @@ class="dark:text-immich-dark-gray" size="20" padding="2" - on:click={discard} - aria-hidden="true" + onclick={discard} + aria-hidden={true} tabindex={-1} /> </div> <p class="whitespace-pre-wrap pl-[28px] pr-[16px] text-sm" data-testid="message"> {#if isComponentNotification(notification)} - <svelte:component this={notification.component.type} {...notification.component.props} /> + <notification.component.type {...notification.component.props} /> {:else} {notification.message} {/if} @@ -110,7 +113,7 @@ <button type="button" class="{buttonStyle[notification.type]} rounded px-3 pt-1.5 pb-1 transition-all duration-200" - on:click={handleButtonClick} + onclick={handleButtonClick} aria-hidden="true" tabindex={-1} > diff --git a/web/src/lib/components/shared-components/number-range-input.svelte b/web/src/lib/components/shared-components/number-range-input.svelte index 2e7dca878129e..6ee993cf88bae 100644 --- a/web/src/lib/components/shared-components/number-range-input.svelte +++ b/web/src/lib/components/shared-components/number-range-input.svelte @@ -2,14 +2,38 @@ import { clamp } from 'lodash-es'; import type { ClipboardEventHandler } from 'svelte/elements'; - export let id: string; - export let min: number; - export let max: number; - export let step: number | string = 'any'; - export let required = true; - export let value: number | null = null; - export let onInput: (value: number | null) => void; - export let onPaste: ClipboardEventHandler<HTMLInputElement> | undefined = undefined; + interface Props { + id: string; + min: number; + max: number; + step?: number | string; + required?: boolean; + value?: number; + onInput: (value: number | null) => void; + onPaste?: ClipboardEventHandler<HTMLInputElement>; + } + + let { + id, + min, + max, + step = 'any', + required = true, + value = $bindable(), + onInput, + onPaste = undefined, + }: Props = $props(); + + const oninput = () => { + if (!value) { + return; + } + + if (value !== null && (value < min || value > max)) { + value = clamp(value, min, max); + } + onInput(value); + }; </script> <input @@ -21,11 +45,6 @@ {step} {required} bind:value - on:input={() => { - if (value !== null && (value < min || value > max)) { - value = clamp(value, min, max); - } - onInput(value); - }} - on:paste={onPaste} + {oninput} + onpaste={onPaste} /> diff --git a/web/src/lib/components/shared-components/password-field.svelte b/web/src/lib/components/shared-components/password-field.svelte index e623d08423043..8519f84134b48 100644 --- a/web/src/lib/components/shared-components/password-field.svelte +++ b/web/src/lib/components/shared-components/password-field.svelte @@ -4,28 +4,26 @@ import Icon from '../elements/icon.svelte'; import { t } from 'svelte-i18n'; - interface $$Props extends HTMLInputAttributes { + interface Props extends HTMLInputAttributes { password: string; autocomplete: AutoFill; required?: boolean; onInput?: (value: string) => void; } - export let password: $$Props['password']; - export let required = true; - export let onInput: $$Props['onInput'] = undefined; + let { password = $bindable(), required = true, onInput = undefined, ...rest }: Props = $props(); - let showPassword = false; + let showPassword = $state(false); </script> <div class="relative w-full"> <input - {...$$restProps} + {...rest} class="immich-form-input w-full !pr-12" type={showPassword ? 'text' : 'password'} {required} value={password} - on:input={(e) => { + oninput={(e) => { password = e.currentTarget.value; onInput?.(password); }} @@ -36,7 +34,7 @@ type="button" tabindex="-1" class="absolute inset-y-0 end-0 px-4 text-gray-700 dark:text-gray-200" - on:click={() => (showPassword = !showPassword)} + onclick={() => (showPassword = !showPassword)} title={showPassword ? $t('hide_password') : $t('show_password')} > <Icon path={showPassword ? mdiEyeOffOutline : mdiEyeOutline} size="1.25em" /> diff --git a/web/src/lib/components/shared-components/portal/portal.svelte b/web/src/lib/components/shared-components/portal/portal.svelte index 7a9e577083015..60ccc993af4e6 100644 --- a/web/src/lib/components/shared-components/portal/portal.svelte +++ b/web/src/lib/components/shared-components/portal/portal.svelte @@ -1,6 +1,6 @@ -<script context="module" lang="ts"> +<script module lang="ts"> import { handlePromiseError } from '$lib/utils'; - import { tick } from 'svelte'; + import { tick, type Snippet } from 'svelte'; /** * Usage: <div use:portal={'css selector'}> or <div use:portal={document.body}> @@ -64,12 +64,17 @@ Used for every occurrence of an HTML tag in a message ``` --> <script lang="ts"> - /** - * DOM Element or CSS Selector - */ - export let target: HTMLElement | string = 'body'; + interface Props { + /** + * DOM Element or CSS Selector + */ + target?: HTMLElement | string; + children?: Snippet; + } + + let { target = 'body', children }: Props = $props(); </script> <div use:portal={target} hidden> - <slot /> + {@render children?.()} </div> diff --git a/web/src/lib/components/shared-components/profile-image-cropper.svelte b/web/src/lib/components/shared-components/profile-image-cropper.svelte index 3dabd86d4f489..b8ac86676115d 100644 --- a/web/src/lib/components/shared-components/profile-image-cropper.svelte +++ b/web/src/lib/components/shared-components/profile-image-cropper.svelte @@ -10,12 +10,20 @@ import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import { t } from 'svelte-i18n'; - export let asset: AssetResponseDto; - export let onClose: () => void; + interface Props { + asset: AssetResponseDto; + onClose: () => void; + } - let imgElement: HTMLDivElement; + let { asset, onClose }: Props = $props(); + + let imgElement: HTMLDivElement | undefined = $state(); onMount(() => { + if (!imgElement) { + return; + } + imgElement.style.width = '100%'; }); @@ -45,6 +53,10 @@ }; const handleSetProfilePicture = async () => { + if (!imgElement) { + return; + } + try { const blob = await domtoimage.toBlob(imgElement); if (await hasTransparentPixels(blob)) { @@ -79,7 +91,8 @@ <PhotoViewer bind:element={imgElement} {asset} /> </div> </div> - <svelte:fragment slot="sticky-bottom"> - <Button fullwidth on:click={handleSetProfilePicture}>{$t('set_as_profile_picture')}</Button> - </svelte:fragment> + + {#snippet stickyBottom()} + <Button fullwidth onclick={handleSetProfilePicture}>{$t('set_as_profile_picture')}</Button> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/shared-components/progress-bar/progress-bar.svelte b/web/src/lib/components/shared-components/progress-bar/progress-bar.svelte index 81de8a24a1bb7..0ccb8f9556961 100644 --- a/web/src/lib/components/shared-components/progress-bar/progress-bar.svelte +++ b/web/src/lib/components/shared-components/progress-bar/progress-bar.svelte @@ -1,4 +1,4 @@ -<script context="module" lang="ts"> +<script module lang="ts"> export enum ProgressBarStatus { Playing = 'playing', Paused = 'paused', @@ -11,41 +11,49 @@ import { onMount } from 'svelte'; import { tweened } from 'svelte/motion'; - /** - * Autoplay on mount - * @default false - */ - export let autoplay = false; - - /** - * Progress bar status - */ - export let status: ProgressBarStatus = ProgressBarStatus.Paused; - - export let hidden = false; - - export let duration = 5; - - export let onDone: () => void; - export let onPlaying: () => void = () => {}; - export let onPaused: () => void = () => {}; + interface Props { + /** + * Autoplay on mount + * @default false + */ + autoplay?: boolean; + /** + * Progress bar status + */ + status?: ProgressBarStatus; + hidden?: boolean; + duration?: number; + onDone: () => void; + onPlaying?: () => void; + onPaused?: () => void; + } - const onChange = async () => { - progress = setDuration(duration); + let { + autoplay = false, + status = $bindable(), + hidden = false, + duration = 5, + onDone, + onPlaying = () => {}, + onPaused = () => {}, + }: Props = $props(); + + const onChange = async (progressDuration: number) => { + progress = setDuration(progressDuration); await play(); }; let progress = setDuration(duration); - // svelte 5, again.... - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - $: duration, handlePromiseError(onChange()); + $effect(() => { + handlePromiseError(onChange(duration)); + }); - $: { + $effect(() => { if ($progress === 1) { onDone(); } - } + }); onMount(async () => { if (autoplay) { diff --git a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte index 3bd462f9976ff..00800ab489a5c 100644 --- a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte +++ b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte @@ -7,7 +7,11 @@ import { preferences } from '$lib/stores/user.store'; import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils'; - export let onDone: () => void; + interface Props { + onDone: () => void; + } + + let { onDone }: Props = $props(); </script> <div class="m-auto w-3/4 text-center flex flex-col place-content-center place-items-center dark:text-white my-6"> @@ -25,6 +29,6 @@ </div> <div class="mt-6 w-full"> - <Button fullwidth on:click={onDone}>{$t('ok')}</Button> + <Button fullwidth onclick={onDone}>{$t('ok')}</Button> </div> </div> diff --git a/web/src/lib/components/shared-components/purchasing/purchase-content.svelte b/web/src/lib/components/shared-components/purchasing/purchase-content.svelte index 0d782f85b34f7..6a4e7f1a4b9db 100644 --- a/web/src/lib/components/shared-components/purchasing/purchase-content.svelte +++ b/web/src/lib/components/shared-components/purchasing/purchase-content.svelte @@ -8,12 +8,15 @@ import { purchaseStore } from '$lib/stores/purchase.store'; import { t } from 'svelte-i18n'; - export let onActivate: () => void; + interface Props { + onActivate: () => void; + showTitle?: boolean; + showMessage?: boolean; + } - export let showTitle = true; - export let showMessage = true; - let productKey = ''; - let isLoading = false; + let { onActivate, showTitle = true, showMessage = true }: Props = $props(); + let productKey = $state(''); + let isLoading = $state(false); const activate = async () => { try { @@ -61,7 +64,7 @@ <div class="mt-6"> <p class="dark:text-immich-gray">{$t('purchase_input_suggestion')}</p> - <form class="mt-2 flex gap-2" on:submit={activate}> + <form class="mt-2 flex gap-2" onsubmit={activate}> <input class="immich-form-input w-full" id="purchaseKey" diff --git a/web/src/lib/components/shared-components/purchasing/purchase-modal.svelte b/web/src/lib/components/shared-components/purchasing/purchase-modal.svelte index 52757bc32a290..0334fb9e99848 100644 --- a/web/src/lib/components/shared-components/purchasing/purchase-modal.svelte +++ b/web/src/lib/components/shared-components/purchasing/purchase-modal.svelte @@ -5,9 +5,13 @@ import Portal from '$lib/components/shared-components/portal/portal.svelte'; - export let onClose: () => void; + interface Props { + onClose: () => void; + } - let showProductActivated = false; + let { onClose }: Props = $props(); + + let showProductActivated = $state(false); </script> <Portal> diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte index a55ad1a69cc78..bdcca509bbc30 100644 --- a/web/src/lib/components/shared-components/scrubber/scrubber.svelte +++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte @@ -7,28 +7,45 @@ import { isTimelineScrolling } from '$lib/stores/timeline.store'; import { fade, fly } from 'svelte/transition'; - export let timelineTopOffset = 0; - export let timelineBottomOffset = 0; - export let height = 0; - export let assetStore: AssetStore; - export let invisible = false; - export let scrubOverallPercent: number = 0; - export let scrubBucketPercent: number = 0; - export let scrubBucket: { bucketDate: string | undefined } | undefined = undefined; - export let leadout: boolean = false; - export let onScrub: ScrubberListener | undefined = undefined; - export let startScrub: ScrubberListener | undefined = undefined; - export let stopScrub: ScrubberListener | undefined = undefined; + interface Props { + timelineTopOffset?: number; + timelineBottomOffset?: number; + height?: number; + assetStore: AssetStore; + invisible?: boolean; + scrubOverallPercent?: number; + scrubBucketPercent?: number; + scrubBucket?: { bucketDate: string | undefined } | undefined; + leadout?: boolean; + onScrub?: ScrubberListener | undefined; + startScrub?: ScrubberListener | undefined; + stopScrub?: ScrubberListener | undefined; + } + + let { + timelineTopOffset = 0, + timelineBottomOffset = 0, + height = 0, + assetStore, + invisible = false, + scrubOverallPercent = 0, + scrubBucketPercent = 0, + scrubBucket = undefined, + leadout = false, + onScrub = undefined, + startScrub = undefined, + stopScrub = undefined, + }: Props = $props(); - let isHover = false; - let isDragging = false; - let hoverLabel: string | undefined; + let isHover = $state(false); + let isDragging = $state(false); + let hoverLabel: string | undefined = $state(); let bucketDate: string | undefined; - let hoverY = 0; + let hoverY = $state(0); let clientY = 0; - let windowHeight = 0; - let scrollBar: HTMLElement | undefined; - let segments: Segment[] = []; + let windowHeight = $state(0); + let scrollBar: HTMLElement | undefined = $state(); + let segments: Segment[] = $state([]); const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2); const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2); @@ -70,10 +87,14 @@ return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2; } }; - $: scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent); - $: timelineFullHeight = $assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset; - $: relativeTopOffset = toScrollY(timelineTopOffset / timelineFullHeight); - $: relativeBottomOffset = toScrollY(timelineBottomOffset / timelineFullHeight); + let scrollY = $state(0); + $effect(() => { + scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent); + }); + + let timelineFullHeight = $derived($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset); + let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight)); + let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight)); const listener: BucketListener = (event) => { const { type } = event; @@ -204,12 +225,12 @@ <svelte:window bind:innerHeight={windowHeight} - on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })} - on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} - on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} + onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })} + onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} + onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} /> -<!-- svelte-ignore a11y-no-static-element-interactions --> +<!-- svelte-ignore a11y_no_static_element_interactions --> <div transition:fly={{ x: 50, duration: 250 }} @@ -223,8 +244,8 @@ style:background-color={isDragging ? 'transparent' : 'transparent'} draggable="false" bind:this={scrollBar} - on:mouseenter={() => (isHover = true)} - on:mouseleave={() => (isHover = false)} + onmouseenter={() => (isHover = true)} + onmouseleave={() => (isHover = false)} > {#if hoverLabel && (isHover || isDragging)} <div diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 67c3cfe757ddb..d92bd1806c83b 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -15,35 +15,38 @@ import { generateId } from '$lib/utils/generate-id'; import { tick } from 'svelte'; - export let value = ''; - export let grayTheme: boolean; - export let searchQuery: MetadataSearchDto | SmartSearchDto = {}; + interface Props { + value?: string; + grayTheme: boolean; + searchQuery?: MetadataSearchDto | SmartSearchDto; + onSearch?: () => void; + } - $: showClearIcon = value.length > 0; + let { value = $bindable(''), grayTheme, searchQuery = {}, onSearch }: Props = $props(); - let input: HTMLInputElement; + let showClearIcon = $derived(value.length > 0); - let showSuggestions = false; - let showFilter = false; - let isSearchSuggestions = false; - let selectedId: string | undefined; - let moveSelection: (direction: 1 | -1) => void; - let clearSelection: () => void; - let selectActiveOption: () => void; + let input = $state<HTMLInputElement>(); + let searchHistoryBox = $state<ReturnType<typeof SearchHistoryBox>>(); + let showSuggestions = $state(false); + let showFilter = $state(false); + let isSearchSuggestions = $state(false); + let selectedId: string | undefined = $state(); const listboxId = generateId(); - const onSearch = async (payload: SmartSearchDto | MetadataSearchDto) => { + const handleSearch = async (payload: SmartSearchDto | MetadataSearchDto) => { const params = getMetadataSearchQuery(payload); closeDropdown(); showFilter = false; $isSearchEnabled = false; await goto(`${AppRoute.SEARCH}?${params}`); + onSearch?.(); }; const clearSearchTerm = (searchTerm: string) => { - input.focus(); + input?.focus(); $savedSearchTerms = $savedSearchTerms.filter((item) => item !== searchTerm); }; @@ -57,7 +60,7 @@ }; const clearAllSearchTerms = () => { - input.focus(); + input?.focus(); $savedSearchTerms = []; }; @@ -82,7 +85,7 @@ const onHistoryTermClick = async (searchTerm: string) => { value = searchTerm; const searchPayload = { query: searchTerm }; - await onSearch(searchPayload); + await handleSearch(searchPayload); }; const onFilterClick = () => { @@ -95,13 +98,13 @@ }; const onSubmit = () => { - handlePromiseError(onSearch({ query: value })); + handlePromiseError(handleSearch({ query: value })); saveSearchTerm(value); }; const onClear = () => { value = ''; - input.focus(); + input?.focus(); }; const onEscape = () => { @@ -112,19 +115,19 @@ const onArrow = async (direction: 1 | -1) => { openDropdown(); await tick(); - moveSelection(direction); + searchHistoryBox?.moveSelection(direction); }; const onEnter = (event: KeyboardEvent) => { if (selectedId) { event.preventDefault(); - selectActiveOption(); + searchHistoryBox?.selectActiveOption(); } }; const onInput = () => { openDropdown(); - clearSelection(); + searchHistoryBox?.clearSelection(); }; const openDropdown = () => { @@ -133,14 +136,19 @@ const closeDropdown = () => { showSuggestions = false; - clearSelection(); + searchHistoryBox?.clearSelection(); + }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + onSubmit(); }; </script> <svelte:window use:shortcuts={[ { shortcut: { key: 'Escape' }, onShortcut: onEscape }, - { shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input.select() }, + { shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input?.select() }, { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick }, ]} /> @@ -151,9 +159,9 @@ autocomplete="off" class="select-text text-sm" action={AppRoute.SEARCH} - on:reset={() => (value = '')} - on:submit|preventDefault={onSubmit} - on:focusin={onFocusIn} + onreset={() => (value = '')} + {onsubmit} + onfocusin={onFocusIn} role="search" > <div use:focusOutside={{ onFocusOut: closeDropdown }} tabindex="-1"> @@ -171,8 +179,8 @@ pattern="^(?!m:$).*$" bind:value bind:this={input} - on:focus={openDropdown} - on:input={onInput} + onfocus={openDropdown} + oninput={onInput} disabled={showFilter} role="combobox" aria-controls={listboxId} @@ -191,13 +199,11 @@ <!-- SEARCH HISTORY BOX --> <SearchHistoryBox + bind:this={searchHistoryBox} + bind:isSearchSuggestions id={listboxId} searchQuery={value} isOpen={showSuggestions} - bind:isSearchSuggestions - bind:moveSelection - bind:clearSelection - bind:selectActiveOption onClearAllSearchTerms={clearAllSearchTerms} onClearSearchTerm={(searchTerm) => clearSearchTerm(searchTerm)} onSelectSearchTerm={(searchTerm) => handlePromiseError(onHistoryTermClick(searchTerm))} @@ -206,19 +212,30 @@ </div> <div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-2'} flex items-center pl-6 transition-all"> - <CircleIconButton title={$t('show_search_options')} icon={mdiTune} on:click={onFilterClick} size="20" /> + <CircleIconButton title={$t('show_search_options')} icon={mdiTune} onclick={onFilterClick} size="20" /> </div> {#if showClearIcon} <div class="absolute inset-y-0 right-0 flex items-center pr-2"> - <CircleIconButton on:click={onClear} icon={mdiClose} title={$t('clear')} size="20" /> + <CircleIconButton onclick={onClear} icon={mdiClose} title={$t('clear')} size="20" /> </div> {/if} <div class="absolute inset-y-0 left-0 flex items-center pl-2"> - <CircleIconButton type="submit" disabled={showFilter} title={$t('search')} icon={mdiMagnify} size="20" /> + <CircleIconButton + type="submit" + disabled={showFilter} + title={$t('search')} + icon={mdiMagnify} + size="20" + onclick={() => {}} + /> </div> </form> {#if showFilter} - <SearchFilterModal {searchQuery} onSearch={(payload) => onSearch(payload)} onClose={() => (showFilter = false)} /> + <SearchFilterModal + {searchQuery} + onSearch={(payload) => handleSearch(payload)} + onClose={() => (showFilter = false)} + /> {/if} </div> diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte index 3ac8cb8d5aa4f..08ed57d70e744 100644 --- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export interface SearchCameraFilter { make?: string; model?: string; @@ -6,20 +6,21 @@ </script> <script lang="ts"> + import { run } from 'svelte/legacy'; + import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte'; import { handlePromiseError } from '$lib/utils'; import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk'; import { t } from 'svelte-i18n'; - export let filters: SearchCameraFilter; + interface Props { + filters: SearchCameraFilter; + } - let makes: string[] = []; - let models: string[] = []; + let { filters = $bindable() }: Props = $props(); - $: makeFilter = filters.make; - $: modelFilter = filters.model; - $: handlePromiseError(updateMakes()); - $: handlePromiseError(updateModels(makeFilter)); + let makes: string[] = $state([]); + let models: string[] = $state([]); async function updateMakes() { const results: Array<string | null> = await getSearchSuggestions({ @@ -47,6 +48,14 @@ filters.model = undefined; } } + let makeFilter = $derived(filters.make); + let modelFilter = $derived(filters.model); + run(() => { + handlePromiseError(updateMakes()); + }); + run(() => { + handlePromiseError(updateModels(makeFilter)); + }); </script> <div id="camera-selection"> diff --git a/web/src/lib/components/shared-components/search-bar/search-date-section.svelte b/web/src/lib/components/shared-components/search-bar/search-date-section.svelte index 6b661b6c036d7..ea27142074f47 100644 --- a/web/src/lib/components/shared-components/search-bar/search-date-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-date-section.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export interface SearchDateFilter { takenBefore?: string; takenAfter?: string; @@ -9,7 +9,11 @@ import DateInput from '$lib/components/elements/date-input.svelte'; import { t } from 'svelte-i18n'; - export let filters: SearchDateFilter; + interface Props { + filters: SearchDateFilter; + } + + let { filters = $bindable() }: Props = $props(); </script> <div id="date-range-selection" class="grid grid-auto-fit-40 gap-5"> diff --git a/web/src/lib/components/shared-components/search-bar/search-display-section.svelte b/web/src/lib/components/shared-components/search-bar/search-display-section.svelte index 00a540306807c..06fa3c5bdfad4 100644 --- a/web/src/lib/components/shared-components/search-bar/search-display-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-display-section.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export interface SearchDisplayFilters { isNotInAlbum?: boolean; isArchive?: boolean; @@ -10,7 +10,11 @@ import Checkbox from '$lib/components/elements/checkbox.svelte'; import { t } from 'svelte-i18n'; - export let filters: SearchDisplayFilters; + interface Props { + filters: SearchDisplayFilters; + } + + let { filters = $bindable() }: Props = $props(); </script> <div id="display-options-selection"> diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index 3ec539ad976b4..4b53f60b5fff4 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> import type { SearchLocationFilter } from './search-location-section.svelte'; import type { SearchDisplayFilters } from './search-display-section.svelte'; import type { SearchDateFilter } from './search-date-section.svelte'; @@ -36,10 +36,15 @@ import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import { mdiTune } from '@mdi/js'; import { generateId } from '$lib/utils/generate-id'; + import { SvelteSet } from 'svelte/reactivity'; - export let searchQuery: MetadataSearchDto | SmartSearchDto; - export let onClose: () => void; - export let onSearch: (search: SmartSearchDto | MetadataSearchDto) => void; + interface Props { + searchQuery: MetadataSearchDto | SmartSearchDto; + onClose: () => void; + onSearch: (search: SmartSearchDto | MetadataSearchDto) => void; + } + + let { searchQuery, onClose, onSearch }: Props = $props(); const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined); const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined; @@ -50,10 +55,10 @@ return value === null ? undefined : value; } - let filter: SearchFilter = { + let filter: SearchFilter = $state({ query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '', queryType: 'query' in searchQuery ? 'smart' : 'metadata', - personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []), + personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), location: { country: withNullAsUndefined(searchQuery.country), state: withNullAsUndefined(searchQuery.state), @@ -78,7 +83,7 @@ : searchQuery.type === AssetTypeEnum.Video ? MediaType.Video : MediaType.All, - }; + }); const resetForm = () => { filter = { @@ -122,10 +127,20 @@ onSearch(payload); }; + + const onreset = (event: Event) => { + event.preventDefault(); + resetForm(); + }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + search(); + }; </script> <FullScreenModal icon={mdiTune} width="extra-wide" title={$t('search_options')} {onClose}> - <form id={formId} autocomplete="off" on:submit|preventDefault={search} on:reset|preventDefault={resetForm}> + <form id={formId} autocomplete="off" {onsubmit} {onreset}> <div class="space-y-10 pb-10" tabindex="-1"> <!-- PEOPLE --> <SearchPeopleSection bind:selectedPeople={filter.personIds} /> @@ -152,8 +167,8 @@ </div> </form> - <svelte:fragment slot="sticky-bottom"> + {#snippet stickyBottom()} <Button type="reset" color="gray" fullwidth form={formId}>{$t('clear_all')}</Button> <Button type="submit" fullwidth form={formId}>{$t('search')}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/shared-components/search-bar/search-history-box.svelte b/web/src/lib/components/shared-components/search-bar/search-history-box.svelte index ca25ef5691ec9..92a2f8847e17f 100644 --- a/web/src/lib/components/shared-components/search-bar/search-history-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-history-box.svelte @@ -6,22 +6,41 @@ import { t } from 'svelte-i18n'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; - export let id: string; - export let searchQuery: string = ''; - export let isSearchSuggestions: boolean = false; - export let isOpen: boolean = false; - export let onSelectSearchTerm: (searchTerm: string) => void; - export let onClearSearchTerm: (searchTerm: string) => void; - export let onClearAllSearchTerms: () => void; - export let onActiveSelectionChange: (selectedId: string | undefined) => void; + interface Props { + id: string; + searchQuery?: string; + isSearchSuggestions?: boolean; + isOpen?: boolean; + onSelectSearchTerm: (searchTerm: string) => void; + onClearSearchTerm: (searchTerm: string) => void; + onClearAllSearchTerms: () => void; + onActiveSelectionChange: (selectedId: string | undefined) => void; + } + + let { + id, + searchQuery = '', + isSearchSuggestions = $bindable(false), + isOpen = false, + onSelectSearchTerm, + onClearSearchTerm, + onClearAllSearchTerms, + onActiveSelectionChange, + }: Props = $props(); + + let filteredSearchTerms = $derived( + $savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())), + ); + + $effect(() => { + isSearchSuggestions = filteredSearchTerms.length > 0; + }); - $: filteredSearchTerms = $savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())); - $: isSearchSuggestions = filteredSearchTerms.length > 0; - $: showClearAll = searchQuery === ''; - $: suggestionCount = showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length; + let showClearAll = $derived(searchQuery === ''); + let suggestionCount = $derived(showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length); - let selectedIndex: number | undefined = undefined; - let element: HTMLDivElement; + let selectedIndex: number | undefined = $state(undefined); + let element = $state<HTMLDivElement>(); export function moveSelection(increment: 1 | -1) { if (!isSearchSuggestions) { @@ -45,7 +64,7 @@ if (selectedIndex === undefined) { return; } - const selectedElement = element.querySelector(`#${getId(selectedIndex)}`) as HTMLElement; + const selectedElement = element?.querySelector(`#${getId(selectedIndex)}`) as HTMLElement; selectedElement?.click(); } @@ -86,7 +105,7 @@ type="button" class="rounded-lg p-2 font-semibold text-immich-primary aria-selected:bg-immich-primary/25 hover:bg-immich-primary/25 dark:text-immich-dark-primary" role="option" - on:click={() => handleClearAll()} + onclick={() => handleClearAll()} tabindex="-1" aria-selected={selectedIndex === 0} aria-label={$t('clear_all_recent_searches')} @@ -100,11 +119,11 @@ {@const index = showClearAll ? i + 1 : i} <div class="flex w-full items-center justify-between text-sm text-black dark:text-gray-300"> <div class="relative w-full items-center"> - <!-- svelte-ignore a11y-click-events-have-key-events --> + <!-- svelte-ignore a11y_click_events_have_key_events --> <div id={getId(index)} class="relative flex w-full cursor-pointer gap-3 py-3 pl-5 hover:bg-gray-100 aria-selected:bg-gray-100 dark:aria-selected:bg-gray-500/30 dark:hover:bg-gray-500/30" - on:click={() => handleSelect(savedSearchTerm)} + onclick={() => handleSelect(savedSearchTerm)} role="option" tabindex="-1" aria-selected={selectedIndex === index} @@ -120,7 +139,7 @@ size="18" padding="1" tabindex={-1} - on:click={() => handleClearSingle(savedSearchTerm)} + onclick={() => handleClearSingle(savedSearchTerm)} /> </div> </div> diff --git a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte index 71912264ed7ac..d68578276c7d4 100644 --- a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export interface SearchLocationFilter { country?: string; state?: string; @@ -7,22 +7,22 @@ </script> <script lang="ts"> + import { run } from 'svelte/legacy'; + import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte'; import { handlePromiseError } from '$lib/utils'; import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk'; import { t } from 'svelte-i18n'; - export let filters: SearchLocationFilter; + interface Props { + filters: SearchLocationFilter; + } - let countries: string[] = []; - let states: string[] = []; - let cities: string[] = []; + let { filters = $bindable() }: Props = $props(); - $: countryFilter = filters.country; - $: stateFilter = filters.state; - $: handlePromiseError(updateCountries()); - $: handlePromiseError(updateStates(countryFilter)); - $: handlePromiseError(updateCities(countryFilter, stateFilter)); + let countries: string[] = $state([]); + let states: string[] = $state([]); + let cities: string[] = $state([]); async function updateCountries() { const results: Array<string | null> = await getSearchSuggestions({ @@ -64,6 +64,17 @@ filters.city = undefined; } } + let countryFilter = $derived(filters.country); + let stateFilter = $derived(filters.state); + run(() => { + handlePromiseError(updateCountries()); + }); + run(() => { + handlePromiseError(updateStates(countryFilter)); + }); + run(() => { + handlePromiseError(updateCities(countryFilter, stateFilter)); + }); </script> <div id="location-selection"> diff --git a/web/src/lib/components/shared-components/search-bar/search-media-section.svelte b/web/src/lib/components/shared-components/search-bar/search-media-section.svelte index b78868d6146c9..37fa4292ae128 100644 --- a/web/src/lib/components/shared-components/search-bar/search-media-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-media-section.svelte @@ -3,7 +3,11 @@ import { MediaType } from './search-filter-modal.svelte'; import { t } from 'svelte-i18n'; - export let filteredMedia: MediaType; + interface Props { + filteredMedia: MediaType; + } + + let { filteredMedia = $bindable() }: Props = $props(); </script> <div id="media-type-selection"> diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte index 0c8d32a1ae0da..8e5059cbbf17b 100644 --- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte @@ -10,12 +10,16 @@ import { t } from 'svelte-i18n'; import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte'; - export let selectedPeople: Set<string>; + interface Props { + selectedPeople: Set<string>; + } + + let { selectedPeople = $bindable() }: Props = $props(); let peoplePromise = getPeople(); - let showAllPeople = false; - let name = ''; - let numberOfPeople = 1; + let showAllPeople = $state(false); + let name = $state(''); + let numberOfPeople = $state(1); function orderBySelectedPeopleFirst(people: PersonResponseDto[]) { return [ @@ -72,7 +76,7 @@ ) ? 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white' : 'border-transparent'}" - on:click={() => togglePersonSelection(person.id)} + onclick={() => togglePersonSelection(person.id)} > <ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" /> <p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p> @@ -86,7 +90,7 @@ shadow={false} color="text-primary" class="flex gap-2 place-items-center" - on:click={() => (showAllPeople = !showAllPeople)} + onclick={() => (showAllPeople = !showAllPeople)} > {#if showAllPeople} <span><Icon path={mdiClose} ariaHidden /></span> diff --git a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte index c3145b2f0c16d..2f118e6567704 100644 --- a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte @@ -2,8 +2,12 @@ import RadioButton from '$lib/components/elements/radio-button.svelte'; import { t } from 'svelte-i18n'; - export let query: string | undefined; - export let queryType: 'smart' | 'metadata' = 'smart'; + interface Props { + query: string | undefined; + queryType?: 'smart' | 'metadata'; + } + + let { query = $bindable(), queryType = $bindable('smart') }: Props = $props(); </script> <fieldset> diff --git a/web/src/lib/components/shared-components/server-about-modal.svelte b/web/src/lib/components/shared-components/server-about-modal.svelte index 1373a98d3f527..cf935cd3143b3 100644 --- a/web/src/lib/components/shared-components/server-about-modal.svelte +++ b/web/src/lib/components/shared-components/server-about-modal.svelte @@ -7,10 +7,13 @@ import { mdiAlert } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; - export let onClose: () => void; + interface Props { + onClose: () => void; + info: ServerAboutResponseDto; + versions: ServerVersionHistoryResponseDto[]; + } - export let info: ServerAboutResponseDto; - export let versions: ServerVersionHistoryResponseDto[]; + let { onClose, info, versions }: Props = $props(); </script> <Portal> diff --git a/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte b/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte index a6257fce297a0..7fbab302d259b 100644 --- a/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte +++ b/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export type AccordionState = Set<string>; const { get: getAccordionState, set: setAccordionState } = createContext<Writable<AccordionState>>(); @@ -11,25 +11,33 @@ import { page } from '$app/stores'; import { handlePromiseError } from '$lib/utils'; import { goto } from '$app/navigation'; + import type { Snippet } from 'svelte'; const getParamValues = (param: string) => { return new Set(($page.url.searchParams.get(param) || '').split(' ').filter((x) => x !== '')); }; - export let queryParam: string; - export let state: Writable<AccordionState> = writable(getParamValues(queryParam)); + interface Props { + queryParam: string; + state?: Writable<AccordionState>; + children?: Snippet; + } + + let { queryParam, state = writable(getParamValues(queryParam)), children }: Props = $props(); setAccordionState(state); - $: if (queryParam && $state) { - const searchParams = new URLSearchParams($page.url.searchParams); - if ($state.size > 0) { - searchParams.set(queryParam, [...$state].join(' ')); - } else { - searchParams.delete(queryParam); - } + $effect(() => { + if (queryParam && $state) { + const searchParams = new URLSearchParams($page.url.searchParams); + if ($state.size > 0) { + searchParams.set(queryParam, [...$state].join(' ')); + } else { + searchParams.delete(queryParam); + } - handlePromiseError(goto(`?${searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true })); - } + handlePromiseError(goto(`?${searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true })); + } + }); </script> -<slot /> +{@render children?.()} diff --git a/web/src/lib/components/shared-components/settings/setting-accordion.svelte b/web/src/lib/components/shared-components/settings/setting-accordion.svelte index d8b50b21320e6..0fe1c9dc14a2e 100755 --- a/web/src/lib/components/shared-components/settings/setting-accordion.svelte +++ b/web/src/lib/components/shared-components/settings/setting-accordion.svelte @@ -1,21 +1,34 @@ <script lang="ts"> import { slide } from 'svelte/transition'; import { getAccordionState } from './setting-accordion-state.svelte'; - import { onDestroy } from 'svelte'; + import { onDestroy, onMount, type Snippet } from 'svelte'; import Icon from '$lib/components/elements/icon.svelte'; const accordionState = getAccordionState(); - export let title: string; - export let subtitle = ''; - export let key: string; - export let isOpen = $accordionState.has(key); - export let autoScrollTo = false; - export let icon = ''; + interface Props { + title: string; + subtitle?: string; + key: string; + isOpen?: boolean; + autoScrollTo?: boolean; + icon?: string; + subtitleSnippet?: Snippet; + children?: Snippet; + } - let accordionElement: HTMLDivElement; + let { + title, + subtitle = '', + key, + isOpen = $bindable($accordionState.has(key)), + autoScrollTo = false, + icon = '', + subtitleSnippet, + children, + }: Props = $props(); - $: setIsOpen(isOpen); + let accordionElement: HTMLDivElement | undefined = $state(); const setIsOpen = (isOpen: boolean) => { if (isOpen) { @@ -23,7 +36,7 @@ if (autoScrollTo) { setTimeout(() => { - accordionElement.scrollIntoView({ + accordionElement?.scrollIntoView({ behavior: 'smooth', block: 'start', }); @@ -38,6 +51,15 @@ onDestroy(() => { setIsOpen(false); }); + + const onclick = () => { + isOpen = !isOpen; + setIsOpen(isOpen); + }; + + onMount(() => { + setIsOpen(isOpen); + }); </script> <div @@ -49,7 +71,7 @@ <button type="button" aria-expanded={isOpen} - on:click={() => (isOpen = !isOpen)} + {onclick} class="flex w-full place-items-center justify-between text-left" > <div> @@ -62,9 +84,9 @@ </h2> </div> - <slot name="subtitle"> + {#if subtitleSnippet}{@render subtitleSnippet()}{:else} <p class="text-sm dark:text-immich-dark-fg mt-1">{subtitle}</p> - </slot> + {/if} </div> <div @@ -88,7 +110,7 @@ {#if isOpen} <ul transition:slide={{ duration: 150 }} class="mb-2 ml-4"> - <slot /> + {@render children?.()} </ul> {/if} </div> diff --git a/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte b/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte index 97bcb1d4993be..95edac6dfbae4 100644 --- a/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte +++ b/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte @@ -3,10 +3,14 @@ import type { ResetOptions } from '$lib/utils/dipatch'; import { t } from 'svelte-i18n'; - export let showResetToDefault = true; - export let disabled = false; - export let onReset: (options: ResetOptions) => void; - export let onSave: () => void; + interface Props { + showResetToDefault?: boolean; + disabled?: boolean; + onReset: (options: ResetOptions) => void; + onSave: () => void; + } + + let { showResetToDefault = true, disabled = false, onReset, onSave }: Props = $props(); </script> <div class="mt-8 flex justify-between gap-2"> @@ -14,7 +18,7 @@ {#if showResetToDefault} <button type="button" - on:click={() => onReset({ default: true })} + onclick={() => onReset({ default: true })} class="bg-none text-sm font-medium text-immich-primary hover:text-immich-primary/75 dark:text-immich-dark-primary hover:dark:text-immich-dark-primary/75" > {$t('reset_to_default')} @@ -23,7 +27,7 @@ </div> <div class="right"> - <Button {disabled} size="sm" color="gray" on:click={() => onReset({ default: false })}>{$t('reset')}</Button> - <Button type="submit" {disabled} size="sm" on:click={() => onSave()}>{$t('save')}</Button> + <Button {disabled} size="sm" color="gray" onclick={() => onReset({ default: false })}>{$t('reset')}</Button> + <Button type="submit" {disabled} size="sm" onclick={() => onSave()}>{$t('save')}</Button> </div> </div> diff --git a/web/src/lib/components/shared-components/settings/setting-checkboxes.svelte b/web/src/lib/components/shared-components/settings/setting-checkboxes.svelte index 3def0ce08d0f4..09f0ea438b55c 100644 --- a/web/src/lib/components/shared-components/settings/setting-checkboxes.svelte +++ b/web/src/lib/components/shared-components/settings/setting-checkboxes.svelte @@ -4,13 +4,25 @@ import { fly } from 'svelte/transition'; import { t } from 'svelte-i18n'; - export let value: string[]; - export let options: { value: string; text: string }[]; - export let label = ''; - export let desc = ''; - export let name = ''; - export let isEdited = false; - export let disabled = false; + interface Props { + value: string[]; + options: { value: string; text: string }[]; + label?: string; + desc?: string; + name?: string; + isEdited?: boolean; + disabled?: boolean; + } + + let { + value = $bindable(), + options, + label = '', + desc = '', + name = '', + isEdited = false, + disabled = false, + }: Props = $props(); function handleCheckboxChange(option: string) { value = value.includes(option) ? value.filter((item) => item !== option) : [...value, option]; @@ -46,7 +58,7 @@ checked={value.includes(option.value)} {disabled} labelClass="text-gray-500 dark:text-gray-300" - on:change={() => handleCheckboxChange(option.value)} + onchange={() => handleCheckboxChange(option.value)} /> {/each} </div> diff --git a/web/src/lib/components/shared-components/settings/setting-combobox.svelte b/web/src/lib/components/shared-components/settings/setting-combobox.svelte index 722af048a5d99..5314ad7193d4d 100644 --- a/web/src/lib/components/shared-components/settings/setting-combobox.svelte +++ b/web/src/lib/components/shared-components/settings/setting-combobox.svelte @@ -3,14 +3,29 @@ import { fly } from 'svelte/transition'; import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; import { t } from 'svelte-i18n'; + import type { Snippet } from 'svelte'; - export let title: string; - export let comboboxPlaceholder: string; - export let subtitle = ''; - export let isEdited = false; - export let options: ComboBoxOption[]; - export let selectedOption: ComboBoxOption; - export let onSelect: (combobox: ComboBoxOption | undefined) => void; + interface Props { + title: string; + comboboxPlaceholder: string; + subtitle?: string; + isEdited?: boolean; + options: ComboBoxOption[]; + selectedOption: ComboBoxOption; + onSelect: (combobox: ComboBoxOption | undefined) => void; + children?: Snippet; + } + + let { + title, + comboboxPlaceholder, + subtitle = '', + isEdited = false, + options, + selectedOption, + onSelect, + children, + }: Props = $props(); </script> <div class="grid grid-cols-2"> @@ -33,6 +48,6 @@ </div> <div class="flex items-center"> <Combobox label={title} hideLabel={true} {selectedOption} {options} placeholder={comboboxPlaceholder} {onSelect} /> - <slot /> + {@render children?.()} </div> </div> diff --git a/web/src/lib/components/shared-components/settings/setting-dropdown.svelte b/web/src/lib/components/shared-components/settings/setting-dropdown.svelte index 20324fe4f8084..57e78e6c6f2fe 100644 --- a/web/src/lib/components/shared-components/settings/setting-dropdown.svelte +++ b/web/src/lib/components/shared-components/settings/setting-dropdown.svelte @@ -3,14 +3,27 @@ import { fly } from 'svelte/transition'; import Dropdown, { type RenderedOption } from '$lib/components/elements/dropdown.svelte'; import { t } from 'svelte-i18n'; + import type { Snippet } from 'svelte'; - export let title: string; - export let subtitle = ''; - export let options: RenderedOption[]; - export let selectedOption: RenderedOption; - export let isEdited = false; + interface Props { + title: string; + subtitle?: string; + options: RenderedOption[]; + selectedOption: RenderedOption; + isEdited?: boolean; + onToggle: (option: RenderedOption) => void; + children?: Snippet; + } - export let onToggle: (option: RenderedOption) => void; + let { + title, + subtitle = '', + options, + selectedOption = $bindable(), + isEdited = false, + onToggle, + children, + }: Props = $props(); </script> <div class="flex place-items-center justify-between"> @@ -30,7 +43,7 @@ </div> <p class="text-sm dark:text-immich-dark-fg">{subtitle}</p> - <slot /> + {@render children?.()} </div> <div class="w-fit"> <Dropdown diff --git a/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts b/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts index 642492dda5ac7..80cb920074960 100644 --- a/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts +++ b/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts @@ -1,7 +1,7 @@ +import { SettingInputFieldType } from '$lib/constants'; import { render } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; -// @ts-expect-error the import works but tsc check errors -import SettingInputField, { SettingInputFieldType } from './setting-input-field.svelte'; +import SettingInputField from './setting-input-field.svelte'; describe('SettingInputField component', () => { it('validates number input on blur', async () => { diff --git a/web/src/lib/components/shared-components/settings/setting-input-field.svelte b/web/src/lib/components/shared-components/settings/setting-input-field.svelte index 410adc6458407..1463cc48407b8 100644 --- a/web/src/lib/components/shared-components/settings/setting-input-field.svelte +++ b/web/src/lib/components/shared-components/settings/setting-input-field.svelte @@ -1,36 +1,47 @@ -<script lang="ts" context="module"> - export enum SettingInputFieldType { - EMAIL = 'email', - TEXT = 'text', - NUMBER = 'number', - PASSWORD = 'password', - COLOR = 'color', - } -</script> - <script lang="ts"> 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 } from 'svelte'; + import { onMount, tick, type Snippet } from 'svelte'; + import { SettingInputFieldType } from '$lib/constants'; + + interface Props { + inputType: SettingInputFieldType; + value: string | number; + min?: number; + max?: number; + step?: string; + label?: string; + description?: string; + title?: string; + required?: boolean; + disabled?: boolean; + isEdited?: boolean; + autofocus?: boolean; + passwordAutocomplete?: AutoFill; + descriptionSnippet?: Snippet; + } - export let inputType: SettingInputFieldType; - export let value: string | number; - export let min = Number.MIN_SAFE_INTEGER; - export let max = Number.MAX_SAFE_INTEGER; - export let step = '1'; - export let label = ''; - export let desc = ''; - export let title = ''; - export let required = false; - export let disabled = false; - export let isEdited = false; - export let autofocus = false; - export let passwordAutocomplete: AutoFill = 'current-password'; + let { + inputType, + value = $bindable(), + min = Number.MIN_SAFE_INTEGER, + max = Number.MAX_SAFE_INTEGER, + step = '1', + label = '', + description = '', + title = '', + required = false, + disabled = false, + isEdited = false, + autofocus = false, + passwordAutocomplete = 'current-password', + descriptionSnippet, + }: Props = $props(); - let input: HTMLInputElement; + let input: HTMLInputElement | undefined = $state(); const handleChange: FormEventHandler<HTMLInputElement> = (e) => { value = e.currentTarget.value; @@ -73,12 +84,12 @@ {/if} </div> - {#if desc} + {#if description} <p class="immich-form-label pb-2 text-sm" id="{label}-desc"> - {desc} + {description} </p> {:else} - <slot name="desc" /> + {@render descriptionSnippet?.()} {/if} {#if inputType !== SettingInputFieldType.PASSWORD} @@ -87,7 +98,7 @@ <input bind:this={input} class="immich-form-input w-full pb-2 rounded-none mr-1" - aria-describedby={desc ? `${label}-desc` : undefined} + aria-describedby={description ? `${label}-desc` : undefined} aria-labelledby="{label}-label" id={label} name={label} @@ -97,7 +108,7 @@ {step} {required} {value} - on:change={handleChange} + onchange={handleChange} {disabled} {title} /> @@ -107,7 +118,7 @@ bind:this={input} class="immich-form-input w-full pb-2" class:color-picker={inputType === SettingInputFieldType.COLOR} - aria-describedby={desc ? `${label}-desc` : undefined} + aria-describedby={description ? `${label}-desc` : undefined} aria-labelledby="{label}-label" id={label} name={label} @@ -117,14 +128,14 @@ {step} {required} {value} - on:change={handleChange} + onchange={handleChange} {disabled} {title} /> </div> {:else} <PasswordField - aria-describedby={desc ? `${label}-desc` : undefined} + aria-describedby={description ? `${label}-desc` : undefined} aria-labelledby="{label}-label" id={label} name={label} diff --git a/web/src/lib/components/shared-components/settings/setting-select.svelte b/web/src/lib/components/shared-components/settings/setting-select.svelte index 92cabbff2534c..44f03075da8c7 100644 --- a/web/src/lib/components/shared-components/settings/setting-select.svelte +++ b/web/src/lib/components/shared-components/settings/setting-select.svelte @@ -5,15 +5,29 @@ import Icon from '$lib/components/elements/icon.svelte'; import { mdiChevronDown } from '@mdi/js'; - export let value: string | number; - export let options: { value: string | number; text: string }[]; - export let label = ''; - export let desc = ''; - export let name = ''; - export let isEdited = false; - export let number = false; - export let disabled = false; - export let onSelect: (setting: string | number) => void = () => {}; + interface Props { + value: string | number; + options: { value: string | number; text: string }[]; + label?: string; + desc?: string; + name?: string; + isEdited?: boolean; + number?: boolean; + disabled?: boolean; + onSelect?: (setting: string | number) => void; + } + + let { + value = $bindable(), + options, + label = '', + desc = '', + name = '', + isEdited = false, + number = false, + disabled = false, + onSelect = () => {}, + }: Props = $props(); const handleChange = (e: Event) => { value = (e.target as HTMLInputElement).value; @@ -62,7 +76,7 @@ {name} id="{name}-select" bind:value - on:change={handleChange} + onchange={handleChange} > {#each options as option} <option value={option.value}>{option.text}</option> diff --git a/web/src/lib/components/shared-components/settings/setting-switch.svelte b/web/src/lib/components/shared-components/settings/setting-switch.svelte index 11716526f85dc..29c1f213d3526 100644 --- a/web/src/lib/components/shared-components/settings/setting-switch.svelte +++ b/web/src/lib/components/shared-components/settings/setting-switch.svelte @@ -4,18 +4,32 @@ import Slider from '$lib/components/elements/slider.svelte'; import { generateId } from '$lib/utils/generate-id'; import { t } from 'svelte-i18n'; + import type { Snippet } from 'svelte'; - export let title: string; - export let subtitle = ''; - export let checked = false; - export let disabled = false; - export let isEdited = false; - export let onToggle: (isChecked: boolean) => void = () => {}; + interface Props { + title: string; + subtitle?: string; + checked?: boolean; + disabled?: boolean; + isEdited?: boolean; + onToggle?: (isChecked: boolean) => void; + children?: Snippet; + } + + let { + title, + subtitle = '', + checked = $bindable(false), + disabled = false, + isEdited = false, + onToggle = () => {}, + children, + }: Props = $props(); let id: string = generateId(); - $: sliderId = `${id}-slider`; - $: subtitleId = subtitle ? `${id}-subtitle` : undefined; + let sliderId = $derived(`${id}-slider`); + let subtitleId = $derived(subtitle ? `${id}-subtitle` : undefined); </script> <div class="flex place-items-center justify-between"> @@ -37,7 +51,7 @@ {#if subtitle} <p id={subtitleId} class="text-sm dark:text-immich-dark-fg">{subtitle}</p> {/if} - <slot /> + {@render children?.()} </div> <Slider id={sliderId} bind:checked {disabled} {onToggle} ariaDescribedBy={subtitleId} /> diff --git a/web/src/lib/components/shared-components/settings/setting-textarea.svelte b/web/src/lib/components/shared-components/settings/setting-textarea.svelte index 5c7b138388ed0..9f9f885263e48 100644 --- a/web/src/lib/components/shared-components/settings/setting-textarea.svelte +++ b/web/src/lib/components/shared-components/settings/setting-textarea.svelte @@ -2,13 +2,27 @@ import { quintOut } from 'svelte/easing'; import { fly } from 'svelte/transition'; import { t } from 'svelte-i18n'; + import type { Snippet } from 'svelte'; - export let value: string; - export let label = ''; - export let desc = ''; - export let required = false; - export let disabled = false; - export let isEdited = false; + interface Props { + value: string; + label?: string; + description?: string; + required?: boolean; + disabled?: boolean; + isEdited?: boolean; + descriptionSnippet?: Snippet; + } + + let { + value = $bindable(), + label = '', + description = '', + required = false, + disabled = false, + isEdited = false, + descriptionSnippet, + }: Props = $props(); const handleInput = (e: Event) => { value = (e.target as HTMLInputElement).value; @@ -32,23 +46,23 @@ {/if} </div> - {#if desc} + {#if description} <p class="immich-form-label pb-2 text-sm" id="{label}-desc"> - {desc} + {description} </p> {:else} - <slot name="desc" /> + {@render descriptionSnippet?.()} {/if} <textarea class="immich-form-input w-full pb-2" - aria-describedby={desc ? `${label}-desc` : undefined} + aria-describedby={description ? `${label}-desc` : undefined} aria-labelledby="{label}-label" id={label} name={label} {required} {value} - on:input={handleInput} + oninput={handleInput} {disabled} ></textarea> </div> diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte index 2bd1b8976bd17..a3cfd83ad51d6 100644 --- a/web/src/lib/components/shared-components/show-shortcuts.svelte +++ b/web/src/lib/components/shared-components/show-shortcuts.svelte @@ -15,25 +15,31 @@ info?: string; } - export let onClose: () => void; + interface Props { + onClose: () => void; + shortcuts?: Shortcuts; + } - export let shortcuts: Shortcuts = { - general: [ - { key: ['←', '→'], action: $t('previous_or_next_photo') }, - { key: ['Esc'], action: $t('back_close_deselect') }, - { key: ['Ctrl', 'k'], action: $t('search_your_photos') }, - { key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') }, - ], - actions: [ - { key: ['f'], action: $t('favorite_or_unfavorite_photo') }, - { key: ['i'], action: $t('show_or_hide_info') }, - { key: ['s'], action: $t('stack_selected_photos') }, - { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') }, - { key: ['⇧', 'd'], action: $t('download') }, - { key: ['Space'], action: $t('play_or_pause_video') }, - { key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') }, - ], - }; + let { + onClose, + shortcuts = { + general: [ + { key: ['←', '→'], action: $t('previous_or_next_photo') }, + { key: ['Esc'], action: $t('back_close_deselect') }, + { key: ['Ctrl', 'k'], action: $t('search_your_photos') }, + { key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') }, + ], + actions: [ + { key: ['f'], action: $t('favorite_or_unfavorite_photo') }, + { key: ['i'], action: $t('show_or_hide_info') }, + { key: ['s'], action: $t('stack_selected_photos') }, + { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') }, + { key: ['⇧', 'd'], action: $t('download') }, + { key: ['Space'], action: $t('play_or_pause_video') }, + { key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') }, + ], + }, + }: Props = $props(); </script> <FullScreenModal title={$t('keyboard_shortcuts')} width="auto" {onClose}> diff --git a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte b/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte index 68c58ab155e6d..58ce0c85747a9 100644 --- a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte +++ b/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte @@ -3,7 +3,11 @@ import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { t } from 'svelte-i18n'; - export let albumType: keyof AlbumStatisticsResponseDto; + interface Props { + albumType: keyof AlbumStatisticsResponseDto; + } + + let { albumType }: Props = $props(); const handleAlbumCount = async () => { try { diff --git a/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte b/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte index 1da245390b3a5..5e4589be18564 100644 --- a/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte +++ b/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte @@ -3,7 +3,11 @@ import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { t } from 'svelte-i18n'; - export let assetStats: NonNullable<Parameters<typeof getAssetStatistics>[0]>; + interface Props { + assetStats: NonNullable<Parameters<typeof getAssetStatistics>[0]>; + } + + let { assetStats }: Props = $props(); </script> {#await getAssetStatistics(assetStats)} diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte index a284c7efc1b95..2c4ab8818c201 100644 --- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte +++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte @@ -18,12 +18,12 @@ import { getButtonVisibility } from '$lib/utils/purchase-utils'; import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte'; - let showMessage = false; - let isOpen = false; - let hoverMessage = false; - let hoverButton = false; + let showMessage = $state(false); + let isOpen = $state(false); + let hoverMessage = $state(false); + let hoverButton = $state(false); - let showBuyButton = getButtonVisibility(); + let showBuyButton = $state(getButtonVisibility()); const { isPurchased } = purchaseStore; @@ -63,13 +63,15 @@ } }; - $: if (showMessage && !hoverMessage && !hoverButton) { - setTimeout(() => { - if (!hoverMessage && !hoverButton) { - showMessage = false; - } - }, 300); - } + $effect(() => { + if (showMessage && !hoverMessage && !hoverButton) { + setTimeout(() => { + if (!hoverMessage && !hoverButton) { + showMessage = false; + } + }, 300); + } + }); </script> {#if isOpen} @@ -79,7 +81,7 @@ <div class="hidden md:block license-status pl-4 text-sm"> {#if $isPurchased && $preferences.purchase.showSupportBadge} <button - on:click={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)} + onclick={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)} class="w-full" type="button" > @@ -88,11 +90,11 @@ {:else if !$isPurchased && showBuyButton && getAccountAge() > 14} <button type="button" - on:click={openPurchaseModal} - on:mouseover={onButtonHover} - on:mouseleave={() => (hoverButton = false)} - on:focus={onButtonHover} - on:blur={() => (hoverButton = false)} + onclick={openPurchaseModal} + onmouseover={onButtonHover} + onmouseleave={() => (hoverButton = false)} + onfocus={onButtonHover} + onblur={() => (hoverButton = false)} class="p-2 flex justify-between place-items-center place-content-center border border-immich-primary/20 dark:border-immich-dark-primary/10 mt-2 rounded-lg shadow-md dark:bg-immich-dark-primary/10 w-full" > <div class="flex justify-between w-full place-items-center place-content-center"> @@ -122,10 +124,10 @@ <div class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6" transition:fade={{ duration: 150 }} - on:mouseover={() => (hoverMessage = true)} - on:mouseleave={() => (hoverMessage = false)} - on:focus={() => (hoverMessage = true)} - on:blur={() => (hoverMessage = false)} + onmouseover={() => (hoverMessage = true)} + onmouseleave={() => (hoverMessage = false)} + onfocus={() => (hoverMessage = true)} + onblur={() => (hoverMessage = false)} role="dialog" > <div class="flex justify-between place-items-center"> @@ -134,7 +136,7 @@ </div> <CircleIconButton icon={mdiClose} - on:click={() => { + onclick={() => { showMessage = false; }} title={$t('close')} @@ -157,12 +159,12 @@ </p> </div> - <Button class="mt-2" fullwidth on:click={openPurchaseModal}>{$t('purchase_button_buy_immich')}</Button> + <Button class="mt-2" fullwidth onclick={openPurchaseModal}>{$t('purchase_button_buy_immich')}</Button> <div class="mt-3 flex gap-4"> - <Button size="sm" fullwidth shadow={false} color="transparent-gray" on:click={() => hideButton(true)}> + <Button size="sm" fullwidth shadow={false} color="transparent-gray" onclick={() => hideButton(true)}> {$t('purchase_button_never_show_again')} </Button> - <Button size="sm" fullwidth shadow={false} color="transparent-gray" on:click={() => hideButton(false)}> + <Button size="sm" fullwidth shadow={false} color="transparent-gray" onclick={() => hideButton(false)}> {$t('purchase_button_reminder')} </Button> </div> diff --git a/web/src/lib/components/shared-components/side-bar/server-status.svelte b/web/src/lib/components/shared-components/side-bar/server-status.svelte index 9774c07c637b5..2a0e6a082140f 100644 --- a/web/src/lib/components/shared-components/side-bar/server-status.svelte +++ b/web/src/lib/components/shared-components/side-bar/server-status.svelte @@ -15,21 +15,22 @@ const { serverVersion, connected } = websocketStore; - let isOpen = false; + let isOpen = $state(false); - $: isMain = info?.sourceRef === 'main' && info.repository === 'immich-app/immich'; - $: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null; - - let info: ServerAboutResponseDto; - let versions: ServerVersionHistoryResponseDto[] = []; + let info: ServerAboutResponseDto | undefined = $state(); + let versions: ServerVersionHistoryResponseDto[] = $state([]); onMount(async () => { await requestServerInfo(); [info, versions] = await Promise.all([getAboutInfo(), getVersionHistory()]); }); + let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich'); + let version = $derived( + $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null, + ); </script> -{#if isOpen} +{#if isOpen && info} <ServerAboutModal onClose={() => (isOpen = false)} {info} {versions} /> {/if} @@ -50,9 +51,9 @@ <div class="flex justify-between justify-items-center"> {#if $connected && version} - <button type="button" on:click={() => (isOpen = true)} class="dark:text-immich-gray flex gap-1"> + <button type="button" onclick={() => (isOpen = true)} class="dark:text-immich-gray flex gap-1"> {#if isMain} - <Icon path={mdiAlert} size="1.5em" color="#ffcc4d" /> {info.sourceRef} + <Icon path={mdiAlert} size="1.5em" color="#ffcc4d" /> {info?.sourceRef} {:else} {version} {/if} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte index 4590b122554aa..d3fd94ae081cb 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte @@ -4,17 +4,34 @@ import { mdiInformationOutline } from '@mdi/js'; import { resolveRoute } from '$app/paths'; import { page } from '$app/stores'; + import type { Snippet } from 'svelte'; - export let title: string; - export let routeId: string; - export let icon: string; - export let flippedLogo = false; - export let isSelected = false; - export let preloadData = true; + interface Props { + title: string; + routeId: string; + icon: string; + flippedLogo?: boolean; + isSelected?: boolean; + preloadData?: boolean; + moreInformation?: Snippet; + } - let showMoreInformation = false; - $: routePath = resolveRoute(routeId, {}); - $: isSelected = ($page.route.id?.match(/^\/(admin|\(user\))\/[^/]*/) || [])[0] === routeId; + let { + title, + routeId, + icon, + flippedLogo = false, + isSelected = $bindable(false), + preloadData = true, + moreInformation, + }: Props = $props(); + + let showMoreInformation = $state(false); + let routePath = $derived(resolveRoute(routeId, {})); + + $effect(() => { + isSelected = ($page.route.id?.match(/^\/(admin|\(user\))\/[^/]*/) || [])[0] === routeId; + }); </script> <a @@ -37,12 +54,12 @@ <div class="h-0 overflow-hidden transition-[height] delay-1000 duration-100 sm:group-hover:h-auto group-hover:sm:overflow-visible md:h-auto md:overflow-visible" > - {#if $$slots.moreInformation} - <!-- svelte-ignore a11y-no-static-element-interactions --> + {#if moreInformation} + <!-- svelte-ignore a11y_no_static_element_interactions --> <div class="relative flex cursor-default select-none justify-center" - on:mouseenter={() => (showMoreInformation = true)} - on:mouseleave={() => (showMoreInformation = false)} + onmouseenter={() => (showMoreInformation = true)} + onmouseleave={() => (showMoreInformation = false)} > <div class="p-1 text-gray-600 hover:cursor-help dark:text-gray-400"> <Icon path={mdiInformationOutline} /> @@ -55,7 +72,7 @@ class:hidden={!showMoreInformation} transition:fade={{ duration: 200 }} > - <slot name="moreInformation" /> + {@render moreInformation?.()} </div> </div> {/if} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte index 233010153fd92..37867da7affd7 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte @@ -1,4 +1,11 @@ <script lang="ts"> + import type { Snippet } from 'svelte'; + + interface Props { + children?: Snippet; + } + + let { children }: Props = $props(); </script> <section @@ -6,5 +13,5 @@ tabindex="-1" class="immich-scrollbar group relative z-10 flex w-18 flex-col gap-1 overflow-y-auto bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg hover:sm:w-64 hover:sm:border-r hover:sm:pr-6 hover:sm:shadow-2xl hover:sm:dark:border-r-immich-dark-gray md:w-64 md:pr-6 hover:md:border-none hover:md:shadow-none" > - <slot /> + {@render children?.()} </section> diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index fab7c6ed6dce9..54607e1779f9b 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -30,14 +30,14 @@ import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte'; import { preferences } from '$lib/stores/user.store'; - let isArchiveSelected: boolean; - let isFavoritesSelected: boolean; - let isMapSelected: boolean; - let isPeopleSelected: boolean; - let isPhotosSelected: boolean; - let isSharingSelected: boolean; - let isTrashSelected: boolean; - let isUtilitiesSelected: boolean; + let isArchiveSelected: boolean = $state(false); + let isFavoritesSelected: boolean = $state(false); + let isMapSelected: boolean = $state(false); + let isPeopleSelected: boolean = $state(false); + let isPhotosSelected: boolean = $state(false); + let isSharingSelected: boolean = $state(false); + let isTrashSelected: boolean = $state(false); + let isUtilitiesSelected: boolean = $state(false); </script> <SideBarSection> @@ -48,9 +48,9 @@ bind:isSelected={isPhotosSelected} icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline} > - <svelte:fragment slot="moreInformation"> + {#snippet moreInformation()} <MoreInformationAssets assetStats={{ isArchived: false }} /> - </svelte:fragment> + {/snippet} </SideBarLink> {#if $featureFlags.search} @@ -81,9 +81,9 @@ icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline} bind:isSelected={isSharingSelected} > - <svelte:fragment slot="moreInformation"> + {#snippet moreInformation()} <MoreInformationAlbums albumType="shared" /> - </svelte:fragment> + {/snippet} </SideBarLink> <div class="text-xs transition-all duration-200 dark:text-immich-dark-fg"> @@ -97,15 +97,15 @@ icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline} bind:isSelected={isFavoritesSelected} > - <svelte:fragment slot="moreInformation"> + {#snippet moreInformation()} <MoreInformationAssets assetStats={{ isFavorite: true }} /> - </svelte:fragment> + {/snippet} </SideBarLink> <SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo> - <svelte:fragment slot="moreInformation"> + {#snippet moreInformation()} <MoreInformationAlbums albumType="owned" /> - </svelte:fragment> + {/snippet} </SideBarLink> {#if $preferences.tags.enabled && $preferences.tags.sidebarWeb} @@ -129,9 +129,9 @@ bind:isSelected={isArchiveSelected} icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline} > - <svelte:fragment slot="moreInformation"> + {#snippet moreInformation()} <MoreInformationAssets assetStats={{ isArchived: true }} /> - </svelte:fragment> + {/snippet} </SideBarLink> {#if $featureFlags.trash} @@ -141,9 +141,9 @@ bind:isSelected={isTrashSelected} icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline} > - <svelte:fragment slot="moreInformation"> + {#snippet moreInformation()} <MoreInformationAssets assetStats={{ isTrashed: true }} /> - </svelte:fragment> + {/snippet} </SideBarLink> {/if} </nav> diff --git a/web/src/lib/components/shared-components/side-bar/storage-space.svelte b/web/src/lib/components/shared-components/side-bar/storage-space.svelte index c62b73e1b2872..c0de9378acaff 100644 --- a/web/src/lib/components/shared-components/side-bar/storage-space.svelte +++ b/web/src/lib/components/shared-components/side-bar/storage-space.svelte @@ -8,12 +8,12 @@ import { getByteUnitString } from '../../../utils/byte-units'; import LoadingSpinner from '../loading-spinner.svelte'; - let usageClasses = ''; + let usageClasses = $state(''); - $: hasQuota = $user?.quotaSizeInBytes !== null; - $: availableBytes = (hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0; - $: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0; - $: usedPercentage = Math.min(Math.round((usedBytes / availableBytes) * 100), 100); + let hasQuota = $derived($user?.quotaSizeInBytes !== null); + let availableBytes = $derived((hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0); + let usedBytes = $derived((hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0); + let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100)); const onUpdate = () => { usageClasses = getUsageClass(); @@ -31,9 +31,11 @@ return 'bg-immich-primary dark:bg-immich-dark-primary'; }; - $: if ($user) { - onUpdate(); - } + $effect(() => { + if ($user) { + onUpdate(); + } + }); onMount(async () => { await requestServerInfo(); diff --git a/web/src/lib/components/shared-components/side-bar/supporter-badge.svelte b/web/src/lib/components/shared-components/side-bar/supporter-badge.svelte index f2cb326c399ec..3d5e815996a39 100644 --- a/web/src/lib/components/shared-components/side-bar/supporter-badge.svelte +++ b/web/src/lib/components/shared-components/side-bar/supporter-badge.svelte @@ -2,8 +2,12 @@ import { t } from 'svelte-i18n'; import ImmichLogo from '../immich-logo.svelte'; - export let centered = false; - export let logoSize: 'sm' | 'lg' = 'sm'; + interface Props { + centered?: boolean; + logoSize?: 'sm' | 'lg'; + } + + let { centered = false, logoSize = 'sm' }: Props = $props(); </script> <div diff --git a/web/src/lib/components/shared-components/single-grid-row.svelte b/web/src/lib/components/shared-components/single-grid-row.svelte index 90020f2922d24..7764b9eb17038 100644 --- a/web/src/lib/components/shared-components/single-grid-row.svelte +++ b/web/src/lib/components/shared-components/single-grid-row.svelte @@ -1,10 +1,14 @@ <script lang="ts"> - let className = ''; - export { className as class }; - export let itemCount = 1; + interface Props { + class?: string; + itemCount?: number; + children?: import('svelte').Snippet<[{ itemCount: number }]>; + } + + let { class: className = '', itemCount = $bindable(1), children }: Props = $props(); - let container: HTMLElement | undefined; - let contentRect: DOMRectReadOnly | undefined; + let container: HTMLElement | undefined = $state(); + let contentRect: DOMRectReadOnly | undefined = $state(); const getGridGap = (element: Element) => { const style = getComputedStyle(element); @@ -28,11 +32,13 @@ return Math.floor((containerWidth + columnGap) / (childWidth + columnGap)) || 1; }; - $: if (container && contentRect) { - itemCount = getItemCount(container, contentRect.width); - } + $effect(() => { + if (container && contentRect) { + itemCount = getItemCount(container, contentRect.width); + } + }); </script> <div class={className} bind:this={container} bind:contentRect> - <slot {itemCount} /> + {@render children?.({ itemCount })} </div> diff --git a/web/src/lib/components/shared-components/star-rating.svelte b/web/src/lib/components/shared-components/star-rating.svelte index ee1b2b743357a..333248c227ad3 100644 --- a/web/src/lib/components/shared-components/star-rating.svelte +++ b/web/src/lib/components/shared-components/star-rating.svelte @@ -5,17 +5,23 @@ import { generateId } from '$lib/utils/generate-id'; import { t } from 'svelte-i18n'; - export let count = 5; - export let rating: number; - export let readOnly = false; - export let onRating: (rating: number) => void | undefined; + interface Props { + count?: number; + rating: number; + readOnly?: boolean; + onRating: (rating: number) => void | undefined; + } - let ratingSelection = 0; - let hoverRating = 0; - let focusRating = 0; + let { count = 5, rating, readOnly = false, onRating }: Props = $props(); + + let ratingSelection = $state(rating); + let hoverRating = $state(0); + let focusRating = $state(0); let timeoutId: ReturnType<typeof setTimeout> | undefined; - $: ratingSelection = rating; + $effect(() => { + ratingSelection = rating; + }); const starIcon = 'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z'; @@ -53,10 +59,10 @@ }; </script> -<!-- svelte-ignore a11y-mouse-events-have-key-events --> +<!-- svelte-ignore a11y_mouse_events_have_key_events --> <fieldset class="text-immich-primary dark:text-immich-dark-primary w-fit cursor-default" - on:mouseleave={() => setHoverRating(0)} + onmouseleave={() => setHoverRating(0)} use:focusOutside={{ onFocusOut: reset }} use:shortcuts={[ { shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() }, @@ -69,13 +75,13 @@ {@const value = index + 1} {@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)} {@const starId = `${id}-${value}`} - <!-- svelte-ignore a11y-mouse-events-have-key-events --> - <!-- svelte-ignore a11y-no-noninteractive-tabindex --> + <!-- svelte-ignore a11y_mouse_events_have_key_events --> + <!-- svelte-ignore a11y_no_noninteractive_tabindex --> <label for={starId} class:cursor-pointer={!readOnly} class:ring-2={focusRating === value} - on:mouseover={() => setHoverRating(value)} + onmouseover={() => setHoverRating(value)} tabindex={-1} data-testid="star" > @@ -96,10 +102,10 @@ id={starId} bind:group={ratingSelection} disabled={readOnly} - on:focus={() => { + onfocus={() => { focusRating = value; }} - on:change={() => handleSelectDebounced(value)} + onchange={() => handleSelectDebounced(value)} class="sr-only" /> {/each} @@ -108,7 +114,7 @@ {#if ratingSelection > 0 && !readOnly} <button type="button" - on:click={() => { + onclick={() => { ratingSelection = 0; handleSelect(ratingSelection); }} diff --git a/web/src/lib/components/shared-components/theme-button.svelte b/web/src/lib/components/shared-components/theme-button.svelte index f5ba87799b4ef..446668256f98f 100644 --- a/web/src/lib/components/shared-components/theme-button.svelte +++ b/web/src/lib/components/shared-components/theme-button.svelte @@ -5,14 +5,15 @@ import { colorTheme, handleToggleTheme } from '$lib/stores/preferences.store'; import { t } from 'svelte-i18n'; - // svelte-ignore reactive_declaration_non_reactive_property - $: icon = $colorTheme.value === Theme.LIGHT ? moonPath : sunPath; - // svelte-ignore reactive_declaration_non_reactive_property - $: viewBox = $colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox; - // svelte-ignore reactive_declaration_non_reactive_property - $: isDark = $colorTheme.value === Theme.DARK; + 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); - export let padding: Padding = '3'; + interface Props { + padding?: Padding; + } + + let { padding = '3' }: Props = $props(); </script> {#if !$colorTheme.system} @@ -22,7 +23,7 @@ {viewBox} role="switch" aria-checked={isDark ? 'true' : 'false'} - on:click={handleToggleTheme} + onclick={handleToggleTheme} {padding} /> {/if} diff --git a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte index a3c49a1430c4e..1d841339bc0a5 100644 --- a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte +++ b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte @@ -4,12 +4,16 @@ import { mdiArrowUpLeft, mdiChevronRight } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let pathSegments: string[] = []; - export let getLink: (path: string) => string; - export let title: string; - export let icon: string; + interface Props { + pathSegments?: string[]; + getLink: (path: string) => string; + title: string; + icon: string; + } - $: isRoot = pathSegments.length === 0; + let { pathSegments = [], getLink, title, icon }: Props = $props(); + + let isRoot = $derived(pathSegments.length === 0); </script> <nav class="flex items-center py-2"> @@ -21,6 +25,7 @@ href={getLink(pathSegments.slice(0, -1).join('/'))} class="mr-2" padding="2" + onclick={() => {}} /> </div> {/if} @@ -37,6 +42,7 @@ size="1.25em" padding="2" aria-current={isRoot ? 'page' : undefined} + onclick={() => {}} /> </li> {#each pathSegments as segment, index} diff --git a/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte b/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte index 759a3e5e6579e..1b4d30d0500f8 100644 --- a/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte +++ b/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte @@ -1,9 +1,13 @@ <script lang="ts"> import Icon from '$lib/components/elements/icon.svelte'; - export let items: string[] = []; - export let icon: string; - export let onClick: (path: string) => void; + interface Props { + items?: string[]; + icon: string; + onClick: (path: string) => void; + } + + let { items = [], icon, onClick }: Props = $props(); </script> {#if items.length > 0} @@ -13,7 +17,7 @@ {#each items as item} <button class="flex flex-col place-items-center gap-2 py-2 px-4 hover:bg-immich-primary/10 dark:hover:bg-immich-primary/40 rounded-xl" - on:click={() => onClick(item)} + onclick={() => onClick(item)} title={item} type="button" > diff --git a/web/src/lib/components/shared-components/tree/tree-items.svelte b/web/src/lib/components/shared-components/tree/tree-items.svelte index 4bdc95db9f0f6..c6db9fec8d684 100644 --- a/web/src/lib/components/shared-components/tree/tree-items.svelte +++ b/web/src/lib/components/shared-components/tree/tree-items.svelte @@ -2,12 +2,16 @@ import Tree from '$lib/components/shared-components/tree/tree.svelte'; import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils'; - export let items: RecursiveObject; - export let parent = ''; - export let active = ''; - export let icons: { default: string; active: string }; - export let getLink: (path: string) => string; - export let getColor: (path: string) => string | undefined = () => undefined; + interface Props { + items: RecursiveObject; + parent?: string; + active?: string; + icons: { default: string; active: string }; + getLink: (path: string) => string; + getColor?: (path: string) => string | undefined; + } + + let { items, parent = '', active = '', icons, getLink, getColor = () => undefined }: Props = $props(); </script> <ul class="list-none ml-2"> diff --git a/web/src/lib/components/shared-components/tree/tree.svelte b/web/src/lib/components/shared-components/tree/tree.svelte index 5c4b367a5482f..c6a13ec197e80 100644 --- a/web/src/lib/components/shared-components/tree/tree.svelte +++ b/web/src/lib/components/shared-components/tree/tree.svelte @@ -4,19 +4,31 @@ import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils'; import { mdiChevronDown, mdiChevronRight } from '@mdi/js'; - export let tree: RecursiveObject; - export let parent: string; - export let value: string; - export let active = ''; - export let icons: { default: string; active: string }; - export let getLink: (path: string) => string; - export let getColor: (path: string) => string | undefined; + interface Props { + tree: RecursiveObject; + parent: string; + value: string; + active?: string; + icons: { default: string; active: string }; + getLink: (path: string) => string; + getColor: (path: string) => string | undefined; + } - $: path = normalizeTreePath(`${parent}/${value}`); - $: isActive = active === path || active.startsWith(`${path}/`); - $: isOpen = isActive; - $: isTarget = active === path; - $: color = getColor(path); + let { tree, parent, value, active = '', icons, getLink, getColor }: Props = $props(); + + let path = $derived(normalizeTreePath(`${parent}/${value}`)); + let isActive = $derived(active === path || active.startsWith(`${path}/`)); + let isOpen = $state(false); + $effect(() => { + isOpen = isActive; + }); + let isTarget = $derived(active === path); + let color = $derived(getColor(path)); + + const onclick = (event: MouseEvent) => { + event.preventDefault(); + isOpen = !isOpen; + }; </script> <a @@ -24,11 +36,7 @@ title={value} class={`flex flex-grow place-items-center pl-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`} > - <button - type="button" - on:click|preventDefault={() => (isOpen = !isOpen)} - class={Object.values(tree).length === 0 ? 'invisible' : ''} - > + <button type="button" {onclick} class={Object.values(tree).length === 0 ? 'invisible' : ''}> <Icon path={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size={20} /> </button> <div> diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte index 7765e2ce5ce95..bd3b7856d10ef 100644 --- a/web/src/lib/components/shared-components/upload-asset-preview.svelte +++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte @@ -20,7 +20,11 @@ import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - export let uploadAsset: UploadAsset; + interface Props { + uploadAsset: UploadAsset; + } + + let { uploadAsset }: Props = $props(); const handleDismiss = (uploadAsset: UploadAsset) => { uploadAssetsStore.removeItem(uploadAsset.id); @@ -74,16 +78,16 @@ > <Icon path={mdiOpenInNew} size="20" /> </a> - <button type="button" on:click={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}> + <button type="button" onclick={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}> <Icon path={mdiClose} size="20" /> </button> </div> {:else if uploadAsset.state === UploadState.ERROR} <div class="flex items-center justify-between gap-1"> - <button type="button" on:click={() => handleRetry(uploadAsset)} class="" aria-hidden="true" tabindex={-1}> + <button type="button" onclick={() => handleRetry(uploadAsset)} class="" aria-hidden="true" tabindex={-1}> <Icon path={mdiRestart} size="20" /> </button> - <button type="button" on:click={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}> + <button type="button" onclick={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}> <Icon path={mdiClose} size="20" /> </button> </div> diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index d5360532862b1..7dd6d25596471 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -11,9 +11,9 @@ import { t } from 'svelte-i18n'; import { locale } from '$lib/stores/preferences.store'; - let showDetail = false; - let showOptions = false; - let concurrency = uploadExecutionQueue.concurrency; + let showDetail = $state(false); + let showOptions = $state(false); + let concurrency = $state(uploadExecutionQueue.concurrency); let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore; @@ -27,16 +27,18 @@ } }; - $: if ($isUploading) { - autoHide(); - } + $effect(() => { + if ($isUploading) { + autoHide(); + } + }); </script> {#if $isUploading} <div in:fade={{ duration: 250 }} out:fade={{ duration: 250 }} - on:outroend={() => { + onoutroend={() => { if ($stats.errors > 0) { notificationController.show({ message: $t('upload_errors', { values: { count: $stats.errors } }), @@ -92,14 +94,14 @@ icon={mdiCog} size="14" padding="1" - on:click={() => (showOptions = !showOptions)} + onclick={() => (showOptions = !showOptions)} /> <CircleIconButton title={$t('minimize')} icon={mdiWindowMinimize} size="14" padding="1" - on:click={() => (showDetail = false)} + onclick={() => (showDetail = false)} /> </div> {#if $isDismissible} @@ -108,7 +110,7 @@ icon={mdiCancel} size="14" padding="1" - on:click={() => uploadAssetsStore.dismissErrors()} + onclick={() => uploadAssetsStore.dismissErrors()} /> {/if} </div> @@ -128,7 +130,7 @@ max="50" step="1" bind:value={concurrency} - on:change={() => (uploadExecutionQueue.concurrency = concurrency)} + onchange={() => (uploadExecutionQueue.concurrency = concurrency)} /> </div> {/if} @@ -143,7 +145,7 @@ <button type="button" in:scale={{ duration: 250, easing: quartInOut }} - on:click={() => (showDetail = true)} + onclick={() => (showDetail = true)} class="absolute -left-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-primary p-5 text-xs text-gray-200" > {$remainingUploads.toLocaleString($locale)} @@ -152,7 +154,7 @@ <button type="button" in:scale={{ duration: 250, easing: quartInOut }} - on:click={() => (showDetail = true)} + onclick={() => (showDetail = true)} class="absolute -right-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-error p-5 text-xs text-gray-200" > {$stats.errors.toLocaleString($locale)} @@ -161,7 +163,7 @@ <button type="button" in:scale={{ duration: 250, easing: quartInOut }} - on:click={() => (showDetail = true)} + onclick={() => (showDetail = true)} class="flex h-16 w-16 place-content-center place-items-center rounded-full bg-gray-200 p-5 text-sm text-immich-primary shadow-lg dark:bg-gray-600 dark:text-immich-gray" > <div class="animate-pulse"> diff --git a/web/src/lib/components/shared-components/user-avatar.svelte b/web/src/lib/components/shared-components/user-avatar.svelte index 74750953b5211..938f5695089a7 100644 --- a/web/src/lib/components/shared-components/user-avatar.svelte +++ b/web/src/lib/components/shared-components/user-avatar.svelte @@ -1,4 +1,4 @@ -<script lang="ts" context="module"> +<script lang="ts" module> export type Size = 'full' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl'; </script> @@ -16,25 +16,34 @@ profileChangedAt: string; } - export let user: User; - export let color: UserAvatarColor | undefined = undefined; - export let size: Size = 'full'; - export let rounded = true; - export let interactive = false; - export let showTitle = true; - export let showProfileImage = true; - export let label: string | undefined = undefined; + interface Props { + user: User; + color?: UserAvatarColor | undefined; + size?: Size; + rounded?: boolean; + interactive?: boolean; + showTitle?: boolean; + showProfileImage?: boolean; + label?: string | undefined; + } - let img: HTMLImageElement; - let showFallback = true; + let { + user, + color = undefined, + size = 'full', + rounded = true, + interactive = false, + showTitle = true, + showProfileImage = true, + label = undefined, + }: Props = $props(); - // sveeeeeeelteeeeee fiveeeeee - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - $: img, user, void tryLoadImage(); + let img: HTMLImageElement | undefined = $state(); + let showFallback = $state(true); const tryLoadImage = async () => { try { - await img.decode(); + await img?.decode(); showFallback = false; } catch { showFallback = true; @@ -64,12 +73,20 @@ xxxl: 'w-28 h-28', }; - $: colorClass = colorClasses[color || user.avatarColor]; - $: sizeClass = sizeClasses[size]; - $: title = label ?? `${user.name} (${user.email})`; - $: interactiveClass = interactive - ? 'border-2 border-immich-primary hover:border-immich-dark-primary dark:hover:border-immich-primary dark:border-immich-dark-primary transition-colors' - : ''; + $effect(() => { + if (img && user) { + tryLoadImage().catch(console.error); + } + }); + + let colorClass = $derived(colorClasses[color || user.avatarColor]); + let sizeClass = $derived(sizeClasses[size]); + let title = $derived(label ?? `${user.name} (${user.email})`); + let interactiveClass = $derived( + interactive + ? 'border-2 border-immich-primary hover:border-immich-dark-primary dark:hover:border-immich-primary dark:border-immich-dark-primary transition-colors' + : '', + ); </script> <figure diff --git a/web/src/lib/components/shared-components/version-announcement-box.svelte b/web/src/lib/components/shared-components/version-announcement-box.svelte index fb5466e7aea87..62e9baf7793d4 100644 --- a/web/src/lib/components/shared-components/version-announcement-box.svelte +++ b/web/src/lib/components/shared-components/version-announcement-box.svelte @@ -6,18 +6,12 @@ import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; - let showModal = false; + let showModal = $state(false); const { release } = websocketStore; const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`; - $: releaseVersion = $release && semverToName($release.releaseVersion); - $: serverVersion = $release && semverToName($release.serverVersion); - $: if ($release?.isAvailable) { - handleRelease(); - } - const onAcknowledge = () => { localStorage.setItem('appVersion', releaseVersion); showModal = false; @@ -34,21 +28,30 @@ console.error('Error [VersionAnnouncementBox]:', error); } }; + let releaseVersion = $derived($release && semverToName($release.releaseVersion)); + let serverVersion = $derived($release && semverToName($release.serverVersion)); + $effect(() => { + if ($release?.isAvailable) { + handleRelease(); + } + }); </script> {#if showModal} <FullScreenModal title="🎉 {$t('new_version_available')}" onClose={() => (showModal = false)}> <div> - <FormatMessage key="version_announcement_message" let:tag let:message> - {#if tag === 'link'} - <span class="font-medium underline"> - <a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer"> - {message} - </a> - </span> - {:else if tag === 'code'} - <code>{message}</code> - {/if} + <FormatMessage key="version_announcement_message"> + {#snippet children({ tag, message })} + {#if tag === 'link'} + <span class="font-medium underline"> + <a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer"> + {message} + </a> + </span> + {:else if tag === 'code'} + <code>{message}</code> + {/if} + {/snippet} </FormatMessage> </div> @@ -60,8 +63,8 @@ <code>{$t('latest_version')}: {releaseVersion}</code> </div> - <svelte:fragment slot="sticky-bottom"> - <Button fullwidth on:click={onAcknowledge}>{$t('acknowledge')}</Button> - </svelte:fragment> + {#snippet stickyBottom()} + <Button fullwidth onclick={onAcknowledge}>{$t('acknowledge')}</Button> + {/snippet} </FullScreenModal> {/if} diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte index f955d8479a293..9ec9fc76cea66 100644 --- a/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte +++ b/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte @@ -7,8 +7,12 @@ import { mdiContentCopy } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let link: SharedLinkResponseDto; - export let menuItem = false; + interface Props { + link: SharedLinkResponseDto; + menuItem?: boolean; + } + + let { link, menuItem = false }: Props = $props(); const handleCopy = async () => { await copyToClipboard(makeSharedLinkUrl($serverConfig.externalDomain, link.key)); @@ -18,5 +22,5 @@ {#if menuItem} <MenuOption text={$t('copy_link')} icon={mdiContentCopy} onClick={handleCopy} /> {:else} - <CircleIconButton title={$t('copy_link')} icon={mdiContentCopy} on:click={handleCopy} /> + <CircleIconButton title={$t('copy_link')} icon={mdiContentCopy} onclick={handleCopy} /> {/if} diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte index d458d5d77aced..8c81e736bbfba 100644 --- a/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte +++ b/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte @@ -4,12 +4,16 @@ import { mdiDelete } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let menuItem = false; - export let onDelete: () => void; + interface Props { + menuItem?: boolean; + onDelete: () => void; + } + + let { menuItem = false, onDelete }: Props = $props(); </script> {#if menuItem} <MenuOption text={$t('delete_link')} icon={mdiDelete} onClick={onDelete} /> {:else} - <CircleIconButton title={$t('delete_link')} icon={mdiDelete} on:click={onDelete} /> + <CircleIconButton title={$t('delete_link')} icon={mdiDelete} onclick={onDelete} /> {/if} diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte index 49c610563218c..0a0c5a4736127 100644 --- a/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte +++ b/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte @@ -4,12 +4,16 @@ import { mdiCircleEditOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let menuItem = false; - export let onEdit: () => void; + interface Props { + menuItem?: boolean; + onEdit: () => void; + } + + let { menuItem = false, onEdit }: Props = $props(); </script> {#if menuItem} <MenuOption text={$t('edit_link')} icon={mdiCircleEditOutline} onClick={onEdit} /> {:else} - <CircleIconButton title={$t('edit_link')} icon={mdiCircleEditOutline} on:click={onEdit} /> + <CircleIconButton title={$t('edit_link')} icon={mdiCircleEditOutline} onclick={onEdit} /> {/if} diff --git a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte index bf5031e39d037..76822cc786f4f 100644 --- a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte @@ -1,13 +1,16 @@ <script lang="ts"> import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; - export let alt; - export let preload = false; - export let src: string; - let className = ''; - export { className as class }; + interface Props { + alt?: string; + preload?: boolean; + src: string; + class?: string; + } - let isBroken = false; + let { alt, preload = false, src, class: className = '' }: Props = $props(); + + let isBroken = $state(false); </script> {#if isBroken} @@ -15,7 +18,7 @@ {:else} <img {alt} - on:error={() => (isBroken = true)} + onerror={() => (isBroken = true)} class="size-full rounded-xl object-cover aspect-square {className}" data-testid="album-image" draggable="false" diff --git a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte index 087204d6a5df0..1e09c6bcfa9bc 100644 --- a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte @@ -1,8 +1,11 @@ <script lang="ts"> - export let alt = ''; - export let preload = false; - let className = ''; - export { className as class }; + interface Props { + alt?: string; + preload?: boolean; + class?: string; + } + + let { alt = '', preload = false, class: className = '' }: Props = $props(); </script> <enhanced:img diff --git a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte index 09f32d7dacebb..6f15cca45fc54 100644 --- a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte @@ -6,10 +6,13 @@ import { getAssetThumbnailUrl } from '$lib/utils'; import { t } from 'svelte-i18n'; - export let link: SharedLinkResponseDto; - export let preload = false; - let className = ''; - export { className as class }; + interface Props { + link: SharedLinkResponseDto; + preload?: boolean; + class?: string; + } + + let { link, preload = false, class: className = '' }: Props = $props(); </script> <div class="relative shrink-0 size-24"> diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte index 13beec0ec077a..70f62475334da 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -12,13 +12,17 @@ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import { mdiDotsVertical } from '@mdi/js'; - export let link: SharedLinkResponseDto; - export let onDelete: () => void; - export let onEdit: () => void; + interface Props { + link: SharedLinkResponseDto; + onDelete: () => void; + onEdit: () => void; + } + + let { link, onDelete, onEdit }: Props = $props(); let now = DateTime.now(); - $: expiresAt = link.expiresAt ? DateTime.fromISO(link.expiresAt) : undefined; - $: isExpired = expiresAt ? now > expiresAt : false; + let expiresAt = $derived(link.expiresAt ? DateTime.fromISO(link.expiresAt) : undefined); + let isExpired = $derived(expiresAt ? now > expiresAt : false); const getCountDownExpirationDate = (expiresAtDate: DateTime, now: DateTime) => { const relativeUnits: ToRelativeUnit[] = ['days', 'hours', 'minutes', 'seconds']; diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte index 6f0397be98f19..723960d9141cb 100644 --- a/web/src/lib/components/slideshow-settings.svelte +++ b/web/src/lib/components/slideshow-settings.svelte @@ -1,8 +1,6 @@ <script lang="ts"> import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { mdiArrowDownThin, @@ -17,10 +15,15 @@ import type { RenderedOption } from './elements/dropdown.svelte'; import SettingDropdown from './shared-components/settings/setting-dropdown.svelte'; import { t } from 'svelte-i18n'; + import { SettingInputFieldType } from '$lib/constants'; const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook, slideshowTransition } = slideshowStore; - export let onClose = () => {}; + interface Props { + onClose?: () => void; + } + + let { onClose = () => {} }: Props = $props(); const navigationOptions: Record<SlideshowNavigation, RenderedOption> = { [SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') }, @@ -46,7 +49,7 @@ }; </script> -<FullScreenModal title={$t('slideshow_settings')} {onClose}> +<FullScreenModal title={$t('slideshow_settings')} onClose={() => onClose()}> <div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"> <SettingDropdown title={$t('direction')} @@ -69,12 +72,13 @@ <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('duration')} - desc={$t('admin.slideshow_duration_description')} + description={$t('admin.slideshow_duration_description')} min={1} bind:value={$slideshowDelay} /> </div> - <svelte:fragment slot="sticky-bottom"> - <Button fullwidth color="primary" on:click={onClose}>{$t('done')}</Button> - </svelte:fragment> + + {#snippet stickyBottom()} + <Button fullwidth color="primary" onclick={(_) => onClose()}>{$t('done')}</Button> + {/snippet} </FullScreenModal> diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index 4bfd9a4e0e2cf..63209ca289c7e 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -19,26 +19,7 @@ import { fade } from 'svelte/transition'; import { invalidateAll } from '$app/navigation'; - let time = new Date(); - - $: formattedDate = time.toLocaleString(editedLocale, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }); - $: timePortion = time.toLocaleString(editedLocale, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); - $: selectedDate = `${formattedDate} ${timePortion}`; - $: editedLocale = findLocale($locale).code; - // svelte-ignore reactive_declaration_non_reactive_property - $: selectedOption = { - value: findLocale(editedLocale).code || fallbackLocale.code, - label: findLocale(editedLocale).name || fallbackLocale.name, - }; - $: closestLanguage = getClosestAvailableLocale([$lang], langCodes); + let time = $state(new Date()); onMount(() => { const interval = setInterval(() => { @@ -90,6 +71,27 @@ $locale = newLocale; } }; + let editedLocale = $derived(findLocale($locale).code); + let formattedDate = $derived( + time.toLocaleString(editedLocale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }), + ); + let timePortion = $derived( + time.toLocaleString(editedLocale, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }), + ); + let selectedDate = $derived(`${formattedDate} ${timePortion}`); + let selectedOption = $derived({ + value: findLocale(editedLocale).code || fallbackLocale.code, + label: findLocale(editedLocale).name || fallbackLocale.name, + }); + let closestLanguage = $derived(getClosestAvailableLocale([$lang], langCodes)); </script> <section class="my-4"> diff --git a/web/src/lib/components/user-settings-page/change-password-settings.svelte b/web/src/lib/components/user-settings-page/change-password-settings.svelte index 39fd78e037cf7..b31cbac34f8b5 100644 --- a/web/src/lib/components/user-settings-page/change-password-settings.svelte +++ b/web/src/lib/components/user-settings-page/change-password-settings.svelte @@ -8,14 +8,13 @@ import Button from '$lib/components/elements/buttons/button.svelte'; import type { HttpError } from '@sveltejs/kit'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { t } from 'svelte-i18n'; + import { SettingInputFieldType } from '$lib/constants'; - let password = ''; - let newPassword = ''; - let confirmPassword = ''; + let password = $state(''); + let newPassword = $state(''); + let confirmPassword = $state(''); const handleChangePassword = async () => { try { @@ -37,11 +36,15 @@ }); } }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <section class="my-4"> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingInputField inputType={SettingInputFieldType.PASSWORD} @@ -72,7 +75,7 @@ type="submit" size="sm" disabled={!(password && newPassword && newPassword === confirmPassword)} - on:click={() => handleChangePassword()}>{$t('save')}</Button + onclick={() => handleChangePassword()}>{$t('save')}</Button > </div> </div> diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index d43977ea08a77..5248a6d119be7 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -17,8 +17,12 @@ import { DateTime, type ToRelativeCalendarOptions } from 'luxon'; import { t } from 'svelte-i18n'; - export let device: SessionResponseDto; - export let onDelete: (() => void) | undefined = undefined; + interface Props { + device: SessionResponseDto; + onDelete?: (() => void) | undefined; + } + + let { device, onDelete = undefined }: Props = $props(); const options: ToRelativeCalendarOptions = { unit: 'days', @@ -71,7 +75,7 @@ icon={mdiTrashCanOutline} title={$t('log_out')} size="16" - on:click={onDelete} + onclick={onDelete} /> </div> {/if} diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte index 26e03c35d8acd..bb202b36061ed 100644 --- a/web/src/lib/components/user-settings-page/device-list.svelte +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -7,12 +7,16 @@ import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; - export let devices: SessionResponseDto[]; + interface Props { + devices: SessionResponseDto[]; + } + + let { devices = $bindable() }: Props = $props(); const refresh = () => getSessions().then((_devices) => (devices = _devices)); - $: currentDevice = devices.find((device) => device.current); - $: otherDevices = devices.filter((device) => !device.current); + let currentDevice = $derived(devices.find((device) => device.current)); + let otherDevices = $derived(devices.filter((device) => !device.current)); const handleDelete = async (device: SessionResponseDto) => { const isConfirmed = await dialogController.show({ @@ -78,7 +82,7 @@ {$t('log_out_all_devices').toUpperCase()} </h3> <div class="flex justify-end"> - <Button color="red" size="sm" on:click={handleDeleteAll}>{$t('log_out_all_devices')}</Button> + <Button color="red" size="sm" onclick={handleDeleteAll}>{$t('log_out_all_devices')}</Button> </div> {/if} </section> diff --git a/web/src/lib/components/user-settings-page/download-settings.svelte b/web/src/lib/components/user-settings-page/download-settings.svelte index f5b94ebee8f2b..97da347fb7c0f 100644 --- a/web/src/lib/components/user-settings-page/download-settings.svelte +++ b/web/src/lib/components/user-settings-page/download-settings.svelte @@ -10,14 +10,13 @@ import { preferences } from '$lib/stores/user.store'; import Button from '../elements/buttons/button.svelte'; import { t } from 'svelte-i18n'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; + import { SettingInputFieldType } from '$lib/constants'; - let archiveSize = convertFromBytes($preferences?.download?.archiveSize || 4, ByteUnit.GiB); - let includeEmbeddedVideos = $preferences?.download?.includeEmbeddedVideos || false; + let archiveSize = $state(convertFromBytes($preferences?.download?.archiveSize || 4, ByteUnit.GiB)); + let includeEmbeddedVideos = $state($preferences?.download?.includeEmbeddedVideos || false); const handleSave = async () => { try { @@ -36,16 +35,20 @@ handleError(error, $t('errors.unable_to_update_settings')); } }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <section class="my-4"> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingInputField inputType={SettingInputFieldType.NUMBER} label={$t('archive_size')} - desc={$t('archive_size_description')} + description={$t('archive_size_description')} bind:value={archiveSize} /> <SettingSwitch @@ -54,7 +57,7 @@ bind:checked={includeEmbeddedVideos} ></SettingSwitch> <div class="flex justify-end"> - <Button type="submit" size="sm" on:click={() => handleSave()}>{$t('save')}</Button> + <Button type="submit" size="sm" onclick={() => handleSave()}>{$t('save')}</Button> </div> </div> </form> diff --git a/web/src/lib/components/user-settings-page/feature-settings.svelte b/web/src/lib/components/user-settings-page/feature-settings.svelte index d04bbc3e7d3c9..9a60f83647de5 100644 --- a/web/src/lib/components/user-settings-page/feature-settings.svelte +++ b/web/src/lib/components/user-settings-page/feature-settings.svelte @@ -14,22 +14,22 @@ import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; // Folders - let foldersEnabled = $preferences?.folders?.enabled ?? false; - let foldersSidebar = $preferences?.folders?.sidebarWeb ?? false; + let foldersEnabled = $state($preferences?.folders?.enabled ?? false); + let foldersSidebar = $state($preferences?.folders?.sidebarWeb ?? false); // Memories - let memoriesEnabled = $preferences?.memories?.enabled ?? true; + let memoriesEnabled = $state($preferences?.memories?.enabled ?? true); // People - let peopleEnabled = $preferences?.people?.enabled ?? false; - let peopleSidebar = $preferences?.people?.sidebarWeb ?? false; + let peopleEnabled = $state($preferences?.people?.enabled ?? false); + let peopleSidebar = $state($preferences?.people?.sidebarWeb ?? false); // Ratings - let ratingsEnabled = $preferences?.ratings?.enabled ?? false; + let ratingsEnabled = $state($preferences?.ratings?.enabled ?? false); // Tags - let tagsEnabled = $preferences?.tags?.enabled ?? false; - let tagsSidebar = $preferences?.tags?.sidebarWeb ?? false; + let tagsEnabled = $state($preferences?.tags?.enabled ?? false); + let tagsSidebar = $state($preferences?.tags?.sidebarWeb ?? false); const handleSave = async () => { try { @@ -50,11 +50,15 @@ handleError(error, $t('errors.unable_to_update_settings')); } }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <section class="my-4"> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4 flex flex-col"> <SettingAccordion key="folders" title={$t('folders')} subtitle={$t('folders_feature_description')}> <div class="ml-4 mt-6"> @@ -116,7 +120,7 @@ </SettingAccordion> <div class="flex justify-end"> - <Button type="submit" size="sm" on:click={() => handleSave()}>{$t('save')}</Button> + <Button type="submit" size="sm" onclick={() => handleSave()}>{$t('save')}</Button> </div> </div> </form> diff --git a/web/src/lib/components/user-settings-page/notifications-settings.svelte b/web/src/lib/components/user-settings-page/notifications-settings.svelte index 275f628f0a552..bec0633964542 100644 --- a/web/src/lib/components/user-settings-page/notifications-settings.svelte +++ b/web/src/lib/components/user-settings-page/notifications-settings.svelte @@ -12,9 +12,9 @@ import Button from '../elements/buttons/button.svelte'; import { t } from 'svelte-i18n'; - let emailNotificationsEnabled = $preferences?.emailNotifications?.enabled ?? true; - let albumInviteNotificationEnabled = $preferences?.emailNotifications?.albumInvite ?? true; - let albumUpdateNotificationEnabled = $preferences?.emailNotifications?.albumUpdate ?? true; + let emailNotificationsEnabled = $state($preferences?.emailNotifications?.enabled ?? true); + let albumInviteNotificationEnabled = $state($preferences?.emailNotifications?.albumInvite ?? true); + let albumUpdateNotificationEnabled = $state($preferences?.emailNotifications?.albumUpdate ?? true); const handleSave = async () => { try { @@ -37,11 +37,15 @@ handleError(error, $t('errors.unable_to_update_settings')); } }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + }; </script> <section class="my-4"> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" {onsubmit}> <div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4"> <SettingSwitch @@ -67,7 +71,7 @@ </div> <div class="flex justify-end"> - <Button type="submit" size="sm" on:click={() => handleSave()}>{$t('save')}</Button> + <Button type="submit" size="sm" onclick={() => handleSave()}>{$t('save')}</Button> </div> </div> </form> diff --git a/web/src/lib/components/user-settings-page/oauth-settings.svelte b/web/src/lib/components/user-settings-page/oauth-settings.svelte index 10e71e64eb467..8dbe3539b8b27 100644 --- a/web/src/lib/components/user-settings-page/oauth-settings.svelte +++ b/web/src/lib/components/user-settings-page/oauth-settings.svelte @@ -11,9 +11,13 @@ import { notificationController, NotificationType } from '../shared-components/notification/notification'; import { t } from 'svelte-i18n'; - export let user: UserAdminResponseDto; + interface Props { + user: UserAdminResponseDto; + } - let loading = true; + let { user = $bindable() }: Props = $props(); + + let loading = $state(true); onMount(async () => { if (oauth.isCallback(window.location)) { @@ -58,9 +62,9 @@ </div> {:else if $featureFlags.oauth} {#if user.oauthId} - <Button size="sm" on:click={() => handleUnlink()}>{$t('unlink_oauth')}</Button> + <Button size="sm" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button> {:else} - <Button size="sm" on:click={() => oauth.authorize(window.location)}>{$t('link_to_oauth')}</Button> + <Button size="sm" onclick={() => oauth.authorize(window.location)}>{$t('link_to_oauth')}</Button> {/if} {/if} </div> diff --git a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte index 8ab747aa276da..070246b612d37 100644 --- a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte +++ b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte @@ -6,12 +6,16 @@ import Button from '../elements/buttons/button.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte'; - export let user: UserResponseDto; - export let onClose: () => void; - export let onAddUsers: (users: UserResponseDto[]) => void; + interface Props { + user: UserResponseDto; + onClose: () => void; + onAddUsers: (users: UserResponseDto[]) => void; + } - let availableUsers: UserResponseDto[] = []; - let selectedUsers: UserResponseDto[] = []; + let { user, onClose, onAddUsers }: Props = $props(); + + let availableUsers: UserResponseDto[] = $state([]); + let selectedUsers: UserResponseDto[] = $state([]); onMount(async () => { let users = await searchUsers(); @@ -38,7 +42,7 @@ {#each availableUsers as user} <button type="button" - on:click={() => selectUser(user)} + onclick={() => selectUser(user)} class="flex w-full place-items-center gap-4 px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl" > {#if selectedUsers.includes(user)} @@ -68,7 +72,7 @@ {#if selectedUsers.length > 0} <div class="pt-5"> - <Button size="sm" fullwidth on:click={() => onAddUsers(selectedUsers)}>{$t('add')}</Button> + <Button size="sm" fullwidth onclick={() => onAddUsers(selectedUsers)}>{$t('add')}</Button> </div> {/if} </div> diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte index 050e2c42f3cac..7d575105478a6 100644 --- a/web/src/lib/components/user-settings-page/partner-settings.svelte +++ b/web/src/lib/components/user-settings-page/partner-settings.svelte @@ -27,11 +27,15 @@ inTimeline: boolean; } - export let user: UserResponseDto; + interface Props { + user: UserResponseDto; + } + + let { user }: Props = $props(); - let createPartnerFlag = false; + let createPartnerFlag = $state(false); // let removePartnerDto: PartnerResponseDto | null = null; - let partners: Array<PartnerSharing> = []; + let partners: Array<PartnerSharing> = $state([]); onMount(async () => { await refreshPartners(); @@ -139,7 +143,7 @@ {#if partner.sharedByMe} <CircleIconButton - on:click={() => handleRemovePartner(partner.user)} + onclick={() => handleRemovePartner(partner.user)} icon={mdiClose} size={'16'} title={$t('stop_sharing_photos_with_user')} @@ -186,7 +190,7 @@ {/if} <div class="flex justify-end mt-5"> - <Button size="sm" on:click={() => (createPartnerFlag = true)}>{$t('add_partner')}</Button> + <Button size="sm" onclick={() => (createPartnerFlag = true)}>{$t('add_partner')}</Button> </div> </section> diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index a63bdb3ca9cb6..c5c6bae9e5478 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -19,11 +19,15 @@ import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; - export let keys: ApiKeyResponseDto[]; + interface Props { + keys: ApiKeyResponseDto[]; + } + + let { keys = $bindable() }: Props = $props(); - let newKey: { name: string } | null = null; - let editKey: ApiKeyResponseDto | null = null; - let secret = ''; + let newKey: { name: string } | null = $state(null); + let editKey: ApiKeyResponseDto | null = $state(null); + let secret = $state(''); const format: Intl.DateTimeFormatOptions = { month: 'short', @@ -118,7 +122,7 @@ <section class="my-4"> <div class="flex flex-col gap-2" in:fade={{ duration: 500 }}> <div class="mb-2 flex justify-end"> - <Button size="sm" on:click={() => (newKey = { name: $t('api_key') })}>{$t('new_api_key')}</Button> + <Button size="sm" onclick={() => (newKey = { name: $t('api_key') })}>{$t('new_api_key')}</Button> </div> {#if keys.length > 0} @@ -152,14 +156,14 @@ icon={mdiPencilOutline} title={$t('edit_key')} size="16" - on:click={() => (editKey = key)} + onclick={() => (editKey = key)} /> <CircleIconButton color="primary" icon={mdiTrashCanOutline} title={$t('delete_key')} size="16" - on:click={() => handleDelete(key)} + onclick={() => handleDelete(key)} /> </td> </tr> diff --git a/web/src/lib/components/user-settings-page/user-profile-settings.svelte b/web/src/lib/components/user-settings-page/user-profile-settings.svelte index 1f3b59bfdd8ce..a49eabcf13ffc 100644 --- a/web/src/lib/components/user-settings-page/user-profile-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-profile-settings.svelte @@ -1,11 +1,12 @@ <script lang="ts"> + import { createBubbler, preventDefault } from 'svelte/legacy'; + + const bubble = createBubbler(); import { notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { user } from '$lib/stores/user.store'; import { updateMyUser } from '@immich/sdk'; import { cloneDeep } from 'lodash-es'; @@ -13,8 +14,9 @@ import { handleError } from '../../utils/handle-error'; import Button from '../elements/buttons/button.svelte'; import { t } from 'svelte-i18n'; + import { SettingInputFieldType } from '$lib/constants'; - let editedUser = cloneDeep($user); + let editedUser = $state(cloneDeep($user)); const handleSaveProfile = async () => { try { @@ -40,7 +42,7 @@ <section class="my-4"> <div in:fade={{ duration: 500 }}> - <form autocomplete="off" on:submit|preventDefault> + <form autocomplete="off" onsubmit={preventDefault(bubble('submit'))}> <div class="ml-4 mt-4 flex flex-col gap-4"> <SettingInputField inputType={SettingInputFieldType.TEXT} @@ -67,7 +69,7 @@ /> <div class="flex justify-end"> - <Button type="submit" size="sm" on:click={() => handleSaveProfile()}>{$t('save')}</Button> + <Button type="submit" size="sm" onclick={() => handleSaveProfile()}>{$t('save')}</Button> </div> </div> </form> diff --git a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte index 71f76d07c0a85..240865123498e 100644 --- a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte @@ -24,8 +24,8 @@ import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils'; const { isPurchased } = purchaseStore; - let isServerProduct = false; - let serverPurchaseInfo: LicenseResponseDto | null = null; + let isServerProduct = $state(false); + let serverPurchaseInfo: LicenseResponseDto | null = $state(null); const checkPurchaseInfo = async () => { const serverInfo = await getAboutInfo(); @@ -145,7 +145,7 @@ {#if $user.isAdmin} <div class="text-right mt-4"> - <Button size="sm" color="red" on:click={removeServerProductKey}>{$t('purchase_button_remove_key')}</Button> + <Button size="sm" color="red" onclick={removeServerProductKey}>{$t('purchase_button_remove_key')}</Button> </div> {/if} {:else} @@ -169,8 +169,7 @@ </div> <div class="text-right mt-4"> - <Button size="sm" color="red" on:click={removeIndividualProductKey}>{$t('purchase_button_remove_key')}</Button - > + <Button size="sm" color="red" onclick={removeIndividualProductKey}>{$t('purchase_button_remove_key')}</Button> </div> {/if} {:else} diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index f355c3105cba2..6f8a0ce4dc270 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -33,8 +33,12 @@ mdiTwoFactorAuthentication, } from '@mdi/js'; - export let keys: ApiKeyResponseDto[] = []; - export let sessions: SessionResponseDto[] = []; + interface Props { + keys?: ApiKeyResponseDto[]; + sessions?: SessionResponseDto[]; + } + + let { keys = $bindable([]), sessions = $bindable([]) }: Props = $props(); let oauthOpen = oauth.isCallback(window.location) || diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte index 2103250b54da9..19190745d1427 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte @@ -7,13 +7,17 @@ import { mdiHeart, mdiMagnifyPlus, mdiImageMultipleOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let asset: AssetResponseDto; - export let isSelected: boolean; - export let onSelectAsset: (asset: AssetResponseDto) => void; - export let onViewAsset: (asset: AssetResponseDto) => void; + interface Props { + asset: AssetResponseDto; + isSelected: boolean; + onSelectAsset: (asset: AssetResponseDto) => void; + onViewAsset: (asset: AssetResponseDto) => void; + } - $: isFromExternalLibrary = !!asset.libraryId; - $: assetData = JSON.stringify(asset, null, 2); + let { asset, isSelected, onSelectAsset, onViewAsset }: Props = $props(); + + let isFromExternalLibrary = $derived(!!asset.libraryId); + let assetData = $derived(JSON.stringify(asset, null, 2)); </script> <div @@ -24,7 +28,7 @@ <div class="relative w-full"> <button type="button" - on:click={() => onSelectAsset(asset)} + onclick={() => onSelectAsset(asset)} class="block relative w-full" aria-pressed={isSelected} aria-label={$t('keep')} @@ -74,7 +78,7 @@ <button type="button" - on:click={() => onViewAsset(asset)} + onclick={() => onViewAsset(asset)} class="absolute rounded-full top-1 left-1 text-gray-200 p-2 hover:text-white bg-black/35 hover:bg-black/50" title={$t('view')} > diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index fd5b68d8c38a6..6b9bc93c1e9a1 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -11,21 +11,26 @@ import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; + import { SvelteSet } from 'svelte/reactivity'; - export let assets: AssetResponseDto[]; - export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void; - export let onStack: (assets: AssetResponseDto[]) => void; + interface Props { + assets: AssetResponseDto[]; + onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void; + onStack: (assets: AssetResponseDto[]) => void; + } + + let { assets, onResolve, onStack }: Props = $props(); const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore; const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id); - let selectedAssetIds = new Set<string>(); - $: trashCount = assets.length - selectedAssetIds.size; + let selectedAssetIds = $state(new SvelteSet<string>()); + let trashCount = $derived(assets.length - selectedAssetIds.size); onMount(() => { const suggestedAsset = suggestDuplicateByFileSize(assets); if (!suggestedAsset) { - selectedAssetIds = new Set(assets[0].id); + selectedAssetIds = new SvelteSet(assets[0].id); return; } @@ -53,7 +58,7 @@ }; const onSelectAll = () => { - selectedAssetIds = new Set(assets.map((asset) => asset.id)); + selectedAssetIds = new SvelteSet(assets.map((asset) => asset.id)); }; const handleResolve = () => { @@ -100,12 +105,12 @@ <button type="button" class="px-4 py-3 flex place-items-center gap-2 rounded-tl-full rounded-bl-full dark:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/90 bg-immich-primary/25 hover:bg-immich-primary/50" - on:click={onSelectAll}><Icon path={mdiCheck} size="20" />{$t('select_keep_all')}</button + onclick={onSelectAll}><Icon path={mdiCheck} size="20" />{$t('select_keep_all')}</button > <button type="button" class="px-4 py-3 flex place-items-center gap-2 rounded-tr-full rounded-br-full dark:bg-immich-dark-primary/50 hover:dark:bg-immich-dark-primary/70 bg-immich-primary hover:bg-immich-primary/80 text-white" - on:click={onSelectNone}><Icon path={mdiTrashCanOutline} size="20" />{$t('select_trash_all')}</button + onclick={onSelectNone}><Icon path={mdiTrashCanOutline} size="20" />{$t('select_trash_all')}</button > </div> @@ -116,7 +121,7 @@ size="sm" color="primary" class="flex place-items-center rounded-tl-full rounded-bl-full gap-2" - on:click={handleResolve} + onclick={handleResolve} > <Icon path={mdiCheck} size="20" />{$t('keep_all')} </Button> @@ -125,7 +130,7 @@ size="sm" color="red" class="flex place-items-center rounded-tl-full rounded-bl-full gap-2 py-3" - on:click={handleResolve} + onclick={handleResolve} > <Icon path={mdiTrashCanOutline} size="20" />{trashCount === assets.length ? $t('trash_all') @@ -136,7 +141,7 @@ size="sm" color="primary" class="flex place-items-center rounded-tr-full rounded-br-full gap-2" - on:click={handleStack} + onclick={handleStack} disabled={selectedAssetIds.size !== 1} > <Icon path={mdiImageMultipleOutline} size="20" />{$t('stack')} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 9e83baef09b04..db127bbf109ba 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -334,3 +334,30 @@ export enum ImmichProduct { Client = 'immich-client', Server = 'immich-server', } + +export enum SettingInputFieldType { + EMAIL = 'email', + TEXT = 'text', + NUMBER = 'number', + PASSWORD = 'password', + COLOR = 'color', +} + +export enum AlbumPageViewMode { + LINK_SHARING = 'link-sharing', + SELECT_USERS = 'select-users', + SELECT_THUMBNAIL = 'select-thumbnail', + SELECT_ASSETS = 'select-assets', + VIEW_USERS = 'view-users', + VIEW = 'view', + OPTIONS = 'options', +} + +export enum PersonPageViewMode { + VIEW_ASSETS = 'view-assets', + SELECT_PERSON = 'select-person', + MERGE_PEOPLE = 'merge-people', + SUGGEST_MERGE = 'suggest-merge', + BIRTH_DATE = 'birth-date', + UNASSIGN_ASSETS = 'unassign-faces', +} diff --git a/web/src/routes/(user)/+layout.svelte b/web/src/routes/(user)/+layout.svelte index bf24d0e7e48c9..feda36fa0124e 100644 --- a/web/src/routes/(user)/+layout.svelte +++ b/web/src/routes/(user)/+layout.svelte @@ -1,13 +1,21 @@ <script lang="ts"> + import { run } from 'svelte/legacy'; + import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte'; import { page } from '$app/stores'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import type { Snippet } from 'svelte'; + interface Props { + children?: Snippet; + } + + let { children }: Props = $props(); let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore; // $page.data.asset is loaded by route specific +page.ts loaders if that // route contains the assetId path. - $: { + run(() => { if ($page.data.asset) { setAsset($page.data.asset); } else { @@ -15,11 +23,11 @@ } const asset = $page.url.searchParams.get('at'); $gridScrollTarget = { at: asset }; - } + }); </script> <div class:display-none={$showAssetViewer}> - <slot /> + {@render children?.()} </div> <UploadCover /> diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index 35402ce331d49..29079a48b8bb5 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -10,16 +10,22 @@ import SearchBar from '$lib/components/elements/search-bar.svelte'; import { t } from 'svelte-i18n'; - export let data: PageData; + interface Props { + data: PageData; + } - let searchQuery = ''; - let albumGroups: string[] = []; + let { data }: Props = $props(); + + let searchQuery = $state(''); + let albumGroups: string[] = $state([]); </script> <UserPageLayout title={data.meta.title}> - <div class="flex place-items-center gap-2" slot="buttons"> - <AlbumsControls {albumGroups} bind:searchQuery /> - </div> + {#snippet buttons()} + <div class="flex place-items-center gap-2"> + <AlbumsControls {albumGroups} bind:searchQuery /> + </div> + {/snippet} <div class="xl:hidden"> <div class="w-fit h-14 dark:text-immich-dark-fg py-2"> @@ -43,6 +49,8 @@ {searchQuery} bind:albumGroupIds={albumGroups} > - <EmptyPlaceholder slot="empty" text={$t('no_albums_message')} onClick={() => createAlbumAndRedirect()} /> + {#snippet empty()} + <EmptyPlaceholder text={$t('no_albums_message')} onClick={() => createAlbumAndRedirect()} /> + {/snippet} </Albums> </UserPageLayout> diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9ccb2b7182e7d..2a78b02aa66ee 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -32,7 +32,7 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; - import { AppRoute } from '$lib/constants'; + import { AppRoute, AlbumPageViewMode } from '$lib/constants'; import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; @@ -87,69 +87,33 @@ import { confirmAlbumDelete } from '$lib/utils/album-utils'; import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data = $bindable() }: Props = $props(); let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore; let { slideshowState, slideshowNavigation } = slideshowStore; - let oldAt: AssetGridRouteSearchParams | null | undefined; - - $: album = data.album; - $: albumId = album.id; - $: albumKey = `${albumId}_${albumOrder}`; + let oldAt: AssetGridRouteSearchParams | null | undefined = $state(); - $: { - if (!album.isActivityEnabled && $numberOfComments === 0) { - isShowActivity = false; - } - } - - enum ViewMode { - LINK_SHARING = 'link-sharing', - SELECT_USERS = 'select-users', - SELECT_THUMBNAIL = 'select-thumbnail', - SELECT_ASSETS = 'select-assets', - VIEW_USERS = 'view-users', - VIEW = 'view', - OPTIONS = 'options', - } + let backUrl: string = $state(AppRoute.ALBUMS); + let viewMode = $state(AlbumPageViewMode.VIEW); + let isCreatingSharedAlbum = $state(false); + let isShowActivity = $state(false); + let isLiked: ActivityResponseDto | null = $state(null); + let reactions: ActivityResponseDto[] = $state([]); + let globalWidth: number = $state(0); + let assetGridWidth: number = $derived(isShowActivity ? globalWidth - (globalWidth < 768 ? 360 : 460) : globalWidth); + let albumOrder: AssetOrder | undefined = $state(data.album.order); - let backUrl: string = AppRoute.ALBUMS; - let viewMode = ViewMode.VIEW; - let isCreatingSharedAlbum = false; - let isShowActivity = false; - let isLiked: ActivityResponseDto | null = null; - let reactions: ActivityResponseDto[] = []; - let globalWidth: number; - let assetGridWidth: number; - let albumOrder: AssetOrder | undefined = data.album.order; - - $: assetStore = new AssetStore({ albumId, order: albumOrder }); const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; - $: timelineStore = new AssetStore({ isArchived: false, withPartners: true }, albumId); const timelineInteractionStore = createAssetInteractionStore(); const { selectedAssets: timelineSelected } = timelineInteractionStore; - $: isOwned = $user.id == album.ownerId; - $: isAllUserOwned = [...$selectedAssets].every((asset) => asset.ownerId === $user.id); - $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite); - $: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived); - $: { - assetGridWidth = isShowActivity ? globalWidth - (globalWidth < 768 ? 360 : 460) : globalWidth; - } - $: showActivityStatus = - album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0); - - // svelte-ignore reactive_declaration_non_reactive_property - $: isEditor = - album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor || - album.ownerId === $user.id; - - // svelte-ignore reactive_declaration_non_reactive_property - $: albumHasViewers = album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer); - afterNavigate(({ from }) => { let url: string | undefined = from?.url?.pathname; @@ -171,12 +135,15 @@ const handleToggleEnableActivity = async () => { try { - await updateAlbumInfo({ + const updateAlbum = await updateAlbumInfo({ id: album.id, updateAlbumDto: { isActivityEnabled: !album.isActivityEnabled, }, }); + + album = { ...album, isActivityEnabled: updateAlbum.isActivityEnabled }; + await refreshAlbum(); notificationController.show({ type: NotificationType.Info, @@ -236,11 +203,6 @@ isShowActivity = !isShowActivity; }; - $: if (album.albumUsers.length > 0) { - handlePromiseError(getFavorite()); - handlePromiseError(getNumberOfComments()); - } - const handleStartSlideshow = async () => { const asset = $slideshowNavigation === SlideshowNavigation.Shuffle ? await assetStore.getRandomAsset() : assetStore.assets[0]; @@ -251,21 +213,21 @@ }; const handleEscape = async () => { - if (viewMode === ViewMode.SELECT_USERS) { - viewMode = ViewMode.VIEW; + if (viewMode === AlbumPageViewMode.SELECT_USERS) { + viewMode = AlbumPageViewMode.VIEW; return; } - if (viewMode === ViewMode.SELECT_ASSETS) { + if (viewMode === AlbumPageViewMode.SELECT_ASSETS) { await handleCloseSelectAssets(); return; } - if (viewMode === ViewMode.LINK_SHARING) { - viewMode = ViewMode.VIEW; + if (viewMode === AlbumPageViewMode.LINK_SHARING) { + viewMode = AlbumPageViewMode.VIEW; return; } - if (viewMode === ViewMode.OPTIONS) { - viewMode = ViewMode.VIEW; + if (viewMode === AlbumPageViewMode.OPTIONS) { + viewMode = AlbumPageViewMode.VIEW; return; } if ($showAssetViewer) { @@ -280,7 +242,7 @@ }; const refreshAlbum = async () => { - data.album = await getAlbumInfo({ id: album.id, withoutAssets: true }); + album = await getAlbumInfo({ id: album.id, withoutAssets: true }); }; const handleAddAssets = async () => { @@ -308,7 +270,7 @@ }; const setModeToView = async () => { - viewMode = ViewMode.VIEW; + viewMode = AlbumPageViewMode.VIEW; assetStore.destroy(); assetStore = new AssetStore({ albumId, order: albumOrder }); timelineStore.destroy(); @@ -341,13 +303,13 @@ }); await refreshAlbum(); - viewMode = ViewMode.VIEW; + viewMode = AlbumPageViewMode.VIEW; } catch (error) { handleError(error, $t('errors.error_adding_users_to_album')); } }; - const handleRemoveUser = async (userId: string, nextViewMode: ViewMode) => { + const handleRemoveUser = async (userId: string, nextViewMode: AlbumPageViewMode) => { if (userId == 'me' || userId === $user.id) { await goto(backUrl); return; @@ -357,7 +319,7 @@ await refreshAlbum(); // Dynamically set the view mode based on the passed argument - viewMode = album.albumUsers.length > 0 ? nextViewMode : ViewMode.VIEW; + viewMode = album.albumUsers.length > 0 ? nextViewMode : AlbumPageViewMode.VIEW; } catch (error) { handleError(error, $t('errors.error_deleting_shared_user')); } @@ -371,7 +333,7 @@ const isConfirmed = await confirmAlbumDelete(album); if (!isConfirmed) { - viewMode = ViewMode.VIEW; + viewMode = AlbumPageViewMode.VIEW; return; } @@ -381,7 +343,7 @@ } catch (error) { handleError(error, $t('errors.unable_to_delete_album')); } finally { - viewMode = ViewMode.VIEW; + viewMode = AlbumPageViewMode.VIEW; } }; @@ -391,11 +353,11 @@ }; const handleUpdateThumbnail = async (assetId: string) => { - if (viewMode !== ViewMode.SELECT_THUMBNAIL) { + if (viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL) { return; } - viewMode = ViewMode.VIEW; + viewMode = AlbumPageViewMode.VIEW; assetInteractionStore.clearMultiselect(); await updateThumbnail(assetId); @@ -432,6 +394,40 @@ assetStore.destroy(); timelineStore.destroy(); }); + + let album = $state(data.album); + let albumId = $derived(album.id); + let albumKey = $derived(`${albumId}_${albumOrder}`); + + $effect(() => { + if (!album.isActivityEnabled && $numberOfComments === 0) { + isShowActivity = false; + } + }); + + let assetStore = $derived(new AssetStore({ albumId, order: albumOrder })); + let timelineStore = $derived(new AssetStore({ isArchived: false, withPartners: true }, albumId)); + + let isOwned = $derived($user.id == album.ownerId); + let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); + let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); + let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); + + let showActivityStatus = $derived( + album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0), + ); + let isEditor = $derived( + album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor || + album.ownerId === $user.id, + ); + + let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer)); + $effect(() => { + if (album.albumUsers.length > 0) { + handlePromiseError(getFavorite()); + handlePromiseError(getNumberOfComments()); + } + }); </script> <div class="flex overflow-hidden" bind:clientWidth={globalWidth}> @@ -475,14 +471,14 @@ </ButtonContextMenu> </AssetSelectControlBar> {:else} - {#if viewMode === ViewMode.VIEW} + {#if viewMode === AlbumPageViewMode.VIEW} <ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(backUrl)}> - <svelte:fragment slot="trailing"> + {#snippet trailing()} {#if isEditor} <CircleIconButton title={$t('add_photos')} - on:click={async () => { - viewMode = ViewMode.SELECT_ASSETS; + onclick={async () => { + viewMode = AlbumPageViewMode.SELECT_ASSETS; oldAt = { at: $gridScrollTarget?.at }; await navigate( { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } }, @@ -496,23 +492,27 @@ {#if isOwned} <CircleIconButton title={$t('share')} - on:click={() => (viewMode = ViewMode.SELECT_USERS)} + onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)} icon={mdiShareVariantOutline} /> {/if} {#if album.assetCount > 0} - <CircleIconButton title={$t('slideshow')} on:click={handleStartSlideshow} icon={mdiPresentationPlay} /> - <CircleIconButton title={$t('download')} on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} /> + <CircleIconButton title={$t('slideshow')} onclick={handleStartSlideshow} icon={mdiPresentationPlay} /> + <CircleIconButton title={$t('download')} onclick={handleDownloadAlbum} icon={mdiFolderDownloadOutline} /> {#if isOwned} <ButtonContextMenu icon={mdiDotsVertical} title={$t('album_options')}> <MenuOption icon={mdiImageOutline} text={$t('select_album_cover')} - onClick={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} + onClick={() => (viewMode = AlbumPageViewMode.SELECT_THUMBNAIL)} + /> + <MenuOption + icon={mdiCogOutline} + text={$t('options')} + onClick={() => (viewMode = AlbumPageViewMode.OPTIONS)} /> - <MenuOption icon={mdiCogOutline} text={$t('options')} onClick={() => (viewMode = ViewMode.OPTIONS)} /> <MenuOption icon={mdiDeleteOutline} text={$t('delete_album')} onClick={() => handleRemoveAlbum()} /> </ButtonContextMenu> {/if} @@ -523,18 +523,18 @@ size="sm" rounded="lg" disabled={album.assetCount === 0} - on:click={() => (viewMode = ViewMode.SELECT_USERS)} + onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)} > {$t('share')} </Button> {/if} - </svelte:fragment> + {/snippet} </ControlAppBar> {/if} - {#if viewMode === ViewMode.SELECT_ASSETS} + {#if viewMode === AlbumPageViewMode.SELECT_ASSETS} <ControlAppBar onClose={handleCloseSelectAssets}> - <svelte:fragment slot="leading"> + {#snippet leading()} <p class="text-lg dark:text-immich-dark-fg"> {#if $timelineSelected.size === 0} {$t('add_to_album')} @@ -542,26 +542,28 @@ {$t('selected_count', { values: { count: $timelineSelected.size } })} {/if} </p> - </svelte:fragment> + {/snippet} - <svelte:fragment slot="trailing"> + {#snippet trailing()} <button type="button" - on:click={handleSelectFromComputer} + onclick={handleSelectFromComputer} class="rounded-lg px-6 py-2 text-sm font-medium text-immich-primary transition-all hover:bg-immich-primary/10 dark:text-immich-dark-primary dark:hover:bg-immich-dark-primary/25" > {$t('select_from_computer')} </button> - <Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} on:click={handleAddAssets} + <Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} onclick={handleAddAssets} >{$t('done')}</Button > - </svelte:fragment> + {/snippet} </ControlAppBar> {/if} - {#if viewMode === ViewMode.SELECT_THUMBNAIL} - <ControlAppBar onClose={() => (viewMode = ViewMode.VIEW)}> - <svelte:fragment slot="leading">{$t('select_album_cover')}</svelte:fragment> + {#if viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} + <ControlAppBar onClose={() => (viewMode = AlbumPageViewMode.VIEW)}> + {#snippet leading()} + {$t('select_album_cover')} + {/snippet} </ControlAppBar> {/if} {/if} @@ -572,7 +574,7 @@ > <!-- Use key because AssetGrid can't deal with changing stores --> {#key albumKey} - {#if viewMode === ViewMode.SELECT_ASSETS} + {#if viewMode === AlbumPageViewMode.SELECT_ASSETS} <AssetGrid enableRouting={false} assetStore={timelineStore} @@ -586,13 +588,13 @@ {assetStore} {assetInteractionStore} isShared={album.albumUsers.length > 0} - isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL} - singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL} + isSelectionMode={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} + singleSelect={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} showArchiveIcon onSelect={({ id }) => handleUpdateThumbnail(id)} onEscape={handleEscape} > - {#if viewMode !== ViewMode.SELECT_THUMBNAIL} + {#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL} <!-- ALBUM TITLE --> <section class="pt-8 md:pt-24"> <AlbumTitle @@ -616,18 +618,18 @@ color="gray" size="20" icon={mdiLink} - on:click={() => (viewMode = ViewMode.LINK_SHARING)} + onclick={() => (viewMode = AlbumPageViewMode.LINK_SHARING)} /> {/if} <!-- owner --> - <button type="button" on:click={() => (viewMode = ViewMode.VIEW_USERS)}> + <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}> <UserAvatar user={album.owner} size="md" /> </button> <!-- users with write access (collaborators) --> {#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)} - <button type="button" on:click={() => (viewMode = ViewMode.VIEW_USERS)}> + <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}> <UserAvatar {user} size="md" /> </button> {/each} @@ -639,7 +641,7 @@ color="gray" size="20" icon={mdiDotsVertical} - on:click={() => (viewMode = ViewMode.VIEW_USERS)} + onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)} /> {/if} @@ -648,7 +650,7 @@ color="gray" size="20" icon={mdiPlus} - on:click={() => (viewMode = ViewMode.SELECT_USERS)} + onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)} title={$t('add_more_users')} /> {/if} @@ -665,7 +667,7 @@ <p class="text-xs dark:text-immich-dark-fg">{$t('add_photos').toUpperCase()}</p> <button type="button" - on:click={() => (viewMode = ViewMode.SELECT_ASSETS)} + onclick={() => (viewMode = AlbumPageViewMode.SELECT_ASSETS)} class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary" > <span class="text-text-immich-primary dark:text-immich-dark-primary" @@ -717,29 +719,29 @@ </div> {/if} </div> -{#if viewMode === ViewMode.SELECT_USERS} +{#if viewMode === AlbumPageViewMode.SELECT_USERS} <UserSelectionModal {album} onSelect={handleAddUsers} - onShare={() => (viewMode = ViewMode.LINK_SHARING)} - onClose={() => (viewMode = ViewMode.VIEW)} + onShare={() => (viewMode = AlbumPageViewMode.LINK_SHARING)} + onClose={() => (viewMode = AlbumPageViewMode.VIEW)} /> {/if} -{#if viewMode === ViewMode.LINK_SHARING} - <CreateSharedLinkModal albumId={album.id} onClose={() => (viewMode = ViewMode.VIEW)} /> +{#if viewMode === AlbumPageViewMode.LINK_SHARING} + <CreateSharedLinkModal albumId={album.id} onClose={() => (viewMode = AlbumPageViewMode.VIEW)} /> {/if} -{#if viewMode === ViewMode.VIEW_USERS} +{#if viewMode === AlbumPageViewMode.VIEW_USERS} <ShareInfoModal - onClose={() => (viewMode = ViewMode.VIEW)} + onClose={() => (viewMode = AlbumPageViewMode.VIEW)} {album} - onRemove={(userId) => handleRemoveUser(userId, ViewMode.VIEW_USERS)} + onRemove={(userId) => handleRemoveUser(userId, AlbumPageViewMode.VIEW_USERS)} onRefreshAlbum={refreshAlbum} /> {/if} -{#if viewMode === ViewMode.OPTIONS && $user} +{#if viewMode === AlbumPageViewMode.OPTIONS && $user} <AlbumOptions {album} order={albumOrder} @@ -748,11 +750,11 @@ albumOrder = order; await setModeToView(); }} - onRemove={(userId) => handleRemoveUser(userId, ViewMode.OPTIONS)} + onRemove={(userId) => handleRemoveUser(userId, AlbumPageViewMode.OPTIONS)} onRefreshAlbum={refreshAlbum} - onClose={() => (viewMode = ViewMode.VIEW)} + onClose={() => (viewMode = AlbumPageViewMode.VIEW)} onToggleEnabledActivity={handleToggleEnableActivity} - onShowSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} + onShowSelectSharedUser={() => (viewMode = AlbumPageViewMode.SELECT_USERS)} /> {/if} diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2ce13093513cc..3402dff9600ca 100644 --- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -19,13 +19,17 @@ import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); const assetStore = new AssetStore({ isArchived: true }); const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; - $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite); + let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); onDestroy(() => { assetStore.destroy(); @@ -51,6 +55,8 @@ <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}> <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}> - <EmptyPlaceholder text={$t('no_archived_assets_message')} slot="empty" /> + {#snippet empty()} + <EmptyPlaceholder text={$t('no_archived_assets_message')} /> + {/snippet} </AssetGrid> </UserPageLayout> diff --git a/web/src/routes/(user)/buy/+page.svelte b/web/src/routes/(user)/buy/+page.svelte index 1f71269c117d3..eb0194c4472f3 100644 --- a/web/src/routes/(user)/buy/+page.svelte +++ b/web/src/routes/(user)/buy/+page.svelte @@ -11,8 +11,12 @@ import { purchaseStore } from '$lib/stores/purchase.store'; import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte'; - export let data: PageData; - let showLicenseActivated = false; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); + let showLicenseActivated = $state(false); const { isPurchased } = purchaseStore; </script> diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index ebd2e96b5a1e9..c31c0538e4ef3 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -12,7 +12,11 @@ import { websocketEvents } from '$lib/stores/websocket'; import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); enum Field { CITY = 'exifInfo.city', @@ -23,9 +27,10 @@ return targetField?.items || []; }; - $: places = getFieldItems(data.items, Field.CITY); - $: people = data.response.people; - $: hasPeople = data.response.total > 0; + let places = $derived(getFieldItems(data.items, Field.CITY)); + let people = $state(data.response.people); + + let hasPeople = $derived(data.response.total > 0); onMount(() => { return websocketEvents.on('on_person_thumbnail', (personId: string) => { @@ -51,13 +56,21 @@ draggable="false">{$t('view_all')}</a > </div> - <SingleGridRow class="grid md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4" let:itemCount> - {#each people.slice(0, itemCount) as person (person.id)} - <a href="{AppRoute.PEOPLE}/{person.id}" class="text-center"> - <ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" /> - <p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p> - </a> - {/each} + <SingleGridRow class="grid md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4"> + {#snippet children({ itemCount })} + {#each people.slice(0, itemCount) as person (person.id)} + <a href="{AppRoute.PEOPLE}/{person.id}" class="text-center"> + <ImageThumbnail + circle + shadow + url={getPeopleThumbnailUrl(person)} + altText={person.name} + widthStyle="100%" + /> + <p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p> + </a> + {/each} + {/snippet} </SingleGridRow> </div> {/if} @@ -72,23 +85,29 @@ draggable="false">{$t('view_all')}</a > </div> - <SingleGridRow class="grid md:grid-auto-fill-36 grid-auto-fill-28 gap-x-4" let:itemCount> - {#each places.slice(0, itemCount) as item (item.data.id)} - <a class="relative" href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city: item.value })}" draggable="false"> - <div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter"> - <img - src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })} - alt={item.value} - class="object-cover aspect-square w-full" - /> - </div> - <span - class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer" + <SingleGridRow class="grid md:grid-auto-fill-36 grid-auto-fill-28 gap-x-4"> + {#snippet children({ itemCount })} + {#each places.slice(0, itemCount) as item (item.data.id)} + <a + class="relative" + href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city: item.value })}" + draggable="false" > - {item.value} - </span> - </a> - {/each} + <div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter"> + <img + src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })} + alt={item.value} + class="object-cover aspect-square w-full" + /> + </div> + <span + class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer" + > + {item.value} + </span> + </a> + {/each} + {/snippet} </SingleGridRow> </div> {/if} diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 13e70c9161540..8699582f9ae22 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -21,13 +21,17 @@ import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); const assetStore = new AssetStore({ isFavorite: true }); const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; - $: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived); + let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived)); onDestroy(() => { assetStore.destroy(); @@ -56,6 +60,8 @@ <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}> <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}> - <EmptyPlaceholder text={$t('no_favorites_message')} slot="empty" /> + {#snippet empty()} + <EmptyPlaceholder text={$t('no_favorites_message')} /> + {/snippet} </AssetGrid> </UserPageLayout> diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2cd3d8c9f3108..255a4373caf65 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -18,15 +18,19 @@ import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte'; import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; - export let data: PageData; + interface Props { + data: PageData; + } - let selectedAssets: Set<AssetResponseDto> = new Set(); - const viewport: Viewport = { width: 0, height: 0 }; + let { data }: Props = $props(); - $: pathSegments = data.path ? data.path.split('/') : []; - $: tree = buildTree($foldersStore?.uniquePaths || []); - $: currentPath = $page.url.searchParams.get(QueryParameter.PATH) || ''; - $: currentTreeItems = currentPath ? data.currentFolders : Object.keys(tree); + let selectedAssets: Set<AssetResponseDto> = $state(new Set()); + const viewport: Viewport = $state({ width: 0, height: 0 }); + + let pathSegments = $derived(data.path ? data.path.split('/') : []); + let tree = $derived(buildTree($foldersStore?.uniquePaths || [])); + let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); + let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree)); onMount(async () => { await foldersStore.fetchUniquePaths(); @@ -48,20 +52,22 @@ </script> <UserPageLayout title={data.meta.title}> - <SideBarSection slot="sidebar"> - <SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} /> - <section> - <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div> - <div class="h-full"> - <TreeItems - icons={{ default: mdiFolderOutline, active: mdiFolder }} - items={tree} - active={currentPath} - {getLink} - /> - </div> - </section> - </SideBarSection> + {#snippet sidebar()} + <SideBarSection> + <SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} /> + <section> + <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div> + <div class="h-full"> + <TreeItems + icons={{ default: mdiFolderOutline, active: mdiFolder }} + items={tree} + active={currentPath} + {getLink} + /> + </div> + </section> + </SideBarSection> + {/snippet} <Breadcrumbs {pathSegments} icon={mdiFolderHome} title={$t('folders')} {getLink} /> diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index adbc3cfe699a3..613ae4d66bed7 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,4 +1,6 @@ <script lang="ts"> + import { run } from 'svelte/legacy'; + import { goto } from '$app/navigation'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import MapSettingsModal from '$lib/components/map-page/map-settings-modal.svelte'; @@ -17,15 +19,19 @@ import { handlePromiseError } from '$lib/utils'; import { navigate } from '$lib/utils/navigation'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore; let abortController: AbortController; - let mapMarkers: MapMarkerResponseDto[] = []; - let viewingAssets: string[] = []; + let mapMarkers: MapMarkerResponseDto[] = $state([]); + let viewingAssets: string[] = $state([]); let viewingAssetCursor = 0; - let showSettingsModal = false; + let showSettingsModal = $state(false); onMount(async () => { mapMarkers = await loadMapMarkers(); @@ -36,9 +42,11 @@ assetViewingStore.showAssetViewer(false); }); - $: if (!$featureFlags.map) { - handlePromiseError(goto(AppRoute.PHOTOS)); - } + run(() => { + if (!$featureFlags.map) { + handlePromiseError(goto(AppRoute.PHOTOS)); + } + }); const omit = (obj: MapSettings, key: string) => { return Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key)); }; diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2caab9de82508..4332e5339eaec 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -15,7 +15,11 @@ import { mdiPlus, mdiArrowLeft } from '@mdi/js'; import { t } from 'svelte-i18n'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); const assetStore = new AssetStore({ userId: data.partner.id, isArchived: false, withStacked: true }); const assetInteractionStore = createAssetInteractionStore(); @@ -39,11 +43,11 @@ </AssetSelectControlBar> {:else} <ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(AppRoute.SHARING)}> - <svelte:fragment slot="leading"> + {#snippet leading()} <p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg"> {data.partner.name}'s photos </p> - </svelte:fragment> + {/snippet} </ControlAppBar> {/if} <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} /> diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index b6d25c48bf937..5b3fbeea03718 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -38,34 +38,35 @@ import { fly } from 'svelte/transition'; import type { PageData } from './$types'; - export let data: PageData; - - $: people = data.people.people; - $: visiblePeople = people.filter((people) => !people.isHidden); - $: countVisiblePeople = searchName ? searchedPeopleLocal.length : data.people.total - data.people.hidden; - $: showPeople = searchName ? searchedPeopleLocal : visiblePeople; - - let selectHidden = false; - let searchName = ''; - let showChangeNameModal = false; - let showSetBirthDateModal = false; - let showMergeModal = false; - let personName = ''; - let nextPage = data.people.hasNextPage ? 2 : null; - let personMerge1: PersonResponseDto; - let personMerge2: PersonResponseDto; - let potentialMergePeople: PersonResponseDto[] = []; - let edittingPerson: PersonResponseDto | null = null; - let searchedPeopleLocal: PersonResponseDto[] = []; - let handleSearchPeople: (force?: boolean, name?: string) => Promise<void>; - let changeNameInputEl: HTMLInputElement | null; - let innerHeight: number; - + interface Props { + data: PageData; + } + + let { data }: Props = $props(); + + let selectHidden = $state(false); + let searchName = $state(''); + let showChangeNameModal = $state(false); + let showSetBirthDateModal = $state(false); + let showMergeModal = $state(false); + let personName = $state(''); + let nextPage = $state(data.people.hasNextPage ? 2 : null); + let personMerge1 = $state<PersonResponseDto>(); + let personMerge2 = $state<PersonResponseDto>(); + let potentialMergePeople: PersonResponseDto[] = $state([]); + let edittingPerson: PersonResponseDto | null = $state(null); + let searchedPeopleLocal: PersonResponseDto[] = $state([]); + // let handleSearchPeople: (force?: boolean, name?: string) => Promise<void> = $state(); + let changeNameInputEl = $state<HTMLInputElement>(); + let innerHeight = $state(0); + let searchPeopleElement = $state<ReturnType<typeof SearchPeople>>(); onMount(() => { const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE); if (getSearchedPeople) { searchName = getSearchedPeople; - handlePromiseError(handleSearchPeople(true, searchName)); + if (searchPeopleElement) { + handlePromiseError(searchPeopleElement.searchPeople(true, searchName)); + } } return websocketEvents.on('on_person_thumbnail', (personId: string) => { for (const person of people) { @@ -198,7 +199,9 @@ ); }; - const submitNameChange = async () => { + const submitNameChange = async (event: Event) => { + event.preventDefault(); + potentialMergePeople = []; showChangeNameModal = false; if (!edittingPerson || personName === edittingPerson.name) { @@ -225,9 +228,9 @@ potentialMergePeople = people .filter( (person: PersonResponseDto) => - personMerge2.name.toLowerCase() === person.name.toLowerCase() && + personMerge2?.name.toLowerCase() === person.name.toLowerCase() && person.id !== personMerge2.id && - person.id !== personMerge1.id && + person.id !== personMerge1?.id && !person.isHidden, ) .slice(0, 3); @@ -293,11 +296,26 @@ const onResetSearchBar = async () => { await clearQueryParam(QueryParameter.SEARCHED_PEOPLE, $page.url); }; + + let people = $state(data.people.people); + $effect(() => { + people = data.people.people; + }); + let visiblePeople = $derived(people.filter((people) => !people.isHidden)); + let countVisiblePeople = $derived(searchName ? searchedPeopleLocal.length : data.people.total - data.people.hidden); + let showPeople = $derived(searchName ? searchedPeopleLocal : visiblePeople); + + // const submitNameChange = (event: Event) => { + // event.preventDefault(); + // if (searchPeopleElement) { + // handlePromiseError(searchPeopleElement.searchPeople(true, searchName)); + // } + // }; </script> <svelte:window bind:innerHeight /> -{#if showMergeModal} +{#if showMergeModal && personMerge1 && personMerge2} <MergeSuggestionModal {personMerge1} {personMerge2} @@ -312,23 +330,23 @@ title={$t('people')} description={countVisiblePeople === 0 && !searchName ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`} > - <svelte:fragment slot="buttons"> + {#snippet buttons()} {#if people.length > 0} <div class="flex gap-2 items-center justify-center"> <div class="hidden sm:block"> <div class="w-40 lg:w-80 h-10"> <SearchPeople + bind:this={searchPeopleElement} type="searchBar" placeholder={$t('search_people')} onReset={onResetSearchBar} onSearch={handleSearch} bind:searchName bind:searchedPeopleLocal - bind:handleSearch={handleSearchPeople} /> </div> </div> - <LinkButton on:click={() => (selectHidden = !selectHidden)}> + <LinkButton onclick={() => (selectHidden = !selectHidden)}> <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm"> <Icon path={mdiEyeOutline} size="18" /> <p class="ml-2">{$t('show_and_hide_people')}</p> @@ -336,24 +354,20 @@ </LinkButton> </div> {/if} - </svelte:fragment> + {/snippet} {#if countVisiblePeople > 0 && (!searchName || searchedPeopleLocal.length > 0)} - <PeopleInfiniteScroll - people={showPeople} - hasNextPage={!!nextPage && !searchName} - {loadNextPage} - let:person - let:index - > - <PeopleCard - {person} - preload={index < 20} - onChangeName={() => handleChangeName(person)} - onSetBirthDate={() => handleSetBirthDate(person)} - onMergePeople={() => handleMergePeople(person)} - onHidePerson={() => handleHidePerson(person)} - /> + <PeopleInfiniteScroll people={showPeople} hasNextPage={!!nextPage && !searchName} {loadNextPage}> + {#snippet children({ person, index })} + <PeopleCard + {person} + preload={index < 20} + onChangeName={() => handleChangeName(person)} + onSetBirthDate={() => handleSetBirthDate(person)} + onMergePeople={() => handleMergePeople(person)} + onHidePerson={() => handleHidePerson(person)} + /> + {/snippet} </PeopleInfiniteScroll> {:else} <div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white"> @@ -368,7 +382,7 @@ {#if showChangeNameModal} <FullScreenModal title={$t('change_name')} onClose={() => (showChangeNameModal = false)}> - <form on:submit|preventDefault={submitNameChange} autocomplete="off" id="change-name-form"> + <form onsubmit={submitNameChange} autocomplete="off" id="change-name-form"> <div class="flex flex-col gap-2"> <label class="immich-form-label" for="name">{$t('name')}</label> <input @@ -381,16 +395,17 @@ /> </div> </form> - <svelte:fragment slot="sticky-bottom"> + + {#snippet stickyBottom()} <Button color="gray" fullwidth - on:click={() => { + onclick={() => { showChangeNameModal = false; }}>{$t('cancel')}</Button > <Button type="submit" fullwidth form="change-name-form">{$t('ok')}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> {/if} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index d68367d106cb5..d9b7c6a08feba 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -25,7 +25,7 @@ NotificationType, notificationController, } from '$lib/components/shared-components/notification/notification'; - import { AppRoute, QueryParameter } from '$lib/constants'; + import { AppRoute, PersonPageViewMode, QueryParameter } from '$lib/constants'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; @@ -58,47 +58,33 @@ import { t } from 'svelte-i18n'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; - export let data: PageData; + interface Props { + data: PageData; + } - let numberOfAssets = data.statistics.assets; - let { isViewing: showAssetViewer } = assetViewingStore; + let { data = $bindable() }: Props = $props(); - enum ViewMode { - VIEW_ASSETS = 'view-assets', - SELECT_PERSON = 'select-person', - MERGE_PEOPLE = 'merge-people', - SUGGEST_MERGE = 'suggest-merge', - BIRTH_DATE = 'birth-date', - UNASSIGN_ASSETS = 'unassign-faces', - } + let numberOfAssets = $state(data.statistics.assets); + let { isViewing: showAssetViewer } = assetViewingStore; let assetStore = new AssetStore({ isArchived: false, personId: data.person.id, }); - $: person = data.person; - $: thumbnailData = getPeopleThumbnailUrl(person); - $: if (person) { - handlePromiseError(updateAssetCount()); - handlePromiseError(assetStore.updateOptions({ personId: person.id })); - } - const assetInteractionStore = createAssetInteractionStore(); const { selectedAssets, isMultiSelectState } = assetInteractionStore; - let viewMode: ViewMode = ViewMode.VIEW_ASSETS; - let isEditingName = false; - let previousRoute: string = AppRoute.EXPLORE; + let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS); + let isEditingName = $state(false); + let previousRoute: string = $state(AppRoute.EXPLORE); let people: PersonResponseDto[] = []; - let personMerge1: PersonResponseDto; - let personMerge2: PersonResponseDto; - let potentialMergePeople: PersonResponseDto[] = []; - - let refreshAssetGrid = false; + let personMerge1: PersonResponseDto | undefined = $state(); + let personMerge2: PersonResponseDto | undefined = $state(); + let potentialMergePeople: PersonResponseDto[] = $state([]); let personName = ''; - let suggestedPeople: PersonResponseDto[] = []; + let suggestedPeople: PersonResponseDto[] = $state([]); /** * Save the word used to search people name: for example, @@ -107,11 +93,8 @@ * However, it needs to make a new api request if searching 'r' returns 20 names (arbitrary value, the limit sent back by the server). * or if the new search word starts with another word / letter **/ - let isSearchingPeople = false; - let suggestionContainer: HTMLDivElement; - - $: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived); - $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite); + let isSearchingPeople = $state(false); + let suggestionContainer: HTMLElement | undefined = $state(); onMount(() => { const action = $page.url.searchParams.get(QueryParameter.ACTION); @@ -120,7 +103,7 @@ previousRoute = getPreviousRoute; } if (action == 'merge') { - viewMode = ViewMode.MERGE_PEOPLE; + viewMode = PersonPageViewMode.MERGE_PEOPLE; } return websocketEvents.on('on_person_thumbnail', (personId: string) => { @@ -131,7 +114,7 @@ }); const handleEscape = async () => { - if ($showAssetViewer || viewMode === ViewMode.SUGGEST_MERGE) { + if ($showAssetViewer || viewMode === PersonPageViewMode.SUGGEST_MERGE) { return; } if ($isMultiSelectState) { @@ -162,11 +145,11 @@ const handleUnmerge = () => { $assetStore.removeAssets([...$selectedAssets].map((a) => a.id)); assetInteractionStore.clearMultiselect(); - viewMode = ViewMode.VIEW_ASSETS; + viewMode = PersonPageViewMode.VIEW_ASSETS; }; const handleReassignAssets = () => { - viewMode = ViewMode.UNASSIGN_ASSETS; + viewMode = PersonPageViewMode.UNASSIGN_ASSETS; }; const toggleHidePerson = async () => { @@ -191,13 +174,11 @@ await updateAssetCount(); await handleGoBack(); - data.person = person; - - refreshAssetGrid = !refreshAssetGrid; + data = { ...data, person }; }; const handleSelectFeaturePhoto = async (asset: AssetResponseDto) => { - if (viewMode !== ViewMode.SELECT_PERSON) { + if (viewMode !== PersonPageViewMode.SELECT_PERSON) { return; } try { @@ -209,12 +190,12 @@ assetInteractionStore.clearMultiselect(); - viewMode = ViewMode.VIEW_ASSETS; + viewMode = PersonPageViewMode.VIEW_ASSETS; }; const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => { const [personToMerge, personToBeMergedIn] = response; - viewMode = ViewMode.VIEW_ASSETS; + viewMode = PersonPageViewMode.VIEW_ASSETS; isEditingName = false; try { await mergePerson({ @@ -228,7 +209,6 @@ people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); if (personToBeMergedIn.name != personName && person.id === personToBeMergedIn.id) { await updateAssetCount(); - refreshAssetGrid = !refreshAssetGrid; return; } await goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true }); @@ -243,11 +223,11 @@ personName = person.name; personMerge1 = person; personMerge2 = person2; - viewMode = ViewMode.SUGGEST_MERGE; + viewMode = PersonPageViewMode.SUGGEST_MERGE; }; const changeName = async () => { - viewMode = ViewMode.VIEW_ASSETS; + viewMode = PersonPageViewMode.VIEW_ASSETS; person.name = personName; try { isEditingName = false; @@ -264,7 +244,7 @@ }; const handleCancelEditName = () => { - if (viewMode === ViewMode.SUGGEST_MERGE) { + if (viewMode === PersonPageViewMode.SUGGEST_MERGE) { return; } isSearchingPeople = false; @@ -295,13 +275,13 @@ potentialMergePeople = result .filter( (person: PersonResponseDto) => - personMerge2.name.toLowerCase() === person.name.toLowerCase() && + personMerge2?.name.toLowerCase() === person.name.toLowerCase() && person.id !== personMerge2.id && - person.id !== personMerge1.id && + person.id !== personMerge1?.id && !person.isHidden, ) .slice(0, 3); - viewMode = ViewMode.SUGGEST_MERGE; + viewMode = PersonPageViewMode.SUGGEST_MERGE; return; } await changeName(); @@ -309,7 +289,7 @@ const handleSetBirthDate = async (birthDate: string) => { try { - viewMode = ViewMode.VIEW_ASSETS; + viewMode = PersonPageViewMode.VIEW_ASSETS; person.birthDate = birthDate; const updatedPerson = await updatePerson({ @@ -331,7 +311,7 @@ }; const handleGoBack = async () => { - viewMode = ViewMode.VIEW_ASSETS; + viewMode = PersonPageViewMode.VIEW_ASSETS; if ($page.url.searchParams.has(QueryParameter.ACTION)) { $page.url.searchParams.delete(QueryParameter.ACTION); await goto($page.url); @@ -341,37 +321,50 @@ onDestroy(() => { assetStore.destroy(); }); + let person = $derived(data.person); + + let thumbnailData = $derived(getPeopleThumbnailUrl(person)); + + $effect(() => { + if (person) { + handlePromiseError(updateAssetCount()); + handlePromiseError(assetStore.updateOptions({ personId: person.id })); + } + }); + + let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived)); + let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); </script> -{#if viewMode === ViewMode.UNASSIGN_ASSETS} +{#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS} <UnMergeFaceSelector assetIds={[...$selectedAssets].map((a) => a.id)} personAssets={person} - onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} onConfirm={handleUnmerge} /> {/if} -{#if viewMode === ViewMode.SUGGEST_MERGE} +{#if viewMode === PersonPageViewMode.SUGGEST_MERGE && personMerge1 && personMerge2} <MergeSuggestionModal {personMerge1} {personMerge2} {potentialMergePeople} - onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} onReject={changeName} onConfirm={handleMergeSamePerson} /> {/if} -{#if viewMode === ViewMode.BIRTH_DATE} +{#if viewMode === PersonPageViewMode.BIRTH_DATE} <SetBirthDateModal birthDate={person.birthDate ?? ''} - onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} onUpdate={handleSetBirthDate} /> {/if} -{#if viewMode === ViewMode.MERGE_PEOPLE} +{#if viewMode === PersonPageViewMode.MERGE_PEOPLE} <MergeFaceSelector {person} onBack={handleGoBack} onMerge={handleMerge} /> {/if} @@ -399,14 +392,14 @@ </ButtonContextMenu> </AssetSelectControlBar> {:else} - {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} + {#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE || viewMode === PersonPageViewMode.BIRTH_DATE} <ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}> - <svelte:fragment slot="trailing"> + {#snippet trailing()} <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <MenuOption text={$t('select_featured_photo')} icon={mdiAccountBoxOutline} - onClick={() => (viewMode = ViewMode.SELECT_PERSON)} + onClick={() => (viewMode = PersonPageViewMode.SELECT_PERSON)} /> <MenuOption text={person.isHidden ? $t('unhide_person') : $t('hide_person')} @@ -416,21 +409,23 @@ <MenuOption text={$t('set_date_of_birth')} icon={mdiCalendarEditOutline} - onClick={() => (viewMode = ViewMode.BIRTH_DATE)} + onClick={() => (viewMode = PersonPageViewMode.BIRTH_DATE)} /> <MenuOption text={$t('merge_people')} icon={mdiAccountMultipleCheckOutline} - onClick={() => (viewMode = ViewMode.MERGE_PEOPLE)} + onClick={() => (viewMode = PersonPageViewMode.MERGE_PEOPLE)} /> </ButtonContextMenu> - </svelte:fragment> + {/snippet} </ControlAppBar> {/if} - {#if viewMode === ViewMode.SELECT_PERSON} - <ControlAppBar onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}> - <svelte:fragment slot="leading">{$t('select_featured_photo')}</svelte:fragment> + {#if viewMode === PersonPageViewMode.SELECT_PERSON} + <ControlAppBar onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}> + {#snippet leading()} + {$t('select_featured_photo')} + {/snippet} </ControlAppBar> {/if} {/if} @@ -442,12 +437,12 @@ enableRouting={true} {assetStore} {assetInteractionStore} - isSelectionMode={viewMode === ViewMode.SELECT_PERSON} - singleSelect={viewMode === ViewMode.SELECT_PERSON} + isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON} + singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON} onSelect={handleSelectFeaturePhoto} onEscape={handleEscape} > - {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} + {#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE || viewMode === PersonPageViewMode.BIRTH_DATE} <!-- Person information block --> <div class="relative w-fit p-4 sm:px-6" @@ -473,7 +468,7 @@ type="button" class="flex items-center justify-center" title={$t('edit_name')} - on:click={() => (isEditingName = true)} + onclick={() => (isEditingName = true)} > <ImageThumbnail circle @@ -510,11 +505,11 @@ {#each suggestedPeople as person, index (person.id)} <button type="button" - class="flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index === + class="flex w-full border border-gray-200 dark:border-immich-dark-gray h-14 place-items-center bg-gray-100 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index === suggestedPeople.length - 1 ? 'rounded-b-lg border-b' : ''}" - on:click={() => handleSuggestPeople(person)} + onclick={() => handleSuggestPeople(person)} > <ImageThumbnail circle diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index b44c58bc76284..7e233fcd17b94 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -35,13 +35,12 @@ const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; - let isAllFavorite: boolean; - let isAllOwned: boolean; - let isAssetStackSelected: boolean; - let isLinkActionAvailable: boolean; + let isAllFavorite = $state(false); + let isAllOwned = $state(false); + let isAssetStackSelected = $state(false); + let isLinkActionAvailable = $state(false); - // svelte-ignore reactive_declaration_non_reactive_property - $: { + $effect(() => { const selection = [...$selectedAssets]; isAllOwned = selection.every((asset) => asset.ownerId === $user.id); isAllFavorite = selection.every((asset) => asset.isFavorite); @@ -52,7 +51,7 @@ selection.some((asset) => asset.type === AssetTypeEnum.Image) && selection.some((asset) => asset.type === AssetTypeEnum.Image); isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate); - } + }); const handleEscape = () => { if ($showAssetViewer) { @@ -134,6 +133,8 @@ {#if $preferences.memories.enabled} <MemoryLane /> {/if} - <EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} slot="empty" /> + {#snippet empty()} + <EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} /> + {/snippet} </AssetGrid> </UserPageLayout> diff --git a/web/src/routes/(user)/places/+page.svelte b/web/src/routes/(user)/places/+page.svelte index 28c8e95cb1331..1808755482672 100644 --- a/web/src/routes/(user)/places/+page.svelte +++ b/web/src/routes/(user)/places/+page.svelte @@ -9,7 +9,11 @@ import { t } from 'svelte-i18n'; import { getAssetThumbnailUrl } from '$lib/utils'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); type AssetWithCity = AssetResponseDto & { exifInfo: { @@ -17,10 +21,10 @@ }; }; - $: places = data.items.filter((item): item is AssetWithCity => !!item.exifInfo?.city); - $: hasPlaces = places.length > 0; + let places = $derived(data.items.filter((item): item is AssetWithCity => !!item.exifInfo?.city)); + let hasPlaces = $derived(places.length > 0); - let innerHeight: number; + let innerHeight: number = $state(0); </script> <svelte:window bind:innerHeight /> diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 4605a2207eb47..0b6fba1613656 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -40,24 +40,40 @@ import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte'; import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation'; import { t } from 'svelte-i18n'; - import { afterUpdate, tick } from 'svelte'; + import { onMount, tick } from 'svelte'; import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; const MAX_ASSET_COUNT = 5000; let { isViewing: showAssetViewer } = assetViewingStore; - const viewport: Viewport = { width: 0, height: 0 }; + const viewport: Viewport = $state({ width: 0, height: 0 }); // The GalleryViewer pushes it's own history state, which causes weird // behavior for history.back(). To prevent that we store the previous page // manually and navigate back to that. - let previousRoute = AppRoute.EXPLORE as string; + let previousRoute = $state(AppRoute.EXPLORE as string); let nextPage: number | null = 1; - let searchResultAlbums: AlbumResponseDto[] = []; - let searchResultAssets: AssetResponseDto[] = []; - let isLoading = true; - let scrollY = 0; + let searchResultAlbums: AlbumResponseDto[] = $state([]); + let searchResultAssets: AssetResponseDto[] = $state([]); + let isLoading = $state(true); + let scrollY = $state(0); let scrollYHistory = 0; + let selectedAssets: Set<AssetResponseDto> = $state(new Set()); + + type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>; + + let isMultiSelectionMode = $derived(selectedAssets.size > 0); + let isAllArchived = $derived([...selectedAssets].every((asset) => asset.isArchived)); + let isAllFavorite = $derived([...selectedAssets].every((asset) => asset.isFavorite)); + let searchQuery = $derived($page.url.searchParams.get(QueryParameter.QUERY)); + + onMount(() => { + if (terms && $featureFlags.loaded) { + handlePromiseError(onSearchQueryUpdate()); + } + }); + + let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); const onEscape = () => { if ($showAssetViewer) { @@ -74,8 +90,7 @@ $preventRaceConditionSearchBar = false; }; - // save and restore scroll position - afterUpdate(() => { + $effect(() => { if (scrollY) { scrollYHistory = scrollY; } @@ -105,11 +120,6 @@ }); }); - let selectedAssets: Set<AssetResponseDto> = new Set(); - $: isMultiSelectionMode = selectedAssets.size > 0; - $: isAllArchived = [...selectedAssets].every((asset) => asset.isArchived); - $: isAllFavorite = [...selectedAssets].every((asset) => asset.isFavorite); - const onAssetDelete = (assetIds: string[]) => { const assetIdSet = new Set(assetIds); searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); @@ -118,16 +128,6 @@ selectedAssets = new Set(searchResultAssets); }; - type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>; - - $: searchQuery = $page.url.searchParams.get(QueryParameter.QUERY); - let terms: SearchTerms; - $: terms = searchQuery ? JSON.parse(searchQuery) : {}; - - $: if (terms && $featureFlags.loaded) { - handlePromiseError(onSearchQueryUpdate()); - } - async function onSearchQueryUpdate() { nextPage = 1; searchResultAssets = []; @@ -234,7 +234,7 @@ <div class="fixed z-[100] top-0 left-0 w-full"> <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}> <CreateSharedLink /> - <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} /> + <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} /> <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}> <AddToAlbum {onAddToAlbum} /> <AddToAlbum shared {onAddToAlbum} /> @@ -256,45 +256,52 @@ <div class="fixed z-[100] top-0 left-0 w-full"> <ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}> <div class="w-full flex-1 pl-4"> - <SearchBar grayTheme={false} value={terms.query ?? ''} searchQuery={terms} /> + <SearchBar + grayTheme={false} + value={terms?.query ?? ''} + searchQuery={terms} + onSearch={() => handlePromiseError(onSearchQueryUpdate())} + /> </div> </ControlAppBar> </div> {/if} </section> -<section - id="search-chips" - class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24" -> - {#each getObjectKeys(terms) as key (key)} - {@const value = terms[key]} - <div class="flex place-content-center place-items-center text-xs"> - <div - class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary +{#if terms} + <section + id="search-chips" + class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24" + > + {#each getObjectKeys(terms) as key (key)} + {@const value = terms[key]} + <div class="flex place-content-center place-items-center text-xs"> + <div + class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary {value === true ? 'rounded-full' : 'rounded-tl-full rounded-bl-full'}" - > - {getHumanReadableSearchKey(key)} - </div> - - {#if value !== true} - <div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full"> - {#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'} - {getHumanReadableDate(value)} - {:else if key === 'personIds' && Array.isArray(value)} - {#await getPersonName(value) then personName} - {personName} - {/await} - {:else if value === null || value === ''} - {$t('unknown')} - {:else} - {value} - {/if} + > + {getHumanReadableSearchKey(key as keyof SearchTerms)} </div> - {/if} - </div> - {/each} -</section> + + {#if value !== true} + <div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full"> + {#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'} + {getHumanReadableDate(value)} + {:else if key === 'personIds' && Array.isArray(value)} + {#await getPersonName(value) then personName} + {personName} + {/await} + {:else if value === null || value === ''} + {$t('unknown')} + {:else} + {value} + {/if} + </div> + {/if} + </div> + {/each} + </section> +{/if} <section class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4" diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte index f4fac282bae76..dfe465f94defd 100644 --- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -15,21 +15,24 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { tick } from 'svelte'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); let { gridScrollTarget } = assetViewingStore; - let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data; - let { title, description } = meta; - let isOwned = $user ? $user.id === sharedLink?.userId : false; - let password = ''; - let innerWidth: number; + let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = $state(data); + let { title, description } = $state(meta); + let isOwned = $derived($user ? $user.id === sharedLink?.userId : false); + let password = $state(''); + let innerWidth: number = $state(0); const handlePasswordSubmit = async () => { try { sharedLink = await getMySharedLink({ password, key }); setSharedLink(sharedLink); passwordRequired = false; - isOwned = $user ? $user.id === sharedLink.userId : false; title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich'; description = sharedLink.description || @@ -43,6 +46,11 @@ handleError(error, $t('errors.unable_to_get_shared_link')); } }; + + const onsubmit = async (event: Event) => { + event.preventDefault(); + await handlePasswordSubmit(); + }; </script> <svelte:window bind:innerWidth /> @@ -54,13 +62,13 @@ {#if passwordRequired} <header> <ControlAppBar showBackButton={false}> - <svelte:fragment slot="leading"> + {#snippet leading()} <ImmichLogoSmallLink width={innerWidth} /> - </svelte:fragment> + {/snippet} - <svelte:fragment slot="trailing"> + {#snippet trailing()} <ThemeButton /> - </svelte:fragment> + {/snippet} </ControlAppBar> </header> <main @@ -72,7 +80,7 @@ {$t('sharing_enter_password')} </div> <div class="mt-4"> - <form novalidate autocomplete="off" on:submit|preventDefault={handlePasswordSubmit}> + <form novalidate autocomplete="off" {onsubmit}> <input type="password" class="immich-form-input mr-2" placeholder={$t('password')} bind:value={password} /> <Button type="submit">{$t('submit')}</Button> </form> diff --git a/web/src/routes/(user)/sharing/+page.svelte b/web/src/routes/(user)/sharing/+page.svelte index 35279a02dbde2..1e59a2720db15 100644 --- a/web/src/routes/(user)/sharing/+page.svelte +++ b/web/src/routes/(user)/sharing/+page.svelte @@ -20,7 +20,11 @@ import Albums from '$lib/components/album-page/albums-list.svelte'; import { t } from 'svelte-i18n'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); const settings: AlbumViewSettings = { view: AlbumViewMode.Cover, @@ -34,21 +38,23 @@ </script> <UserPageLayout title={data.meta.title}> - <div class="flex" slot="buttons"> - <LinkButton on:click={() => createAlbumAndRedirect()}> - <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm"> - <Icon path={mdiPlusBoxOutline} size="18" class="shrink-0" /> - <span class="leading-none max-sm:text-xs">{$t('create_album')}</span> - </div> - </LinkButton> + {#snippet buttons()} + <div class="flex"> + <LinkButton onclick={() => createAlbumAndRedirect()}> + <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm"> + <Icon path={mdiPlusBoxOutline} size="18" class="shrink-0" /> + <span class="leading-none max-sm:text-xs">{$t('create_album')}</span> + </div> + </LinkButton> - <LinkButton href={AppRoute.SHARED_LINKS}> - <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm"> - <Icon path={mdiLink} size="18" class="shrink-0" /> - <span class="leading-none max-sm:text-xs">{$t('shared_links')}</span> - </div> - </LinkButton> - </div> + <LinkButton href={AppRoute.SHARED_LINKS}> + <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm"> + <Icon path={mdiLink} size="18" class="shrink-0" /> + <span class="leading-none max-sm:text-xs">{$t('shared_links')}</span> + </div> + </LinkButton> + </div> + {/snippet} <div class="flex flex-col"> {#if data.partners.length > 0} @@ -89,7 +95,9 @@ <!-- Shared Album List --> <Albums sharedAlbums={data.sharedAlbums} userSettings={settings} showOwner> <!-- Empty List --> - <EmptyPlaceholder slot="empty" text={$t('no_shared_albums_message')} src={empty2Url} /> + {#snippet empty()} + <EmptyPlaceholder text={$t('no_shared_albums_message')} src={empty2Url} /> + {/snippet} </Albums> </div> </div> diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte index 67e80f4703858..8ed4ae9fb6a1a 100644 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte +++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte @@ -15,8 +15,8 @@ import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; - let sharedLinks: SharedLinkResponseDto[] = []; - let editSharedLink: SharedLinkResponseDto | null = null; + let sharedLinks: SharedLinkResponseDto[] = $state([]); + let editSharedLink: SharedLinkResponseDto | null = $state(null); const refresh = async () => { sharedLinks = await getAllSharedLinks(); @@ -53,7 +53,9 @@ </script> <ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(AppRoute.SHARING)}> - <svelte:fragment slot="leading">{$t('shared_links')}</svelte:fragment> + {#snippet leading()} + {$t('shared_links')} + {/snippet} </ControlAppBar> <section class="mt-[120px] flex flex-col pb-[120px] container max-w-screen-lg mx-auto px-3"> diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index ce91abb451b8d..c52f0acb9e399 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,13 +11,11 @@ notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; - import { AppRoute, AssetAction, QueryParameter } from '$lib/constants'; + import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetStore } from '$lib/stores/assets.store'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; @@ -29,10 +27,14 @@ import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte'; import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); - $: pathSegments = data.path ? data.path.split('/') : []; - $: currentPath = $page.url.searchParams.get(QueryParameter.PATH) || ''; + let pathSegments = $derived(data.path ? data.path.split('/') : []); + let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); const assetInteractionStore = createAssetInteractionStore(); @@ -42,14 +44,19 @@ const assetStore = new AssetStore({}); - $: tags = data.tags; - $: tagsMap = buildMap(tags); - $: tag = currentPath ? tagsMap[currentPath] : null; - $: tagId = tag?.id; - $: tree = buildTree(tags.map((tag) => tag.value)); - $: { + let tags = $state<TagResponseDto[]>([]); + $effect(() => { + tags = data.tags; + }); + + let tagsMap = $derived(buildMap(tags)); + let tag = $derived(currentPath ? tagsMap[currentPath] : null); + let tagId = $derived(tag?.id); + let tree = $derived(buildTree(tags.map((tag) => tag.value))); + + $effect.pre(() => { void assetStore.updateOptions({ tagId }); - } + }); const handleNavigation = async (tag: string) => { await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`)); @@ -67,15 +74,15 @@ const navigateToView = (path: string) => goto(getLink(path)); - let isNewOpen = false; - let newTagValue = ''; + let isNewOpen = $state(false); + let newTagValue = $state(''); const handleCreate = () => { newTagValue = tag ? tag.value + '/' : ''; isNewOpen = true; }; - let isEditOpen = false; - let newTagColor = ''; + let isEditOpen = $state(false); + let newTagColor = $state(''); const handleEdit = () => { newTagColor = tag?.color ?? ''; isEditOpen = true; @@ -135,49 +142,66 @@ const parentPath = pathSegments.slice(0, -1).join('/'); await navigateToView(parentPath); }; + + const onsubmit = async (event: Event) => { + event.preventDefault(); + await handleSubmit(); + }; </script> <UserPageLayout title={data.meta.title} scrollbar={false}> - <SideBarSection slot="sidebar"> - <SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} /> - <section> - <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div> - <div class="h-full"> - <TreeItems icons={{ default: mdiTag, active: mdiTag }} items={tree} active={currentPath} {getLink} {getColor} /> - </div> - </section> - </SideBarSection> - - <section slot="buttons"> - <LinkButton on:click={handleCreate}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiPlus} size="18" /> - <p class="hidden md:block">{$t('create_tag')}</p> - </div> - </LinkButton> - - {#if pathSegments.length > 0 && tag} - <LinkButton on:click={handleEdit}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiPencil} size="18" /> - <p class="hidden md:block">{$t('edit_tag')}</p> + {#snippet sidebar()} + <SideBarSection> + <SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} /> + <section> + <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div> + <div class="h-full"> + <TreeItems + icons={{ default: mdiTag, active: mdiTag }} + items={tree} + active={currentPath} + {getLink} + {getColor} + /> </div> - </LinkButton> - <LinkButton on:click={handleDelete}> + </section> + </SideBarSection> + {/snippet} + + {#snippet buttons()} + <section> + <LinkButton onclick={handleCreate}> <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiTrashCanOutline} size="18" /> - <p class="hidden md:block">{$t('delete_tag')}</p> + <Icon path={mdiPlus} size="18" /> + <p class="hidden md:block">{$t('create_tag')}</p> </div> </LinkButton> - {/if} - </section> + + {#if pathSegments.length > 0 && tag} + <LinkButton onclick={handleEdit}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiPencil} size="18" /> + <p class="hidden md:block">{$t('edit_tag')}</p> + </div> + </LinkButton> + <LinkButton onclick={handleDelete}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiTrashCanOutline} size="18" /> + <p class="hidden md:block">{$t('delete_tag')}</p> + </div> + </LinkButton> + {/if} + </section> + {/snippet} <Breadcrumbs {pathSegments} icon={mdiTagMultiple} title={$t('tags')} {getLink} /> <section class="mt-2 h-full"> {#if tag} <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}> - <TreeItemThumbnails items={data.children} icon={mdiTag} onClick={handleNavigation} slot="empty" /> + {#snippet empty()} + <TreeItemThumbnails items={data.children} icon={mdiTag} onClick={handleNavigation} /> + {/snippet} </AssetGrid> {:else} <TreeItemThumbnails items={Object.keys(tree)} icon={mdiTag} onClick={handleNavigation} /> @@ -193,7 +217,7 @@ </p> </div> - <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="create-tag-form"> + <form {onsubmit} autocomplete="off" id="create-tag-form"> <div class="my-4 flex flex-col gap-2"> <SettingInputField inputType={SettingInputFieldType.TEXT} @@ -204,16 +228,17 @@ /> </div> </form> - <svelte:fragment slot="sticky-bottom"> - <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button> + + {#snippet stickyBottom()} + <Button color="gray" fullwidth onclick={() => handleCancel()}>{$t('cancel')}</Button> <Button type="submit" fullwidth form="create-tag-form">{$t('create')}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> {/if} {#if isEditOpen} <FullScreenModal title={$t('edit_tag')} icon={mdiTag} onClose={handleCancel}> - <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="edit-tag-form"> + <form {onsubmit} autocomplete="off" id="edit-tag-form"> <div class="my-4 flex flex-col gap-2"> <SettingInputField inputType={SettingInputFieldType.COLOR} @@ -222,9 +247,10 @@ /> </div> </form> - <svelte:fragment slot="sticky-bottom"> - <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button> + + {#snippet stickyBottom()} + <Button color="gray" fullwidth onclick={() => handleCancel()}>{$t('cancel')}</Button> <Button type="submit" fullwidth form="edit-tag-form">{$t('save')}</Button> - </svelte:fragment> + {/snippet} </FullScreenModal> {/if} diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 862d9382a462f..8803ea38c826a 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -27,7 +27,11 @@ import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); if (!$featureFlags.trash) { handlePromiseError(goto(AppRoute.PHOTOS)); @@ -99,26 +103,30 @@ {#if $featureFlags.loaded && $featureFlags.trash} <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}> - <div class="flex place-items-center gap-2" slot="buttons"> - <LinkButton on:click={handleRestoreTrash} disabled={$isMultiSelectState}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiHistory} size="18" /> - {$t('restore_all')} - </div> - </LinkButton> - <LinkButton on:click={() => handleEmptyTrash()} disabled={$isMultiSelectState}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiDeleteForeverOutline} size="18" /> - {$t('empty_trash')} - </div> - </LinkButton> - </div> + {#snippet buttons()} + <div class="flex place-items-center gap-2"> + <LinkButton onclick={handleRestoreTrash} disabled={$isMultiSelectState}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiHistory} size="18" /> + {$t('restore_all')} + </div> + </LinkButton> + <LinkButton onclick={() => handleEmptyTrash()} disabled={$isMultiSelectState}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiDeleteForeverOutline} size="18" /> + {$t('empty_trash')} + </div> + </LinkButton> + </div> + {/snippet} <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore}> <p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4"> {$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })} </p> - <EmptyPlaceholder text={$t('trash_no_results_message')} src={empty3Url} slot="empty" /> + {#snippet empty()} + <EmptyPlaceholder text={$t('trash_no_results_message')} src={empty3Url} /> + {/snippet} </AssetGrid> </UserPageLayout> {/if} diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte index 4ed46b580fd31..53cc661a30bd8 100644 --- a/web/src/routes/(user)/user-settings/+page.svelte +++ b/web/src/routes/(user)/user-settings/+page.svelte @@ -7,18 +7,22 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; - export let data: PageData; - export let isShowKeyboardShortcut = false; + interface Props { + data: PageData; + isShowKeyboardShortcut?: boolean; + } + + let { data, isShowKeyboardShortcut = $bindable(false) }: Props = $props(); </script> <UserPageLayout title={data.meta.title}> - <svelte:fragment slot="buttons"> + {#snippet buttons()} <CircleIconButton icon={mdiKeyboard} title={$t('show_keyboard_shortcuts')} - on:click={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)} + onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)} /> - </svelte:fragment> + {/snippet} <section class="mx-4 flex place-content-center"> <div class="w-full max-w-3xl"> <UserSettingsList keys={data.keys} sessions={data.sessions} /> diff --git a/web/src/routes/(user)/utilities/+page.svelte b/web/src/routes/(user)/utilities/+page.svelte index bf18b99436aa9..6713fe4a4b6b5 100644 --- a/web/src/routes/(user)/utilities/+page.svelte +++ b/web/src/routes/(user)/utilities/+page.svelte @@ -3,7 +3,11 @@ import type { PageData } from './$types'; import UtilitiesMenu from '$lib/components/utilities-page/utilities-menu.svelte'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); </script> <UserPageLayout title={data.meta.title}> diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index e1029b7ccbfff..fd2bcb438c582 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -22,8 +22,12 @@ import Icon from '$lib/components/elements/icon.svelte'; import { locale } from '$lib/stores/preferences.store'; - export let data: PageData; - export let isShowKeyboardShortcut = false; + interface Props { + data: PageData; + isShowKeyboardShortcut?: boolean; + } + + let { data = $bindable(), isShowKeyboardShortcut = $bindable(false) }: Props = $props(); interface Shortcuts { general: ExplainedShortcut[]; @@ -46,8 +50,8 @@ ], }; - $: hasDuplicates = data.duplicates.length > 0; - + let duplicates = $state(data.duplicates); + let hasDuplicates = $derived(duplicates.length > 0); const withConfirmation = async (callback: () => Promise<void>, prompt?: string, confirmText?: string) => { if (prompt && confirmText) { const isConfirmed = await dialogController.show({ prompt, confirmText }); @@ -82,7 +86,7 @@ await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !$featureFlags.trash } }); await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } }); - data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); + duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); deletedNotification(trashIds.length); }, @@ -95,14 +99,12 @@ await stackAssets(assets, false); const duplicateAssetIds = assets.map((asset) => asset.id); await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } }); - data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); + duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); }; const handleDeduplicateAll = async () => { - const idsToKeep = data.duplicates - .map((group) => suggestDuplicateByFileSize(group.assets)) - .map((asset) => asset?.id); - const idsToDelete = data.duplicates.flatMap((group, i) => + const idsToKeep = duplicates.map((group) => suggestDuplicateByFileSize(group.assets)).map((asset) => asset?.id); + const idsToDelete = duplicates.flatMap((group, i) => group.assets.map((asset) => asset.id).filter((asset) => asset !== idsToKeep[i]), ); @@ -125,7 +127,7 @@ }, }); - data.duplicates = []; + duplicates = []; deletedNotification(idsToDelete.length); }, @@ -135,12 +137,12 @@ }; const handleKeepAll = async () => { - const ids = data.duplicates.flatMap((group) => group.assets.map((asset) => asset.id)); + const ids = duplicates.flatMap((group) => group.assets.map((asset) => asset.id)); return withConfirmation( async () => { await updateAssets({ assetBulkUpdateDto: { ids, duplicateId: null } }); - data.duplicates = []; + duplicates = []; notificationController.show({ message: $t('resolved_all_duplicates'), @@ -153,38 +155,40 @@ }; </script> -<UserPageLayout title={data.meta.title + ` (${data.duplicates.length.toLocaleString($locale)})`} scrollbar={true}> - <div class="flex place-items-center gap-2" slot="buttons"> - <LinkButton on:click={() => handleDeduplicateAll()} disabled={!hasDuplicates}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiTrashCanOutline} size="18" /> - {$t('deduplicate_all')} - </div> - </LinkButton> - <LinkButton on:click={() => handleKeepAll()} disabled={!hasDuplicates}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiCheckOutline} size="18" /> - {$t('keep_all')} - </div> - </LinkButton> - <CircleIconButton - icon={mdiKeyboard} - title={$t('show_keyboard_shortcuts')} - on:click={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)} - /> - </div> +<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}> + {#snippet buttons()} + <div class="flex place-items-center gap-2"> + <LinkButton onclick={() => handleDeduplicateAll()} disabled={!hasDuplicates}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiTrashCanOutline} size="18" /> + {$t('deduplicate_all')} + </div> + </LinkButton> + <LinkButton onclick={() => handleKeepAll()} disabled={!hasDuplicates}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiCheckOutline} size="18" /> + {$t('keep_all')} + </div> + </LinkButton> + <CircleIconButton + icon={mdiKeyboard} + title={$t('show_keyboard_shortcuts')} + onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)} + /> + </div> + {/snippet} <div class="mt-4"> - {#if data.duplicates && data.duplicates.length > 0} + {#if duplicates && duplicates.length > 0} <div class="mb-4 text-sm dark:text-white"> <p>{$t('duplicates_description')}</p> </div> - {#key data.duplicates[0].duplicateId} + {#key duplicates[0].duplicateId} <DuplicatesCompareControl - assets={data.duplicates[0].assets} + assets={duplicates[0].assets} onResolve={(duplicateAssetIds, trashIds) => - handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)} - onStack={(assets) => handleStack(data.duplicates[0].duplicateId, assets)} + handleResolve(duplicates[0].duplicateId, duplicateAssetIds, trashIds)} + onStack={(assets) => handleStack(duplicates[0].duplicateId, assets)} /> {/key} {:else} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 8f8bd033eb412..a6e6727d39c3b 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,4 +1,6 @@ <script lang="ts"> + import { run } from 'svelte/legacy'; + import { afterNavigate, beforeNavigate } from '$app/navigation'; import { page } from '$app/stores'; import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte'; @@ -16,22 +18,20 @@ import { user } from '$lib/stores/user.store'; import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket'; import { copyToClipboard, setKey } from '$lib/utils'; - import { onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount, type Snippet } from 'svelte'; import '../app.css'; import { isAssetViewerRoute, isSharedLinkRoute } from '$lib/utils/navigation'; import DialogWrapper from '$lib/components/shared-components/dialog/dialog-wrapper.svelte'; import { t } from 'svelte-i18n'; import Error from '$lib/components/error.svelte'; import { shortcut } from '$lib/actions/shortcut'; + interface Props { + children?: Snippet; + } - let showNavigationLoadingBar = false; - $: changeTheme($colorTheme); + let { children }: Props = $props(); - $: if ($user) { - openWebsocketConnection(); - } else { - closeWebsocketConnection(); - } + let showNavigationLoadingBar = $state(false); const changeTheme = (theme: ThemeSetting) => { if (theme.system) { @@ -82,6 +82,16 @@ afterNavigate(() => { showNavigationLoadingBar = false; }); + run(() => { + changeTheme($colorTheme); + }); + run(() => { + if ($user) { + openWebsocketConnection(); + } else { + closeWebsocketConnection(); + } + }); </script> <svelte:head> @@ -135,7 +145,7 @@ {#if $page.data.error} <Error error={$page.data.error}></Error> {:else} - <slot /> + {@render children?.()} {/if} {#if showNavigationLoadingBar} diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index 16c2541e61b53..b323a136aad1b 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -18,13 +18,17 @@ import { t } from 'svelte-i18n'; import type { PageData } from './$types'; - export let data: PageData; + interface Props { + data: PageData; + } - let jobs: AllJobStatusResponseDto; + let { data }: Props = $props(); + + let jobs: AllJobStatusResponseDto | undefined = $state(); let running = true; - let isOpen = false; - let selectedJob: ComboBoxOption | undefined = undefined; + let isOpen = $state(false); + let selectedJob: ComboBoxOption | undefined = $state(undefined); onMount(async () => { while (running) { @@ -58,23 +62,30 @@ handleError(error, $t('errors.unable_to_submit_job')); } }; + + const onsubmit = async (event: Event) => { + event.preventDefault(); + await handleCreate(); + }; </script> <UserPageLayout title={data.meta.title} admin> - <div class="flex justify-end" slot="buttons"> - <LinkButton on:click={() => (isOpen = true)}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiPlus} size="18" /> - {$t('admin.create_job')} - </div> - </LinkButton> - <LinkButton href="{AppRoute.ADMIN_SETTINGS}?isOpen=job"> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiCog} size="18" /> - {$t('admin.manage_concurrency')} - </div> - </LinkButton> - </div> + {#snippet buttons()} + <div class="flex justify-end"> + <LinkButton onclick={() => (isOpen = true)}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiPlus} size="18" /> + {$t('admin.create_job')} + </div> + </LinkButton> + <LinkButton href="{AppRoute.ADMIN_SETTINGS}?isOpen=job"> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiCog} size="18" /> + {$t('admin.manage_concurrency')} + </div> + </LinkButton> + </div> + {/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 jobs} @@ -92,15 +103,17 @@ onConfirm={handleCreate} onCancel={handleCancel} > - <form on:submit|preventDefault={handleCreate} autocomplete="off" id="create-tag-form" slot="prompt" class="w-full"> - <div class="flex flex-col gap-1 text-left"> - <Combobox - bind:selectedOption={selectedJob} - label={$t('jobs')} - {options} - placeholder={$t('admin.search_jobs')} - /> - </div> - </form> + {#snippet promptSnippet()} + <form {onsubmit} autocomplete="off" id="create-tag-form" class="w-full"> + <div class="flex flex-col gap-1 text-left"> + <Combobox + bind:selectedOption={selectedJob} + label={$t('jobs')} + {options} + placeholder={$t('admin.search_jobs')} + /> + </div> + </form> + {/snippet} </ConfirmDialog> {/if} diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 6f61572b0edfe..b89e81ebf687d 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -36,32 +36,36 @@ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import { locale } from '$lib/stores/preferences.store'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); - let libraries: LibraryResponseDto[] = []; + let libraries: LibraryResponseDto[] = $state([]); let stats: LibraryStatsResponseDto[] = []; - let owner: UserResponseDto[] = []; + let owner: UserResponseDto[] = $state([]); let photos: number[] = []; let videos: number[] = []; - let totalCount: number[] = []; - let diskUsage: number[] = []; - let diskUsageUnit: ByteUnit[] = []; - let editImportPaths: number | null; - let editScanSettings: number | null; - let renameLibrary: number | null; + let totalCount: number[] = $state([]); + let diskUsage: number[] = $state([]); + let diskUsageUnit: ByteUnit[] = $state([]); + let editImportPaths: number | undefined = $state(); + let editScanSettings: number | undefined = $state(); + let renameLibrary: number | undefined = $state(); let updateLibraryIndex: number | null; let dropdownOpen: boolean[] = []; - let toCreateLibrary = false; + let toCreateLibrary = $state(false); onMount(async () => { await readLibraryList(); }); const closeAll = () => { - editImportPaths = null; - editScanSettings = null; - renameLibrary = null; + editImportPaths = undefined; + editScanSettings = undefined; + renameLibrary = undefined; updateLibraryIndex = null; for (let index = 0; index < dropdownOpen.length; index++) { @@ -213,22 +217,24 @@ {/if} <UserPageLayout title={data.meta.title} admin> - <div class="flex justify-end gap-2" slot="buttons"> - {#if libraries.length > 0} - <LinkButton on:click={() => handleScanAll()}> + {#snippet buttons()} + <div class="flex justify-end gap-2"> + {#if libraries.length > 0} + <LinkButton onclick={() => handleScanAll()}> + <div class="flex gap-1 text-sm"> + <Icon path={mdiSync} size="18" /> + <span>{$t('scan_all_libraries')}</span> + </div> + </LinkButton> + {/if} + <LinkButton onclick={() => (toCreateLibrary = true)}> <div class="flex gap-1 text-sm"> - <Icon path={mdiSync} size="18" /> - <span>{$t('scan_all_libraries')}</span> + <Icon path={mdiPlusBoxOutline} size="18" /> + <span>{$t('create_library')}</span> </div> </LinkButton> - {/if} - <LinkButton on:click={() => (toCreateLibrary = true)}> - <div class="flex gap-1 text-sm"> - <Icon path={mdiPlusBoxOutline} size="18" /> - <span>{$t('create_library')}</span> - </div> - </LinkButton> - </div> + </div> + {/snippet} <section class="my-4"> <div class="flex flex-col gap-2" in:fade={{ duration: 500 }}> {#if libraries.length > 0} @@ -311,13 +317,17 @@ {#if renameLibrary === index} <!-- svelte-ignore node_invalid_placement_ssr --> <div transition:slide={{ duration: 250 }}> - <LibraryRenameForm {library} onSubmit={handleUpdate} onCancel={() => (renameLibrary = null)} /> + <LibraryRenameForm {library} onSubmit={handleUpdate} onCancel={() => (renameLibrary = undefined)} /> </div> {/if} {#if editImportPaths === index} <!-- svelte-ignore node_invalid_placement_ssr --> <div transition:slide={{ duration: 250 }}> - <LibraryImportPathsForm {library} onSubmit={handleUpdate} onCancel={() => (editImportPaths = null)} /> + <LibraryImportPathsForm + {library} + onSubmit={handleUpdate} + onCancel={() => (editImportPaths = undefined)} + /> </div> {/if} {#if editScanSettings === index} @@ -326,7 +336,7 @@ <LibraryScanSettingsForm {library} onSubmit={handleUpdate} - onCancel={() => (editScanSettings = null)} + onCancel={() => (editScanSettings = undefined)} /> </div> {/if} diff --git a/web/src/routes/admin/repair/+page.svelte b/web/src/routes/admin/repair/+page.svelte index e8cb0649c2d72..9f19fddd03662 100644 --- a/web/src/routes/admin/repair/+page.svelte +++ b/web/src/routes/admin/repair/+page.svelte @@ -19,7 +19,11 @@ import { t } from 'svelte-i18n'; import { locale } from '$lib/stores/preferences.store'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); interface UntrackedFile { filename: string; @@ -33,12 +37,12 @@ const normalize = (filenames: string[]) => filenames.map((filename) => ({ filename, checksum: null })); - let checking = false; - let repairing = false; + let checking = $state(false); + let repairing = $state(false); - let orphans: FileReportItemDto[] = data.orphans; - let extras: UntrackedFile[] = normalize(data.extras); - let matches: Match[] = []; + let orphans: FileReportItemDto[] = $state(data.orphans); + let extras: UntrackedFile[] = $state(normalize(data.extras)); + let matches: Match[] = $state([]); const handleDownload = () => { if (extras.length > 0) { @@ -180,33 +184,34 @@ </script> <UserPageLayout title={data.meta.title} admin> - <svelte:fragment slot="sidebar" /> - <div class="flex justify-end gap-2" slot="buttons"> - <LinkButton on:click={() => handleRepair()} disabled={matches.length === 0 || repairing}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiWrench} size="18" /> - {$t('admin.repair_all')} - </div> - </LinkButton> - <LinkButton on:click={() => handleCheckAll()} disabled={extras.length === 0 || checking}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiCheckAll} size="18" /> - {$t('admin.check_all')} - </div> - </LinkButton> - <LinkButton on:click={() => handleDownload()} disabled={extras.length + orphans.length === 0}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiDownload} size="18" /> - {$t('export')} - </div> - </LinkButton> - <LinkButton on:click={() => handleRefresh()}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiRefresh} size="18" /> - {$t('refresh')} - </div> - </LinkButton> - </div> + {#snippet buttons()} + <div class="flex justify-end gap-2"> + <LinkButton onclick={() => handleRepair()} disabled={matches.length === 0 || repairing}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiWrench} size="18" /> + {$t('admin.repair_all')} + </div> + </LinkButton> + <LinkButton onclick={() => handleCheckAll()} disabled={extras.length === 0 || checking}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiCheckAll} size="18" /> + {$t('admin.check_all')} + </div> + </LinkButton> + <LinkButton onclick={() => handleDownload()} disabled={extras.length + orphans.length === 0}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiDownload} size="18" /> + {$t('export')} + </div> + </LinkButton> + <LinkButton onclick={() => handleRefresh()}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiRefresh} size="18" /> + {$t('refresh')} + </div> + </LinkButton> + </div> + {/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} @@ -238,7 +243,7 @@ <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" - on:click={() => handleSplit(match)} + onclick={() => handleSplit(match)} > <td class="text-sm text-ellipsis flex flex-col gap-1 font-mono"> <span>{match.orphan.pathValue} =></span> @@ -279,8 +284,8 @@ tabindex="0" title={orphan.pathValue} > - <td on:click={() => copyToClipboard(orphan.pathValue)}> - <CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" /> + <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-left" title={orphan.pathValue}> {orphan.pathValue} @@ -318,11 +323,11 @@ <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" - on:click={() => handleCheckOne(extra.filename)} + onclick={() => handleCheckOne(extra.filename)} title={extra.filename} > - <td on:click={() => copyToClipboard(extra.filename)}> - <CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" /> + <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 pr-5"> <span class="text-ellipsis grow truncate font-mono text-sm pr-5" title={extra.filename} diff --git a/web/src/routes/admin/server-status/+page.svelte b/web/src/routes/admin/server-status/+page.svelte index 54f62b3adb753..0aa4c3dd6909d 100644 --- a/web/src/routes/admin/server-status/+page.svelte +++ b/web/src/routes/admin/server-status/+page.svelte @@ -6,7 +6,11 @@ import type { PageData } from './$types'; import { asyncTimeout } from '$lib/utils'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data = $bindable() }: Props = $props(); let running = true; diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 9eb7351060048..6a712450514fd 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -27,7 +27,6 @@ import { featureFlags } from '$lib/stores/server-config.store'; import { copyToClipboard } from '$lib/utils'; import { downloadBlob } from '$lib/utils/asset-utils'; - import type { SystemConfigDto } from '@immich/sdk'; import { mdiAccountOutline, mdiAlert, @@ -53,16 +52,20 @@ } from '@mdi/js'; import type { PageData } from './$types'; import { t } from 'svelte-i18n'; - import type { ComponentType, SvelteComponent } from 'svelte'; + import type { Component } from 'svelte'; import type { SettingsComponentProps } from '$lib/components/admin-page/settings/admin-settings'; import SearchBar from '$lib/components/elements/search-bar.svelte'; - export let data: PageData; + interface Props { + data: PageData; + } - let config = data.configs; - let handleSave: (update: Partial<SystemConfigDto>) => Promise<void>; + let { data }: Props = $props(); - type SettingsComponent = ComponentType<SvelteComponent<SettingsComponentProps>>; + let config = $state(data.configs); + let adminSettingElement = $state<ReturnType<typeof AdminSettings>>(); + + type SettingsComponent = Component<SettingsComponentProps>; // https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793 const jsonReplacer = (key: string, value: unknown) => @@ -85,7 +88,8 @@ setTimeout(() => downloadManager.clear(downloadKey), 5000); }; - let inputElement: HTMLInputElement; + let inputElement: HTMLInputElement | undefined = $state(); + const uploadConfig = (e: Event) => { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) { @@ -94,7 +98,7 @@ const reader = async () => { const text = await file.text(); const newConfig = JSON.parse(text); - await handleSave(newConfig); + await adminSettingElement?.handleSave(newConfig); }; reader().catch((error) => console.error('Error handling JSON config upload', error)); }; @@ -227,15 +231,17 @@ }, ]; - let searchQuery = ''; + let searchQuery = $state(''); - $: filteredSettings = settings.filter(({ title, subtitle }) => { - const query = searchQuery.toLowerCase(); - return title.toLowerCase().includes(query) || subtitle.toLowerCase().includes(query); - }); + let filteredSettings = $derived( + settings.filter(({ title, subtitle }) => { + const query = searchQuery.toLowerCase(); + return title.toLowerCase().includes(query) || subtitle.toLowerCase().includes(query); + }), + ); </script> -<input bind:this={inputElement} type="file" accept=".json" style="display: none" on:change={uploadConfig} /> +<input bind:this={inputElement} type="file" accept=".json" style="display: none" onchange={uploadConfig} /> <div class="h-svh flex flex-col overflow-hidden"> {#if $featureFlags.configFile} @@ -248,54 +254,58 @@ {/if} <UserPageLayout title={data.meta.title} admin> - <div class="flex justify-end gap-2" slot="buttons"> - <div class="hidden lg:block"> - <SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} /> - </div> - <LinkButton on:click={() => copyToClipboard(JSON.stringify(config, jsonReplacer, 2))}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiContentCopy} size="18" /> - {$t('copy_to_clipboard')} + {#snippet buttons()} + <div class="flex justify-end gap-2"> + <div class="hidden lg:block"> + <SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} /> </div> - </LinkButton> - <LinkButton on:click={() => downloadConfig()}> - <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiDownload} size="18" /> - {$t('export_as_json')} - </div> - </LinkButton> - {#if !$featureFlags.configFile} - <LinkButton on:click={() => inputElement?.click()}> + <LinkButton onclick={() => copyToClipboard(JSON.stringify(config, jsonReplacer, 2))}> <div class="flex place-items-center gap-2 text-sm"> - <Icon path={mdiUpload} size="18" /> - {$t('import_from_json')} + <Icon path={mdiContentCopy} size="18" /> + {$t('copy_to_clipboard')} </div> </LinkButton> - {/if} - </div> - - <AdminSettings bind:config let:handleReset bind:handleSave let:savedConfig let:defaultConfig> - <section id="setting-content" class="flex place-content-center sm:mx-4"> - <section class="w-full pb-28 sm:w-5/6 md:w-[896px]"> - <div class="block lg:hidden"> - <SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} /> + <LinkButton onclick={() => downloadConfig()}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiDownload} size="18" /> + {$t('export_as_json')} </div> - <SettingAccordionState queryParam={QueryParameter.IS_OPEN}> - {#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)} - <SettingAccordion {title} {subtitle} {key} {icon}> - <Component - onSave={(config) => handleSave(config)} - onReset={(options) => handleReset(options)} - disabled={$featureFlags.configFile} - {defaultConfig} - {config} - {savedConfig} - /> - </SettingAccordion> - {/each} - </SettingAccordionState> + </LinkButton> + {#if !$featureFlags.configFile} + <LinkButton onclick={() => inputElement?.click()}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiUpload} size="18" /> + {$t('import_from_json')} + </div> + </LinkButton> + {/if} + </div> + {/snippet} + + <AdminSettings bind:config bind:this={adminSettingElement}> + {#snippet children({ savedConfig, defaultConfig })} + <section id="setting-content" class="flex place-content-center sm:mx-4"> + <section class="w-full pb-28 sm:w-5/6 md:w-[896px]"> + <div class="block lg:hidden"> + <SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} /> + </div> + <SettingAccordionState queryParam={QueryParameter.IS_OPEN}> + {#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)} + <SettingAccordion {title} {subtitle} {key} {icon}> + <Component + onSave={(config) => adminSettingElement?.handleSave(config)} + onReset={(options) => adminSettingElement?.handleReset(options)} + disabled={$featureFlags.configFile} + bind:config + {defaultConfig} + {savedConfig} + /> + </SettingAccordion> + {/each} + </SettingAccordionState> + </section> </section> - </section> + {/snippet} </AdminSettings> </UserPageLayout> </div> diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index 80c0169176107..d93a8a5731d73 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -27,16 +27,20 @@ import { t } from 'svelte-i18n'; import type { PageData } from './$types'; - export let data: PageData; + interface Props { + data: PageData; + } - let allUsers: UserAdminResponseDto[] = []; - let shouldShowEditUserForm = false; - let shouldShowCreateUserForm = false; - let shouldShowPasswordResetSuccess = false; - let shouldShowDeleteConfirmDialog = false; - let shouldShowRestoreDialog = false; - let selectedUser: UserAdminResponseDto; - let newPassword: string; + let { data }: Props = $props(); + + let allUsers: UserAdminResponseDto[] = $state([]); + let shouldShowEditUserForm = $state(false); + let shouldShowCreateUserForm = $state(false); + let shouldShowPasswordResetSuccess = $state(false); + let shouldShowDeleteConfirmDialog = $state(false); + let shouldShowRestoreDialog = $state(false); + let selectedUser = $state<UserAdminResponseDto>(); + let newPassword = $state(''); const refresh = async () => { allUsers = await searchUsersAdmin({ withDeleted: true }); @@ -117,7 +121,7 @@ /> {/if} - {#if shouldShowEditUserForm} + {#if shouldShowEditUserForm && selectedUser} <EditUserForm user={selectedUser} bind:newPassword @@ -128,7 +132,7 @@ /> {/if} - {#if shouldShowDeleteConfirmDialog} + {#if shouldShowDeleteConfirmDialog && selectedUser} <DeleteConfirmDialog user={selectedUser} onSuccess={onUserDelete} @@ -137,7 +141,7 @@ /> {/if} - {#if shouldShowRestoreDialog} + {#if shouldShowRestoreDialog && selectedUser} <RestoreDialogue user={selectedUser} onSuccess={onUserRestore} @@ -155,7 +159,7 @@ hideCancelButton={true} confirmColor="green" > - <svelte:fragment slot="prompt"> + {#snippet promptSnippet()} <div class="flex flex-col gap-4"> <p>{$t('admin.user_password_has_been_reset')}</p> @@ -165,7 +169,7 @@ > {newPassword} </code> - <LinkButton on:click={() => copyToClipboard(newPassword)} title={$t('copy_password')}> + <LinkButton onclick={() => copyToClipboard(newPassword)} title={$t('copy_password')}> <div class="flex place-items-center gap-2 text-sm"> <Icon path={mdiContentCopy} size="18" /> </div> @@ -174,7 +178,7 @@ <p>{$t('admin.user_password_reset_description')}</p> </div> - </svelte:fragment> + {/snippet} </ConfirmDialog> {/if} @@ -223,7 +227,7 @@ title={$t('edit_user')} color="primary" size="16" - on:click={() => editUserHandler(immichUser)} + onclick={() => editUserHandler(immichUser)} /> {#if immichUser.id !== $user.id} <CircleIconButton @@ -231,7 +235,7 @@ title={$t('delete_user')} color="primary" size="16" - on:click={() => deleteUserHandler(immichUser)} + onclick={() => deleteUserHandler(immichUser)} /> {/if} {/if} @@ -243,7 +247,7 @@ })} color="primary" size="16" - on:click={() => restoreUserHandler(immichUser)} + onclick={() => restoreUserHandler(immichUser)} /> {/if} </td> @@ -253,7 +257,7 @@ </tbody> </table> - <Button size="sm" on:click={() => (shouldShowCreateUserForm = true)}>{$t('create_user')}</Button> + <Button size="sm" onclick={() => (shouldShowCreateUserForm = true)}>{$t('create_user')}</Button> </section> </section> </UserPageLayout> diff --git a/web/src/routes/auth/change-password/+page.svelte b/web/src/routes/auth/change-password/+page.svelte index eaf5a88fe20be..21226b9387ee3 100644 --- a/web/src/routes/auth/change-password/+page.svelte +++ b/web/src/routes/auth/change-password/+page.svelte @@ -8,7 +8,11 @@ import type { PageData } from './$types'; import { t } from 'svelte-i18n'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); const onSuccess = async () => { await goto(AppRoute.AUTH_LOGIN); @@ -18,12 +22,14 @@ </script> <FullscreenContainer title={data.meta.title}> - <p slot="message"> - {$t('hi_user', { values: { name: $user.name, email: $user.email } })} - <br /> - <br /> - {$t('change_password_description')} - </p> + {#snippet message()} + <p> + {$t('hi_user', { values: { name: $user.name, email: $user.email } })} + <br /> + <br /> + {$t('change_password_description')} + </p> + {/snippet} <ChangePasswordForm {onSuccess} /> </FullscreenContainer> diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index dd0f64c5a844e..0ab506f5e3ca6 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -6,15 +6,21 @@ import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; import type { PageData } from './$types'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); </script> {#if $featureFlags.loaded} <FullscreenContainer title={data.meta.title} showMessage={!!$serverConfig.loginPageMessage}> - <p slot="message"> - <!-- eslint-disable-next-line svelte/no-at-html-tags --> - {@html $serverConfig.loginPageMessage} - </p> + {#snippet message()} + <p> + <!-- eslint-disable-next-line svelte/no-at-html-tags --> + {@html $serverConfig.loginPageMessage} + </p> + {/snippet} <LoginForm onSuccess={async () => await goto(AppRoute.PHOTOS, { invalidateAll: true })} diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index ddb30d1b45eff..0c8e33e37b968 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -1,4 +1,6 @@ <script lang="ts"> + import { run } from 'svelte/legacy'; + import { goto } from '$app/navigation'; import { page } from '$app/stores'; import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte'; @@ -9,7 +11,7 @@ import { retrieveServerConfig } from '$lib/stores/server-config.store'; import { updateAdminOnboarding } from '@immich/sdk'; - let index = 0; + let index = $state(0); interface OnboardingStep { name: string; @@ -27,11 +29,11 @@ { name: 'storage', component: OnboadingStorageTemplate }, ]; - $: { + run(() => { const stepState = $page.url.searchParams.get('step'); const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState); index = temporaryIndex >= 0 ? temporaryIndex : 0; - } + }); const handleDoneClicked = async () => { if (index >= onboardingSteps.length - 1) { @@ -50,6 +52,8 @@ await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`); } }; + + const SvelteComponent = $derived(onboardingSteps[index].component); </script> <section id="onboarding-page" class="min-w-screen flex min-h-screen p-4"> @@ -61,11 +65,7 @@ ></div> </div> <div class="w-full min-w-screen py-8 flex h-full place-content-center place-items-center"> - <svelte:component - this={onboardingSteps[index].component} - onDone={handleDoneClicked} - onPrevious={handlePrevious} - /> + <SvelteComponent onDone={handleDoneClicked} onPrevious={handlePrevious} /> </div> </div> </section> diff --git a/web/src/routes/auth/register/+page.svelte b/web/src/routes/auth/register/+page.svelte index 9c1f0ca6c49d4..2e55ba7435ecd 100644 --- a/web/src/routes/auth/register/+page.svelte +++ b/web/src/routes/auth/register/+page.svelte @@ -4,13 +4,19 @@ import type { PageData } from './$types'; import { t } from 'svelte-i18n'; - export let data: PageData; + interface Props { + data: PageData; + } + + let { data }: Props = $props(); </script> <FullscreenContainer title={data.meta.title}> - <p slot="message"> - {$t('admin.registration_description')} - </p> + {#snippet message()} + <p> + {$t('admin.registration_description')} + </p> + {/snippet} <AdminRegistrationForm /> </FullscreenContainer>