Merge remote-tracking branch 'origin/main' into timeline_events
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
> 0.2% and last 4 major versions
|
||||
> 0.5%
|
||||
not dead
|
||||
edge >= 135
|
||||
not edge < 135
|
||||
@@ -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',
|
||||
|
||||
Generated
+88
-5
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
+45
-64
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-16
@@ -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() {
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 } })}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+33
-77
@@ -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);
|
||||
|
||||
+4
-4
@@ -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);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
await modalManager.show(UserCreateModal, {});
|
||||
await modalManager.show(UserCreateModal);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
return 'bg-yellow-500';
|
||||
}
|
||||
|
||||
return 'bg-immich-primary dark:bg-immich-dark-primary';
|
||||
return 'bg-primary';
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -14,6 +14,9 @@ const config = {
|
||||
},
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
paths: {
|
||||
relative: false,
|
||||
},
|
||||
adapter: adapter({
|
||||
fallback: 'index.html',
|
||||
precompress: true,
|
||||
|
||||
Reference in New Issue
Block a user