mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into fix/map-sidepanel-queries
This commit is contained in:
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.7.4",
|
||||
"version": "2.7.5",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/ui": "^0.69.0",
|
||||
"@immich/ui": "^0.76.0",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.14.0",
|
||||
@@ -52,7 +52,7 @@
|
||||
"maplibre-gl": "^5.6.2",
|
||||
"pmtiles": "^4.3.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"simple-icons": "^15.15.0",
|
||||
"simple-icons": "^16.0.0",
|
||||
"socket.io-client": "~4.8.0",
|
||||
"svelte-gestures": "^5.2.2",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
@@ -73,7 +73,7 @@
|
||||
"@socket.io/component-emitter": "^3.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/enhanced-img": "^0.10.4",
|
||||
"@sveltejs/kit": "^2.27.1",
|
||||
"@sveltejs/kit": "^2.56.1",
|
||||
"@sveltejs/vite-plugin-svelte": "7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
@@ -100,7 +100,7 @@
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^7.0.0",
|
||||
"svelte": "5.54.1",
|
||||
"svelte": "5.55.1",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.2.2",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@immich/ui/theme/default.css';
|
||||
@source "../node_modules/@immich/ui";
|
||||
/* @import '/usr/ui/dist/theme/default.css'; */
|
||||
/* @import '../../../ui/packages/ui/dist/theme/default.css'; */
|
||||
|
||||
@utility immich-form-input {
|
||||
@apply bg-gray-100 ring-1 ring-gray-200 transition outline-none focus-within:ring-1 disabled:cursor-not-allowed dark:bg-gray-800 dark:ring-neutral-900 flex w-full items-center rounded-lg disabled:bg-gray-300 disabled:text-dark dark:disabled:bg-gray-900 dark:disabled:text-gray-200 flex-1 py-2.5 text-base pl-4 pr-4;
|
||||
|
||||
+25
-44
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html class="dark">
|
||||
<head>
|
||||
<!-- (used for SSR) -->
|
||||
<!-- metadata:tags -->
|
||||
@@ -15,7 +15,22 @@
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180.png" />
|
||||
<link rel="preload" as="font" type="font/ttf" href="%app.font%" crossorigin="anonymous" />
|
||||
<link rel="preload" as="font" type="font/ttf" href="%app.monofont%" crossorigin="anonymous" />
|
||||
|
||||
<script>
|
||||
try {
|
||||
const preference = JSON.parse(localStorage.getItem('immich-ui-theme'));
|
||||
const prefersDark = globalThis.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (preference === 'light' || (preference !== 'dark' && !prefersDark)) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.classList.add('light');
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
</script>
|
||||
|
||||
%sveltekit.head%
|
||||
|
||||
<style>
|
||||
/* prevent FOUC */
|
||||
html {
|
||||
@@ -23,6 +38,14 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
html.light {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
@@ -57,53 +80,11 @@
|
||||
0s linear 0.3s forwards delayedVisibility,
|
||||
loadspin 8s linear infinite;
|
||||
}
|
||||
|
||||
.bg-immich-bg {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.dark .dark\:bg-immich-dark-bg {
|
||||
background-color: black;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
/**
|
||||
* Prevent FOUC on page load.
|
||||
*/
|
||||
const colorThemeKeyName = 'color-theme';
|
||||
|
||||
let theme = localStorage.getItem(colorThemeKeyName);
|
||||
if (!theme) {
|
||||
theme = { value: 'light', system: true };
|
||||
} else if (theme === 'dark' || theme === 'light') {
|
||||
theme = { value: theme, system: false };
|
||||
localStorage.setItem(colorThemeKeyName, JSON.stringify(theme));
|
||||
} else {
|
||||
theme = JSON.parse(theme);
|
||||
}
|
||||
|
||||
let themeValue = theme.value;
|
||||
if (theme.system) {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
themeValue = 'dark';
|
||||
} else {
|
||||
themeValue = 'light';
|
||||
}
|
||||
}
|
||||
|
||||
if (themeValue === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href="/custom.css" />
|
||||
</head>
|
||||
|
||||
<noscript
|
||||
class="absolute z-1000 flex h-screen w-screen place-content-center place-items-center bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg"
|
||||
>
|
||||
<noscript style="position: absolute; top: 0px; z-index: 1000; color: black; background-color: white">
|
||||
To use Immich, you must enable JavaScript or use a JavaScript compatible browser.
|
||||
</noscript>
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { decodeBase64 } from '$lib/utils';
|
||||
import { thumbHashToRGBA } from 'thumbhash';
|
||||
|
||||
/**
|
||||
* Renders a thumbnail onto a canvas from a base64 encoded hash.
|
||||
*/
|
||||
export function thumbhash(canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) {
|
||||
render(canvas, options);
|
||||
|
||||
return {
|
||||
update(newOptions: { base64ThumbHash: string }) {
|
||||
render(canvas, newOptions);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const render = (canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(options.base64ThumbHash));
|
||||
const pixels = ctx.createImageData(w, h);
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
pixels.data.set(rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
};
|
||||
@@ -124,9 +124,6 @@ export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTML
|
||||
{ capture: true, signal },
|
||||
);
|
||||
|
||||
if (options?.zoomTarget) {
|
||||
options.zoomTarget.style.willChange = 'transform';
|
||||
}
|
||||
node.style.overflow = 'visible';
|
||||
node.style.touchAction = 'none';
|
||||
return {
|
||||
@@ -138,9 +135,6 @@ export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTML
|
||||
},
|
||||
destroy() {
|
||||
controller.abort();
|
||||
if (options?.zoomTarget) {
|
||||
options.zoomTarget.style.willChange = '';
|
||||
}
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountMultipleOutline,
|
||||
mdiBookshelf,
|
||||
mdiCog,
|
||||
mdiKeyboard,
|
||||
mdiServer,
|
||||
mdiSync,
|
||||
mdiThemeLightDark,
|
||||
} from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export const getPagesProvider = ($t: MessageFormatter) => {
|
||||
const adminPages: ActionItem[] = [
|
||||
{
|
||||
title: $t('admin.user_management'),
|
||||
description: $t('admin.users_page_description'),
|
||||
icon: mdiAccountMultipleOutline,
|
||||
onAction: () => goto(Route.users()),
|
||||
},
|
||||
{
|
||||
title: $t('admin.system_settings'),
|
||||
description: $t('admin.settings_page_description'),
|
||||
icon: mdiCog,
|
||||
onAction: () => goto(Route.systemSettings()),
|
||||
},
|
||||
{
|
||||
title: $t('admin.queues'),
|
||||
description: $t('admin.queues_page_description'),
|
||||
icon: mdiSync,
|
||||
onAction: () => goto(Route.queues()),
|
||||
},
|
||||
{
|
||||
title: $t('external_libraries'),
|
||||
description: $t('admin.external_libraries_page_description'),
|
||||
icon: mdiBookshelf,
|
||||
onAction: () => goto(Route.libraries()),
|
||||
},
|
||||
{
|
||||
title: $t('server_stats'),
|
||||
description: $t('admin.server_stats_page_description'),
|
||||
icon: mdiServer,
|
||||
onAction: () => goto(Route.systemStatistics()),
|
||||
},
|
||||
].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin }));
|
||||
|
||||
return defaultProvider({ name: $t('page'), actions: adminPages });
|
||||
};
|
||||
|
||||
const getMyImmichLink = () => {
|
||||
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
|
||||
};
|
||||
|
||||
export const getSettingsProvider = ($t: MessageFormatter) => {
|
||||
const settings: ActionItem[] = [
|
||||
{
|
||||
title: $t('theme'),
|
||||
description: $t('toggle_theme_description'),
|
||||
icon: mdiThemeLightDark,
|
||||
onAction: () => themeManager.toggle(),
|
||||
shortcuts: { shift: true, key: 't' },
|
||||
},
|
||||
{
|
||||
title: $t('system_theme'),
|
||||
description: $t('system_theme_command_description', {
|
||||
values: { value: themeManager.prefersDark ? $t('dark') : $t('light') },
|
||||
}),
|
||||
icon: mdiThemeLightDark,
|
||||
onAction: () => themeManager.setPreference(ThemePreference.System),
|
||||
},
|
||||
{
|
||||
title: $t('screencast_mode_title'),
|
||||
description: $t('screencast_mode_description'),
|
||||
icon: mdiKeyboard,
|
||||
onAction: () => screencastManager.toggle(),
|
||||
},
|
||||
{
|
||||
title: $t('my_immich_title'),
|
||||
description: $t('my_immich_description'),
|
||||
onAction: () => copyToClipboard(getMyImmichLink().toString()),
|
||||
shortcuts: { ctrl: true, shift: true, key: 'm' },
|
||||
},
|
||||
];
|
||||
|
||||
return defaultProvider({ name: $t('command'), actions: settings });
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import ImageLayer from '$lib/components/ImageLayer.svelte';
|
||||
import Thumbhash from '$lib/components/Thumbhash.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { getAssetUrls } from '$lib/utils';
|
||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
@@ -98,13 +98,13 @@
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
const { width, height, left, top } = $derived.by(() => {
|
||||
const { width, height, insetInlineStart, top } = $derived.by(() => {
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const { width, height } = scaleFn(imageDimensions, container);
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
left: (container.width - width) / 2 + 'px',
|
||||
insetInlineStart: (container.width - width) / 2 + 'px',
|
||||
top: (container.height - height) / 2 + 'px',
|
||||
};
|
||||
});
|
||||
@@ -148,10 +148,16 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
||||
<div class="relative h-full w-full overflow-hidden" bind:this={ref}>
|
||||
{@render backdrop?.()}
|
||||
|
||||
<div class="absolute inset-0 pointer-events-none" style:left style:top style:width style:height>
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
style:inset-inline-start={insetInlineStart}
|
||||
style:top
|
||||
style:width
|
||||
style:height
|
||||
>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
@@ -159,7 +165,7 @@
|
||||
{#if show.thumbhash}
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
||||
<Thumbhash base64ThumbHash={asset.thumbhash} class="h-full w-full absolute" />
|
||||
{:else if show.spinner}
|
||||
<DelayedLoadingSpinner />
|
||||
{/if}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import type { QueueSnapshot } from '$lib/types';
|
||||
import type { QueueResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, Theme, theme } from '@immich/ui';
|
||||
import { LoadingSpinner, Theme, themeManager } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount } from 'svelte';
|
||||
import uPlot, { type AlignedData, type Axis } from 'uplot';
|
||||
@@ -55,7 +55,7 @@
|
||||
const data = $derived(normalizeData(queueManager.snapshots));
|
||||
|
||||
let chartElement: HTMLDivElement | undefined = $state();
|
||||
let isDark = $derived(theme.value === Theme.Dark);
|
||||
let isDark = $derived(themeManager.value === Theme.Dark);
|
||||
let plot: uPlot;
|
||||
|
||||
const axisOptions: Axis = {
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
const onThemeChange = () => plot?.redraw(false);
|
||||
|
||||
$effect(() => theme.value && onThemeChange());
|
||||
$effect(() => themeManager.value && onThemeChange());
|
||||
|
||||
onMount(() => {
|
||||
plot = new uPlot(options, data as AlignedData, chartElement);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { decodeBase64 } from '$lib/utils';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import type { HTMLCanvasAttributes } from 'svelte/elements';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { thumbHashToRGBA } from 'thumbhash';
|
||||
|
||||
type Props = HTMLCanvasAttributes & {
|
||||
base64ThumbHash: string;
|
||||
fadeOut?: boolean;
|
||||
};
|
||||
|
||||
const { base64ThumbHash, fadeOut = false, class: className, ...restProps }: Props = $props();
|
||||
|
||||
const {
|
||||
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
|
||||
} = TUNABLES;
|
||||
|
||||
let canvas = $state<HTMLCanvasElement>();
|
||||
|
||||
$effect(() => {
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx) {
|
||||
return;
|
||||
}
|
||||
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash));
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const pixels = ctx.createImageData(w, h);
|
||||
pixels.data.set(rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class={className}
|
||||
out:fade={{ duration: fadeOut ? THUMBHASH_FADE_DURATION : 0 }}
|
||||
{...restProps}
|
||||
></canvas>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import { getReleaseType, semverToName } from '$lib/utils';
|
||||
import { modalManager } from '@immich/ui';
|
||||
@@ -12,7 +12,7 @@
|
||||
}>();
|
||||
|
||||
const onReleaseEvent = async (release: ReleaseEvent) => {
|
||||
if (!release.isAvailable || !$user.isAdmin) {
|
||||
if (!release.isAvailable || !authManager.user.isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,14 @@
|
||||
isEdited={!(configToEdit.oauth.timeout === config.oauth.timeout)}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.oauth_allow_insecure_requests')}
|
||||
subtitle={$t('admin.oauth_allow_insecure_requests_description')}
|
||||
bind:checked={configToEdit.oauth.allowInsecureRequests}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.allowInsecureRequests === config.oauth.allowInsecureRequests)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_storage_label_claim')}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { handleSystemConfigSave } from '$lib/services/system-config.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { sendTestEmailAdmin } from '@immich/sdk';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
@@ -45,7 +45,9 @@
|
||||
},
|
||||
});
|
||||
|
||||
toastManager.primary($t('admin.notification_email_test_email_sent', { values: { email: $user.email } }));
|
||||
toastManager.primary(
|
||||
$t('admin.notification_email_test_email_sent', { values: { email: authManager.user.email } }),
|
||||
);
|
||||
|
||||
if (!disabled) {
|
||||
await handleSystemConfigSave({ notifications: configToEdit.notifications });
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleSystemConfigSave } from '$lib/services/system-config.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
|
||||
import { Heading, Link, LoadingSpinner, Text } from '@immich/ui';
|
||||
import handlebar from 'handlebars';
|
||||
@@ -177,7 +177,10 @@
|
||||
<p class="text-sm">
|
||||
<FormatMessage
|
||||
key="admin.storage_template_path_length"
|
||||
values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
|
||||
values={{
|
||||
length: parsedTemplate().length + authManager.user.id.length + 'UPLOAD_LOCATION'.length,
|
||||
limit: 260,
|
||||
}}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<span class="font-semibold text-primary">{message}</span>
|
||||
@@ -186,7 +189,10 @@
|
||||
</p>
|
||||
|
||||
<p class="text-sm">
|
||||
<FormatMessage key="admin.storage_template_user_label" values={{ label: $user.storageLabel || $user.id }}>
|
||||
<FormatMessage
|
||||
key="admin.storage_template_user_label"
|
||||
values={{ label: authManager.user.storageLabel || authManager.user.id }}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<code class="text-primary">{message}</code>
|
||||
{/snippet}
|
||||
@@ -195,7 +201,7 @@
|
||||
|
||||
<p class="p-4 py-2 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
|
||||
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
|
||||
>UPLOAD_LOCATION/library/{$user.storageLabel || $user.id}</span
|
||||
>UPLOAD_LOCATION/library/{authManager.user.storageLabel || authManager.user.id}</span
|
||||
>/{parsedTemplate()}.jpg
|
||||
</p>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { getShortDateRange } from '$lib/utils/date-time';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
@@ -85,7 +85,7 @@
|
||||
{/if}
|
||||
|
||||
{#if showOwner}
|
||||
{#if $user.id === album.ownerId}
|
||||
{#if authManager.user.id === album.ownerId}
|
||||
<p>{$t('owned')}</p>
|
||||
{:else if album.owner}
|
||||
<p>{$t('shared_by_user', { values: { user: album.owner.name } })}</p>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import MapModal from '$lib/modals/MapModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import { getAlbumMapMarkers, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiMapOutline } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
@@ -14,7 +15,7 @@
|
||||
}
|
||||
|
||||
let { album }: Props = $props();
|
||||
let abortController: AbortController;
|
||||
let cancelable: AbortController;
|
||||
|
||||
let returnToMap = $state(false);
|
||||
let mapMarkers: MapMarkerResponseDto[] = $state([]);
|
||||
@@ -24,7 +25,7 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
abortController?.abort();
|
||||
cancelable?.abort();
|
||||
assetViewerManager.showAssetViewer(false);
|
||||
});
|
||||
|
||||
@@ -35,30 +36,17 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function loadMapMarkers() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
const loadMapMarkers = async () => {
|
||||
cancelable?.abort();
|
||||
cancelable = new AbortController();
|
||||
|
||||
try {
|
||||
return await getAlbumMapMarkers({ ...authManager.params, id: album.id }, { signal: cancelable.signal });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
return [];
|
||||
}
|
||||
abortController = new AbortController();
|
||||
|
||||
let albumInfo: AlbumResponseDto = await getAlbumInfo({ id: album.id, withoutAssets: false, ...authManager.params });
|
||||
|
||||
let markers: MapMarkerResponseDto[] = [];
|
||||
for (const asset of albumInfo.assets) {
|
||||
if (asset.exifInfo?.latitude && asset.exifInfo?.longitude) {
|
||||
markers.push({
|
||||
id: asset.id,
|
||||
lat: asset.exifInfo.latitude,
|
||||
lon: asset.exifInfo.longitude,
|
||||
city: asset.exifInfo?.city ?? null,
|
||||
country: asset.exifInfo?.country ?? null,
|
||||
state: asset.exifInfo?.state ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = async () => {
|
||||
const assetIds = await modalManager.show(MapModal, { mapMarkers });
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
|
||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||
import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service';
|
||||
@@ -16,7 +17,6 @@
|
||||
SortOrder,
|
||||
type AlbumViewSettings,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSelectedAlbumGroupOption, sortAlbums, stringToSortOrder, type AlbumGroup } from '$lib/utils/album-utils';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
/** Group by owner */
|
||||
[AlbumGroupBy.Owner]: (order, albums): AlbumGroup[] => {
|
||||
const currentUserId = $user.id;
|
||||
const currentUserId = authManager.user.id;
|
||||
const groupedByOwnerIds = groupBy(albums, 'ownerId');
|
||||
|
||||
const sortSign = order === SortOrder.Desc ? -1 : 1;
|
||||
@@ -130,7 +130,7 @@
|
||||
return sharedAlbums;
|
||||
}
|
||||
default: {
|
||||
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== $user.id);
|
||||
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== authManager.user.id);
|
||||
return nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@
|
||||
albumGroupIds = groupedAlbums.map(({ id }) => id);
|
||||
});
|
||||
|
||||
let showFullContextMenu = $derived(allowEdit && selectedAlbum && selectedAlbum.ownerId === $user.id);
|
||||
let showFullContextMenu = $derived(allowEdit && selectedAlbum && selectedAlbum.ownerId === authManager.user.id);
|
||||
|
||||
onMount(async () => {
|
||||
if (allowEdit) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { dateFormats } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
@@ -43,7 +43,7 @@
|
||||
icon={mdiShareVariantOutline}
|
||||
size="16"
|
||||
class="inline ms-1 opacity-70"
|
||||
title={album.ownerId === $user.id
|
||||
title={album.ownerId === authManager.user.id
|
||||
? $t('shared_by_you')
|
||||
: $t('shared_by_user', { values: { user: album.owner.name } })}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
@@ -42,7 +42,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={$preferences?.ratings.enabled
|
||||
use:shortcuts={authManager.authenticated && authManager.preferences.ratings.enabled
|
||||
? [
|
||||
{ shortcut: { key: '0' }, onShortcut: () => rateAsset(null) },
|
||||
...[1, 2, 3, 4, 5].map((rating) => ({
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAssetType } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { isTenMinutesApart } from '$lib/utils/timesince';
|
||||
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
|
||||
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui';
|
||||
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
|
||||
import * as luxon from 'luxon';
|
||||
@@ -39,7 +40,6 @@
|
||||
};
|
||||
|
||||
interface Props {
|
||||
user: UserResponseDto;
|
||||
assetId?: string | undefined;
|
||||
albumId: string;
|
||||
assetType?: AssetTypeEnum | undefined;
|
||||
@@ -47,7 +47,7 @@
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled }: Props = $props();
|
||||
let { assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled }: Props = $props();
|
||||
|
||||
let innerHeight: number = $state(0);
|
||||
let activityHeight: number = $state(0);
|
||||
@@ -147,7 +147,7 @@
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||
{#if reaction.user.id === authManager.user.id || albumOwnerId === authManager.user.id}
|
||||
<div class="me-4">
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
@@ -200,7 +200,7 @@
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||
{#if reaction.user.id === authManager.user.id || albumOwnerId === authManager.user.id}
|
||||
<div class="me-4">
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
@@ -238,7 +238,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" noTitle />
|
||||
<UserAvatar user={authManager.user} size="md" noTitle />
|
||||
</div>
|
||||
<form class="flex w-full items-center max-h-56 gap-1" {onsubmit}>
|
||||
<Textarea
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
|
||||
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { preferencesFactory } from '@test-data/factories/preferences-factory';
|
||||
@@ -36,7 +36,7 @@ describe('AssetViewerNavBar component', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetSavedUser();
|
||||
authManager.reset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -44,8 +44,8 @@ describe('AssetViewerNavBar component', () => {
|
||||
});
|
||||
|
||||
it('shows back button', () => {
|
||||
const prefs = preferencesFactory.build({ cast: { gCastEnabled: false } });
|
||||
preferencesStore.set(prefs);
|
||||
const preferences = preferencesFactory.build({ cast: { gCastEnabled: false } });
|
||||
authManager.setPreferences(preferences);
|
||||
|
||||
const asset = assetFactory.build({ isTrashed: false });
|
||||
const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
|
||||
@@ -57,10 +57,10 @@ describe('AssetViewerNavBar component', () => {
|
||||
const ownerId = 'id-of-the-user';
|
||||
const user = userAdminFactory.build({ id: ownerId });
|
||||
const asset = assetFactory.build({ ownerId, isTrashed: false });
|
||||
userStore.set(user);
|
||||
authManager.setUser(user);
|
||||
|
||||
const prefs = preferencesFactory.build({ cast: { gCastEnabled: false } });
|
||||
preferencesStore.set(prefs);
|
||||
const preferences = preferencesFactory.build({ cast: { gCastEnabled: false } });
|
||||
authManager.setPreferences(preferences);
|
||||
|
||||
const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
|
||||
expect(getByLabelText('delete')).toBeInTheDocument();
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
|
||||
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
|
||||
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
|
||||
import RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
|
||||
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
|
||||
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
|
||||
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
|
||||
@@ -19,13 +18,14 @@
|
||||
import LoadingDots from '$lib/components/LoadingDots.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 RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getGlobalActions } from '$lib/services/app.service';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSharedLink, withoutIcons } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
@@ -81,8 +81,8 @@
|
||||
setPlayOriginalVideo,
|
||||
}: Props = $props();
|
||||
|
||||
const isOwner = $derived($user && asset.ownerId === $user?.id);
|
||||
const isAlbumOwner = $derived($user && album?.ownerId === $user?.id);
|
||||
const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id);
|
||||
const isAlbumOwner = $derived(authManager.authenticated && album?.ownerId === authManager.user.id);
|
||||
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
|
||||
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
||||
|
||||
@@ -90,9 +90,8 @@
|
||||
|
||||
const Close: ActionItem = $derived({
|
||||
title: $t('go_back'),
|
||||
type: $t('assets'),
|
||||
icon: languageManager.rtl ? mdiArrowRight : mdiArrowLeft,
|
||||
$if: () => !!onClose && !assetViewerManager.isFaceEditMode,
|
||||
$if: () => !!onClose && !assetViewerManager.isFaceEditMode && !assetViewerManager.isEditFacesPanelOpen,
|
||||
onAction: () => onClose?.(),
|
||||
shortcuts: [{ key: 'Escape' }],
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
|
||||
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import { updateAsset } from '@immich/sdk';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
@@ -43,7 +43,7 @@ describe('AssetViewer', () => {
|
||||
|
||||
afterEach(() => {
|
||||
slideshowStore.slideshowState.set(SlideshowState.None);
|
||||
resetSavedUser();
|
||||
authManager.reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -56,8 +56,9 @@ describe('AssetViewer', () => {
|
||||
const user = userAdminFactory.build({ id: ownerId });
|
||||
const asset = assetFactory.build({ ownerId, isFavorite: false, isTrashed: false });
|
||||
|
||||
userStore.set(user);
|
||||
preferencesStore.set(preferencesFactory.build({ cast: { gCastEnabled: false } }));
|
||||
authManager.setUser(user);
|
||||
authManager.setPreferences(preferencesFactory.build({ cast: { gCastEnabled: false } }));
|
||||
|
||||
vi.mocked(updateAsset).mockResolvedValue({ ...asset, isFavorite: true });
|
||||
|
||||
const { getByLabelText, queryByLabelText } = renderWithTooltips(AssetViewer, {
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { navigateToAsset } from '$lib/utils/asset-utils';
|
||||
@@ -174,7 +173,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
activityManager.reset();
|
||||
assetViewerManager.closeEditor();
|
||||
assetViewerManager.resetPanelState();
|
||||
syncAssetViewerOpenClass(false);
|
||||
preloadManager.destroy();
|
||||
});
|
||||
@@ -629,7 +628,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
|
||||
{#if isShared && album && assetViewerManager.isShowActivityPanel && authManager.authenticated}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="activity-panel"
|
||||
@@ -637,7 +636,6 @@
|
||||
translate="yes"
|
||||
>
|
||||
<ActivityViewer
|
||||
user={$user}
|
||||
disabled={!album.isActivityEnabled}
|
||||
assetType={asset.type}
|
||||
albumOwnerId={album.ownerId}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon, modalManager } from '@immich/ui';
|
||||
import { mdiCalendar, mdiPencil } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
};
|
||||
|
||||
const { asset }: Props = $props();
|
||||
|
||||
const timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
|
||||
const dateTime = $derived(
|
||||
timeZone && asset.exifInfo?.dateTimeOriginal
|
||||
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||
: fromISODateTimeUTC(asset.localDateTime),
|
||||
);
|
||||
const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id);
|
||||
|
||||
const handleChangeDate = async () => {
|
||||
if (!isOwner) {
|
||||
return;
|
||||
}
|
||||
|
||||
await modalManager.show(AssetChangeDateModal, {
|
||||
asset: toTimelineAsset(asset),
|
||||
initialDate: dateTime,
|
||||
initialTimeZone: timeZone,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if dateTime}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
|
||||
onclick={handleChangeDate}
|
||||
title={isOwner ? $t('edit_date') : ''}
|
||||
class:hover:text-primary={isOwner}
|
||||
data-testid="detail-panel-edit-date-button"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<Icon icon={mdiCalendar} size="24" />
|
||||
|
||||
<div>
|
||||
<p>
|
||||
{dateTime.toLocaleString({ month: 'short', day: 'numeric', year: 'numeric' }, { locale: $locale })}
|
||||
</p>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>
|
||||
{dateTime.toLocaleString(
|
||||
{
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: timeZone ? 'longOffset' : undefined,
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isOwner}
|
||||
<div class="p-1">
|
||||
<Icon icon={mdiPencil} size="20" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if !dateTime && isOwner}
|
||||
<div class="flex justify-between place-items-start gap-4 py-4">
|
||||
<div class="flex gap-4">
|
||||
<Icon icon={mdiCalendar} size="24" />
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<Icon icon={mdiPencil} size="20" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import StarRating, { type Rating } from '$lib/elements/StarRating.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
@@ -25,7 +24,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if !authManager.isSharedLink && $preferences?.ratings.enabled}
|
||||
{#if !authManager.isSharedLink && authManager.authenticated && authManager.preferences.ratings.enabled}
|
||||
<section class="px-4 pt-4">
|
||||
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import DetailPanelDate from '$lib/components/asset-viewer/detail-panel-date.svelte';
|
||||
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
|
||||
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
|
||||
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
|
||||
@@ -8,16 +9,13 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getParentPath } from '$lib/utils/tree-utils';
|
||||
import {
|
||||
AssetMediaSize,
|
||||
@@ -26,9 +24,8 @@
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
mdiCamera,
|
||||
mdiCameraIris,
|
||||
mdiClose,
|
||||
@@ -55,18 +52,11 @@
|
||||
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $state(false);
|
||||
let isOwner = $derived($user?.id === asset.ownerId);
|
||||
let showEditFaces = $derived(assetViewerManager.isEditFacesPanelOpen);
|
||||
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
|
||||
let people = $derived(asset.people || []);
|
||||
let unassignedFaces = $derived(asset.unassignedFaces || []);
|
||||
let showingHiddenPeople = $state(false);
|
||||
let timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
|
||||
let dateTime = $derived(
|
||||
timeZone && asset.exifInfo?.dateTimeOriginal
|
||||
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||
: fromISODateTimeUTC(asset.localDateTime),
|
||||
);
|
||||
let latlng = $derived(
|
||||
(() => {
|
||||
const lat = asset.exifInfo?.latitude;
|
||||
@@ -105,7 +95,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
showEditFaces = false;
|
||||
assetViewerManager.closeEditFacesPanel();
|
||||
previousId = asset.id;
|
||||
});
|
||||
|
||||
@@ -121,27 +111,13 @@
|
||||
|
||||
const handleRefreshPeople = async () => {
|
||||
asset = await getAssetInfo({ id: asset.id });
|
||||
showEditFaces = false;
|
||||
assetViewerManager.closeEditFacesPanel();
|
||||
};
|
||||
|
||||
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
||||
// Remove the last part of the path to get the parent path
|
||||
return Route.folders({ path: getParentPath(asset.originalPath) });
|
||||
};
|
||||
|
||||
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
|
||||
|
||||
const handleChangeDate = async () => {
|
||||
if (!isOwner) {
|
||||
return;
|
||||
}
|
||||
|
||||
await modalManager.show(AssetChangeDateModal, {
|
||||
asset: toTimelineAsset(asset),
|
||||
initialDate: dateTime,
|
||||
initialTimeZone: timeZone,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents onAlbumAddAssets={() => (albums = refreshAlbums())} />
|
||||
@@ -167,7 +143,7 @@
|
||||
</div>
|
||||
<div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
|
||||
<p>
|
||||
{#if $user?.isAdmin}
|
||||
{#if authManager.authenticated && authManager.user.isAdmin}
|
||||
{$t('admin.asset_offline_description')}
|
||||
{:else}
|
||||
{$t('asset_offline_description')}
|
||||
@@ -218,7 +194,7 @@
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => (showEditFaces = true)}
|
||||
onclick={() => assetViewerManager.openEditFacesPanel()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -295,65 +271,7 @@
|
||||
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
|
||||
{/if}
|
||||
|
||||
{#if dateTime}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
|
||||
onclick={handleChangeDate}
|
||||
title={isOwner ? $t('edit_date') : ''}
|
||||
class:hover:text-primary={isOwner}
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<Icon icon={mdiCalendar} size="24" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
{dateTime.toLocaleString(
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
</p>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>
|
||||
{dateTime.toLocaleString(
|
||||
{
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: timeZone ? 'longOffset' : undefined,
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isOwner}
|
||||
<div class="p-1">
|
||||
<Icon icon={mdiPencil} size="20" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if !dateTime && isOwner}
|
||||
<div class="flex justify-between place-items-start gap-4 py-4">
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<Icon icon={mdiCalendar} size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<Icon icon={mdiPencil} size="20" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<DetailPanelDate {asset} />
|
||||
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><Icon icon={mdiImageOutline} size="24" /></div>
|
||||
@@ -369,11 +287,11 @@
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={toggleAssetPath}
|
||||
onclick={() => assetViewerManager.toggleAssetPath()}
|
||||
/>
|
||||
{/if}
|
||||
</p>
|
||||
{#if showAssetPath}
|
||||
{#if assetViewerManager.isShowAssetPath}
|
||||
<p class="text-xs opacity-50 break-all pb-2 hover:text-primary" transition:slide={{ duration: 250 }}>
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve this is supposed to be treated as an absolute/external link -->
|
||||
<a href={getAssetFolderHref(asset)} title={$t('go_to_folder')} class="whitespace-pre-wrap">
|
||||
@@ -566,7 +484,7 @@
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
{#if $preferences?.tags?.enabled}
|
||||
{#if authManager.authenticated && authManager.preferences.tags.enabled}
|
||||
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
<DetailPanelTags {asset} {isOwner} />
|
||||
</section>
|
||||
@@ -576,7 +494,7 @@
|
||||
<PersonSidePanel
|
||||
assetId={asset.id}
|
||||
assetType={asset.type}
|
||||
onClose={() => (showEditFaces = false)}
|
||||
onClose={() => assetViewerManager.closeEditFacesPanel()}
|
||||
onRefresh={handleRefreshPeople}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import Thumbhash from '$lib/components/Thumbhash.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
@@ -106,13 +106,13 @@
|
||||
assetViewerManager.animatedZoom(targetZoom);
|
||||
};
|
||||
|
||||
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
||||
|
||||
$effect(() => {
|
||||
if (assetViewerManager.isFaceEditMode && assetViewerManager.zoom > 1) {
|
||||
const onFaceEditModeChange = (isFaceEditMode: boolean) => {
|
||||
if (isFaceEditMode && assetViewerManager.zoom > 1) {
|
||||
onZoom();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
||||
|
||||
// TODO move to action + command palette
|
||||
const onCopyShortcut = (event: KeyboardEvent) => {
|
||||
@@ -200,7 +200,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<AssetViewerEvents {onCopy} {onZoom} />
|
||||
<AssetViewerEvents {onCopy} {onZoom} {onFaceEditModeChange} />
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={[
|
||||
@@ -242,10 +242,7 @@
|
||||
>
|
||||
{#snippet backdrop()}
|
||||
{#if blurredSlideshow}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
|
||||
class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw"
|
||||
></canvas>
|
||||
<Thumbhash base64ThumbHash={asset.thumbhash!} class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet overlays()}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
@@ -10,7 +9,6 @@
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import {
|
||||
@@ -27,6 +25,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
import { fade } from 'svelte/transition';
|
||||
import Thumbhash from '$lib/components/Thumbhash.svelte';
|
||||
import ImageThumbnail from './image-thumbnail.svelte';
|
||||
import VideoThumbnail from './video-thumbnail.svelte';
|
||||
interface Props {
|
||||
@@ -75,10 +74,6 @@
|
||||
dimmed = false,
|
||||
}: Props = $props();
|
||||
|
||||
let {
|
||||
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
|
||||
} = TUNABLES;
|
||||
|
||||
let usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
|
||||
let element: HTMLElement | undefined = $state();
|
||||
let mouseOver = $state(false);
|
||||
@@ -296,7 +291,7 @@
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
||||
{:else if asset.isImage && asset.duration && mouseOver}
|
||||
<!-- GIF -->
|
||||
<div class="absolute h-full w-full pointer-events-none">
|
||||
<ImageThumbnail
|
||||
@@ -312,16 +307,14 @@
|
||||
{/if}
|
||||
|
||||
{#if (!loaded || thumbError) && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
<Thumbhash
|
||||
base64ThumbHash={asset.thumbhash}
|
||||
data-testid="thumbhash"
|
||||
class="absolute top-0 object-cover group-focus-visible:rounded-lg"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
class:rounded-xl={selected}
|
||||
class={['absolute top-0 object-cover group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
|
||||
style="width: {width}px; height: {height}px"
|
||||
draggable="false"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
></canvas>
|
||||
fadeOut
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- icon overlay -->
|
||||
@@ -376,7 +369,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
|
||||
{#if asset.isImage && asset.duration}
|
||||
<div class="z-2 absolute inset-e-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon icon={mouseOver ? mdiMotionPauseOutline : mdiFileGifBox} size="24" />
|
||||
|
||||
@@ -36,14 +36,18 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!enablePlayback) {
|
||||
// Reset remaining time when playback is disabled.
|
||||
remainingSeconds = durationInSeconds;
|
||||
|
||||
if (player) {
|
||||
// Cancel video buffering.
|
||||
player.src = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
const video = player;
|
||||
return () => {
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
};
|
||||
});
|
||||
const onMouseEnter = () => {
|
||||
if (playbackOnIconHover) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
@@ -187,6 +188,19 @@
|
||||
|
||||
<OnEvents {onPersonThumbnailReady} />
|
||||
|
||||
<svelte:document
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Escape' },
|
||||
onShortcut: () => {
|
||||
if (showSelectedFaces) {
|
||||
showSelectedFaces = false;
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<section
|
||||
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
||||
class="absolute top-0 h-full w-90 overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
||||
@@ -104,7 +103,8 @@
|
||||
});
|
||||
} else {
|
||||
progressBarController = new Tween<number>(0, {
|
||||
duration: (from: number, to: number) => (to ? $preferences.memories.duration * 1000 * (to - from) : 0),
|
||||
duration: (from: number, to: number) =>
|
||||
to ? authManager.preferences.memories.duration * 1000 * (to - from) : 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -362,7 +362,7 @@
|
||||
unarchive={assetMultiSelectManager.isAllArchived}
|
||||
onArchive={handleDeleteOrArchiveAssets}
|
||||
/>
|
||||
{#if $preferences.tags.enabled && assetMultiSelectManager.isAllUserOwned}
|
||||
{#if authManager.preferences.tags.enabled && assetMultiSelectManager.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem onAssetDelete={handleDeleteOrArchiveAssets} />
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { OnboardingRole } from '$lib/types';
|
||||
import { Logo } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let userRole = $derived(
|
||||
$user.isAdmin && !serverConfigManager.value.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER,
|
||||
authManager.user.isAdmin && !serverConfigManager.value.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="gap-4">
|
||||
<Logo variant="icon" size="giant" class="mb-2" />
|
||||
<p class="font-medium mb-6 text-6xl text-primary">
|
||||
{$t('onboarding_welcome_user', { values: { user: $user.name } })}
|
||||
{$t('onboarding_welcome_user', { values: { user: authManager.user.name } })}
|
||||
</p>
|
||||
<p class="text-3xl pb-6 font-light">
|
||||
{userRole == OnboardingRole.SERVER
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import StorageTemplateSettings from '$lib/components/admin-settings/StorageTemplateSettings.svelte';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Link } from '@immich/ui';
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</FormatMessage>
|
||||
</p>
|
||||
|
||||
{#if $user}
|
||||
{#if authManager.authenticated}
|
||||
<StorageTemplateSettings minified duration={0} saveOnClose />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
|
||||
import { Theme } from '$lib/constants';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { Icon, themeManager, ThemePreference } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
@@ -13,7 +11,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="w-1/2 aspect-square bg-light dark:bg-dark rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-primary dark:border dark:border-transparent"
|
||||
onclick={() => themeManager.setTheme(Theme.LIGHT)}
|
||||
onclick={() => themeManager.setPreference(ThemePreference.Light)}
|
||||
>
|
||||
<div
|
||||
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary"
|
||||
@@ -25,7 +23,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="w-1/2 aspect-square bg-dark dark:bg-light rounded-3xl transition-all shadow-sm hover:shadow-xl dark:border-[3px] dark:border-immich-dark-primary border border-transparent"
|
||||
onclick={() => themeManager.setTheme(Theme.DARK)}
|
||||
onclick={() => themeManager.setPreference(ThemePreference.Dark)}
|
||||
>
|
||||
<div
|
||||
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary"
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
<script lang="ts">
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateMyPreferences } from '@immich/sdk';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let gCastEnabled = $state($preferences?.cast?.gCastEnabled ?? false);
|
||||
let gCastEnabled = $state(authManager.authenticated ? authManager.preferences.cast.gCastEnabled : false);
|
||||
|
||||
onDestroy(async () => {
|
||||
try {
|
||||
const data = await updateMyPreferences({
|
||||
userPreferencesUpdateDto: {
|
||||
cast: { gCastEnabled },
|
||||
},
|
||||
});
|
||||
|
||||
$preferences = { ...data };
|
||||
const response = await updateMyPreferences({ userPreferencesUpdateDto: { cast: { gCastEnabled } } });
|
||||
authManager.setPreferences(response);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_settings'));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { setSharedLink } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
let { sharedLink, passwordRequired, key, slug, meta } = $state(data);
|
||||
let { title, description } = $state(meta);
|
||||
let isOwned = $derived($user ? $user.id === sharedLink?.userId : false);
|
||||
let isOwned = $derived(authManager.authenticated && authManager.user.id === sharedLink?.userId);
|
||||
let password = $state('');
|
||||
|
||||
const handlePasswordSubmit = async () => {
|
||||
|
||||
@@ -2,22 +2,27 @@
|
||||
import { ByteUnit } from '$lib/utils/byte-units';
|
||||
import { Icon, Text } from '@immich/ui';
|
||||
|
||||
type ValueData = {
|
||||
value: number;
|
||||
unit?: ByteUnit | undefined;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
title: string;
|
||||
value: number;
|
||||
unit?: ByteUnit | undefined;
|
||||
valuePromise: Promise<ValueData>;
|
||||
}
|
||||
|
||||
let { icon, title, value, unit = undefined }: Props = $props();
|
||||
let { icon, title, valuePromise }: Props = $props();
|
||||
const zeros = (data?: ValueData) => {
|
||||
let length = 13;
|
||||
if (data) {
|
||||
const valueLength = data.value.toString().length;
|
||||
length = length - valueLength;
|
||||
}
|
||||
|
||||
const zeros = $derived(() => {
|
||||
const maxLength = 13;
|
||||
const valueLength = value.toString().length;
|
||||
const zeroLength = maxLength - valueLength;
|
||||
|
||||
return '0'.repeat(zeroLength);
|
||||
});
|
||||
return '0'.repeat(length);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex h-35 w-full flex-col justify-between rounded-3xl bg-subtle text-primary p-5">
|
||||
@@ -26,10 +31,37 @@
|
||||
<Text size="giant" fontWeight="medium">{title}</Text>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto font-mono text-2xl font-medium">
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span><span>{value}</span>
|
||||
{#if unit}
|
||||
<code class="font-mono text-base font-normal">{unit}</code>
|
||||
{/if}
|
||||
</div>
|
||||
{#await valuePromise}
|
||||
<div class="mx-auto font-mono text-2xl font-medium relative">
|
||||
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros()}</span>
|
||||
</div>
|
||||
{:then data}
|
||||
<div class="mx-auto font-mono text-2xl font-medium relative">
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros(data)}</span><span>{data.value}</span>
|
||||
{#if data.unit}
|
||||
<code class="font-mono text-base font-normal">{data.unit}</code>
|
||||
{/if}
|
||||
</div>
|
||||
{:catch _}
|
||||
<div class="mx-auto font-mono text-2xl font-medium relative">
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shimmer-text {
|
||||
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 1) 100%);
|
||||
mask-size: 200% 100%;
|
||||
animation: shimmer 2.25s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
mask-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
mask-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import StatsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import type { ServerStatsResponseDto } from '@immich/sdk';
|
||||
import type { ServerStatsResponseDto, UserAdminResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Code,
|
||||
FormatBytes,
|
||||
@@ -19,10 +19,28 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
stats: ServerStatsResponseDto;
|
||||
statsPromise: Promise<ServerStatsResponseDto>;
|
||||
users: UserAdminResponseDto[];
|
||||
};
|
||||
|
||||
const { stats }: Props = $props();
|
||||
const { statsPromise, users }: Props = $props();
|
||||
|
||||
const photosPromise = $derived.by(() => statsPromise.then((data) => ({ value: data.photos })));
|
||||
|
||||
const videosPromise = $derived.by(() => statsPromise.then((data) => ({ value: data.videos })));
|
||||
|
||||
const storagePromise = $derived.by(() =>
|
||||
statsPromise.then((data) => {
|
||||
const TiB = 1024 ** 4;
|
||||
const [value, unit] = getBytesWithUnit(data.usage, data.usage > TiB ? 2 : 0);
|
||||
return { value, unit };
|
||||
}),
|
||||
);
|
||||
|
||||
const getStorageUsageWithUnit = (usage: number) => {
|
||||
const TiB = 1024 ** 4;
|
||||
return getBytesWithUnit(usage, usage > TiB ? 2 : 0);
|
||||
};
|
||||
|
||||
const zeros = (value: number, maxLength = 13) => {
|
||||
const valueLength = value.toString().length;
|
||||
@@ -31,18 +49,26 @@
|
||||
return '0'.repeat(zeroLength);
|
||||
};
|
||||
|
||||
const TiB = 1024 ** 4;
|
||||
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
|
||||
const getUserStatsPromise = async (userId: string) => {
|
||||
const stats = await statsPromise;
|
||||
return stats.usageByUser.find((userStats) => userStats.userId === userId);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet placeholder()}
|
||||
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-16"></span></TableCell>
|
||||
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-16"></span></TableCell>
|
||||
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-24"></span></TableCell>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-5 my-4">
|
||||
<div>
|
||||
<Text class="mb-2" fontWeight="medium">{$t('total_usage')}</Text>
|
||||
|
||||
<div class="hidden justify-between lg:flex gap-4">
|
||||
<StatsCard icon={mdiCameraIris} title={$t('photos')} value={stats.photos} />
|
||||
<StatsCard icon={mdiPlayCircle} title={$t('videos')} value={stats.videos} />
|
||||
<StatsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
|
||||
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
|
||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
|
||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} valuePromise={storagePromise} />
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex lg:hidden">
|
||||
@@ -54,7 +80,13 @@
|
||||
</div>
|
||||
|
||||
<div class="relative text-center font-mono text-2xl font-medium">
|
||||
<span class="text-light-300">{zeros(stats.photos)}</span><span class="text-primary">{stats.photos}</span>
|
||||
{#await statsPromise}
|
||||
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros(0)}</span>
|
||||
{:then stats}
|
||||
<span class="text-light-300">{zeros(stats.photos)}</span><span class="text-primary">{stats.photos}</span>
|
||||
{:catch}
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros(0)}</span>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-12">
|
||||
@@ -64,7 +96,13 @@
|
||||
</div>
|
||||
|
||||
<div class="relative text-center font-mono text-2xl font-medium">
|
||||
<span class="text-light-300">{zeros(stats.videos)}</span><span class="text-primary">{stats.videos}</span>
|
||||
{#await statsPromise}
|
||||
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros(0)}</span>
|
||||
{:then stats}
|
||||
<span class="text-light-300">{zeros(stats.videos)}</span><span class="text-primary">{stats.videos}</span>
|
||||
{:catch}
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros(0)}</span>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-5">
|
||||
@@ -74,11 +112,20 @@
|
||||
</div>
|
||||
|
||||
<div class="relative flex text-center font-mono text-2xl font-medium">
|
||||
<span class="text-light-300">{zeros(statsUsage)}</span><span class="text-primary">{statsUsage}</span>
|
||||
{#await statsPromise}
|
||||
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros(0)}</span>
|
||||
{:then stats}
|
||||
{@const storageUsageWithUnit = getStorageUsageWithUnit(stats.usage)}
|
||||
<span class="text-light-300">{zeros(storageUsageWithUnit[0])}</span><span class="text-primary"
|
||||
>{storageUsageWithUnit[0]}</span
|
||||
>
|
||||
|
||||
<div class="absolute -end-1.5 -bottom-4">
|
||||
<Code color="muted" class="text-xs font-light font-mono">{statsUsageUnit}</Code>
|
||||
</div>
|
||||
<div class="absolute -end-1.5 -bottom-4">
|
||||
<Code color="muted" class="text-xs font-light font-mono">{storageUsageWithUnit[1]}</Code>
|
||||
</div>
|
||||
{:catch}
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros(0)}</span>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,34 +142,97 @@
|
||||
<TableHeading class="w-1/4">{$t('usage')}</TableHeading>
|
||||
</TableHeader>
|
||||
<TableBody class="block max-h-80 overflow-y-auto">
|
||||
{#each stats.usageByUser as user (user.userId)}
|
||||
{#each users as user (user.id)}
|
||||
<TableRow>
|
||||
<TableCell class="w-1/4">{user.userName}</TableCell>
|
||||
<TableCell class="w-1/4">
|
||||
{user.photos.toLocaleString($locale)} (<FormatBytes bytes={user.usagePhotos} />)</TableCell
|
||||
>
|
||||
<TableCell class="w-1/4">
|
||||
{user.videos.toLocaleString($locale)} (<FormatBytes bytes={user.usageVideos} precision={0} />)</TableCell
|
||||
>
|
||||
<TableCell class="w-1/4">
|
||||
<FormatBytes bytes={user.usage} precision={0} />
|
||||
{#if user.quotaSizeInBytes !== null}
|
||||
/ <FormatBytes bytes={user.quotaSizeInBytes} precision={0} />
|
||||
<TableCell class="w-1/4">{user.name}</TableCell>
|
||||
{#await getUserStatsPromise(user.id)}
|
||||
{@render placeholder()}
|
||||
{:then userStats}
|
||||
{#if userStats}
|
||||
<TableCell class="w-1/4">
|
||||
{userStats.photos.toLocaleString($locale)} (<FormatBytes bytes={userStats.usagePhotos} />)</TableCell
|
||||
>
|
||||
<TableCell class="w-1/4">
|
||||
{userStats.videos.toLocaleString($locale)} (<FormatBytes
|
||||
bytes={userStats.usageVideos}
|
||||
precision={0}
|
||||
/>)</TableCell
|
||||
>
|
||||
<TableCell class="w-1/4">
|
||||
<FormatBytes bytes={userStats.usage} precision={0} />
|
||||
{#if userStats.quotaSizeInBytes !== null}
|
||||
/ <FormatBytes bytes={userStats.quotaSizeInBytes} precision={0} />
|
||||
{/if}
|
||||
<span class="text-primary">
|
||||
{#if userStats.quotaSizeInBytes !== null && userStats.quotaSizeInBytes >= 0}
|
||||
({(userStats.quotaSizeInBytes === 0
|
||||
? 1
|
||||
: userStats.usage / userStats.quotaSizeInBytes
|
||||
).toLocaleString($locale, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 0,
|
||||
})})
|
||||
{:else}
|
||||
({$t('unlimited')})
|
||||
{/if}
|
||||
</span>
|
||||
</TableCell>
|
||||
{:else}
|
||||
{@render placeholder()}
|
||||
{/if}
|
||||
<span class="text-primary">
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
({(user.quotaSizeInBytes === 0 ? 1 : user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 0,
|
||||
})})
|
||||
{:else}
|
||||
({$t('unlimited')})
|
||||
{/if}
|
||||
</span>
|
||||
</TableCell>
|
||||
{/await}
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.skeleton-loader {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: rgba(156, 163, 175, 0.35);
|
||||
}
|
||||
|
||||
.skeleton-loader::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(255, 255, 255, 0.8) 50%,
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-position: 200% 0;
|
||||
animation: skeleton-animation 2000ms infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-animation {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer-text {
|
||||
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 1) 100%);
|
||||
mask-size: 200% 100%;
|
||||
animation: shimmer 2.25s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
mask-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
mask-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
import { mapSettings } from '$lib/stores/preferences.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import {
|
||||
updateStackedAssetInTimeline,
|
||||
updateUnstackedAssetInTimeline,
|
||||
@@ -159,7 +159,7 @@
|
||||
unarchive={assetMultiSelectManager.isAllArchived}
|
||||
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
|
||||
/>
|
||||
{#if $preferences.tags.enabled}
|
||||
{#if authManager.preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets
|
||||
|
||||
@@ -11,14 +11,12 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { Theme } from '$lib/constants';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
|
||||
import { mapSettings } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
|
||||
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import { Icon, modalManager } from '@immich/ui';
|
||||
import { Icon, modalManager, Theme, themeManager } from '@immich/ui';
|
||||
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
|
||||
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
|
||||
import { isEqual, omit } from 'lodash-es';
|
||||
@@ -106,9 +104,9 @@
|
||||
let marker: Marker | null = null;
|
||||
let abortController: AbortController;
|
||||
|
||||
const theme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.LIGHT);
|
||||
const mapTheme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.Light);
|
||||
const styleUrl = $derived(
|
||||
theme === Theme.DARK ? serverConfigManager.value.mapDarkStyleUrl : serverConfigManager.value.mapLightStyleUrl,
|
||||
mapTheme === Theme.Dark ? serverConfigManager.value.mapDarkStyleUrl : serverConfigManager.value.mapLightStyleUrl,
|
||||
);
|
||||
|
||||
export function addClipMapMarker(lng: number, lat: number) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import AvatarEditModal from '$lib/modals/AvatarEditModal.svelte';
|
||||
import HelpAndFeedbackModal from '$lib/modals/HelpAndFeedbackModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
|
||||
import { Button, Icon, IconButton, modalManager } from '@immich/ui';
|
||||
@@ -14,12 +14,11 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
import UserAvatar from '../user-avatar.svelte';
|
||||
|
||||
interface Props {
|
||||
onLogout: () => void;
|
||||
type Props = {
|
||||
onClose?: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { onLogout, onClose = () => {} }: Props = $props();
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let info: ServerAboutResponseDto | undefined = $state();
|
||||
|
||||
@@ -39,7 +38,7 @@
|
||||
class="mx-4 mt-4 flex flex-col items-center justify-center gap-4 rounded-t-3xl bg-white p-4 dark:bg-immich-dark-primary/10"
|
||||
>
|
||||
<div class="relative">
|
||||
<UserAvatar user={$user} size="xl" />
|
||||
<UserAvatar user={authManager.user} size="xl" />
|
||||
<div class="absolute bottom-0 end-0 rounded-full w-6 h-6">
|
||||
<IconButton
|
||||
color="primary"
|
||||
@@ -48,7 +47,7 @@
|
||||
size="tiny"
|
||||
shape="round"
|
||||
onclick={async () => {
|
||||
onClose();
|
||||
onClose?.();
|
||||
await modalManager.show(AvatarEditModal);
|
||||
}}
|
||||
/>
|
||||
@@ -56,9 +55,9 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-center text-lg font-medium text-primary">
|
||||
{$user.name}
|
||||
{authManager.user.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-immich-dark-fg">{$user.email}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-immich-dark-fg">{authManager.user.email}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -76,7 +75,7 @@
|
||||
{$t('account_settings')}
|
||||
</div>
|
||||
</Button>
|
||||
{#if $user.isAdmin}
|
||||
{#if authManager.user.isAdmin}
|
||||
<Button
|
||||
href={Route.systemSettings()}
|
||||
onclick={onClose}
|
||||
@@ -99,7 +98,7 @@
|
||||
<div class="mb-4 flex flex-col">
|
||||
<Button
|
||||
class="m-1 mx-4 rounded-none rounded-b-3xl bg-white p-3 dark:bg-immich-dark-primary/10"
|
||||
onclick={onLogout}
|
||||
href={Route.logout()}
|
||||
leadingIcon={mdiLogout}
|
||||
variant="ghost"
|
||||
color="secondary">{$t('sign_out')}</Button
|
||||
@@ -109,7 +108,7 @@
|
||||
type="button"
|
||||
class="text-center mt-4 underline text-xs text-primary"
|
||||
onclick={async () => {
|
||||
onClose();
|
||||
onClose?.();
|
||||
if (info) {
|
||||
await modalManager.show(HelpAndFeedbackModal, { info });
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { notificationManager } from '$lib/stores/notification-manager.svelte';
|
||||
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { ActionButton, Button, IconButton, Logo } from '@immich/ui';
|
||||
import { mdiBellBadge, mdiBellOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -171,18 +170,15 @@
|
||||
type="button"
|
||||
class="flex ps-2"
|
||||
onclick={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)}
|
||||
title={`${$user.name} (${$user.email})`}
|
||||
title="{authManager.user.name} ({authManager.user.email})"
|
||||
>
|
||||
{#key $user}
|
||||
<UserAvatar user={$user} size="md" noTitle interactive />
|
||||
{#key authManager.user}
|
||||
<UserAvatar user={authManager.user} size="md" noTitle interactive />
|
||||
{/key}
|
||||
</button>
|
||||
|
||||
{#if shouldShowAccountInfoPanel}
|
||||
<AccountInfoPanel
|
||||
onLogout={() => authManager.logout()}
|
||||
onClose={() => (shouldShowAccountInfoPanel = false)}
|
||||
/>
|
||||
<AccountInfoPanel onClose={() => (shouldShowAccountInfoPanel = false)} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
|
||||
import { Button, Icon } from '@immich/ui';
|
||||
import { mdiPartyPopper } from '@mdi/js';
|
||||
@@ -22,7 +22,7 @@
|
||||
<SettingSwitch
|
||||
title={$t('show_supporter_badge')}
|
||||
subtitle={$t('show_supporter_badge_description')}
|
||||
bind:checked={$preferences.purchase.showSupportBadge}
|
||||
bind:checked={authManager.preferences.purchase.showSupportBadge}
|
||||
onToggle={setSupportBadgeVisibility}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { getAllTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { Checkbox, Label, Text } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -40,7 +40,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if $preferences?.tags?.enabled}
|
||||
{#if authManager.authenticated && authManager.preferences.tags.enabled}
|
||||
<div id="location-selection">
|
||||
<form autocomplete="off" id="create-tag-form">
|
||||
<div class="mb-4 flex flex-col">
|
||||
|
||||
+3
-10
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import Combobox from '$lib/components/shared-components/combobox.svelte';
|
||||
import { defaultLang, langs } from '$lib/constants';
|
||||
import { defaultLang } from '$lib/constants';
|
||||
import { lang } from '$lib/stores/preferences.store';
|
||||
import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
|
||||
import { getClosestAvailableLocale, langCodes, langs } from '$lib/utils/i18n';
|
||||
import { Label, Text } from '@immich/ui';
|
||||
import { locale as i18nLocale, t } from 'svelte-i18n';
|
||||
|
||||
@@ -13,14 +13,7 @@
|
||||
|
||||
let { showSettingDescription = false }: Props = $props();
|
||||
|
||||
const langOptions = langs
|
||||
.map((lang) => ({ label: lang.name, value: lang.code }))
|
||||
.sort((a, b) => {
|
||||
if (b.label.startsWith('Development')) {
|
||||
return -1;
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
const langOptions = langs.map((lang) => ({ label: lang.name, value: lang.code }));
|
||||
|
||||
const defaultLangOption = { label: defaultLang.name, value: defaultLang.code };
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import PurchaseModal from '$lib/modals/PurchaseModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { getAccountAge } from '$lib/utils/auth';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getButtonVisibility } from '$lib/utils/purchase-utils';
|
||||
@@ -50,7 +49,8 @@
|
||||
},
|
||||
});
|
||||
|
||||
preferences.set(response);
|
||||
authManager.setPreferences(response);
|
||||
|
||||
showBuyButton = getButtonVisibility();
|
||||
showMessage = false;
|
||||
} catch (error) {
|
||||
@@ -70,7 +70,7 @@
|
||||
</script>
|
||||
|
||||
<div class="license-status ps-4 text-sm">
|
||||
{#if authManager.isPurchased && $preferences.purchase.showSupportBadge}
|
||||
{#if authManager.isPurchased && authManager.preferences.purchase.showSupportBadge}
|
||||
<button
|
||||
onclick={() => goto(Route.userSettings({ isOpen: OpenQueryParam.PURCHASE_SETTINGS }))}
|
||||
class="w-full mt-2"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { releaseManager } from '$lib/managers/release-manager.svelte';
|
||||
import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { websocketStore } from '$lib/stores/websocket';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
@@ -40,7 +40,7 @@
|
||||
);
|
||||
|
||||
const getReleaseInfo = (release?: ReleaseEvent) => {
|
||||
if (!release || !release?.isAvailable || !$user.isAdmin) {
|
||||
if (!release || !release?.isAvailable || !authManager.user.isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
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';
|
||||
@@ -8,9 +8,17 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let hasQuota = $derived($user?.quotaSizeInBytes !== null);
|
||||
let availableBytes = $derived((hasQuota ? $user?.quotaSizeInBytes : userInteraction.serverInfo?.diskSizeRaw) || 0);
|
||||
let usedBytes = $derived((hasQuota ? $user?.quotaUsageInBytes : userInteraction.serverInfo?.diskUseRaw) || 0);
|
||||
let hasQuota = $derived(authManager.user.quotaSizeInBytes !== null);
|
||||
let availableBytes = $derived(
|
||||
(hasQuota && authManager.authenticated
|
||||
? authManager.user.quotaSizeInBytes
|
||||
: userInteraction.serverInfo?.diskSizeRaw) || 0,
|
||||
);
|
||||
let usedBytes = $derived(
|
||||
(hasQuota && authManager.authenticated
|
||||
? authManager.user.quotaUsageInBytes
|
||||
: userInteraction.serverInfo?.diskUseRaw) || 0,
|
||||
);
|
||||
|
||||
const thresholds = [
|
||||
{ from: 0.8, className: 'bg-warning' },
|
||||
@@ -18,7 +26,7 @@
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
if (userInteraction.serverInfo && $user) {
|
||||
if (userInteraction.serverInfo && authManager.authenticated) {
|
||||
return;
|
||||
}
|
||||
await requestServerInfo();
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
||||
import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte';
|
||||
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { recentAlbumsDropdown } from '$lib/stores/preferences.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { NavbarGroup, NavbarItem } from '@immich/ui';
|
||||
import {
|
||||
mdiAccount,
|
||||
@@ -47,11 +47,11 @@
|
||||
<NavbarItem title={$t('map')} href={Route.map()} icon={mdiMapOutline} activeIcon={mdiMap} />
|
||||
{/if}
|
||||
|
||||
{#if $preferences.people.enabled && $preferences.people.sidebarWeb}
|
||||
{#if authManager.preferences.people.enabled && authManager.preferences.people.sidebarWeb}
|
||||
<NavbarItem title={$t('people')} href={Route.people()} icon={mdiAccountOutline} activeIcon={mdiAccount} />
|
||||
{/if}
|
||||
|
||||
{#if $preferences.sharedLinks.enabled && $preferences.sharedLinks.sidebarWeb}
|
||||
{#if authManager.preferences.sharedLinks.enabled && authManager.preferences.sharedLinks.sidebarWeb}
|
||||
<NavbarItem title={$t('shared_links')} href={Route.sharedLinks()} icon={mdiLink} />
|
||||
{/if}
|
||||
|
||||
@@ -79,11 +79,11 @@
|
||||
{/snippet}
|
||||
</NavbarItem>
|
||||
|
||||
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
|
||||
{#if authManager.preferences.tags.enabled && authManager.preferences.tags.sidebarWeb}
|
||||
<NavbarItem title={$t('tags')} href={Route.tags()} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
|
||||
{/if}
|
||||
|
||||
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
|
||||
{#if authManager.preferences.folders.enabled && authManager.preferences.folders.sidebarWeb}
|
||||
<NavbarItem title={$t('folders')} href={Route.folders()} icon={{ icon: mdiFolderOutline, flipped: true }} />
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { Theme } from '$lib/constants';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import { ThemeSwitcher } from '@immich/ui';
|
||||
|
||||
const handleToggleTheme = () => {
|
||||
if (themeManager.theme.system) {
|
||||
return;
|
||||
}
|
||||
|
||||
themeManager.toggleTheme();
|
||||
};
|
||||
import { themeManager, ThemePreference, ThemeSwitcher } from '@immich/ui';
|
||||
</script>
|
||||
|
||||
<svelte:window use:shortcut={{ shortcut: { key: 't', alt: true }, onShortcut: () => handleToggleTheme() }} />
|
||||
|
||||
{#if !themeManager.theme.system}
|
||||
<ThemeSwitcher
|
||||
size="medium"
|
||||
color="secondary"
|
||||
onChange={(theme) => themeManager.setTheme(theme == 'dark' ? Theme.DARK : Theme.LIGHT)}
|
||||
/>
|
||||
{#if themeManager.preference !== ThemePreference.System}
|
||||
<ThemeSwitcher size="medium" color="secondary" />
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import AssetUpdateDescriptionConfirmModal from '$lib/modals/AssetUpdateDescriptionConfirmModal.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAssets } from '@immich/sdk';
|
||||
@@ -19,7 +19,7 @@
|
||||
const handleUpdateDescription = async () => {
|
||||
const description = await modalManager.show(AssetUpdateDescriptionConfirmModal);
|
||||
if (description) {
|
||||
const ids = getOwnedAssetsWithWarning(assetMultiSelectManager.assets, $user);
|
||||
const ids = getOwnedAssetsWithWarning(assetMultiSelectManager.assets, authManager.user);
|
||||
|
||||
try {
|
||||
await updateAssets({ assetBulkUpdateDto: { ids, description } });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAssets } from '@immich/sdk';
|
||||
@@ -22,7 +22,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = getOwnedAssetsWithWarning(assetMultiSelectManager.assets, $user);
|
||||
const ids = getOwnedAssetsWithWarning(assetMultiSelectManager.assets, authManager.user);
|
||||
|
||||
try {
|
||||
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
|
||||
|
||||
@@ -147,7 +147,6 @@
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
|
||||
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
|
||||
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
|
||||
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
|
||||
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
|
||||
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
|
||||
@@ -166,6 +165,9 @@
|
||||
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
|
||||
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
|
||||
);
|
||||
} else {
|
||||
// conflicting shortcuts
|
||||
shortcuts.push({ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') });
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
|
||||
import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
|
||||
import { fallbackLocale, locales } from '$lib/constants';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import {
|
||||
alwaysLoadOriginalFile,
|
||||
alwaysLoadOriginalVideo,
|
||||
@@ -14,7 +13,7 @@
|
||||
showDeleteModal,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||
import { Field, Switch, Text } from '@immich/ui';
|
||||
import { Field, Switch, Text, Theme, themeManager, ThemePreference } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -55,13 +54,21 @@
|
||||
value: findLocale(editedLocale).code || fallbackLocale.code,
|
||||
label: findLocale(editedLocale).name || fallbackLocale.name,
|
||||
});
|
||||
|
||||
const handleToggleSystemTheme = (checked: boolean) => {
|
||||
const current = themeManager.value === Theme.Dark ? ThemePreference.Dark : ThemePreference.Light;
|
||||
themeManager.setPreference(checked ? ThemePreference.System : current);
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8 flex flex-col gap-6">
|
||||
<Field label={$t('theme_selection')} description={$t('theme_selection_description')}>
|
||||
<Switch checked={themeManager.theme.system} onCheckedChange={(checked) => themeManager.setSystem(checked)} />
|
||||
<Switch
|
||||
checked={themeManager.preference === ThemePreference.System}
|
||||
onCheckedChange={handleToggleSystemTheme}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SettingsLanguageSelector showSettingDescription />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateMyPreferences } from '@immich/sdk';
|
||||
@@ -10,12 +10,12 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let archiveSize = $state(convertFromBytes($preferences?.download?.archiveSize || 4, ByteUnit.GiB));
|
||||
let includeEmbeddedVideos = $state($preferences?.download?.includeEmbeddedVideos || false);
|
||||
let archiveSize = $state(convertFromBytes(authManager.preferences.download.archiveSize || 4, ByteUnit.GiB));
|
||||
let includeEmbeddedVideos = $state(authManager.preferences.download.includeEmbeddedVideos || false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const newPreferences = await updateMyPreferences({
|
||||
const response = await updateMyPreferences({
|
||||
userPreferencesUpdateDto: {
|
||||
download: {
|
||||
archiveSize: Math.floor(convertToBytes(archiveSize, ByteUnit.GiB)),
|
||||
@@ -23,7 +23,8 @@
|
||||
},
|
||||
},
|
||||
});
|
||||
$preferences = newPreferences;
|
||||
|
||||
authManager.setPreferences(response);
|
||||
|
||||
toastManager.primary($t('saved_settings'));
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetOrder, updateMyPreferences } from '@immich/sdk';
|
||||
import { Button, Field, NumberInput, Select, Switch, toastManager } from '@immich/ui';
|
||||
@@ -8,37 +8,37 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
// Albums
|
||||
let defaultAssetOrder = $state($preferences?.albums?.defaultAssetOrder ?? AssetOrder.Desc);
|
||||
let defaultAssetOrder = $state(authManager.preferences.albums?.defaultAssetOrder ?? AssetOrder.Desc);
|
||||
|
||||
// Folders
|
||||
let foldersEnabled = $state($preferences?.folders?.enabled ?? false);
|
||||
let foldersSidebar = $state($preferences?.folders?.sidebarWeb ?? false);
|
||||
let foldersEnabled = $state(authManager.preferences.folders?.enabled ?? false);
|
||||
let foldersSidebar = $state(authManager.preferences.folders?.sidebarWeb ?? false);
|
||||
|
||||
// Memories
|
||||
let memoriesEnabled = $state($preferences?.memories?.enabled ?? true);
|
||||
let memoriesDuration = $state($preferences?.memories?.duration ?? 5);
|
||||
let memoriesEnabled = $state(authManager.preferences.memories?.enabled ?? true);
|
||||
let memoriesDuration = $state(authManager.preferences.memories?.duration ?? 5);
|
||||
|
||||
// People
|
||||
let peopleEnabled = $state($preferences?.people?.enabled ?? false);
|
||||
let peopleSidebar = $state($preferences?.people?.sidebarWeb ?? false);
|
||||
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
|
||||
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
|
||||
|
||||
// Ratings
|
||||
let ratingsEnabled = $state($preferences?.ratings?.enabled ?? false);
|
||||
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
|
||||
|
||||
// Shared links
|
||||
let sharedLinksEnabled = $state($preferences?.sharedLinks?.enabled ?? true);
|
||||
let sharedLinkSidebar = $state($preferences?.sharedLinks?.sidebarWeb ?? false);
|
||||
let sharedLinksEnabled = $state(authManager.preferences.sharedLinks?.enabled ?? true);
|
||||
let sharedLinkSidebar = $state(authManager.preferences.sharedLinks?.sidebarWeb ?? false);
|
||||
|
||||
// Tags
|
||||
let tagsEnabled = $state($preferences?.tags?.enabled ?? false);
|
||||
let tagsSidebar = $state($preferences?.tags?.sidebarWeb ?? false);
|
||||
let tagsEnabled = $state(authManager.preferences.tags?.enabled ?? false);
|
||||
let tagsSidebar = $state(authManager.preferences.tags?.sidebarWeb ?? false);
|
||||
|
||||
// Cast
|
||||
let gCastEnabled = $state($preferences?.cast?.gCastEnabled ?? false);
|
||||
let gCastEnabled = $state(authManager.preferences.cast?.gCastEnabled ?? false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const data = await updateMyPreferences({
|
||||
const response = await updateMyPreferences({
|
||||
userPreferencesUpdateDto: {
|
||||
albums: { defaultAssetOrder },
|
||||
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
||||
@@ -51,8 +51,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
$preferences = { ...data };
|
||||
|
||||
authManager.setPreferences(response);
|
||||
toastManager.primary($t('saved_settings'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_settings'));
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateMyPreferences } from '@immich/sdk';
|
||||
import { Button, Field, Switch, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let emailNotificationsEnabled = $state($preferences?.emailNotifications?.enabled ?? true);
|
||||
let albumInviteNotificationEnabled = $state($preferences?.emailNotifications?.albumInvite ?? true);
|
||||
let albumUpdateNotificationEnabled = $state($preferences?.emailNotifications?.albumUpdate ?? true);
|
||||
let emailNotificationsEnabled = $state(authManager.preferences.emailNotifications?.enabled ?? true);
|
||||
let albumInviteNotificationEnabled = $state(authManager.preferences.emailNotifications?.albumInvite ?? true);
|
||||
let albumUpdateNotificationEnabled = $state(authManager.preferences.emailNotifications?.albumUpdate ?? true);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const data = await updateMyPreferences({
|
||||
const response = await updateMyPreferences({
|
||||
userPreferencesUpdateDto: {
|
||||
emailNotifications: {
|
||||
enabled: emailNotificationsEnabled,
|
||||
@@ -22,10 +22,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
$preferences.emailNotifications.enabled = data.emailNotifications.enabled;
|
||||
$preferences.emailNotifications.albumInvite = data.emailNotifications.albumInvite;
|
||||
$preferences.emailNotifications.albumUpdate = data.emailNotifications.albumUpdate;
|
||||
|
||||
authManager.setPreferences(response);
|
||||
toastManager.primary($t('saved_settings'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_settings'));
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
user: UserAdminResponseDto;
|
||||
}
|
||||
|
||||
let { user = $bindable() }: Props = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (oauth.isCallback(globalThis.location)) {
|
||||
try {
|
||||
loading = true;
|
||||
user = await oauth.link(globalThis.location);
|
||||
const response = await oauth.link(globalThis.location);
|
||||
authManager.setUser(response);
|
||||
toastManager.primary($t('linked_oauth_account'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_link_oauth_account'));
|
||||
@@ -35,7 +30,8 @@
|
||||
|
||||
const handleUnlink = async () => {
|
||||
try {
|
||||
user = await oauth.unlink();
|
||||
const response = await oauth.unlink();
|
||||
authManager.setUser(response);
|
||||
toastManager.primary($t('unlinked_oauth_account'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_unlink_account'));
|
||||
@@ -51,7 +47,7 @@
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if featureFlagsManager.value.oauth}
|
||||
{#if user.oauthId}
|
||||
{#if authManager.user.oauthId}
|
||||
<Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
|
||||
{:else}
|
||||
<Button shape="round" size="small" onclick={() => oauth.authorize(globalThis.location)}
|
||||
|
||||
@@ -24,12 +24,6 @@
|
||||
inTimeline: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: UserResponseDto;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
let partners: Array<PartnerSharing> = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
@@ -95,7 +89,7 @@
|
||||
};
|
||||
|
||||
const handleCreatePartners = async () => {
|
||||
const users = await modalManager.show(PartnerSelectionModal, { user });
|
||||
const users = await modalManager.show(PartnerSelectionModal, {});
|
||||
|
||||
if (!users) {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateMyUser } from '@immich/sdk';
|
||||
import { Button, Field, Input, toastManager } from '@immich/ui';
|
||||
@@ -8,7 +8,7 @@
|
||||
import { createBubbler, preventDefault } from 'svelte/legacy';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let editedUser = $state(cloneDeep($user));
|
||||
let editedUser = $state(cloneDeep(authManager.user));
|
||||
const bubble = createBubbler();
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
@@ -21,7 +21,7 @@
|
||||
});
|
||||
|
||||
Object.assign(editedUser, data);
|
||||
$user = data;
|
||||
authManager.setUser(data);
|
||||
|
||||
toastManager.primary($t('saved_profile'));
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { dateFormats } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
|
||||
import {
|
||||
@@ -30,12 +29,12 @@
|
||||
const serverInfo = await getAboutInfo();
|
||||
isServerProduct = serverInfo.licensed;
|
||||
|
||||
const userInfo = await getMyUser();
|
||||
if (userInfo.license) {
|
||||
$user = { ...$user, license: userInfo.license };
|
||||
const response = await getMyUser();
|
||||
if (response.license) {
|
||||
authManager.setUser(response);
|
||||
}
|
||||
|
||||
if (isServerProduct && $user.isAdmin) {
|
||||
if (isServerProduct && authManager.user.isAdmin) {
|
||||
serverPurchaseInfo = await getServerPurchaseInfo();
|
||||
}
|
||||
};
|
||||
@@ -111,7 +110,7 @@
|
||||
<SettingSwitch
|
||||
title={$t('show_supporter_badge')}
|
||||
subtitle={$t('show_supporter_badge_description')}
|
||||
bind:checked={$preferences.purchase.showSupportBadge}
|
||||
bind:checked={authManager.preferences.purchase.showSupportBadge}
|
||||
onToggle={setSupportBadgeVisibility}
|
||||
/>
|
||||
</div>
|
||||
@@ -128,7 +127,7 @@
|
||||
{$t('purchase_server_title')}
|
||||
</p>
|
||||
|
||||
{#if $user.isAdmin && serverPurchaseInfo?.activatedAt}
|
||||
{#if authManager.user.isAdmin && serverPurchaseInfo?.activatedAt}
|
||||
<p class="dark:text-white text-sm mt-1 col-start-2">
|
||||
{$t('purchase_activated_time', {
|
||||
values: {
|
||||
@@ -142,7 +141,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $user.isAdmin}
|
||||
{#if authManager.user.isAdmin}
|
||||
<div class="text-right mt-4">
|
||||
<Button shape="round" size="small" color="danger" onclick={removeServerProductKey}
|
||||
>{$t('purchase_button_remove_key')}</Button
|
||||
@@ -159,11 +158,11 @@
|
||||
<p class="text-primary font-semibold text-lg">
|
||||
{$t('purchase_individual_title')}
|
||||
</p>
|
||||
{#if $user.license?.activatedAt}
|
||||
{#if authManager.user.license?.activatedAt}
|
||||
<p class="dark:text-white text-sm mt-1 col-start-2">
|
||||
{$t('purchase_activated_time', {
|
||||
values: {
|
||||
date: new Date($user.license?.activatedAt).toLocaleString($locale, dateFormats.settings),
|
||||
date: new Date(authManager.user.license?.activatedAt).toLocaleString($locale, dateFormats.settings),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
|
||||
import { OpenQueryParam, QueryParameter } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
@@ -34,7 +33,7 @@
|
||||
import DeviceList from './device-list.svelte';
|
||||
import OAuthSettings from './oauth-settings.svelte';
|
||||
import PartnerSettings from './partner-settings.svelte';
|
||||
import UserAPIKeyList from './user-api-key-list.svelte';
|
||||
import UserApiKeyList from './user-api-key-list.svelte';
|
||||
import UserProfileSettings from './user-profile-settings.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -73,7 +72,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion icon={mdiApi} key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}>
|
||||
<UserAPIKeyList bind:keys />
|
||||
<UserApiKeyList bind:keys />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
@@ -120,7 +119,7 @@
|
||||
subtitle={$t('manage_your_oauth_connection')}
|
||||
isOpen={oauthOpen || undefined}
|
||||
>
|
||||
<OAuthSettings user={$user} />
|
||||
<OAuthSettings />
|
||||
</SettingAccordion>
|
||||
{/if}
|
||||
|
||||
@@ -139,7 +138,7 @@
|
||||
title={$t('partner_sharing')}
|
||||
subtitle={$t('manage_sharing_with_partners')}
|
||||
>
|
||||
<PartnerSettings user={$user} />
|
||||
<PartnerSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
let { assets, asset, isSelected, onSelectAsset, onViewAsset }: Props = $props();
|
||||
|
||||
let isFromExternalLibrary = $derived(!!asset.libraryId);
|
||||
let assetData = $derived(JSON.stringify(asset, null, 2));
|
||||
|
||||
let locationParts = $derived([asset.exifInfo?.city, asset.exifInfo?.state, asset.exifInfo?.country].filter(Boolean));
|
||||
|
||||
@@ -114,7 +113,6 @@
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: asset.id })}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
title={assetData}
|
||||
class="h-60 object-cover w-full rounded-t-md"
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import type { WorkflowPayload } from '$lib/services/workflow.service';
|
||||
import { Button, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, VStack } from '@immich/ui';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Icon,
|
||||
Theme,
|
||||
themeManager,
|
||||
VStack,
|
||||
} from '@immich/ui';
|
||||
import { mdiCodeJson } from '@mdi/js';
|
||||
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
|
||||
|
||||
@@ -15,7 +25,7 @@
|
||||
|
||||
let content: Content = $derived({ json: jsonContent });
|
||||
let canApply = $state(false);
|
||||
let editorClass = $derived(themeManager.isDark ? 'jse-theme-dark' : '');
|
||||
let editorClass = $derived(themeManager.value === Theme.Dark ? 'jse-theme-dark' : '');
|
||||
|
||||
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
|
||||
if (status.contentErrors) {
|
||||
|
||||
+1
-106
@@ -78,12 +78,6 @@ export const timeBeforeShowLoadingSpinner: number = 100;
|
||||
|
||||
export const timeDebounceOnSearch: number = 300;
|
||||
|
||||
// should be the same values as the ones in the app.html
|
||||
export enum Theme {
|
||||
LIGHT = 'light',
|
||||
DARK = 'dark',
|
||||
}
|
||||
|
||||
export const fallbackLocale = {
|
||||
code: 'en-US',
|
||||
name: 'English (US)',
|
||||
@@ -235,114 +229,15 @@ export const locales = [
|
||||
{ code: 'zu-ZA', name: 'Zulu (South Africa)' },
|
||||
];
|
||||
|
||||
interface Lang {
|
||||
export interface Lang {
|
||||
name: string;
|
||||
code: string;
|
||||
loader: () => Promise<{ default: object }>;
|
||||
rtl?: boolean;
|
||||
weblateCode?: string;
|
||||
}
|
||||
|
||||
export const defaultLang: Lang = { name: 'English', code: 'en', loader: () => import('$i18n/en.json') };
|
||||
|
||||
export const langs: Lang[] = [
|
||||
{ name: 'Afrikaans', code: 'af', loader: () => import('$i18n/af.json') },
|
||||
{ name: 'Arabic', code: 'ar', loader: () => import('$i18n/ar.json'), rtl: true },
|
||||
{ name: 'Azerbaijani', code: 'az', loader: () => import('$i18n/az.json'), rtl: true },
|
||||
{ name: 'Belarusian', code: 'be', loader: () => import('$i18n/be.json') },
|
||||
{ name: 'Bulgarian', code: 'bg', loader: () => import('$i18n/bg.json') },
|
||||
{ name: 'Bislama', code: 'bi', loader: () => import('$i18n/bi.json') },
|
||||
{ name: 'Bengali', code: 'bn', loader: () => import('$i18n/bn.json') },
|
||||
{ name: 'Breton', code: 'br', loader: () => import('$i18n/br.json') },
|
||||
{ name: 'Catalan', code: 'ca', loader: () => import('$i18n/ca.json') },
|
||||
{ name: 'Czech', code: 'cs', loader: () => import('$i18n/cs.json') },
|
||||
{ name: 'Chuvash', code: 'cv', loader: () => import('$i18n/cv.json') },
|
||||
{ name: 'Danish', code: 'da', loader: () => import('$i18n/da.json') },
|
||||
{ name: 'German', code: 'de', loader: () => import('$i18n/de.json') },
|
||||
{ name: 'German (Switzerland)', code: 'de-CH', weblateCode: 'de_CH', loader: () => import('$i18n/de_CH.json') },
|
||||
defaultLang,
|
||||
{ name: 'Greek', code: 'el', loader: () => import('$i18n/el.json') },
|
||||
{ name: 'Esperanto', code: 'eo', loader: () => import('$i18n/eo.json') },
|
||||
{ name: 'Spanish', code: 'es', loader: () => import('$i18n/es.json') },
|
||||
{ name: 'Estonian', code: 'et', loader: () => import('$i18n/et.json') },
|
||||
{ name: 'Basque', code: 'eu', loader: () => import('$i18n/eu.json') },
|
||||
{ name: 'Persian', code: 'fa', loader: () => import('$i18n/fa.json'), rtl: true },
|
||||
{ name: 'Finnish', code: 'fi', loader: () => import('$i18n/fi.json') },
|
||||
{ name: 'Filipino', code: 'fil', loader: () => import('$i18n/fil.json') },
|
||||
{ name: 'French', code: 'fr', loader: () => import('$i18n/fr.json') },
|
||||
{ name: 'Irish', code: 'ga', loader: () => import('$i18n/ga.json') },
|
||||
{ name: 'Galician', code: 'gl', loader: () => import('$i18n/gl.json') },
|
||||
{ name: 'Alemannic', code: 'gsw', loader: () => import('$i18n/gsw.json') },
|
||||
{ name: 'Gujarati', code: 'gu', loader: () => import('$i18n/gu.json') },
|
||||
{ name: 'Hebrew', code: 'he', loader: () => import('$i18n/he.json'), rtl: true },
|
||||
{ name: 'Hindi', code: 'hi', loader: () => import('$i18n/hi.json') },
|
||||
{ name: 'Croatian', code: 'hr', loader: () => import('$i18n/hr.json') },
|
||||
{ name: 'Hungarian', code: 'hu', loader: () => import('$i18n/hu.json') },
|
||||
{ name: 'Armenian', code: 'hy', loader: () => import('$i18n/hy.json') },
|
||||
{ name: 'Indonesian', code: 'id', loader: () => import('$i18n/id.json') },
|
||||
{ name: 'Icelandic', code: 'is', loader: () => import('$i18n/is.json') },
|
||||
{ name: 'Italian', code: 'it', loader: () => import('$i18n/it.json') },
|
||||
{ name: 'Japanese', code: 'ja', loader: () => import('$i18n/ja.json') },
|
||||
{ name: 'Georgian', code: 'ka', loader: () => import('$i18n/ka.json') },
|
||||
{ name: 'Kazakh', code: 'kk', loader: () => import('$i18n/kk.json') },
|
||||
{ name: 'Khmer (Central)', code: 'km', loader: () => import('$i18n/km.json') },
|
||||
{ name: 'Kurdish (Northern)', code: 'kmr', loader: () => import('$i18n/kmr.json'), rtl: true },
|
||||
{ name: 'Kannada', code: 'kn', loader: () => import('$i18n/kn.json') },
|
||||
{ name: 'Korean', code: 'ko', loader: () => import('$i18n/ko.json') },
|
||||
{ name: 'Luxembourgish', code: 'lb', loader: () => import('$i18n/lb.json') },
|
||||
{ name: 'Lithuanian', code: 'lt', loader: () => import('$i18n/lt.json') },
|
||||
{ name: 'Latvian', code: 'lv', loader: () => import('$i18n/lv.json') },
|
||||
{ name: 'Malay (Pattani)', code: 'mfa', loader: () => import('$i18n/mfa.json') },
|
||||
{ name: 'Macedonian', code: 'mk', loader: () => import('$i18n/mk.json') },
|
||||
{ name: 'Malayalam', code: 'ml', loader: () => import('$i18n/ml.json') },
|
||||
{ name: 'Mongolian', code: 'mn', loader: () => import('$i18n/mn.json') },
|
||||
{ name: 'Marathi', code: 'mr', loader: () => import('$i18n/mr.json') },
|
||||
{ name: 'Malay', code: 'ms', loader: () => import('$i18n/ms.json') },
|
||||
{ name: 'Norwegian Bokmål', code: 'nb-NO', weblateCode: 'nb_NO', loader: () => import('$i18n/nb_NO.json') },
|
||||
{ name: 'Dutch', code: 'nl', loader: () => import('$i18n/nl.json') },
|
||||
{ name: 'Norwegian Nynorsk', code: 'nn', loader: () => import('$i18n/nn.json') },
|
||||
{ name: 'Punjabi', code: 'pa', loader: () => import('$i18n/pa.json') },
|
||||
{ name: 'Polish', code: 'pl', loader: () => import('$i18n/pl.json') },
|
||||
{ name: 'Portuguese', code: 'pt', loader: () => import('$i18n/pt.json') },
|
||||
{ name: 'Portuguese (Brazil) ', code: 'pt-BR', weblateCode: 'pt_BR', loader: () => import('$i18n/pt_BR.json') },
|
||||
{ name: 'Romanian', code: 'ro', loader: () => import('$i18n/ro.json') },
|
||||
{ name: 'Russian', code: 'ru', loader: () => import('$i18n/ru.json') },
|
||||
{ name: 'Sinhala', code: 'si', loader: () => import('$i18n/si.json') },
|
||||
{ name: 'Slovak', code: 'sk', loader: () => import('$i18n/sk.json') },
|
||||
{ name: 'Slovenian', code: 'sl', loader: () => import('$i18n/sl.json') },
|
||||
{ name: 'Albanian', code: 'sq', loader: () => import('$i18n/sq.json') },
|
||||
{
|
||||
name: 'Serbian (Cyrillic)',
|
||||
code: 'sr-Cyrl',
|
||||
weblateCode: 'sr_Cyrl',
|
||||
loader: () => import('$i18n/sr_Cyrl.json'),
|
||||
},
|
||||
{ name: 'Serbian (Latin)', code: 'sr-Latn', weblateCode: 'sr_Latn', loader: () => import('$i18n/sr_Latn.json') },
|
||||
{ name: 'Swedish', code: 'sv', loader: () => import('$i18n/sv.json') },
|
||||
{ name: 'Tamil', code: 'ta', loader: () => import('$i18n/ta.json') },
|
||||
{ name: 'Telugu', code: 'te', loader: () => import('$i18n/te.json') },
|
||||
{ name: 'Thai', code: 'th', loader: () => import('$i18n/th.json') },
|
||||
{ name: 'Turkish', code: 'tr', loader: () => import('$i18n/tr.json') },
|
||||
{ name: 'Ukrainian', code: 'uk', loader: () => import('$i18n/uk.json') },
|
||||
{ name: 'Urdu', code: 'ur', loader: () => import('$i18n/ur.json'), rtl: true },
|
||||
{ name: 'Uzbek', code: 'uz', loader: () => import('$i18n/uz.json') },
|
||||
{ name: 'Vietnamese', code: 'vi', loader: () => import('$i18n/vi.json') },
|
||||
{ name: 'Cantonese (Traditional Han script)', code: 'yue_Hant', loader: () => import('$i18n/yue_Hant.json') },
|
||||
{
|
||||
name: 'Chinese (Traditional)',
|
||||
code: 'zh-TW',
|
||||
weblateCode: 'zh_Hant',
|
||||
loader: () => import('$i18n/zh_Hant.json'),
|
||||
},
|
||||
{
|
||||
name: 'Chinese (Simplified)',
|
||||
code: 'zh-CN',
|
||||
weblateCode: 'zh_Hans',
|
||||
loader: () => import('$i18n/zh_Hans.json'),
|
||||
},
|
||||
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({ default: {} }) },
|
||||
];
|
||||
|
||||
export enum ImmichProduct {
|
||||
Client = 'immich-client',
|
||||
Server = 'immich-server',
|
||||
|
||||
@@ -33,17 +33,6 @@
|
||||
:global(.dark) [data-skeleton] {
|
||||
background-image: url('/dark_skeleton.png');
|
||||
}
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
[data-skeleton] {
|
||||
visibility: hidden;
|
||||
animation:
|
||||
0s linear 0.1s forwards delayedVisibility,
|
||||
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
.invisible [data-skeleton] {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { langs } from '$lib/constants';
|
||||
import { getClosestAvailableLocale } from '$lib/utils/i18n';
|
||||
import { getClosestAvailableLocale, langs } from '$lib/utils/i18n';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
|
||||
describe('i18n', () => {
|
||||
@@ -12,7 +11,7 @@ describe('i18n', () => {
|
||||
}
|
||||
|
||||
const code = filename.replaceAll('.json', '');
|
||||
const item = langs.find((lang) => lang.weblateCode === code || lang.code === code);
|
||||
const item = langs.find((lang) => lang.code === code);
|
||||
expect(item, `${filename} has no loader`).toBeDefined();
|
||||
if (!item) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
@@ -157,7 +157,7 @@ class ActivityManager {
|
||||
const [liked] = await getActivities({
|
||||
albumId,
|
||||
assetId,
|
||||
userId: get(user).id,
|
||||
userId: authManager.user.id,
|
||||
$type: ReactionType.Like,
|
||||
level: assetId ? undefined : ReactionLevel.Album,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { resetSavedUser, user } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { timelineAssetFactory } from '@test-data/factories/asset-factory';
|
||||
import { preferencesFactory } from '@test-data/factories/preferences-factory';
|
||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||
|
||||
describe('AssetMultiSelectManager', () => {
|
||||
@@ -32,14 +33,15 @@ describe('AssetMultiSelectManager', () => {
|
||||
const cleanup = $effect.root(() => {
|
||||
expect(sut.isAllUserOwned).toBe(false);
|
||||
|
||||
user.set(user1);
|
||||
authManager.setUser(user1);
|
||||
authManager.setPreferences(preferencesFactory.build());
|
||||
expect(sut.isAllUserOwned).toBe(true);
|
||||
|
||||
user.set(user2);
|
||||
authManager.setUser(user2);
|
||||
expect(sut.isAllUserOwned).toBe(false);
|
||||
});
|
||||
|
||||
cleanup();
|
||||
resetSavedUser();
|
||||
authManager.reset();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { fromStore } from 'svelte/store';
|
||||
|
||||
export type AssetMultiSelectOptions = {
|
||||
resetOnNavigate?: boolean;
|
||||
};
|
||||
export class AssetMultiSelectManager {
|
||||
#selectedMap = new SvelteMap<string, TimelineAsset>();
|
||||
#user = fromStore<UserAdminResponseDto | undefined>(user);
|
||||
#userId = $derived(this.#user.current?.id);
|
||||
|
||||
selectAll = $state(false);
|
||||
startAsset = $state<TimelineAsset | null>(null);
|
||||
@@ -23,12 +20,16 @@ export class AssetMultiSelectManager {
|
||||
selectionActive = $derived(this.#selectedMap.size > 0);
|
||||
|
||||
assets = $derived(Array.from(this.#selectedMap.values()));
|
||||
ownedAssets = $derived(this.#userId ? this.assets.filter((asset) => asset.ownerId === this.#userId) : this.assets);
|
||||
ownedAssets = $derived(
|
||||
authManager.authenticated ? this.assets.filter((asset) => asset.ownerId === authManager.user.id) : this.assets,
|
||||
);
|
||||
|
||||
isAllTrashed = $derived(this.assets.every((asset) => asset.isTrashed));
|
||||
isAllArchived = $derived(this.assets.every((asset) => asset.visibility === AssetVisibility.Archive));
|
||||
isAllFavorite = $derived(this.assets.every((asset) => asset.isFavorite));
|
||||
isAllUserOwned = $derived(this.assets.every((asset) => asset.ownerId === this.#userId));
|
||||
isAllUserOwned = $derived(
|
||||
authManager.authenticated && this.assets.every((asset) => asset.ownerId === authManager.user.id),
|
||||
);
|
||||
|
||||
#unsubscribe?: () => void;
|
||||
|
||||
@@ -44,7 +45,9 @@ export class AssetMultiSelectManager {
|
||||
}
|
||||
|
||||
getOwnedAssets() {
|
||||
return this.#userId ? this.assets.filter((asset) => asset.ownerId === this.#userId) : this.assets;
|
||||
return authManager.authenticated
|
||||
? this.assets.filter((asset) => asset.ownerId === authManager.user.id)
|
||||
: this.assets;
|
||||
}
|
||||
|
||||
hasSelectedAsset(assetId: string) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
|
||||
const isShowAssetPath = new PersistedLocalStorage<boolean>('asset-viewer-show-path', false);
|
||||
|
||||
const createDefaultZoomState = (): ZoomImageWheelState => ({
|
||||
currentRotation: 0,
|
||||
@@ -22,6 +23,7 @@ export type Events = {
|
||||
Zoom: [];
|
||||
ZoomChange: [ZoomImageWheelState];
|
||||
Copy: [];
|
||||
FaceEditModeChange: [boolean];
|
||||
};
|
||||
|
||||
class AssetViewerManager extends BaseEventManager<Events> {
|
||||
@@ -43,6 +45,7 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
isPlayingMotionPhoto = $state(false);
|
||||
isShowEditor = $state(false);
|
||||
#isFaceEditMode = $state(false);
|
||||
#isEditFacesPanelOpen = $state(false);
|
||||
#viewingAssetStoreState = $state<AssetResponseDto>();
|
||||
#viewState = $state<boolean>(false);
|
||||
gridScrollTarget = $state<AssetGridRouteSearchParams | null | undefined>();
|
||||
@@ -63,10 +66,18 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
return isShowDetailPanel.current;
|
||||
}
|
||||
|
||||
get isShowAssetPath() {
|
||||
return isShowAssetPath.current;
|
||||
}
|
||||
|
||||
get isFaceEditMode() {
|
||||
return this.#isFaceEditMode;
|
||||
}
|
||||
|
||||
get isEditFacesPanelOpen() {
|
||||
return this.#isEditFacesPanelOpen;
|
||||
}
|
||||
|
||||
get zoomState() {
|
||||
return this.#zoomState;
|
||||
}
|
||||
@@ -101,6 +112,10 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
isShowDetailPanel.current = value;
|
||||
}
|
||||
|
||||
private set isShowAssetPath(value: boolean) {
|
||||
isShowAssetPath.current = value;
|
||||
}
|
||||
|
||||
onZoomChange(state: ZoomImageWheelState) {
|
||||
// bypass event emitter to avoid loop
|
||||
this.#zoomState = state;
|
||||
@@ -147,6 +162,10 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
this.isShowActivityPanel = false;
|
||||
}
|
||||
|
||||
toggleAssetPath() {
|
||||
this.isShowAssetPath = !this.isShowAssetPath;
|
||||
}
|
||||
|
||||
toggleDetailPanel() {
|
||||
this.closeActivityPanel();
|
||||
this.isShowDetailPanel = !this.isShowDetailPanel;
|
||||
@@ -167,12 +186,30 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
|
||||
toggleFaceEditMode() {
|
||||
this.#isFaceEditMode = !this.#isFaceEditMode;
|
||||
this.emit('FaceEditModeChange', this.#isFaceEditMode);
|
||||
}
|
||||
|
||||
closeFaceEditMode() {
|
||||
if (this.#isFaceEditMode) {
|
||||
this.emit('FaceEditModeChange', false);
|
||||
}
|
||||
this.#isFaceEditMode = false;
|
||||
}
|
||||
|
||||
openEditFacesPanel() {
|
||||
this.#isEditFacesPanelOpen = true;
|
||||
}
|
||||
|
||||
closeEditFacesPanel() {
|
||||
this.#isEditFacesPanelOpen = false;
|
||||
}
|
||||
|
||||
resetPanelState() {
|
||||
this.closeEditor();
|
||||
this.closeFaceEditMode();
|
||||
this.closeEditFacesPanel();
|
||||
}
|
||||
|
||||
setAsset(asset: AssetResponseDto) {
|
||||
this.#viewingAssetStoreState = asset;
|
||||
this.#viewState = true;
|
||||
|
||||
@@ -1,58 +1,137 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { isSharedLinkRoute } from '$lib/utils/navigation';
|
||||
import { getAboutInfo, logout, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
getAboutInfo,
|
||||
getMyPreferences,
|
||||
getMyUser,
|
||||
logout,
|
||||
type UserAdminResponseDto,
|
||||
type UserPreferencesResponseDto,
|
||||
} from '@immich/sdk';
|
||||
|
||||
class AuthManager {
|
||||
isPurchased = $state(false);
|
||||
isSharedLink = $derived(isSharedLinkRoute(page.route?.id));
|
||||
params = $derived(this.isSharedLink ? { key: page.params.key, slug: page.params.slug } : {});
|
||||
|
||||
#user = $state<UserAdminResponseDto>();
|
||||
#preferences = $state<UserPreferencesResponseDto>();
|
||||
|
||||
get authenticated() {
|
||||
return !!(this.#user && this.#preferences);
|
||||
}
|
||||
|
||||
get user() {
|
||||
if (!this.#user) {
|
||||
throw new TypeError('AuthManager.user is undefined');
|
||||
}
|
||||
|
||||
return this.#user;
|
||||
}
|
||||
|
||||
get preferences() {
|
||||
if (!this.#preferences) {
|
||||
throw new TypeError('AuthManager.preferences is undefined');
|
||||
}
|
||||
|
||||
return this.#preferences;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
AuthUserLoaded: (user) => this.onAuthUserLoaded(user),
|
||||
SessionDelete: () => goto(Route.logout()),
|
||||
});
|
||||
}
|
||||
|
||||
private async onAuthUserLoaded(user: UserAdminResponseDto) {
|
||||
if (user.license?.activatedAt) {
|
||||
authManager.isPurchased = true;
|
||||
async load() {
|
||||
if (authManager.authenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverInfo = await getAboutInfo().catch(() => undefined);
|
||||
if (serverInfo?.licensed) {
|
||||
authManager.isPurchased = true;
|
||||
if (!this.#hasAuthCookie()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.refresh();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const [user, preferences] = await Promise.all([getMyUser(), getMyPreferences()]);
|
||||
this.#preferences = preferences;
|
||||
this.#user = user;
|
||||
|
||||
if (user.license?.activatedAt) {
|
||||
this.isPurchased = true;
|
||||
} else {
|
||||
// check server status
|
||||
const serverInfo = await getAboutInfo().catch(() => {});
|
||||
if (serverInfo?.licensed) {
|
||||
this.isPurchased = true;
|
||||
}
|
||||
}
|
||||
|
||||
eventManager.emit('AuthUserLoaded', user);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
setUser(user: UserAdminResponseDto) {
|
||||
this.#user = user;
|
||||
}
|
||||
|
||||
setPreferences(preferences: UserPreferencesResponseDto) {
|
||||
this.#preferences = preferences;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
let redirectUri;
|
||||
let redirectUri = Route.login();
|
||||
|
||||
try {
|
||||
const response = await logout();
|
||||
if (response.redirectUri) {
|
||||
redirectUri = response.redirectUri;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error logging out:', error);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
redirectUri = redirectUri ?? Route.login();
|
||||
|
||||
try {
|
||||
if (redirectUri.startsWith('/')) {
|
||||
await goto(redirectUri);
|
||||
} else {
|
||||
globalThis.location.href = redirectUri;
|
||||
}
|
||||
} finally {
|
||||
if (redirectUri.startsWith('/')) {
|
||||
this.isPurchased = false;
|
||||
|
||||
this.reset();
|
||||
eventManager.emit('AuthLogout');
|
||||
|
||||
await goto(redirectUri);
|
||||
} else {
|
||||
globalThis.location.href = redirectUri;
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#user = undefined;
|
||||
this.#preferences = undefined;
|
||||
}
|
||||
|
||||
#hasAuthCookie() {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cookie of document.cookie.split('; ')) {
|
||||
const [name] = cookie.split('=');
|
||||
if (name === 'immich_is_authenticated') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const authManager = new AuthManager();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||
import type { TreeNode } from '$lib/utils/tree-utils';
|
||||
@@ -27,7 +26,6 @@ export type Events = {
|
||||
AuthUserLoaded: [UserAdminResponseDto];
|
||||
|
||||
LanguageChange: [{ name: string; code: string; rtl?: boolean }];
|
||||
ThemeChange: [ThemeSetting];
|
||||
|
||||
ApiKeyCreate: [ApiKeyResponseDto];
|
||||
ApiKeyUpdate: [ApiKeyResponseDto];
|
||||
@@ -76,6 +74,7 @@ export type Events = {
|
||||
UserAdminDeleted: [{ id: string }];
|
||||
|
||||
SessionLocked: [];
|
||||
SessionDelete: [];
|
||||
|
||||
SystemConfigUpdate: [SystemConfigDto];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { langs } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { lang } from '$lib/stores/preferences.store';
|
||||
import { langs } from '$lib/utils/i18n';
|
||||
|
||||
class LanguageManager {
|
||||
constructor() {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { asLocalTimeISO } from '$lib/utils/date-time';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
type MemoryIndex = {
|
||||
memoryIndex: number;
|
||||
@@ -31,7 +30,7 @@ class MemoryManager {
|
||||
});
|
||||
|
||||
// loaded event might have already happened
|
||||
if (get(user)) {
|
||||
if (authManager.authenticated) {
|
||||
void this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { Theme } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
||||
import { onThemeChange as onUiThemeChange, theme as uiTheme, type Theme as UiTheme } from '@immich/ui';
|
||||
|
||||
export interface ThemeSetting {
|
||||
value: Theme;
|
||||
system: boolean;
|
||||
}
|
||||
|
||||
const getDefaultTheme = () => {
|
||||
if (!browser) {
|
||||
return Theme.DARK;
|
||||
}
|
||||
|
||||
return globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT;
|
||||
};
|
||||
|
||||
class ThemeManager {
|
||||
#theme = new PersistedLocalStorage<ThemeSetting>(
|
||||
'color-theme',
|
||||
{ value: getDefaultTheme(), system: false },
|
||||
{
|
||||
valid: (value): value is ThemeSetting => {
|
||||
return Object.values(Theme).includes((value as ThemeSetting)?.value);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
get theme() {
|
||||
return this.#theme.current;
|
||||
}
|
||||
|
||||
value = $derived(this.theme.value);
|
||||
|
||||
isDark = $derived(this.value === Theme.DARK);
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
AppInit: () => this.#onAppInit(),
|
||||
});
|
||||
}
|
||||
|
||||
setSystem(system: boolean) {
|
||||
this.#update(system ? 'system' : getDefaultTheme());
|
||||
}
|
||||
|
||||
setTheme(theme: Theme) {
|
||||
this.#update(theme);
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
this.#update(this.value === Theme.DARK ? Theme.LIGHT : Theme.DARK);
|
||||
}
|
||||
|
||||
#onAppInit() {
|
||||
const syncSystemTheme = () => {
|
||||
this.#update(this.theme.system ? 'system' : this.theme.value);
|
||||
};
|
||||
|
||||
syncSystemTheme();
|
||||
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncSystemTheme, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
#update(value: Theme | 'system') {
|
||||
const theme: ThemeSetting =
|
||||
value === 'system' ? { system: true, value: getDefaultTheme() } : { system: false, value };
|
||||
|
||||
document.documentElement.classList.toggle('dark', !(theme.value === Theme.LIGHT));
|
||||
|
||||
this.#theme.current = theme;
|
||||
|
||||
uiTheme.value = theme.value as unknown as UiTheme;
|
||||
onUiThemeChange();
|
||||
|
||||
eventManager.emit('ThemeChange', theme);
|
||||
}
|
||||
}
|
||||
|
||||
export const themeManager = new ThemeManager();
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { initInput } from '$lib/actions/focus';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { handleAddUsersToAlbum } from '$lib/services/album.service';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
import { searchUsers, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||
import { FormModal, ListButton, LoadingSpinner, Stack, Text } from '@immich/ui';
|
||||
import { sortBy } from 'lodash-es';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
@@ -12,11 +15,22 @@
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let search = $state('');
|
||||
|
||||
const { album, onClose }: Props = $props();
|
||||
|
||||
let users: UserResponseDto[] = $state([]);
|
||||
const excludedUserIds = $derived([album.ownerId, ...album.albumUsers.map(({ user: { id } }) => id)]);
|
||||
const filteredUsers = $derived(users.filter(({ id }) => !excludedUserIds.includes(id)));
|
||||
const filteredUsers = $derived(
|
||||
sortBy(
|
||||
users.filter(
|
||||
(user) =>
|
||||
!excludedUserIds.includes(user.id) &&
|
||||
normalizeSearchString(user.name).includes(normalizeSearchString(search)),
|
||||
),
|
||||
['name'],
|
||||
),
|
||||
);
|
||||
const selectedUsers = new SvelteMap<string, UserResponseDto>();
|
||||
let loading = $state(true);
|
||||
|
||||
@@ -55,6 +69,12 @@
|
||||
</div>
|
||||
{:else}
|
||||
<Stack>
|
||||
<input
|
||||
class="border-b-4 border-immich-bg px-6 py-2 text-2xl focus:border-immich-primary dark:border-immich-dark-gray dark:focus:border-immich-dark-primary"
|
||||
placeholder={$t('search')}
|
||||
bind:value={search}
|
||||
use:initInput
|
||||
/>
|
||||
{#each filteredUsers as user (user.id)}
|
||||
<ListButton selected={selectedUsers.has(user.id)} onclick={() => handleToggle(user)}>
|
||||
<UserAvatar {user} size="md" />
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
};
|
||||
|
||||
const refreshAlbum = async () => {
|
||||
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
|
||||
album = await getAlbumInfo({ id: album.id });
|
||||
};
|
||||
|
||||
const onAlbumUserDelete = async ({ userId }: { userId: string }) => {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { getAnimateMock } from '$lib/__mocks__/animate.mock';
|
||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { calcNewDate } from '$lib/modals/timezone-utils';
|
||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -25,6 +27,8 @@ describe('DateSelectionModal component', () => {
|
||||
vi.stubGlobal('visualViewport', getVisualViewportMock());
|
||||
vi.resetAllMocks();
|
||||
Element.prototype.animate = getAnimateMock();
|
||||
|
||||
authManager.setUser(userAdminFactory.build());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import Combobox from '$lib/components/shared-components/combobox.svelte';
|
||||
import DateInput from '$lib/elements/DateInput.svelte';
|
||||
import DurationInput from '$lib/elements/DurationInput.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { getPreferredTimeZone, getTimezones, toIsoDate, type ZoneOption } from '$lib/modals/timezone-utils';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAssets } from '@immich/sdk';
|
||||
@@ -31,7 +31,7 @@
|
||||
let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone));
|
||||
|
||||
const onSubmit = async () => {
|
||||
const ids = getOwnedAssetsWithWarning(assets, $user);
|
||||
const ids = getOwnedAssetsWithWarning(assets, authManager.user);
|
||||
try {
|
||||
if (showRelative && (selectedDuration || selectedOption)) {
|
||||
await updateAssets({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { deleteProfileImage, updateMyUser, UserAvatarColor } from '@immich/sdk';
|
||||
import { Modal, ModalBody, toastManager } from '@immich/ui';
|
||||
@@ -16,13 +16,14 @@
|
||||
|
||||
const onSave = async (color: UserAvatarColor) => {
|
||||
try {
|
||||
if ($user.profileImagePath !== '') {
|
||||
if (authManager.user.profileImagePath !== '') {
|
||||
await deleteProfileImage();
|
||||
}
|
||||
|
||||
toastManager.primary($t('saved_profile'));
|
||||
|
||||
$user = await updateMyUser({ userUpdateMeDto: { avatarColor: color } });
|
||||
const response = await updateMyUser({ userUpdateMeDto: { avatarColor: color } });
|
||||
authManager.setUser(response);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_profile'));
|
||||
@@ -35,7 +36,11 @@
|
||||
<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, profileImagePath: '', avatarColor: color }} size="xl" />
|
||||
<UserAvatar
|
||||
label={color}
|
||||
user={{ ...authManager.user, profileImagePath: '', avatarColor: color }}
|
||||
size="xl"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -55,12 +55,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (searchWord === '') {
|
||||
suggestedPlaces = [];
|
||||
}
|
||||
});
|
||||
|
||||
const handleConfirm = (confirmed?: boolean) => {
|
||||
if (point && confirmed) {
|
||||
geolocationManager.onSelected(point);
|
||||
@@ -71,7 +65,7 @@
|
||||
};
|
||||
|
||||
const getLocation = (name: string, admin1Name?: string, admin2Name?: string): string => {
|
||||
return `${name}${admin1Name ? ', ' + admin1Name : ''}${admin2Name ? ', ' + admin2Name : ''}`;
|
||||
return [name, admin1Name, admin2Name].filter(Boolean).join(', ');
|
||||
};
|
||||
|
||||
const handleSearchPlaces = () => {
|
||||
@@ -150,7 +144,7 @@
|
||||
>
|
||||
{#snippet prompt()}
|
||||
<div class="flex flex-col w-full h-full gap-2">
|
||||
<div class="relative w-64 sm:w-96 z-1">
|
||||
<div class="relative w-64 sm:w-96 z-1" use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }}>
|
||||
{#if suggestionContainer}
|
||||
<div use:listNavigation={suggestionContainer}>
|
||||
<button type="button" class="w-full" onclick={() => (hideSuggestion = false)}>
|
||||
@@ -167,22 +161,18 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="absolute w-full"
|
||||
class="absolute w-full bg-gray-200 dark:bg-gray-700 rounded-b-lg"
|
||||
id="suggestion"
|
||||
bind:this={suggestionContainer}
|
||||
use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }}
|
||||
>
|
||||
{#if !hideSuggestion}
|
||||
{#each suggestedPlaces as place, index (place.latitude + place.longitude)}
|
||||
{#each suggestedPlaces as place (place.latitude + place.longitude)}
|
||||
<button
|
||||
type="button"
|
||||
class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
|
||||
suggestedPlaces.length - 1
|
||||
? 'rounded-b-lg border-b'
|
||||
: ''}"
|
||||
class="flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-12 place-items-center px-5 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] last:rounded-b-lg last:border-b"
|
||||
onclick={() => handleUseSuggested(place.latitude, place.longitude)}
|
||||
>
|
||||
<p class="ms-4 text-sm text-gray-700 dark:text-gray-100 truncate">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-100 truncate">
|
||||
{getLocation(place.name, place.admin1name, place.admin2name)}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { getPartners, PartnerDirection, searchUsers, type UserResponseDto } from '@immich/sdk';
|
||||
import { Button, ListButton, LoadingSpinner, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
user: UserResponseDto;
|
||||
onClose: (users?: UserResponseDto[]) => void;
|
||||
}
|
||||
|
||||
let { user, onClose }: Props = $props();
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let availableUsers: UserResponseDto[] = $state([]);
|
||||
let selectedUsers: UserResponseDto[] = $state([]);
|
||||
@@ -18,7 +18,7 @@
|
||||
let users = await searchUsers();
|
||||
|
||||
// remove current user
|
||||
users = users.filter((_user) => _user.id !== user.id);
|
||||
users = users.filter(({ id }) => id !== authManager.user.id);
|
||||
|
||||
// exclude partners from the list of users available for selection
|
||||
const partners = await getPartners({ direction: PartnerDirection.SharedBy });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createProfileImage, type AssetResponseDto } from '@immich/sdk';
|
||||
import { FormModal, toastManager } from '@immich/ui';
|
||||
@@ -68,10 +68,10 @@
|
||||
return;
|
||||
}
|
||||
const file = new File([blob], 'profile-picture.png', { type: 'image/png' });
|
||||
const { profileImagePath, profileChangedAt } = await createProfileImage({ createProfileImageDto: { file } });
|
||||
await createProfileImage({ createProfileImageDto: { file } });
|
||||
toastManager.primary($t('profile_picture_set'));
|
||||
$user.profileImagePath = profileImagePath;
|
||||
$user.profileChangedAt = profileChangedAt;
|
||||
|
||||
await authManager.refresh();
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import SearchTagsSection from '$lib/components/shared-components/search-bar/search-tags-section.svelte';
|
||||
import SearchTextSection from '$lib/components/shared-components/search-bar/search-text-section.svelte';
|
||||
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { SearchFilter } from '$lib/types';
|
||||
import { parseUtcDate } from '$lib/utils/date-time';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
@@ -193,7 +193,7 @@
|
||||
<SearchDateSection bind:filters={filter.date} />
|
||||
|
||||
<!-- RATING -->
|
||||
{#if $preferences?.ratings.enabled}
|
||||
{#if authManager.authenticated && authManager.preferences.ratings.enabled}
|
||||
<SearchRatingsSection bind:rating={filter.rating} />
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Icon, Modal, ModalBody } from '@immich/ui';
|
||||
import { mdiInformationOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -45,7 +45,7 @@
|
||||
{ key: ['⇧', 'd'], action: $t('download') },
|
||||
{ key: ['Space'], action: $t('play_or_pause_video') },
|
||||
{ key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
|
||||
...($preferences?.ratings.enabled
|
||||
...(authManager.authenticated && authManager.preferences.ratings.enabled
|
||||
? [{ key: ['1-5'], action: $t('rate_asset'), info: $t('zero_to_clear_rating') }]
|
||||
: []),
|
||||
],
|
||||
|
||||
@@ -51,6 +51,7 @@ export const Docs = {
|
||||
export const Route = {
|
||||
// auth
|
||||
login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params),
|
||||
logout: (params?: { continue?: string }) => '/auth/logout' + asQueryString(params),
|
||||
register: () => '/auth/register',
|
||||
changePassword: () => '/auth/change-password',
|
||||
onboarding: (params?: { step?: string }) => '/auth/onboarding' + asQueryString(params),
|
||||
|
||||
@@ -6,11 +6,9 @@ import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte';
|
||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
|
||||
import { downloadArchive } from '$lib/utils/asset-utils';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
@@ -32,7 +30,6 @@ import {
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
|
||||
import { type MessageFormatter } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const getAlbumsActions = ($t: MessageFormatter) => {
|
||||
const Create: ActionItem = {
|
||||
@@ -45,11 +42,10 @@ export const getAlbumsActions = ($t: MessageFormatter) => {
|
||||
};
|
||||
|
||||
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
|
||||
const isOwned = get(user).id === album.ownerId;
|
||||
const isOwned = authManager.user.id === album.ownerId;
|
||||
|
||||
const Share: ActionItem = {
|
||||
title: $t('share'),
|
||||
type: $t('command'),
|
||||
icon: mdiShareVariantOutline,
|
||||
$if: () => isOwned,
|
||||
onAction: () => modalManager.show(AlbumOptionsModal, { album }),
|
||||
@@ -57,7 +53,6 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
|
||||
|
||||
const AddUsers: ActionItem = {
|
||||
title: $t('invite_people'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlus,
|
||||
color: 'primary',
|
||||
onAction: () => modalManager.show(AlbumAddUsersModal, { album }),
|
||||
@@ -65,7 +60,6 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
|
||||
|
||||
const CreateSharedLink: ActionItem = {
|
||||
title: $t('create_link'),
|
||||
type: $t('command'),
|
||||
icon: mdiLink,
|
||||
color: 'primary',
|
||||
onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }),
|
||||
@@ -77,7 +71,6 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
|
||||
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {
|
||||
const AddAssets: ActionItem = {
|
||||
title: $t('add_assets'),
|
||||
type: $t('command'),
|
||||
color: 'primary',
|
||||
icon: mdiPlusBoxOutline,
|
||||
$if: () => assets.length > 0,
|
||||
@@ -92,7 +85,6 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse
|
||||
const Upload: ActionItem = {
|
||||
title: $t('select_from_computer'),
|
||||
description: $t('album_upload_assets'),
|
||||
type: $t('command'),
|
||||
icon: mdiUpload,
|
||||
onAction: () => void openFileUploadDialog({ albumId: album.id }),
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { getAssetActions, handleDownloadAsset } from '$lib/services/asset.service';
|
||||
import { user as userStore } from '$lib/stores/user.store';
|
||||
import { setSharedLink } from '$lib/utils';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getAssetInfo } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { preferencesFactory } from '@test-data/factories/preferences-factory';
|
||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||
import { vitest } from 'vitest';
|
||||
@@ -32,11 +33,15 @@ vitest.mock('$lib/utils', async () => {
|
||||
|
||||
describe('AssetService', () => {
|
||||
describe('getAssetActions', () => {
|
||||
beforeEach(() => {
|
||||
authManager.setPreferences(preferencesFactory.build());
|
||||
});
|
||||
|
||||
it('should allow shared link downloads if the user owns the asset and shared link downloads are disabled', () => {
|
||||
const ownerId = 'owner';
|
||||
const user = userAdminFactory.build({ id: ownerId });
|
||||
const asset = assetFactory.build({ ownerId });
|
||||
userStore.set(user);
|
||||
authManager.setUser(user);
|
||||
setSharedLink(sharedLinkFactory.build({ allowDownload: false }));
|
||||
const assetActions = getAssetActions(() => '', asset);
|
||||
expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(true);
|
||||
@@ -46,7 +51,7 @@ describe('AssetService', () => {
|
||||
const ownerId = 'owner';
|
||||
const user = userAdminFactory.build({ id: 'non-owner' });
|
||||
const asset = assetFactory.build({ ownerId });
|
||||
userStore.set(user);
|
||||
authManager.setUser(user);
|
||||
setSharedLink(sharedLinkFactory.build({ allowDownload: false }));
|
||||
const assetActions = getAssetActions(() => '', asset);
|
||||
expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(false);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
|
||||
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { user as authUser, preferences } from '$lib/stores/user.store';
|
||||
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
|
||||
import { downloadUrl } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -46,15 +45,12 @@ import {
|
||||
mdiTune,
|
||||
} from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const getAssetBulkActions = ($t: MessageFormatter) => {
|
||||
const ownedAssets = assetMultiSelectManager.ownedAssets;
|
||||
const assetIds = ownedAssets.map((asset) => asset.id);
|
||||
const isAllVideos = ownedAssets.every((asset) => asset.isVideo);
|
||||
|
||||
const onAction = async (name: AssetJobName) => {
|
||||
await handleRunAssetJob({ name, assetIds });
|
||||
await handleRunAssetJob({ name, assetIds: ownedAssets.map(({ id }) => id) });
|
||||
assetMultiSelectManager.clear();
|
||||
};
|
||||
|
||||
@@ -62,7 +58,8 @@ export const getAssetBulkActions = ($t: MessageFormatter) => {
|
||||
title: $t('add_to_album'),
|
||||
icon: mdiPlus,
|
||||
shortcuts: [{ key: 'l' }],
|
||||
onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds }),
|
||||
onAction: () =>
|
||||
modalManager.show(AssetAddToAlbumModal, { assetIds: assetMultiSelectManager.assets.map((asset) => asset.id) }),
|
||||
};
|
||||
|
||||
const RefreshFacesJob: ActionItem = {
|
||||
@@ -87,7 +84,7 @@ export const getAssetBulkActions = ($t: MessageFormatter) => {
|
||||
title: $t('refresh_encoded_videos'),
|
||||
icon: mdiCogRefreshOutline,
|
||||
onAction: () => onAction(AssetJobName.TranscodeVideo),
|
||||
$if: () => isAllVideos,
|
||||
$if: () => ownedAssets.every((asset) => asset.isVideo),
|
||||
};
|
||||
|
||||
return { AddToAlbum, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob };
|
||||
@@ -95,15 +92,13 @@ export const getAssetBulkActions = ($t: MessageFormatter) => {
|
||||
|
||||
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
|
||||
const sharedLink = getSharedLink();
|
||||
const currentAuthUser = get(authUser);
|
||||
const userPreferences = get(preferences);
|
||||
const isOwner = !!(currentAuthUser && currentAuthUser.id === asset.ownerId);
|
||||
const authUser = authManager.authenticated ? authManager.user : undefined;
|
||||
const isOwner = !!(authUser && authUser.id === asset.ownerId);
|
||||
|
||||
const Share: ActionItem = {
|
||||
title: $t('share'),
|
||||
icon: mdiShareVariantOutline,
|
||||
type: $t('assets'),
|
||||
$if: () => !!(currentAuthUser && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
|
||||
$if: () => !!(authUser && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
|
||||
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
|
||||
};
|
||||
|
||||
@@ -111,16 +106,14 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
title: $t('download'),
|
||||
icon: mdiDownload,
|
||||
shortcuts: { key: 'd', shift: true },
|
||||
type: $t('assets'),
|
||||
$if: () => !!currentAuthUser,
|
||||
$if: () => !!authUser,
|
||||
onAction: () => handleDownloadAsset(asset, { edited: true }),
|
||||
};
|
||||
|
||||
const DownloadOriginal: ActionItem = {
|
||||
title: $t('download_original'),
|
||||
icon: mdiDownloadBox,
|
||||
type: $t('assets'),
|
||||
$if: () => !!currentAuthUser && asset.isEdited,
|
||||
$if: () => !!authUser && asset.isEdited,
|
||||
onAction: () => handleDownloadAsset(asset, { edited: false }),
|
||||
};
|
||||
|
||||
@@ -132,7 +125,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
const PlayMotionPhoto: ActionItem = {
|
||||
title: $t('play_motion_photo'),
|
||||
icon: mdiMotionPlayOutline,
|
||||
type: $t('assets'),
|
||||
$if: () => !!asset.livePhotoVideoId && !assetViewerManager.isPlayingMotionPhoto,
|
||||
onAction: () => {
|
||||
assetViewerManager.isPlayingMotionPhoto = true;
|
||||
@@ -142,7 +134,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
const StopMotionPhoto: ActionItem = {
|
||||
title: $t('stop_motion_photo'),
|
||||
icon: mdiMotionPauseOutline,
|
||||
type: $t('assets'),
|
||||
$if: () => !!asset.livePhotoVideoId && assetViewerManager.isPlayingMotionPhoto,
|
||||
onAction: () => {
|
||||
assetViewerManager.isPlayingMotionPhoto = false;
|
||||
@@ -152,7 +143,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
const Favorite: ActionItem = {
|
||||
title: $t('to_favorite'),
|
||||
icon: mdiHeartOutline,
|
||||
type: $t('assets'),
|
||||
$if: () => isOwner && !asset.isFavorite,
|
||||
onAction: () => handleFavorite(asset),
|
||||
shortcuts: [{ key: 'f' }],
|
||||
@@ -161,7 +151,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
const Unfavorite: ActionItem = {
|
||||
title: $t('unfavorite'),
|
||||
icon: mdiHeart,
|
||||
type: $t('assets'),
|
||||
$if: () => isOwner && asset.isFavorite,
|
||||
onAction: () => handleUnfavorite(asset),
|
||||
shortcuts: [{ key: 'f' }],
|
||||
@@ -178,7 +167,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
const Offline: ActionItem = {
|
||||
title: $t('asset_offline'),
|
||||
icon: mdiAlertOutline,
|
||||
type: $t('assets'),
|
||||
color: 'danger',
|
||||
$if: () => !!asset.isOffline,
|
||||
onAction: () => assetViewerManager.toggleDetailPanel(),
|
||||
@@ -208,7 +196,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
const Info: ActionItem = {
|
||||
title: $t('info'),
|
||||
icon: mdiInformationOutline,
|
||||
type: $t('assets'),
|
||||
$if: () => asset.hasMetadata,
|
||||
onAction: () => assetViewerManager.toggleDetailPanel(),
|
||||
shortcuts: { key: 'i' },
|
||||
@@ -217,8 +204,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
const Tag: ActionItem = {
|
||||
title: $t('add_tag'),
|
||||
icon: mdiTagPlusOutline,
|
||||
type: $t('assets'),
|
||||
$if: () => userPreferences.tags.enabled,
|
||||
$if: () => authManager.authenticated && authManager.preferences.tags.enabled,
|
||||
onAction: () => modalManager.show(AssetTagModal, { assetIds: [asset.id] }),
|
||||
shortcuts: { key: 't' },
|
||||
};
|
||||
@@ -226,7 +212,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
const TagPeople: ActionItem = {
|
||||
title: $t('tag_people'),
|
||||
icon: mdiFaceRecognition,
|
||||
type: $t('assets'),
|
||||
$if: () => isOwner && asset.type === AssetTypeEnum.Image && !asset.isTrashed,
|
||||
onAction: () => assetViewerManager.toggleFaceEditMode(),
|
||||
shortcuts: { key: 'p' },
|
||||
@@ -315,7 +300,10 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
|
||||
|
||||
if (asset.livePhotoVideoId) {
|
||||
const motionAsset = await getAssetInfo({ ...authManager.params, id: asset.livePhotoVideoId });
|
||||
if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) {
|
||||
if (
|
||||
!isAndroidMotionVideo(motionAsset) ||
|
||||
(authManager.authenticated && authManager.preferences.download.includeEmbeddedVideos)
|
||||
) {
|
||||
const motionFilename = motionAsset.originalFileName;
|
||||
const lastDotIndex = motionFilename.lastIndexOf('.');
|
||||
const motionDownloadFilename =
|
||||
|
||||
@@ -16,14 +16,12 @@ import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export const getDatabaseBackupActions = ($t: MessageFormatter, filename: string) => {
|
||||
const Download: ActionItem = {
|
||||
type: $t('command'),
|
||||
title: $t('download'),
|
||||
icon: mdiDownload,
|
||||
onAction: () => handleDownloadDatabaseBackup(filename),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
type: $t('command'),
|
||||
title: $t('delete'),
|
||||
icon: mdiTrashCanOutline,
|
||||
color: 'danger',
|
||||
|
||||
@@ -26,7 +26,6 @@ import type { MessageFormatter } from 'svelte-i18n';
|
||||
export const getLibrariesActions = ($t: MessageFormatter) => {
|
||||
const ScanAll: ActionItem = {
|
||||
title: $t('scan_all_libraries'),
|
||||
type: $t('command'),
|
||||
icon: mdiSync,
|
||||
onAction: () => handleScanAllLibraries(),
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
@@ -34,7 +33,6 @@ export const getLibrariesActions = ($t: MessageFormatter) => {
|
||||
|
||||
const Create: ActionItem = {
|
||||
title: $t('create_library'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onAction: () => goto(Route.newLibrary()),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
@@ -46,14 +44,12 @@ export const getLibrariesActions = ($t: MessageFormatter) => {
|
||||
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
|
||||
const Detail: ActionItem = {
|
||||
icon: mdiInformationOutline,
|
||||
type: $t('command'),
|
||||
title: $t('details'),
|
||||
onAction: () => goto(Route.viewLibrary(library)),
|
||||
};
|
||||
|
||||
const Edit: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
title: $t('edit'),
|
||||
onAction: () => goto(Route.editLibrary(library)),
|
||||
shortcuts: { key: 'r' },
|
||||
@@ -61,7 +57,6 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
|
||||
const Delete: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
type: $t('command'),
|
||||
title: $t('delete'),
|
||||
color: 'danger',
|
||||
onAction: () => handleDeleteLibrary(library),
|
||||
@@ -71,21 +66,18 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
|
||||
const AddFolder: ActionItem = {
|
||||
icon: mdiPlusBoxOutline,
|
||||
type: $t('command'),
|
||||
title: $t('add'),
|
||||
onAction: () => modalManager.show(LibraryFolderAddModal, { library }),
|
||||
};
|
||||
|
||||
const AddExclusionPattern: ActionItem = {
|
||||
icon: mdiPlusBoxOutline,
|
||||
type: $t('command'),
|
||||
title: $t('add'),
|
||||
onAction: () => modalManager.show(LibraryExclusionPatternAddModal, { library }),
|
||||
};
|
||||
|
||||
const Scan: ActionItem = {
|
||||
icon: mdiSync,
|
||||
type: $t('command'),
|
||||
title: $t('scan_library'),
|
||||
onAction: () => handleScanLibrary(library),
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
@@ -97,14 +89,12 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
|
||||
const Edit: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
title: $t('edit'),
|
||||
onAction: () => modalManager.show(LibraryFolderEditModal, { folder, library }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
type: $t('command'),
|
||||
title: $t('delete'),
|
||||
onAction: () => handleDeleteLibraryFolder(library, folder),
|
||||
};
|
||||
@@ -119,14 +109,12 @@ export const getLibraryExclusionPatternActions = (
|
||||
) => {
|
||||
const Edit: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
title: $t('edit'),
|
||||
onAction: () => modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
type: $t('command'),
|
||||
title: $t('delete'),
|
||||
onAction: () => handleDeleteExclusionPattern(library, exclusionPattern),
|
||||
};
|
||||
|
||||
@@ -64,7 +64,6 @@ export const getQueuesActions = ($t: MessageFormatter, queues: QueueResponseDto[
|
||||
const CreateJob: ActionItem = {
|
||||
icon: mdiPlus,
|
||||
title: $t('admin.create_job'),
|
||||
type: $t('command'),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
onAction: () => modalManager.show(JobCreateModal, {}),
|
||||
};
|
||||
@@ -73,7 +72,6 @@ export const getQueuesActions = ($t: MessageFormatter, queues: QueueResponseDto[
|
||||
icon: mdiCog,
|
||||
title: $t('admin.manage_concurrency'),
|
||||
description: $t('admin.manage_concurrency_description'),
|
||||
type: $t('page'),
|
||||
onAction: () => goto(Route.systemSettings({ isOpen: OpenQueryParam.JOB })),
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ export const getSystemConfigActions = (
|
||||
const CopyToClipboard: ActionItem = {
|
||||
title: $t('copy_to_clipboard'),
|
||||
description: $t('admin.copy_config_to_clipboard_description'),
|
||||
type: $t('command'),
|
||||
icon: mdiContentCopy,
|
||||
onAction: () => handleCopyToClipboard(config),
|
||||
shortcuts: { shift: true, key: 'c' },
|
||||
@@ -27,7 +26,6 @@ export const getSystemConfigActions = (
|
||||
const Download: ActionItem = {
|
||||
title: $t('export_as_json'),
|
||||
description: $t('admin.export_config_as_json_description'),
|
||||
type: $t('command'),
|
||||
icon: mdiDownload,
|
||||
onAction: () => handleDownloadConfig(config),
|
||||
shortcuts: [
|
||||
@@ -39,7 +37,6 @@ export const getSystemConfigActions = (
|
||||
const Upload: ActionItem = {
|
||||
title: $t('import_from_json'),
|
||||
description: $t('admin.import_config_from_json_description'),
|
||||
type: $t('command'),
|
||||
icon: mdiUpload,
|
||||
$if: () => !featureFlags.configFile,
|
||||
onAction: () => handleUploadConfig(),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
|
||||
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
|
||||
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { user as authUser } from '$lib/stores/user.store';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
@@ -32,12 +32,10 @@ import {
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const getUserAdminsActions = ($t: MessageFormatter) => {
|
||||
const Create: ActionItem = {
|
||||
title: $t('create_user'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onAction: () => goto(Route.newUser()),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
@@ -62,9 +60,8 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
||||
const Delete: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
title: $t('delete'),
|
||||
type: $t('command'),
|
||||
color: 'danger',
|
||||
$if: () => get(authUser).id !== user.id && !user.deletedAt,
|
||||
$if: () => authManager.user.id !== user.id && !user.deletedAt,
|
||||
onAction: () => modalManager.show(UserDeleteConfirmModal, { user }),
|
||||
shortcuts: { key: 'Backspace' },
|
||||
shortcutOptions: { ignoreInputFields: true },
|
||||
@@ -76,7 +73,6 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
||||
const Restore: HeaderButtonActionItem = {
|
||||
icon: mdiDeleteRestore,
|
||||
title: $t('restore'),
|
||||
type: $t('command'),
|
||||
color: 'primary',
|
||||
data: {
|
||||
title: $t('admin.user_restore_scheduled_removal', { values: { date: getDeleteDate(user.deletedAt!) } }),
|
||||
@@ -88,14 +84,12 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
||||
const ResetPassword: ActionItem = {
|
||||
icon: mdiLockReset,
|
||||
title: $t('reset_password'),
|
||||
type: $t('command'),
|
||||
$if: () => get(authUser).id !== user.id,
|
||||
$if: () => authManager.user.id !== user.id,
|
||||
onAction: () => handleResetPasswordUserAdmin(user),
|
||||
};
|
||||
|
||||
const ResetPinCode: ActionItem = {
|
||||
icon: mdiLockSmart,
|
||||
type: $t('command'),
|
||||
title: $t('reset_pin_code'),
|
||||
onAction: () => handleResetPinCodeUserAdmin(user),
|
||||
};
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { Theme, defaultLang } from '$lib/constants';
|
||||
import { defaultLang } from '$lib/constants';
|
||||
import { getPreferredLocale } from '$lib/utils/i18n';
|
||||
import { persisted } from 'svelte-persisted-store';
|
||||
|
||||
export interface ThemeSetting {
|
||||
value: Theme;
|
||||
system: boolean;
|
||||
}
|
||||
|
||||
// Locale to use for formatting dates, numbers, etc.
|
||||
export const locale = persisted('locale', 'default', {
|
||||
serializer: {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const user = writable<UserAdminResponseDto>();
|
||||
export const preferences = writable<UserPreferencesResponseDto>();
|
||||
|
||||
/**
|
||||
* Reset the store to its initial undefined value. Make sure to
|
||||
* only do this _after_ redirecting to an unauthenticated page.
|
||||
*/
|
||||
export const resetSavedUser = () => {
|
||||
user.set(undefined as unknown as UserAdminResponseDto);
|
||||
preferences.set(undefined as unknown as UserPreferencesResponseDto);
|
||||
};
|
||||
|
||||
eventManager.on({
|
||||
AuthLogout: () => resetSavedUser(),
|
||||
});
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '@immich/sdk';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { user } from './user.store';
|
||||
|
||||
interface AppRestartEvent {
|
||||
isMaintenanceMode: boolean;
|
||||
@@ -79,7 +78,7 @@ websocket
|
||||
}
|
||||
})
|
||||
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
|
||||
.on('on_session_delete', () => authManager.logout())
|
||||
.on('on_session_delete', () => eventManager.emit('SessionDelete'))
|
||||
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
|
||||
.on('on_asset_update', (asset) => eventManager.emit('AssetUpdate', asset))
|
||||
.on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id }))
|
||||
@@ -88,7 +87,11 @@ websocket
|
||||
|
||||
export const openWebsocketConnection = () => {
|
||||
try {
|
||||
if (get(user) || get(websocketStore.serverRestarting) || page.url.pathname.startsWith(Route.maintenanceMode())) {
|
||||
if (
|
||||
authManager.authenticated ||
|
||||
get(websocketStore.serverRestarting) ||
|
||||
page.url.pathname.startsWith(Route.maintenanceMode())
|
||||
) {
|
||||
websocket.connect();
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { defaultLang, langs, locales } from '$lib/constants';
|
||||
import { defaultLang, locales } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { langs } from '$lib/utils/i18n';
|
||||
import {
|
||||
AssetMediaSize,
|
||||
AssetTypeEnum,
|
||||
@@ -218,7 +219,7 @@ export function getAssetUrls(asset: AssetResponseDto, sharedLink?: SharedLinkRes
|
||||
}
|
||||
|
||||
const forceUseOriginal = (asset: AssetResponseDto) => {
|
||||
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
|
||||
return asset.type === AssetTypeEnum.Image && asset.duration;
|
||||
};
|
||||
|
||||
export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { downloadRequest, withError } from '$lib/utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
type DownloadInfoDto,
|
||||
type ExifResponseDto,
|
||||
type StackResponseDto,
|
||||
type UserPreferencesResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
@@ -102,9 +100,8 @@ export const downloadUrl = (url: string, filename: string) => {
|
||||
};
|
||||
|
||||
export const downloadArchive = async (fileName: string, options: Omit<DownloadInfoDto, 'archiveSize'>) => {
|
||||
const $preferences = get<UserPreferencesResponseDto | undefined>(preferences);
|
||||
const dto = { ...options, archiveSize: $preferences?.download.archiveSize };
|
||||
|
||||
const archiveSize = authManager.authenticated ? authManager.preferences.download.archiveSize : undefined;
|
||||
const dto = { ...options, archiveSize };
|
||||
const [error, downloadInfo] = await withError(() => getDownloadInfo({ ...authManager.params, downloadInfoDto: dto }));
|
||||
if (error) {
|
||||
const $t = get(t);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user