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

This commit is contained in:
Min Idzelis
2025-06-15 02:58:04 +00:00
209 changed files with 7000 additions and 6519 deletions
+5
View File
@@ -0,0 +1,5 @@
> 0.2% and last 4 major versions
> 0.5%
not dead
edge >= 135
not edge < 135
+33
View File
@@ -1,4 +1,6 @@
import js from '@eslint/js';
import tslintPluginCompat from '@koddsson/eslint-plugin-tscompat';
import eslintPluginCompat from 'eslint-plugin-compat';
import eslintPluginSvelte from 'eslint-plugin-svelte';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import globals from 'globals';
@@ -14,6 +16,37 @@ export default typescriptEslint.config(
...eslintPluginSvelte.configs.recommended,
eslintPluginUnicorn.configs.recommended,
js.configs.recommended,
{
plugins: {
tscompat: tslintPluginCompat,
},
rules: {
'tscompat/tscompat': [
'error',
{ browserslist: ['> 0.2% and last 4 major versions', '> 0.5%', 'not dead', 'edge >= 135', 'not edge < 135'] },
],
},
languageOptions: {
parser,
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
},
ignores: ['**/service-worker/**'],
},
{
plugins: {
compat: eslintPluginCompat,
},
settings: {
polyfills: [],
lintAllEsApis: true,
},
rules: {
'compat/compat': 'error',
},
},
{
ignores: [
'**/.DS_Store',
+88 -5
View File
@@ -11,7 +11,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.22.4",
"@immich/ui": "^0.22.7",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
@@ -42,6 +42,7 @@
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.18.0",
"@faker-js/faker": "^9.3.0",
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.0",
@@ -63,6 +64,7 @@
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.0",
"eslint-p": "^0.23.0",
"eslint-plugin-compat": "^6.0.2",
"eslint-plugin-svelte": "^3.9.0",
"eslint-plugin-unicorn": "^59.0.0",
"factory.ts": "^1.4.1",
@@ -74,6 +76,7 @@
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "^5.25.3",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.2.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.6.2",
"typescript": "^5.7.3",
@@ -90,7 +93,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.15.21",
"@types/node": "^22.15.29",
"typescript": "^5.3.3"
}
},
@@ -1330,9 +1333,9 @@
"link": true
},
"node_modules/@immich/ui": {
"version": "0.22.4",
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.22.4.tgz",
"integrity": "sha512-l0H8G8XZ3YaP/pA8NsLhGsNZpTAwcOyEFmF88D5HZkK3nFTZOQFxvzcMfyOeMS6Nevv0CHdvJp3ns0zajfvNzw==",
"version": "0.22.7",
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.22.7.tgz",
"integrity": "sha512-FdA0RDSOO1IDSTQmCbW9u5yXFl59EHu++tYonDR/FEZUKrMwfmQEanePSW5g5KofdumKEuxBN1fWFym3NbB0jQ==",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@mdi/js": "^7.4.47",
@@ -1537,6 +1540,26 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@koddsson/eslint-plugin-tscompat": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@koddsson/eslint-plugin-tscompat/-/eslint-plugin-tscompat-0.2.0.tgz",
"integrity": "sha512-Oqd4kWSX0LiO9wWHjcmDfXZNC7TotFV/tLRhwCFU3XUeb//KYvJ75c9OmeSJ+vBv5lkCeB+xYsqyNrBc5j18XA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@mdn/browser-compat-data": "^6.0.17",
"@typescript-eslint/type-utils": "^8.0.1",
"@typescript-eslint/utils": "^8.0.0",
"browserslist": "^4.23.0"
}
},
"node_modules/@koddsson/eslint-plugin-tscompat/node_modules/@mdn/browser-compat-data": {
"version": "6.0.22",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-6.0.22.tgz",
"integrity": "sha512-zhgOBTouJOd8IbE5dEEcfzg83l+nxKL/7Ru2HPeCVbog9I0JGHg3QZab9IxZquKFTUsc+c7QqU4EVENeZzZWRg==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/@mapbox/geojson-rewind": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
@@ -1687,6 +1710,13 @@
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==",
"license": "Apache-2.0"
},
"node_modules/@mdn/browser-compat-data": {
"version": "5.7.6",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.7.6.tgz",
"integrity": "sha512-7xdrMX0Wk7grrTZQwAoy1GkvPMFoizStUoL+VmtUkAxegbCCec+3FKwOM6yc/uGU5+BEczQHXAlWiqvM8JeENg==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/@namnode/store": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@namnode/store/-/store-0.1.0.tgz",
@@ -3505,6 +3535,16 @@
"node": ">=12"
}
},
"node_modules/ast-metadata-inferer": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/ast-metadata-inferer/-/ast-metadata-inferer-0.8.1.tgz",
"integrity": "sha512-ht3Dm6Zr7SXv6t1Ra6gFo0+kLDglHGrEbYihTkcycrbHw7WCcuhBzPlJYHEsIpycaUwzsJHje+vUcxXUX4ztTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdn/browser-compat-data": "^5.6.19"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -4717,6 +4757,42 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint-plugin-compat": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-6.0.2.tgz",
"integrity": "sha512-1ME+YfJjmOz1blH0nPZpHgjMGK4kjgEeoYqGCqoBPQ/mGu/dJzdoP0f1C8H2jcWZjzhZjAMccbM/VdXhPORIfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdn/browser-compat-data": "^5.5.35",
"ast-metadata-inferer": "^0.8.1",
"browserslist": "^4.24.2",
"caniuse-lite": "^1.0.30001687",
"find-up": "^5.0.0",
"globals": "^15.7.0",
"lodash.memoize": "^4.1.2",
"semver": "^7.6.2"
},
"engines": {
"node": ">=18.x"
},
"peerDependencies": {
"eslint": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0"
}
},
"node_modules/eslint-plugin-compat/node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-svelte": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.9.0.tgz",
@@ -6494,6 +6570,13 @@
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
"integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+4 -1
View File
@@ -28,7 +28,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.22.4",
"@immich/ui": "^0.22.7",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
@@ -59,6 +59,7 @@
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.18.0",
"@faker-js/faker": "^9.3.0",
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.0",
@@ -80,6 +81,7 @@
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.0",
"eslint-p": "^0.23.0",
"eslint-plugin-compat": "^6.0.2",
"eslint-plugin-svelte": "^3.9.0",
"eslint-plugin-unicorn": "^59.0.0",
"factory.ts": "^1.4.1",
@@ -91,6 +93,7 @@
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "^5.25.3",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.2.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.6.2",
"typescript": "^5.7.3",
-12
View File
@@ -41,17 +41,11 @@
--color-immich-bg: rgb(var(--immich-bg));
--color-immich-fg: rgb(var(--immich-fg));
--color-immich-gray: rgb(var(--immich-gray));
--color-immich-error: rgb(var(--immich-error));
--color-immich-success: rgb(var(--immich-success));
--color-immich-warning: rgb(var(--immich-warning));
--color-immich-dark-primary: rgb(var(--immich-dark-primary));
--color-immich-dark-bg: rgb(var(--immich-dark-bg));
--color-immich-dark-fg: rgb(var(--immich-dark-fg));
--color-immich-dark-gray: rgb(var(--immich-dark-gray));
--color-immich-dark-error: rgb(var(--immich-dark-error));
--color-immich-dark-success: rgb(var(--immich-dark-success));
--color-immich-dark-warning: rgb(var(--immich-dark-warning));
}
@theme {
@@ -74,18 +68,12 @@
--immich-primary: 66 80 175;
--immich-bg: 255 255 255;
--immich-fg: 0 0 0;
--immich-error: 229 115 115;
--immich-success: 129 199 132;
--immich-warning: 255 183 77;
/* dark */
--immich-dark-primary: 172 203 250;
--immich-dark-bg: 10 10 10;
--immich-dark-fg: 229 231 235;
--immich-dark-gray: 33 33 33;
--immich-dark-error: 211 47 47;
--immich-dark-success: 56 142 60;
--immich-dark-warning: 245 124 0;
}
*,
@@ -3,6 +3,7 @@
import Icon from '$lib/components/elements/icon.svelte';
import { locale } from '$lib/stores/preferences.store';
import { JobCommand, type JobCommandDto, type JobCountsDto, type QueueStatusDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import {
mdiAlertCircle,
mdiAllInclusive,
@@ -17,7 +18,6 @@
import { t } from 'svelte-i18n';
import JobTileButton from './job-tile-button.svelte';
import JobTileStatus from './job-tile-status.svelte';
import { IconButton } from '@immich/ui';
interface Props {
title: string;
@@ -71,7 +71,7 @@
</span>
<div class="flex gap-2">
{#if jobCounts.failed > 0}
<Badge color="primary">
<Badge>
<div class="flex flex-row gap-1">
<span class="text-sm">
{$t('admin.jobs_failed', { values: { jobCount: jobCounts.failed.toLocaleString($locale) } })}
@@ -88,7 +88,7 @@
</Badge>
{/if}
{#if jobCounts.delayed > 0}
<Badge color="secondary">
<Badge>
<span class="text-sm">
{$t('admin.jobs_delayed', { values: { jobCount: jobCounts.delayed.toLocaleString($locale) } })}
</span>
@@ -36,7 +36,7 @@
const handleSave = async (skipConfirm: boolean) => {
const allMethodsDisabled = !config.oauth.enabled && !config.passwordLogin.enabled;
if (allMethodsDisabled && !skipConfirm) {
const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal, {});
const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal);
if (!isConfirmed) {
return;
}
@@ -182,7 +182,7 @@
label={$t('admin.oauth_storage_quota_default').toUpperCase()}
description={$t('admin.oauth_storage_quota_default_description')}
bind:value={config.oauth.defaultStorageQuota}
required={true}
required={false}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.defaultStorageQuota == savedConfig.oauth.defaultStorageQuota)}
/>
@@ -4,9 +4,11 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { type SystemConfigDto, type SystemConfigTemplateEmailsDto, getNotificationTemplateAdmin } from '@immich/sdk';
import { Button, Modal, ModalBody } from '@immich/ui';
import { Button } from '@immich/ui';
import { mdiEyeOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -18,14 +20,13 @@
let { savedConfig, config = $bindable() }: Props = $props();
let htmlPreview = $state('');
let loadingPreview = $state(false);
const getTemplate = async (name: string, template: string) => {
try {
loadingPreview = true;
const result = await getNotificationTemplateAdmin({ name, templateDto: { template } });
htmlPreview = result.html;
const { html } = await getNotificationTemplateAdmin({ name, templateDto: { template } });
await modalManager.show(EmailTemplatePreviewModal, { html });
} catch (error) {
handleError(error, 'Could not load template.');
} finally {
@@ -33,10 +34,6 @@
}
};
const closePreviewModal = () => {
htmlPreview = '';
};
const templateConfigs = [
{
label: $t('admin.template_email_welcome'),
@@ -66,63 +63,47 @@
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mt-4">
<div class="flex flex-col gap-4">
<SettingAccordion
key="templates"
title={$t('admin.template_email_settings')}
subtitle={$t('admin.template_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.template_email_if_empty">
{$t('admin.template_email_if_empty')}
</FormatMessage>
</p>
<hr />
{#if loadingPreview}
<LoadingSpinner />
{/if}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mt-4">
<div class="flex flex-col gap-4">
<SettingAccordion
key="templates"
title={$t('admin.template_email_settings')}
subtitle={$t('admin.template_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.template_email_if_empty">
{$t('admin.template_email_if_empty')}
</FormatMessage>
</p>
<hr />
{#if loadingPreview}
<LoadingSpinner />
{/if}
{#each templateConfigs as { label, templateKey, descriptionTags, templateName } (templateKey)}
<SettingTextarea
{label}
description={$t('admin.template_email_available_tags', { values: { tags: descriptionTags } })}
bind:value={config.templates.email[templateKey]}
isEdited={isEdited(templateKey)}
disabled={!config.notifications.smtp.enabled}
/>
<div class="flex justify-between">
<Button
size="small"
shape="round"
onclick={() => getTemplate(templateName, config.templates.email[templateKey])}
title={$t('admin.template_email_preview')}
>
<Icon path={mdiEyeOutline} class="me-1" />
{$t('admin.template_email_preview')}
</Button>
</div>
{/each}
</div>
</SettingAccordion>
</div>
{#if htmlPreview}
<Modal title={$t('admin.template_email_preview')} onClose={closePreviewModal} size="medium">
<ModalBody>
<div style="position:relative; width:100%; height:90vh; overflow: hidden">
<iframe
{#each templateConfigs as { label, templateKey, descriptionTags, templateName } (templateKey)}
<SettingTextarea
{label}
description={$t('admin.template_email_available_tags', { values: { tags: descriptionTags } })}
bind:value={config.templates.email[templateKey]}
isEdited={isEdited(templateKey)}
disabled={!config.notifications.smtp.enabled}
/>
<div class="flex justify-between">
<Button
size="small"
shape="round"
onclick={() => getTemplate(templateName, config.templates.email[templateKey])}
title={$t('admin.template_email_preview')}
srcdoc={htmlPreview}
style="width: 100%; height: 100%; border: none; overflow:hidden; position: absolute; top: 0; left: 0;"
></iframe>
>
<Icon path={mdiEyeOutline} class="me-1" />
{$t('admin.template_email_preview')}
</Button>
</div>
</ModalBody>
</Modal>
{/if}
</form>
</div>
{/each}
</div>
</SettingAccordion>
</div>
</form>
</div>
@@ -1,13 +1,12 @@
<script lang="ts">
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { timeToLoadTheMap } from '$lib/constants';
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import MapModal from '$lib/modals/MapModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
import { IconButton, LoadingSpinner, Modal, ModalBody } from '@immich/ui';
import { IconButton } from '@immich/ui';
import { mdiMapOutline } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -22,7 +21,6 @@
let viewingAssets: string[] = $state([]);
let viewingAssetCursor = 0;
let zoom = $derived(1);
let mapMarkers: MapMarkerResponseDto[] = $state([]);
onMount(async () => {
@@ -59,24 +57,17 @@
return markers;
}
function openMap() {
albumMapViewManager.isInMapView = true;
}
async function openMap() {
const assetIds = await modalManager.show(MapModal, { mapMarkers });
function closeMap() {
if (!$showAssetViewer) {
albumMapViewManager.isInMapView = false;
if (assetIds) {
viewingAssets = assetIds;
viewingAssetCursor = 0;
await setAssetId(assetIds[0]);
}
}
async function onViewAssets(assetIds: string[]) {
viewingAssets = assetIds;
viewingAssetCursor = 0;
closeMap();
await setAssetId(assetIds[0]);
}
async function navigateNext() {
if (viewingAssetCursor < viewingAssets.length - 1) {
await setAssetId(viewingAssets[++viewingAssetCursor]);
@@ -112,50 +103,21 @@
aria-label={$t('map')}
/>
{#if albumMapViewManager.isInMapView}
<Modal title={$t('map')} size="medium" onClose={closeMap}>
<ModalBody>
<div class="flex flex-col w-full h-full gap-2 border border-gray-300 dark:border-light rounded-2xl">
<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
center={undefined}
{zoom}
clickable={false}
bind:mapMarkers
onSelect={onViewAssets}
showSettings={false}
rounded
/>
{/await}
</div>
</div>
</ModalBody>
</Modal>
<Portal target="body">
{#if $showAssetViewer}
{#await import('../../../lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
asset={$viewingAsset}
showNavigation={viewingAssets.length > 1}
onNext={navigateNext}
onPrevious={navigatePrevious}
onRandom={navigateRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
isShared={false}
/>
{/await}
{/if}
</Portal>
{/if}
<Portal target="body">
{#if $showAssetViewer}
{#await import('../../../lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
asset={$viewingAsset}
showNavigation={viewingAssets.length > 1}
onNext={navigateNext}
onPrevious={navigatePrevious}
onRandom={navigateRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
isShared={false}
/>
{/await}
{/if}
</Portal>
@@ -1,202 +0,0 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import {
AlbumUserRole,
AssetOrder,
removeUserFromAlbum,
updateAlbumInfo,
updateAlbumUser,
type AlbumResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Modal, ModalBody } from '@immich/ui';
import { mdiArrowDownThin, mdiArrowUpThin, mdiDotsVertical, mdiPlus } from '@mdi/js';
import { findKey } from 'lodash-es';
import { t } from 'svelte-i18n';
import type { RenderedOption } from '../elements/dropdown.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import SettingDropdown from '../shared-components/settings/setting-dropdown.svelte';
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 {
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') },
};
let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]);
const handleToggle = async (returnedOption: RenderedOption): Promise<void> => {
if (selectedOption === returnedOption) {
return;
}
let order: AssetOrder = AssetOrder.Desc;
order = findKey(options, (option) => option === returnedOption) as AssetOrder;
try {
await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
order,
},
});
onChangeOrder(order);
} catch (error) {
handleError(error, $t('errors.unable_to_save_album'));
}
};
const handleMenuRemove = (user: UserResponseDto): void => {
selectedRemoveUser = user;
};
const handleRemoveUser = async (): Promise<void> => {
if (!selectedRemoveUser) {
return;
}
try {
await removeUserFromAlbum({ id: album.id, userId: selectedRemoveUser.id });
onRemove(selectedRemoveUser.id);
notificationController.show({
type: NotificationType.Info,
message: $t('album_user_removed', { values: { user: selectedRemoveUser.name } }),
});
} catch (error) {
handleError(error, $t('errors.unable_to_remove_album_users'));
} finally {
selectedRemoveUser = null;
}
};
const handleUpdateSharedUserRole = async (user: UserResponseDto, role: AlbumUserRole) => {
try {
await updateAlbumUser({ id: album.id, userId: user.id, updateAlbumUserDto: { role } });
const message = $t('user_role_set', {
values: { user: user.name, role: role == AlbumUserRole.Viewer ? $t('role_viewer') : $t('role_editor') },
});
onRefreshAlbum();
notificationController.show({ type: NotificationType.Info, message });
} catch (error) {
handleError(error, $t('errors.unable_to_change_album_user_role'));
} finally {
selectedRemoveUser = null;
}
};
</script>
{#if !selectedRemoveUser}
<Modal title={$t('options')} {onClose} size="small">
<ModalBody>
<div class="items-center justify-center">
<div class="py-2">
<h2 class="text-gray text-sm mb-2">{$t('settings').toUpperCase()}</h2>
<div class="grid p-2 gap-y-2">
{#if order}
<SettingDropdown
title={$t('display_order')}
options={Object.values(options)}
selectedOption={options[order]}
onToggle={handleToggle}
/>
{/if}
<SettingSwitch
title={$t('comments_and_likes')}
subtitle={$t('let_others_respond')}
checked={album.isActivityEnabled}
onToggle={onToggleEnabledActivity}
/>
</div>
</div>
<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" 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>
<div>{$t('invite_people')}</div>
</button>
<div class="flex items-center gap-2 py-2 mt-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
<div>{$t('owner')}</div>
</div>
{#each album.albumUsers as { user, role } (user.id)}
<div class="flex items-center gap-2 py-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
{#if role === AlbumUserRole.Viewer}
{$t('role_viewer')}
{:else}
{$t('role_editor')}
{/if}
{#if user.id !== album.ownerId}
<ButtonContextMenu icon={mdiDotsVertical} size="medium" title={$t('options')}>
{#if role === AlbumUserRole.Viewer}
<MenuOption
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Editor)}
text={$t('allow_edits')}
/>
{:else}
<MenuOption
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Viewer)}
text={$t('disallow_edits')}
/>
{/if}
<!-- Allow deletion for non-owners -->
<MenuOption onClick={() => handleMenuRemove(user)} text={$t('remove')} />
</ButtonContextMenu>
{/if}
</div>
{/each}
</div>
</div>
</div>
</ModalBody>
</Modal>
{/if}
{#if selectedRemoveUser}
<ConfirmModal
title={$t('album_remove_user')}
prompt={$t('album_remove_user_confirmation', { values: { user: selectedRemoveUser.name } })}
confirmText={$t('remove_user')}
onClose={(confirmed) => (confirmed ? handleRemoveUser() : (selectedRemoveUser = null))}
/>
{/if}
@@ -247,7 +247,7 @@
<div class="flex items-center justify-center p-2" bind:clientHeight={chatHeight}>
<div class="flex p-2 gap-4 h-fit bg-gray-200 text-immich-dark-gray rounded-3xl w-full">
<div>
<UserAvatar {user} size="md" showTitle={false} />
<UserAvatar {user} size="md" noTitle />
</div>
<form class="flex w-full max-h-56 gap-1" {onsubmit}>
<div class="flex w-full items-center gap-4">
@@ -139,16 +139,6 @@
}
};
const updateComments = async () => {
if (album) {
try {
await activityManager.refreshActivities(album.id, asset.id);
} catch (error) {
handleError(error, $t('errors.unable_to_get_comments_number'));
}
}
};
const onAssetUpdate = async (assetId: string) => {
if (assetId === asset.id) {
asset = await getAssetInfo({ id: assetId, key: authManager.key });
@@ -185,10 +175,6 @@
if (!sharedLink) {
await handleGetAllAlbums();
}
if (album) {
activityManager.init(album.id, asset.id);
}
});
onDestroy(() => {
@@ -319,8 +305,10 @@
const handleStopSlideshow = async () => {
try {
// eslint-disable-next-line tscompat/tscompat
if (document.fullscreenElement) {
document.body.style.cursor = '';
// eslint-disable-next-line tscompat/tscompat
await document.exitFullscreen();
}
} catch (error) {
@@ -375,8 +363,8 @@
}
});
$effect(() => {
if (isShared && asset.id) {
handlePromiseError(updateComments());
if (album && isShared && asset.id) {
handlePromiseError(activityManager.init(album.id, asset.id));
}
});
$effect(() => {
@@ -515,7 +503,7 @@
onVideoStarted={handleVideoStarted}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0)}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
disabled={!album?.isActivityEnabled}
@@ -46,7 +46,7 @@
{#each tags as tag (tag.id)}
<div class="flex group transition-all">
<a
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
href={encodeURI(`${AppRoute.TAGS}/?path=${tag.value}`)}
>
<p class="text-sm">
@@ -57,6 +57,7 @@
canvas = new Canvas(canvasEl);
configureControlStyle();
// eslint-disable-next-line tscompat/tscompat
faceRect = new Rect({
fill: 'rgba(66,80,175,0.25)',
stroke: 'rgb(66,80,175)',
@@ -101,10 +101,12 @@
};
const onShowSettings = async () => {
// eslint-disable-next-line tscompat/tscompat
if (document.fullscreenElement) {
// eslint-disable-next-line tscompat/tscompat
await document.exitFullscreen();
}
await modalManager.show(SlideshowSettingsModal, {});
await modalManager.show(SlideshowSettingsModal);
};
</script>
+3 -16
View File
@@ -1,29 +1,16 @@
<script lang="ts" module>
export type Color = 'primary' | 'secondary';
export type Rounded = false | true | 'full';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
color?: Color;
rounded?: Rounded;
rounded?: boolean | 'full';
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',
secondary: 'text-immich-dark-bg dark:text-immich-gray dark:bg-gray-600 bg-gray-300 dark:text-immich-gray',
};
let { rounded = true, children }: Props = $props();
</script>
<span
class="inline-block h-min whitespace-nowrap px-3 py-1 text-center align-baseline text-xs leading-none {colorClasses[
color
]}"
class="bg-primary text-subtle inline-block h-min whitespace-nowrap px-3 py-1 text-center align-baseline text-xs leading-none"
class:rounded-md={rounded === true}
class:rounded-full={rounded === 'full'}
>
@@ -169,19 +169,9 @@
>
<td class="w-1/8 text-ellipsis ps-8 text-sm">
{#if validatedPath.isValid}
<Icon
path={mdiCheckCircleOutline}
size="24"
title={validatedPath.message}
class="text-immich-success dark:text-immich-dark-success"
/>
<Icon path={mdiCheckCircleOutline} size="24" title={validatedPath.message} class="text-success" />
{:else}
<Icon
path={mdiAlertOutline}
size="24"
title={validatedPath.message}
class="text-immich-warning dark:text-immich-dark-warning"
/>
<Icon path={mdiAlertOutline} size="24" title={validatedPath.message} class="text-warning" />
{/if}
</td>
@@ -18,7 +18,7 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleUpdateDescription = async () => {
const description = await modalManager.show(AssetUpdateDecriptionConfirmModal, {});
const description = await modalManager.show(AssetUpdateDecriptionConfirmModal);
if (description) {
const ids = getSelectedAssets(getOwnedAssets(), $user);
@@ -703,7 +703,7 @@
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
await modalManager.show(ShortcutsModal);
isShortcutModalOpen = false;
};
@@ -51,6 +51,7 @@
const entries: FileSystemEntry[] = [];
const files: File[] = [];
for (const item of dataTransfer.items) {
// eslint-disable-next-line tscompat/tscompat
const entry = item.webkitGetAsEntry();
if (entry) {
entries.push(entry);
@@ -67,6 +68,7 @@
return handleFiles([...files, ...directoryFiles]);
};
// eslint-disable-next-line tscompat/tscompat
const browserSupportsDirectoryUpload = () => typeof DataTransferItem.prototype.webkitGetAsEntry === 'function';
const getAllFilesFromTransferEntries = async (transferEntries: FileSystemEntry[]): Promise<File[]> => {
@@ -5,11 +5,10 @@
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
@@ -51,7 +51,7 @@
shape="round"
onclick={async () => {
onClose();
await modalManager.show(AvatarEditModal, {});
await modalManager.show(AvatarEditModal);
}}
/>
</div>
@@ -155,7 +155,7 @@
title={`${$user.name} (${$user.email})`}
>
{#key $user}
<UserAvatar user={$user} size="md" showTitle={false} interactive />
<UserAvatar user={$user} size="md" noTitle interactive />
{/key}
</button>
@@ -7,7 +7,7 @@
type ComponentNotification,
type Notification,
} from '$lib/components/shared-components/notification/notification';
import { IconButton } from '@immich/ui';
import { Button, IconButton, type Color } from '@immich/ui';
import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -40,10 +40,10 @@
[NotificationType.Warning]: '#D08613',
};
const buttonStyle: Record<NotificationType, string> = {
[NotificationType.Info]: 'text-white bg-immich-primary hover:bg-immich-primary/75',
[NotificationType.Error]: 'text-white bg-immich-error hover:bg-immich-error/75',
[NotificationType.Warning]: 'text-white bg-immich-warning hover:bg-immich-warning/75',
const colors: Record<NotificationType, Color> = {
[NotificationType.Info]: 'primary',
[NotificationType.Error]: 'danger',
[NotificationType.Warning]: 'warning',
};
onMount(() => {
@@ -111,16 +111,16 @@
</p>
{#if notification.button}
<p class="ps-[28px] mt-2.5 text-sm">
<button
type="button"
class="{buttonStyle[notification.type]} rounded px-3 pt-1.5 pb-1 transition-all duration-200"
<p class="ps-[28px] mt-2.5 light text-light">
<Button
size="small"
color={colors[notification.type]}
onclick={handleButtonClick}
aria-hidden="true"
tabindex={-1}
>
{notification.button.text}
</button>
</Button>
</p>
{/if}
</div>
@@ -310,6 +310,7 @@
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
};
/* eslint-disable tscompat/tscompat */
const getTouch = (event: TouchEvent) => {
if (event.touches.length === 1) {
return event.touches[0];
@@ -354,6 +355,7 @@
isHover = false;
}
};
/* eslint-enable tscompat/tscompat */
onMount(() => {
document.addEventListener('touchmove', onTouchMove, true);
return () => {
@@ -505,10 +507,7 @@
{/if}
<!-- Scroll Position Indicator Line -->
{#if !usingMobileDevice && !isDragging}
<div
class="absolute end-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
style:top="{scrollY + PADDING_TOP - 2}px"
>
<div class="absolute end-0 h-[2px] w-10 bg-primary" style:top="{scrollY + PADDING_TOP - 2}px">
{#if timelineManager.scrolling && scrollHoverLabel && !isHover}
<p
transition:fade={{ duration: 200 }}
@@ -10,11 +10,11 @@
import { generateId } from '$lib/utils/generate-id';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import { onDestroy, tick } from 'svelte';
import { t } from 'svelte-i18n';
import SearchHistoryBox from './search-history-box.svelte';
import { IconButton } from '@immich/ui';
interface Props {
value?: string;
@@ -93,7 +93,7 @@
}
const result = modalManager.open(SearchFilterModal, { searchQuery });
close = result.close;
close = () => result.close(undefined);
closeDropdown();
const searchResult = await result.onClose;
@@ -1,12 +1,12 @@
<script lang="ts">
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { getAllTags, type TagResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiClose } from '@mdi/js';
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { preferences } from '$lib/stores/user.store';
import { getAllTags, type TagResponseDto } from '@immich/sdk';
import { mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
selectedTags: SvelteSet<string>;
@@ -57,7 +57,7 @@
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
@@ -9,7 +9,7 @@
interface Props {
inputType: SettingInputFieldType;
value: string | number | undefined;
value: string | number | undefined | null;
min?: number;
max?: number;
step?: string;
@@ -27,7 +27,7 @@
const { isPurchased } = purchaseStore;
const openPurchaseModal = async () => {
await modalManager.show(PurchaseModal, {});
await modalManager.show(PurchaseModal);
showMessage = false;
};
@@ -1,12 +1,12 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
import { requestServerInfo } from '$lib/utils/auth';
import { getByteUnitString } from '$lib/utils/byte-units';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { getByteUnitString } from '$lib/utils/byte-units';
import LoadingSpinner from '../loading-spinner.svelte';
import { userInteraction } from '$lib/stores/user.svelte';
let usageClasses = $state('');
@@ -28,7 +28,7 @@
return 'bg-yellow-500';
}
return 'bg-immich-primary dark:bg-immich-dark-primary';
return 'bg-primary';
};
$effect(() => {
@@ -44,19 +44,19 @@
<div class="flex items-center gap-2">
<div class="flex items-center justify-center">
{#if uploadAsset.state === UploadState.PENDING}
<Icon path={mdiCircleOutline} size="24" class="text-immich-primary" title={$t('pending')} />
<Icon path={mdiCircleOutline} size="24" class="text-primary" title={$t('pending')} />
{:else if uploadAsset.state === UploadState.STARTED}
<Icon path={mdiLoading} size="24" spin class="text-immich-primary" title={$t('asset_skipped')} />
<Icon path={mdiLoading} size="24" spin class="text-primary" title={$t('asset_skipped')} />
{:else if uploadAsset.state === UploadState.ERROR}
<Icon path={mdiAlertCircle} size="24" class="text-immich-error" title={$t('error')} />
<Icon path={mdiAlertCircle} size="24" class="text-danger" title={$t('error')} />
{:else if uploadAsset.state === UploadState.DUPLICATED}
{#if uploadAsset.isTrashed}
<Icon path={mdiTrashCan} size="24" class="text-gray-500" title={$t('asset_skipped_in_trash')} />
{:else}
<Icon path={mdiAlertCircle} size="24" class="text-immich-warning" title={$t('asset_skipped')} />
<Icon path={mdiAlertCircle} size="24" class="text-warning" title={$t('asset_skipped')} />
{/if}
{:else if uploadAsset.state === UploadState.DONE}
<Icon path={mdiCheckCircle} size="24" class="text-immich-success" title={$t('asset_uploaded')} />
<Icon path={mdiCheckCircle} size="24" class="text-success" title={$t('asset_uploaded')} />
{/if}
</div>
<!-- <span>[{getByteUnitString(uploadAsset.file.size, $locale)}]</span> -->
@@ -105,7 +105,7 @@
{#if uploadAsset.state === UploadState.ERROR}
<div class="flex flex-row justify-between">
<p class="w-full rounded-md text-justify text-immich-error">
<p class="w-full rounded-md text-justify text-danger">
{uploadAsset.error}
</p>
</div>
@@ -68,13 +68,13 @@
</p>
<p class="immich-form-label text-xs">
{$t('upload_status_uploaded')}
<span class="text-immich-success">{$stats.success.toLocaleString($locale)}</span>
<span class="text-success">{$stats.success.toLocaleString($locale)}</span>
-
{$t('upload_status_errors')}
<span class="text-immich-error">{$stats.errors.toLocaleString($locale)}</span>
<span class="text-danger">{$stats.errors.toLocaleString($locale)}</span>
-
{$t('upload_status_duplicates')}
<span class="text-immich-warning">{$stats.duplicates.toLocaleString($locale)}</span>
<span class="text-warning">{$stats.duplicates.toLocaleString($locale)}</span>
</p>
</div>
<div class="flex flex-col items-end">
@@ -142,7 +142,7 @@
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
onclick={() => (showDetail = true)}
class="absolute -start-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"
class="absolute -start-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-primary p-5 text-xs text-gray-200"
>
{$remainingUploads.toLocaleString($locale)}
</button>
@@ -151,7 +151,7 @@
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
onclick={() => (showDetail = true)}
class="absolute -end-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"
class="absolute -end-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-danger p-5 text-xs text-gray-200"
>
{$stats.errors.toLocaleString($locale)}
</button>
@@ -160,7 +160,7 @@
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
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"
class="flex h-16 w-16 place-content-center place-items-center rounded-full bg-subtle p-5 text-sm text-primary shadow-lg"
>
<div class="animate-pulse">
<Icon path={mdiCloudUploadOutline} size="30" />
@@ -18,25 +18,13 @@
interface Props {
user: User;
color?: UserAvatarColor | undefined;
size?: Size;
rounded?: boolean;
interactive?: boolean;
showTitle?: boolean;
showProfileImage?: boolean;
noTitle?: boolean;
label?: string | undefined;
}
let {
user,
color = undefined,
size = 'full',
rounded = true,
interactive = false,
showTitle = true,
showProfileImage = true,
label = undefined,
}: Props = $props();
let { user, size = 'full', interactive = false, noTitle = false, label = undefined }: Props = $props();
let img: HTMLImageElement | undefined = $state();
let showFallback = $state(true);
@@ -51,16 +39,16 @@
};
const colorClasses: Record<UserAvatarColor, string> = {
primary: 'bg-immich-primary dark:bg-immich-dark-primary text-immich-dark-fg dark:text-immich-fg',
pink: 'bg-pink-400 text-immich-bg',
red: 'bg-red-500 text-immich-bg',
yellow: 'bg-yellow-500 text-immich-bg',
blue: 'bg-blue-500 text-immich-bg',
green: 'bg-green-600 text-immich-bg',
purple: 'bg-purple-600 text-immich-bg',
orange: 'bg-orange-600 text-immich-bg',
gray: 'bg-gray-600 text-immich-bg',
amber: 'bg-amber-600 text-immich-bg',
primary: 'bg-primary text-light dark:text-light',
pink: 'bg-pink-400 text-light dark:text-dark',
red: 'bg-red-500 text-light dark:text-dark',
yellow: 'bg-yellow-500 text-light dark:text-dark',
blue: 'bg-blue-500 text-light dark:text-dark',
green: 'bg-green-600 text-light dark:text-dark',
purple: 'bg-purple-600 text-light dark:text-dark',
orange: 'bg-orange-600 text-light dark:text-dark',
gray: 'bg-gray-600 text-light dark:text-dark',
amber: 'bg-amber-600 text-light dark:text-dark',
};
const sizeClasses: Record<Size, string> = {
@@ -79,7 +67,7 @@
}
});
let colorClass = $derived(colorClasses[color || user.avatarColor]);
let colorClass = $derived(colorClasses[user.avatarColor]);
let sizeClass = $derived(sizeClasses[size]);
let title = $derived(label ?? `${user.name} (${user.email})`);
let interactiveClass = $derived(
@@ -90,11 +78,10 @@
</script>
<figure
class="{sizeClass} {colorClass} {interactiveClass} overflow-hidden shadow-md"
class:rounded-full={rounded}
title={showTitle ? title : undefined}
class="{sizeClass} {colorClass} {interactiveClass} overflow-hidden shadow-md rounded-full"
title={noTitle ? undefined : title}
>
{#if showProfileImage && user.profileImagePath}
{#if user.profileImagePath}
<img
bind:this={img}
src={getProfileImageUrl(user)}
@@ -23,8 +23,8 @@
<div
class="max-w-60 rounded-xl border-4 transition-colors font-semibold text-xs {isSelected
? 'bg-immich-primary dark:bg-immich-dark-primary border-immich-primary dark:border-immich-dark-primary'
: 'bg-gray-200 dark:bg-gray-800 border-gray-200 dark:border-gray-800'}"
? 'bg-primary border-primary'
: 'bg-subtle border-subtle'}"
>
<div class="relative w-full">
<button
@@ -1,6 +1,5 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import Icon from '$lib/components/elements/icon.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -117,15 +116,15 @@
<div class="flex flex-wrap gap-y-6 mb-4 px-6 w-full place-content-end justify-between">
<!-- MARK ALL BUTTONS -->
<div class="flex text-xs text-black">
<button
type="button"
class="px-4 py-3 flex place-items-center gap-2 rounded-s-full dark:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/90 bg-immich-primary/25 hover:bg-immich-primary/50"
onclick={onSelectAll}><Icon path={mdiCheck} size="20" />{$t('select_keep_all')}</button
<Button class="rounded-s-full" size="small" color="primary" leadingIcon={mdiCheck} onclick={onSelectAll}
>{$t('select_keep_all')}</Button
>
<button
type="button"
class="px-4 py-3 flex place-items-center gap-2 rounded-e-full dark:bg-immich-dark-primary/50 hover:dark:bg-immich-dark-primary/70 bg-immich-primary hover:bg-immich-primary/80 text-white"
onclick={onSelectNone}><Icon path={mdiTrashCanOutline} size="20" />{$t('select_trash_all')}</button
<Button
class="rounded-e-full"
size="small"
color="secondary"
leadingIcon={mdiTrashCanOutline}
onclick={onSelectNone}>{$t('select_trash_all')}</Button
>
</div>
@@ -134,32 +133,33 @@
{#if trashCount === 0}
<Button
size="small"
leadingIcon={mdiCheck}
color="primary"
class="flex place-items-center rounded-s-full gap-2"
onclick={handleResolve}
>
<Icon path={mdiCheck} size="20" />{$t('keep_all')}
{$t('keep_all')}
</Button>
{:else}
<Button
size="small"
color="danger"
class="flex place-items-center rounded-s-full gap-2 py-3"
leadingIcon={mdiTrashCanOutline}
class="rounded-s-full"
onclick={handleResolve}
>
<Icon path={mdiTrashCanOutline} size="20" />{trashCount === assets.length
? $t('trash_all')
: $t('trash_count', { values: { count: trashCount } })}
{trashCount === assets.length ? $t('trash_all') : $t('trash_count', { values: { count: trashCount } })}
</Button>
{/if}
<Button
size="small"
color="primary"
class="flex place-items-center rounded-e-full gap-2"
leadingIcon={mdiImageMultipleOutline}
class="rounded-e-full"
onclick={handleStack}
disabled={selectedAssetIds.size !== 1}
>
<Icon path={mdiImageMultipleOutline} size="20" />{$t('stack')}
{$t('stack')}
</Button>
</div>
</div>
@@ -1,6 +1,7 @@
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import {
createActivity,
deleteActivity,
@@ -11,9 +12,18 @@ import {
type ActivityCreateDto,
type ActivityResponseDto,
} from '@immich/sdk';
import { t } from 'svelte-i18n';
import { createSubscriber } from 'svelte/reactivity';
import { get } from 'svelte/store';
type CacheKey = string;
type ActivityCache = {
activities: ActivityResponseDto[];
commentCount: number;
likeCount: number;
isLiked: ActivityResponseDto | null;
};
class ActivityManager {
#albumId = $state<string | undefined>();
#assetId = $state<string | undefined>();
@@ -24,10 +34,14 @@ class ActivityManager {
#subscribe;
#cache = new Map<CacheKey, ActivityCache>();
isLoading = $state(false);
constructor() {
this.#subscribe = createSubscriber((update) => {
const unsubscribe = websocketEvents.on('on_activity_change', ({ albumId, assetId }) => {
if (this.#albumId === albumId || this.#assetId === assetId) {
this.#invalidateCache(this.#albumId, this.#assetId);
handlePromiseError(this.refreshActivities(this.#albumId!, this.#assetId));
update();
}
@@ -39,6 +53,10 @@ class ActivityManager {
});
}
get assetId() {
return this.#assetId;
}
get activities() {
this.#subscribe();
return this.#activities;
@@ -59,9 +77,27 @@ class ActivityManager {
return this.#isLiked;
}
init(albumId: string, assetId?: string) {
#getCacheKey(albumId: string, assetId?: string) {
return `${albumId}:${assetId ?? ''}`;
}
async init(albumId: string, assetId?: string) {
if (assetId && assetId === this.#assetId) {
return;
}
this.#albumId = albumId;
this.#assetId = assetId;
try {
await activityManager.refreshActivities(albumId, assetId);
} catch (error) {
handleError(error, get(t)('errors.unable_to_get_comments_number'));
}
}
#invalidateCache(albumId: string, assetId?: string) {
this.#cache.delete(this.#getCacheKey(albumId));
this.#cache.delete(this.#getCacheKey(albumId, assetId));
}
async addActivity(dto: ActivityCreateDto) {
@@ -78,6 +114,7 @@ class ActivityManager {
this.#likeCount++;
}
this.#invalidateCache(this.#albumId, this.#assetId);
handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId));
return activity;
}
@@ -100,6 +137,7 @@ class ActivityManager {
}
await deleteActivity({ id: activity.id });
this.#invalidateCache(this.#albumId, this.#assetId);
handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId));
}
@@ -126,6 +164,20 @@ class ActivityManager {
}
async refreshActivities(albumId: string, assetId?: string) {
this.isLoading = true;
const cacheKey = this.#getCacheKey(albumId, assetId);
if (this.#cache.has(cacheKey)) {
const cached = this.#cache.get(cacheKey)!;
this.#activities = cached.activities;
this.#commentCount = cached.commentCount;
this.#likeCount = cached.likeCount;
this.#isLiked = cached.isLiked ?? null;
this.isLoading = false;
return;
}
this.#activities = await getActivities({ albumId, assetId });
const [liked] = await getActivities({
@@ -140,6 +192,15 @@ class ActivityManager {
const { comments, likes } = await getActivityStatistics({ albumId, assetId });
this.#commentCount = comments;
this.#likeCount = likes;
this.#cache.set(cacheKey, {
activities: this.#activities,
commentCount: this.#commentCount,
likeCount: this.#likeCount,
isLiked: this.#isLiked,
});
this.isLoading = false;
}
reset() {
+22 -11
View File
@@ -1,28 +1,39 @@
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { mount, unmount, type Component, type ComponentProps } from 'svelte';
type OnCloseData<T> = T extends { onClose: (data?: infer R) => void } ? R : never;
type ExtendsEmptyObject<T> = keyof T extends never ? Record<string, never> : T;
type OnCloseData<T> = T extends { onClose: (data?: infer R) => void }
? R | undefined
: T extends { onClose: (data: infer R) => void }
? R
: never;
type ExtendsEmptyObject<T> = keyof T extends never ? never : T;
type StripValueIfOptional<T> = T extends undefined ? undefined : T;
// if the modal does not expect any props, makes the props param optional but also allows passing `{}` and `undefined`
type OptionalParamIfEmpty<T> = ExtendsEmptyObject<T> extends never ? [] | [Record<string, never> | undefined] : [T];
class ModalManager {
show<T extends object>(Component: Component<T>, props: ExtendsEmptyObject<Omit<T, 'onClose'>>) {
return this.open(Component, props).onClose;
show<T extends object>(Component: Component<T>, ...props: OptionalParamIfEmpty<Omit<T, 'onClose'>>) {
return this.open(Component, ...props).onClose;
}
open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: ExtendsEmptyObject<Omit<T, 'onClose'>>) {
open<T extends object, K = OnCloseData<T>>(
Component: Component<T>,
...props: OptionalParamIfEmpty<Omit<T, 'onClose'>>
) {
let modal: object = {};
let onClose: () => Promise<void>;
let onClose: (...args: [StripValueIfOptional<K>]) => Promise<void>;
const deferred = new Promise<K | undefined>((resolve) => {
onClose = async (data?: K) => {
const deferred = new Promise<StripValueIfOptional<K>>((resolve) => {
onClose = async (...args: [StripValueIfOptional<K>]) => {
await unmount(modal);
resolve(data);
resolve(args?.[0]);
};
modal = mount(Component, {
target: document.body,
props: {
...(props as T),
...((props?.[0] ?? {}) as T),
onClose,
},
});
@@ -30,7 +41,7 @@ class ModalManager {
return {
onClose: deferred,
close: () => onClose(),
close: (...args: [StripValueIfOptional<K>]) => onClose(args[0]),
};
}
@@ -1,4 +1,4 @@
import type { TimelinePlainDate } from '$lib/utils/timeline-util';
import { setDifference, type TimelinePlainDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import type { DayGroup } from './day-group.svelte';
import type { MonthGroup } from './month-group.svelte';
@@ -27,7 +27,7 @@ export class GroupInsertionCache {
}
get existingDayGroups() {
return this.changedDayGroups.difference(this.newDayGroups);
return setDifference(this.changedDayGroups, this.newDayGroups);
}
get updatedBuckets() {
@@ -1,4 +1,4 @@
import type { TimelinePlainDate } from '$lib/utils/timeline-util';
import { setDifference, type TimelinePlainDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
@@ -76,7 +76,7 @@ export function runAssetOperation(
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
idsToProcess = idsToProcess.difference(processedIds);
idsToProcess = setDifference(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
@@ -9,6 +9,7 @@ import {
fromTimelinePlainDateTime,
fromTimelinePlainYearMonth,
getTimes,
setDifference,
type TimelinePlainDateTime,
type TimelinePlainYearMonth,
} from '$lib/utils/timeline-util';
@@ -132,7 +133,7 @@ export class MonthGroup {
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
idsToProcess = idsToProcess.difference(processedIds);
idsToProcess = setDifference(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
@@ -474,7 +474,11 @@ export class TimelineManager {
},
{ order: this.#options.order ?? AssetOrder.Desc },
);
return unprocessedIds.values().map((id) => lookup.get(id)!);
const result: TimelineAsset[] = [];
for (const id of unprocessedIds.values()) {
result.push(lookup.get(id)!);
}
return result;
}
removeAssets(ids: string[]) {
+193
View File
@@ -0,0 +1,193 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import {
AlbumUserRole,
AssetOrder,
removeUserFromAlbum,
updateAlbumInfo,
updateAlbumUser,
type AlbumResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Modal, ModalBody } from '@immich/ui';
import { mdiArrowDownThin, mdiArrowUpThin, mdiDotsVertical, mdiPlus } from '@mdi/js';
import { findKey } from 'lodash-es';
import { t } from 'svelte-i18n';
import type { RenderedOption } from '../components/elements/dropdown.svelte';
import { notificationController, NotificationType } from '../components/shared-components/notification/notification';
import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte';
interface Props {
album: AlbumResponseDto;
order: AssetOrder | undefined;
user: UserResponseDto;
onClose: (
result?: { action: 'changeOrder'; order: AssetOrder } | { action: 'shareUser' } | { action: 'refreshAlbum' },
) => void;
}
let { album, order, user, onClose }: Props = $props();
const options: Record<AssetOrder, RenderedOption> = {
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') },
};
let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]);
const handleToggleOrder = async (returnedOption: RenderedOption): Promise<void> => {
if (selectedOption === returnedOption) {
return;
}
let order: AssetOrder = AssetOrder.Desc;
order = findKey(options, (option) => option === returnedOption) as AssetOrder;
try {
await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
order,
},
});
onClose({ action: 'changeOrder', order });
} catch (error) {
handleError(error, $t('errors.unable_to_save_album'));
}
};
const handleToggleActivity = async () => {
try {
album = await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
isActivityEnabled: !album.isActivityEnabled,
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('activity_changed', { values: { enabled: album.isActivityEnabled } }),
});
} catch (error) {
handleError(error, $t('errors.cant_change_activity', { values: { enabled: album.isActivityEnabled } }));
}
};
const handleRemoveUser = async (user: UserResponseDto): Promise<void> => {
const confirmed = await modalManager.showDialog({
title: $t('album_remove_user'),
prompt: $t('album_remove_user_confirmation', { values: { user: user.name } }),
confirmText: $t('remove_user'),
});
if (!confirmed) {
return;
}
try {
await removeUserFromAlbum({ id: album.id, userId: user.id });
onClose({ action: 'refreshAlbum' });
notificationController.show({
type: NotificationType.Info,
message: $t('album_user_removed', { values: { user: user.name } }),
});
} catch (error) {
handleError(error, $t('errors.unable_to_remove_album_users'));
}
};
const handleUpdateSharedUserRole = async (user: UserResponseDto, role: AlbumUserRole) => {
try {
await updateAlbumUser({ id: album.id, userId: user.id, updateAlbumUserDto: { role } });
const message = $t('user_role_set', {
values: { user: user.name, role: role == AlbumUserRole.Viewer ? $t('role_viewer') : $t('role_editor') },
});
onClose({ action: 'refreshAlbum' });
notificationController.show({ type: NotificationType.Info, message });
} catch (error) {
handleError(error, $t('errors.unable_to_change_album_user_role'));
}
};
</script>
<Modal title={$t('options')} onClose={() => onClose({ action: 'refreshAlbum' })} size="small">
<ModalBody>
<div class="items-center justify-center">
<div class="py-2">
<h2 class="text-gray text-sm mb-2">{$t('settings').toUpperCase()}</h2>
<div class="grid p-2 gap-y-2">
{#if order}
<SettingDropdown
title={$t('display_order')}
options={Object.values(options)}
selectedOption={options[order]}
onToggle={handleToggleOrder}
/>
{/if}
<SettingSwitch
title={$t('comments_and_likes')}
subtitle={$t('let_others_respond')}
checked={album.isActivityEnabled}
onToggle={handleToggleActivity}
/>
</div>
</div>
<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" onclick={() => onClose({ action: 'shareUser' })}>
<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>
<div>{$t('invite_people')}</div>
</button>
<div class="flex items-center gap-2 py-2 mt-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
<div>{$t('owner')}</div>
</div>
{#each album.albumUsers as { user, role } (user.id)}
<div class="flex items-center gap-2 py-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
{#if role === AlbumUserRole.Viewer}
{$t('role_viewer')}
{:else}
{$t('role_editor')}
{/if}
{#if user.id !== album.ownerId}
<ButtonContextMenu icon={mdiDotsVertical} size="medium" title={$t('options')}>
{#if role === AlbumUserRole.Viewer}
<MenuOption
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Editor)}
text={$t('allow_edits')}
/>
{:else}
<MenuOption
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Viewer)}
text={$t('disallow_edits')}
/>
{/if}
<!-- Allow deletion for non-owners -->
<MenuOption onClick={() => handleRemoveUser(user)} text={$t('remove')} />
</ButtonContextMenu>
{/if}
</div>
{/each}
</div>
</div>
</div>
</ModalBody>
</Modal>
+1 -1
View File
@@ -85,7 +85,7 @@
{#key user.id}
<div class="flex place-items-center gap-4 p-4">
<div
class="flex h-10 w-10 items-center justify-center rounded-full border bg-immich-dark-success text-3xl text-white dark:border-immich-dark-gray dark:bg-immich-dark-success"
class="flex h-10 w-10 items-center justify-center rounded-full border bg-green-600 text-3xl text-white"
>
<Icon path={mdiCheck} size={24} />
</div>
+1 -1
View File
@@ -77,7 +77,7 @@
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
+3 -3
View File
@@ -34,12 +34,12 @@
};
</script>
<Modal title={$t('select_avatar_color')} size="medium" {onClose}>
<Modal title={$t('select_avatar_color')} size="small" {onClose}>
<ModalBody>
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4 place-items-center">
{#each colors as color (color)}
<button type="button" onclick={() => onSave(color)}>
<UserAvatar label={color} user={$user} {color} size="xl" showProfileImage={false} />
<UserAvatar label={color} user={{ ...$user, profileImagePath: '', avatarColor: color }} size="xl" />
</button>
{/each}
</div>
+1 -1
View File
@@ -10,7 +10,7 @@
confirmColor?: Color;
disabled?: boolean;
size?: 'small' | 'medium';
onClose: (confirmed?: boolean) => void;
onClose: (confirmed: boolean) => void;
promptSnippet?: Snippet;
}
@@ -0,0 +1,23 @@
<script lang="ts">
import { Modal, ModalBody } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
html: string;
onClose: () => void;
}
let { html, onClose }: Props = $props();
</script>
<Modal title={$t('admin.template_email_preview')} {onClose} size="giant">
<ModalBody>
<div class="relative w-full h-240 overflow-hidden">
<iframe
title={$t('admin.template_email_preview')}
srcdoc={html}
class="absolute top-0 left-0 w-full h-full border-none"
></iframe>
</div>
</ModalBody>
</Modal>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { timeToLoadTheMap } from '$lib/constants';
import { delay } from '$lib/utils/asset-utils';
import type { MapMarkerResponseDto } from '@immich/sdk';
import { LoadingSpinner, Modal, ModalBody } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
onClose: (assetIds?: string[]) => void;
mapMarkers: MapMarkerResponseDto[];
zoom?: number;
};
let { onClose, mapMarkers, zoom }: Props = $props();
</script>
<Modal title={$t('map')} size="giant" {onClose}>
<ModalBody>
<div class="flex flex-col w-full h-full gap-2 border border-gray-300 dark:border-light rounded-2xl">
<div class="h-[75vh] min-h-[300px] w-full">
{#await import('../components/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
center={undefined}
{zoom}
clickable={false}
{mapMarkers}
onSelect={onClose}
showSettings={false}
rounded
/>
{/await}
</div>
</div>
</ModalBody>
</Modal>
@@ -77,7 +77,7 @@
</div>
{#if forceDelete}
<p class="text-immich-error">{$t('admin.force_delete_user_warning')}</p>
<p class="text-danger">{$t('admin.force_delete_user_warning')}</p>
<p class="immich-form-label text-sm" id="confirm-user-desc">
{$t('admin.confirm_email_below', { values: { email: user.email } })}
+19 -7
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import { user as authUser } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { Button, Field, Modal, ModalBody, ModalFooter, Switch } from '@immich/ui';
import { mdiAccountEditOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -15,6 +16,11 @@
let { user, onClose }: Props = $props();
let isAdmin = $derived(user.isAdmin);
let name = $derived(user.name);
let email = $derived(user.email);
let storageLabel = $derived(user.storageLabel || '');
let quotaSize = $state(user.quotaSizeInBytes === null ? null : convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB));
const previousQuota = user.quotaSizeInBytes;
@@ -28,14 +34,14 @@
const handleEditUser = async () => {
try {
const { id, email, name, storageLabel } = user;
const newUser = await updateUserAdmin({
id,
id: user.id,
userAdminUpdateDto: {
email,
name,
storageLabel: storageLabel || '',
storageLabel,
quotaSizeInBytes: quotaSize === null ? null : convertToBytes(Number(quotaSize), ByteUnit.GiB),
isAdmin,
},
});
@@ -56,12 +62,12 @@
<form onsubmit={onSubmit} autocomplete="off" id="edit-user-form">
<div class="mb-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} />
<input class="immich-form-input" id="email" name="email" type="email" bind:value={email} />
</div>
<div class="my-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" required bind:value={user.name} />
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={name} />
</div>
<div class="my-4 flex flex-col gap-2">
@@ -89,7 +95,7 @@
id="storage-label"
name="storage-label"
type="text"
bind:value={user.storageLabel}
bind:value={storageLabel}
/>
<p>
@@ -99,6 +105,12 @@
</a>
</p>
</div>
{#if user.id !== $authUser.id}
<Field label={$t('admin.admin_user')}>
<Switch bind:checked={isAdmin} />
</Field>
{/if}
</form>
</ModalBody>
+2 -1
View File
@@ -40,7 +40,8 @@ export function currentUrlReplaceAssetId(assetId: string) {
const params = new URLSearchParams($page.url.search);
// always remove the assetGridScrollTargetParams
params.delete('at');
const searchparams = params.size > 0 ? '?' + params.toString() : '';
const paramsString = params.toString();
const searchparams = paramsString == '' ? '' : '?' + params.toString();
// this contains special casing for the /photos/:assetId photos route, which hangs directly
// off / instead of a subpath, unlike every other asset-containing route.
return isPhotosRoute($page.route.id)
+10
View File
@@ -216,3 +216,13 @@ export const plainDateTimeCompare = (ascending: boolean, a: TimelinePlainDateTim
}
return aDateTime.millisecond - bDateTime.millisecond;
};
export function setDifference<T>(setA: Set<T>, setB: Set<T>): Set<T> {
const result = new Set<T>();
for (const value of setA) {
if (!setB.has(value)) {
result.add(value);
}
}
return result;
}
@@ -4,7 +4,6 @@
import CastButton from '$lib/cast/cast-button.svelte';
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import AlbumOptions from '$lib/components/album-page/album-options.svelte';
import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
import AlbumTitle from '$lib/components/album-page/album-title.svelte';
import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte';
@@ -38,6 +37,7 @@
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
@@ -130,27 +130,6 @@
}
});
const handleToggleEnableActivity = async () => {
try {
const updateAlbum = await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
isActivityEnabled: !album.isActivityEnabled,
},
});
album = { ...album, isActivityEnabled: updateAlbum.isActivityEnabled };
await refreshAlbum();
notificationController.show({
type: NotificationType.Info,
message: $t('activity_changed', { values: { enabled: album.isActivityEnabled } }),
});
} catch (error) {
handleError(error, $t('errors.cant_change_activity', { values: { enabled: album.isActivityEnabled } }));
}
};
const handleFavorite = async () => {
try {
await activityManager.toggleLike();
@@ -159,14 +138,6 @@
}
};
const updateComments = async () => {
try {
await activityManager.refreshActivities(album.id);
} catch (error) {
handleError(error, $t('errors.cant_get_number_of_comments'));
}
};
const handleOpenAndCloseActivityTab = () => {
isShowActivity = !isShowActivity;
};
@@ -270,22 +241,6 @@
}
};
const handleRemoveUser = async (userId: string, nextViewMode: AlbumPageViewMode) => {
if (userId == 'me' || userId === $user.id) {
await goto(backUrl);
return;
}
try {
await refreshAlbum();
// Dynamically set the view mode based on the passed argument
viewMode = album.albumUsers.length > 0 ? nextViewMode : AlbumPageViewMode.VIEW;
} catch (error) {
handleError(error, $t('errors.error_deleting_shared_user'));
}
};
const handleDownloadAlbum = async () => {
await downloadAlbum(album);
};
@@ -388,9 +343,14 @@
}
});
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
$effect(() => {
activityManager.reset();
activityManager.init(album.id);
if ($showAssetViewer || !isShared) {
return;
}
handlePromiseError(activityManager.init(album.id));
});
onDestroy(() => {
@@ -409,12 +369,6 @@
);
let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
$effect(() => {
if (album.albumUsers.length > 0) {
handlePromiseError(updateComments());
}
});
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
const isSelectionMode = $derived(
viewMode === AlbumPageViewMode.SELECT_ASSETS ? true : viewMode === AlbumPageViewMode.SELECT_THUMBNAIL,
);
@@ -462,6 +416,29 @@
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
}
};
const handleOptions = async () => {
const result = await modalManager.show(AlbumOptionsModal, { album, order: albumOrder, user: $user });
if (!result) {
return;
}
switch (result.action) {
case 'changeOrder': {
albumOrder = result.order;
break;
}
case 'shareUser': {
await handleShare();
break;
}
case 'refreshAlbum': {
await refreshAlbum();
break;
}
}
};
</script>
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
@@ -570,7 +547,7 @@
{/if}
</AssetGrid>
{#if showActivityStatus}
{#if showActivityStatus && !activityManager.isLoading}
<div class="absolute z-2 bottom-0 end-0 mb-6 me-6 justify-self-end">
<ActivityStatus
disabled={!album.isActivityEnabled}
@@ -706,11 +683,7 @@
text={$t('select_album_cover')}
onClick={() => (viewMode = AlbumPageViewMode.SELECT_THUMBNAIL)}
/>
<MenuOption
icon={mdiCogOutline}
text={$t('options')}
onClick={() => (viewMode = AlbumPageViewMode.OPTIONS)}
/>
<MenuOption icon={mdiCogOutline} text={$t('options')} onClick={handleOptions} />
{/if}
<MenuOption icon={mdiDeleteOutline} text={$t('delete_album')} onClick={() => handleRemoveAlbum()} />
@@ -782,23 +755,6 @@
{/if}
</div>
{#if viewMode === AlbumPageViewMode.OPTIONS && $user}
<AlbumOptions
{album}
order={albumOrder}
user={$user}
onChangeOrder={async (order) => {
albumOrder = order;
await setModeToView();
}}
onRemove={(userId) => handleRemoveUser(userId, AlbumPageViewMode.OPTIONS)}
onRefreshAlbum={refreshAlbum}
onClose={() => (viewMode = AlbumPageViewMode.VIEW)}
onToggleEnabledActivity={handleToggleEnableActivity}
onShowSelectSharedUser={handleShare}
/>
{/if}
<style>
::placeholder {
color: rgb(60, 60, 60);
@@ -14,7 +14,7 @@
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { handleError } from '$lib/utils/handle-error';
import type { AssetResponseDto } from '@immich/sdk';
import { deleteAssets, updateAssets } from '@immich/sdk';
import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk';
import { Button, HStack, IconButton, Text } from '@immich/ui';
import { mdiCheckOutline, mdiInformationOutline, mdiKeyboard, mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -134,10 +134,10 @@
};
const handleKeepAll = async () => {
const ids = duplicates.flatMap((group) => group.assets.map((asset) => asset.id));
const ids = duplicates.map(({ duplicateId }) => duplicateId);
return withConfirmation(
async () => {
await updateAssets({ assetBulkUpdateDto: { ids, duplicateId: null } });
await deleteDuplicates({ bulkIdsDto: { ids } });
duplicates = [];
@@ -200,7 +200,7 @@
icon={mdiInformationOutline}
aria-label={$t('deduplication_info')}
size="small"
onclick={() => modalManager.show(DuplicatesInformationModal, {})}
onclick={() => modalManager.show(DuplicatesInformationModal)}
/>
</div>
@@ -207,7 +207,7 @@
};
const onCreateNewLibraryClicked = async () => {
const result = await modalManager.show(LibraryUserPickerModal, {});
const result = await modalManager.show(LibraryUserPickerModal);
if (result) {
await handleCreate(result);
}
+1 -1
View File
@@ -58,7 +58,7 @@
};
const handleCreate = async () => {
await modalManager.show(UserCreateModal, {});
await modalManager.show(UserCreateModal);
await refresh();
};
+1 -1
View File
@@ -101,7 +101,7 @@
return 'bg-yellow-500';
}
return 'bg-immich-primary dark:bg-immich-dark-primary';
return 'bg-primary';
};
const handleResetPassword = async () => {
+1 -1
View File
@@ -139,7 +139,7 @@
<div class="flex flex-col w-full">
<div class=" bg-gray-300 dark:bg-gray-600 rounded-md h-2">
<div
class="progress-bar bg-immich-primary dark:bg-immich-dark-primary h-2 rounded-md transition-all duration-200 ease-out"
class="progress-bar bg-primary h-2 rounded-md transition-all duration-200 ease-out"
style="width: {(onboardingProgress / onboardingStepCount) * 100}%"
></div>
</div>
@@ -0,0 +1,18 @@
import { cancelLoad, getCachedOrFetch } from './cache';
export const installBroadcastChannelListener = () => {
const broadcast = new BroadcastChannel('immich');
// eslint-disable-next-line unicorn/prefer-add-event-listener
broadcast.onmessage = (event) => {
if (!event.data) {
return;
}
const urlstring = event.data.url;
const url = new URL(urlstring, event.origin);
if (event.data.type === 'cancel') {
cancelLoad(url.toString());
} else if (event.data.type === 'preload') {
getCachedOrFetch(url);
}
};
};
+106
View File
@@ -0,0 +1,106 @@
import { build, files, version } from '$service-worker';
const useCache = true;
const CACHE = `cache-${version}`;
export const APP_RESOURCES = [
...build, // the app itself
...files, // everything in `static`
];
export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
export async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) {
await caches.delete(key);
}
}
}
export async function addFilesToCache() {
const cache = await caches.open(CACHE);
await cache.addAll(APP_RESOURCES);
}
const pendingLoads = new Map<string, AbortController>();
export async function cancelLoad(urlString: string) {
const pending = pendingLoads.get(urlString);
if (pending) {
pending.abort();
pendingLoads.delete(urlString);
}
}
export async function getCachedOrFetch(request: URL | Request | string, cancelable: boolean = false) {
const cached = await checkCache(request);
if (cached.response) {
return cached.response;
}
try {
if (!cancelable) {
const response = await fetch(request);
checkResponse(response);
return response;
}
return await fetchWithCancellation(request, cached.cache);
} catch {
return new Response(undefined, {
status: 499,
statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself',
});
}
}
async function fetchWithCancellation(request: URL | Request | string, cache: Cache) {
const cacheKey = getCacheKey(request);
const cancelToken = new AbortController();
try {
pendingLoads.set(cacheKey, cancelToken);
const response = await fetch(request, {
signal: cancelToken.signal,
});
checkResponse(response);
setCached(response, cache, cacheKey);
return response;
} finally {
pendingLoads.delete(cacheKey);
}
}
async function checkCache(url: URL | Request | string) {
if (!useCache) {
return;
}
const cache = await caches.open(CACHE);
const response = await cache.match(url);
return { cache, response };
}
async function setCached(response: Response, cache: Cache, cacheKey: URL | Request | string) {
if (response.status === 200) {
cache.put(cacheKey, response.clone());
}
}
function checkResponse(response: Response) {
if (!(response instanceof Response)) {
throw new TypeError('invalid response from fetch');
}
}
function getCacheKey(request: URL | Request | string) {
if (isURL(request)) {
return request.toString();
} else if (isRequest(request)) {
return request.url;
} else {
return request;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { APP_RESOURCES, getCachedOrFetch } from './cache';
function isAssetRequest(pathname: string): boolean {
return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname);
}
function isIgnoredFileType(pathname: string): boolean {
return /\.(png|ico|txt|json|ts|ttf|css|js|svelte)$/.test(pathname);
}
function isIgnoredPath(pathname: string): boolean {
return /^\/(src|api)(\/.*)?$/.test(pathname) || /^\/(node_modules|@vite|@id)(\/.*)?$/.test(pathname);
}
export function handleFetchEvent(event: FetchEvent): void {
if (event.request.method !== 'GET') {
return;
}
const url = new URL(event.request.url);
if (APP_RESOURCES.includes(url.pathname)) {
event.respondWith(getCachedOrFetch(event.request));
return;
}
if (isAssetRequest(url.pathname)) {
event.respondWith(getCachedOrFetch(event.request, true));
return;
}
if (isIgnoredFileType(url.pathname) || isIgnoredPath(url.pathname)) {
return;
}
const slash = new URL('/', url.origin);
event.respondWith(getCachedOrFetch(slash));
}
+15 -75
View File
@@ -2,85 +2,25 @@
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { version } from '$service-worker';
import { installBroadcastChannelListener } from './broadcast-channel';
import { addFilesToCache, deleteOldCaches } from './cache';
import { handleFetchEvent } from './fetch-event';
const useCache = true;
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
const pendingLoads = new Map<string, AbortController>();
// Create a unique cache name for this deployment
const CACHE = `cache-${version}`;
sw.addEventListener('install', (event) => {
event.waitUntil(sw.skipWaiting());
});
sw.addEventListener('activate', (event) => {
const handleActivate = (event: ExtendableEvent) => {
event.waitUntil(sw.clients.claim());
// Remove previous cached data from disk
event.waitUntil(deleteOldCaches());
});
sw.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
const url = new URL(event.request.url);
if (/^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(url.pathname)) {
event.respondWith(immichAsset(url));
}
});
async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) {
await caches.delete(key);
}
}
}
async function immichAsset(url: URL) {
const cache = await caches.open(CACHE);
let response = useCache ? await cache.match(url) : undefined;
if (response) {
return response;
}
try {
const cancelToken = new AbortController();
const request = fetch(url, {
signal: cancelToken.signal,
});
pendingLoads.set(url.toString(), cancelToken);
response = await request;
if (!(response instanceof Response)) {
throw new TypeError('invalid response from fetch');
}
if (response.status === 200) {
cache.put(url, response.clone());
}
return response;
} catch {
return Response.error();
} finally {
pendingLoads.delete(url.toString());
}
}
const broadcast = new BroadcastChannel('immich');
// eslint-disable-next-line unicorn/prefer-add-event-listener
broadcast.onmessage = (event) => {
if (!event.data) {
return;
}
const urlstring = event.data.url;
const url = new URL(urlstring, event.origin);
if (event.data.type === 'cancel') {
const pending = pendingLoads.get(url.toString());
if (pending) {
pending.abort();
pendingLoads.delete(url.toString());
}
} else if (event.data.type === 'preload') {
immichAsset(url);
}
};
const handleInstall = (event: ExtendableEvent) => {
event.waitUntil(sw.skipWaiting());
// Create a new cache and add all files to it
event.waitUntil(addFilesToCache());
};
sw.addEventListener('install', handleInstall);
sw.addEventListener('activate', handleActivate);
sw.addEventListener('fetch', handleFetchEvent);
installBroadcastChannelListener();
+3
View File
@@ -14,6 +14,9 @@ const config = {
},
preprocess: vitePreprocess(),
kit: {
paths: {
relative: false,
},
adapter: adapter({
fallback: 'index.html',
precompress: true,