Merge remote-tracking branch 'origin/main' into feat/yucca-integration

This commit is contained in:
izzy
2026-05-08 14:00:20 +01:00
948 changed files with 57491 additions and 11659 deletions
+1 -1
View File
@@ -1 +1 @@
24.14.1
24.15.0
+3 -2
View File
@@ -1,8 +1,9 @@
{
"importOrder": ["<THIRD_PARTY_MODULES>", "<BUILTIN_MODULES>", "^\\$(.*)$", "^@test-data(.*)$", "^[./]"],
"importOrderSeparation": false,
"jsonRecursiveSort": true,
"organizeImportsSkipDestructiveCodeActions": true,
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-svelte", "prettier-plugin-sort-json"],
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-svelte", "prettier-plugin-sort-json"],
"printWidth": 120,
"semi": true,
"singleQuote": true,
+15 -3
View File
@@ -1,15 +1,16 @@
import js from '@eslint/js';
import tslintPluginCompat from '@koddsson/eslint-plugin-tscompat';
import prettier from 'eslint-config-prettier';
import eslintPluginBetterTailwindcss from 'eslint-plugin-better-tailwindcss';
import eslintPluginCompat from 'eslint-plugin-compat';
import eslintPluginSvelte from 'eslint-plugin-svelte';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import globals from 'globals';
import parser from 'svelte-eslint-parser';
import typescriptEslint from 'typescript-eslint';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import parser from 'svelte-eslint-parser';
import typescriptEslint from 'typescript-eslint';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -18,7 +19,6 @@ export default typescriptEslint.config(
...eslintPluginSvelte.configs.recommended,
eslintPluginUnicorn.configs.recommended,
js.configs.recommended,
prettier,
{
plugins: {
tscompat: tslintPluginCompat,
@@ -134,6 +134,18 @@ export default typescriptEslint.config(
},
},
{
extends: [eslintPluginBetterTailwindcss.configs.recommended],
settings: {
'better-tailwindcss': {
entryPoint: 'src/app.css',
},
},
rules: {
'better-tailwindcss/enforce-consistent-line-wrapping': 'off',
'better-tailwindcss/no-unknown-classes': 'off',
},
files: ['**/*.svelte'],
languageOptions: {
+9 -7
View File
@@ -28,7 +28,7 @@
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.76.0",
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@mdi/js": "^7.4.47",
"@noble/hashes": "^2.2.0",
"@photo-sphere-viewer/core": "^5.14.0",
@@ -51,6 +51,7 @@
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"maplibre-gl": "^5.6.2",
"media-chrome": "^4.19.0",
"orchestration-ui": "0.1.69",
"pmtiles": "^4.3.0",
"qrcode": "^1.5.4",
@@ -77,10 +78,11 @@
"@sveltejs/enhanced-img": "^0.10.4",
"@sveltejs/kit": "^2.56.1",
"@sveltejs/vite-plugin-svelte": "7.0.0",
"@tailwindcss/vite": "^4.2.2",
"@tailwindcss/vite": "^4.2.4",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.8",
"@testing-library/user-event": "^14.5.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/chromecast-caf-sender": "^1.0.11",
"@types/dom-to-image": "^2.6.7",
"@types/justified-layout": "^4.1.4",
@@ -89,8 +91,9 @@
"@types/qrcode": "^1.5.5",
"@vitest/coverage-v8": "^4.0.0",
"dotenv": "^17.0.0",
"eslint": "^10.0.0",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-better-tailwindcss": "^4.5.0",
"eslint-plugin-compat": "^7.0.0",
"eslint-plugin-svelte": "^3.12.4",
"eslint-plugin-unicorn": "^64.0.0",
@@ -98,20 +101,19 @@
"globals": "^17.0.0",
"happy-dom": "^20.0.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^7.0.0",
"svelte": "5.55.1",
"svelte": "5.55.2",
"svelte-check": "^4.4.6",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.2.2",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.0",
"typescript-eslint": "^8.45.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
"volta": {
"node": "24.14.1"
"node": "24.15.0"
}
}
+31 -30
View File
@@ -1,8 +1,38 @@
@import 'tailwindcss';
@import '@immich/ui/theme/default.css';
@source "../node_modules/@immich/ui";
/* @import '../../../ui/packages/ui/dist/theme/default.css'; */
@custom-variant dark (&:where(.dark, .dark *):not(.light));
@theme inline {
--color-immich-primary: rgb(var(--immich-primary));
--color-immich-bg: rgb(var(--immich-bg));
--color-immich-fg: rgb(var(--immich-fg));
--color-immich-gray: rgb(var(--immich-gray));
--color-immich-dark-primary: rgb(var(--immich-dark-primary));
--color-immich-dark-bg: rgb(var(--immich-dark-bg));
--color-immich-dark-fg: rgb(var(--immich-dark-fg));
--color-immich-dark-gray: rgb(var(--immich-dark-gray));
}
@theme {
--font-sans: 'GoogleSans', sans-serif;
--font-mono: 'GoogleSansCode', monospace;
--spacing-18: 4.5rem;
--breakpoint-tall: 800px;
--breakpoint-2xl: 1535px;
--breakpoint-xl: 1279px;
--breakpoint-lg: 1023px;
--breakpoint-md: 767px;
--breakpoint-sm: 639px;
--breakpoint-sidebar: 850px;
}
@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;
}
@@ -34,35 +64,6 @@
grid-template-columns: repeat(auto-fill, minmax(min(calc(var(--spacing) * --value(number)), 100%), 1fr));
}
@custom-variant dark (&:where(.dark, .dark *):not(.light));
@theme inline {
--color-immich-primary: rgb(var(--immich-primary));
--color-immich-bg: rgb(var(--immich-bg));
--color-immich-fg: rgb(var(--immich-fg));
--color-immich-gray: rgb(var(--immich-gray));
--color-immich-dark-primary: rgb(var(--immich-dark-primary));
--color-immich-dark-bg: rgb(var(--immich-dark-bg));
--color-immich-dark-fg: rgb(var(--immich-dark-fg));
--color-immich-dark-gray: rgb(var(--immich-dark-gray));
}
@theme {
--font-sans: 'GoogleSans', sans-serif;
--font-mono: 'GoogleSansCode', monospace;
--spacing-18: 4.5rem;
--breakpoint-tall: 800px;
--breakpoint-2xl: 1535px;
--breakpoint-xl: 1279px;
--breakpoint-lg: 1023px;
--breakpoint-md: 767px;
--breakpoint-sm: 639px;
--breakpoint-sidebar: 850px;
}
@layer base {
:root {
/* light */
@@ -168,7 +169,7 @@
.maplibregl-popup {
.maplibregl-popup-tip {
@apply border-t-subtle! translate-y-[-1px];
@apply border-t-subtle! -translate-y-px;
}
.maplibregl-popup-content {
+4 -4
View File
@@ -1,3 +1,6 @@
import 'svelte-i18n';
import type en from '$i18n/en.json';
/// <reference types="@sveltejs/kit" />
// See https://kit.svelte.dev/docs/types#app
@@ -28,15 +31,12 @@ interface Element {
requestFullscreen?(options?: FullscreenOptions): Promise<void>;
}
import type en from '$i18n/en.json';
import 'svelte-i18n';
type NestedKeys<T, K = keyof T> = K extends keyof T & string
? `${K}` | (T[K] extends object ? `${K}.${NestedKeys<T[K]>}` : never)
: never;
declare module 'svelte-i18n' {
import type { InterpolationValues } from '$lib/elements/format-message.svelte';
import type { InterpolationValues } from '$lib/elements/format-message';
import type { Readable } from 'svelte/store';
type Translations = NestedKeys<typeof en>;
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Handle } from '@sveltejs/kit';
import GoogleSans from '$lib/assets/fonts/GoogleSans/GoogleSans.ttf?url';
import GoogleSansCode from '$lib/assets/fonts/GoogleSansCode/GoogleSansCode.ttf?url';
import type { Handle } from '@sveltejs/kit';
// only used during the build to replace the variables from app.html
export const handle = (async ({ event, resolve }) => {
@@ -1,8 +1,8 @@
import FocusTrapTest from '$lib/actions/__test__/focus-trap-test.svelte';
import { setDefaultTabbleOptions } from '$lib/utils/focus-util';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { tick } from 'svelte';
import FocusTrapTest from '$lib/actions/__test__/FocusTrapTest.svelte';
import { setDefaultTabbleOptions } from '$lib/utils/focus-util';
setDefaultTabbleOptions({ displayCheck: 'none' });
+1 -1
View File
@@ -1,5 +1,5 @@
import { matchesShortcut } from '$lib/actions/shortcut';
import type { ActionReturn } from 'svelte/action';
import { matchesShortcut } from '$lib/actions/shortcut';
interface Options {
onOutclick?: () => void;
@@ -1,6 +1,6 @@
import { shortcuts } from '$lib/actions/shortcut';
import { tick } from 'svelte';
import type { Action } from 'svelte/action';
import { shortcuts } from '$lib/actions/shortcut';
interface Options {
/**
+1 -1
View File
@@ -1,5 +1,5 @@
import { getTabbable } from '$lib/utils/focus-util';
import { tick } from 'svelte';
import { getTabbable } from '$lib/utils/focus-util';
interface Options {
/**
+1 -1
View File
@@ -1,5 +1,5 @@
import { shortcuts } from '$lib/actions/shortcut';
import type { Action } from 'svelte/action';
import { shortcuts } from '$lib/actions/shortcut';
/**
* Enables keyboard navigation (up and down arrows) for a list of elements.
+1 -1
View File
@@ -1,5 +1,5 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
type TouchEventLike = {
+5 -5
View File
@@ -1,8 +1,3 @@
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,
@@ -14,6 +9,11 @@ import {
mdiThemeLightDark,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
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';
export const getPagesProvider = ($t: MessageFormatter) => {
const adminPages: ActionItem[] = [
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { isEnabled } from '$lib/utils';
import { type ActionItem } from '@immich/ui';
+5 -5
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import BrokenAsset from '$lib/components/assets/BrokenAsset.svelte';
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
import ImageLayer from '$lib/components/ImageLayer.svelte';
import Thumbhash from '$lib/components/Thumbhash.svelte';
@@ -148,11 +148,11 @@
});
</script>
<div class="relative h-full w-full overflow-hidden" bind:this={ref}>
<div class="relative size-full overflow-hidden" bind:this={ref}>
{@render backdrop?.()}
<div
class="absolute inset-0 pointer-events-none"
class="pointer-events-none absolute inset-0"
style:inset-inline-start={insetInlineStart}
style:top
style:width
@@ -165,7 +165,7 @@
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<Thumbhash base64ThumbHash={asset.thumbhash} class="h-full w-full absolute" />
<Thumbhash base64ThumbHash={asset.thumbhash} class="absolute size-full" />
{:else if show.spinner}
<DelayedLoadingSpinner />
{/if}
@@ -185,7 +185,7 @@
{/if}
{#if show.brokenAsset}
<BrokenAsset class="text-xl h-full w-full absolute" />
<BrokenAsset class="absolute size-full text-xl" />
{/if}
{#if show.preview}
+1 -1
View File
@@ -15,7 +15,7 @@
<Card color="secondary">
<CardHeader>
<div class="flex w-full justify-between items-center px-4 py-2">
<div class="flex w-full items-center justify-between px-4 py-2">
<div class="flex gap-2 text-primary">
<Icon {icon} size="1.5rem" />
<CardTitle>{title}</CardTitle>
@@ -1,5 +1,5 @@
<script lang="ts">
import ApiKeyGrid from '$lib/components/user-settings-page/user-api-key-grid.svelte';
import ApiKeyGrid from '$lib/components/user-settings-page/UserApiKeyGrid.svelte';
import { Permission } from '@immich/sdk';
import { Checkbox, IconButton, Input, Label } from '@immich/ui';
import { mdiClose } from '@mdi/js';
@@ -50,7 +50,7 @@
</script>
<Label label={$t('permission')} for="permission-container" />
<div class="flex items-center gap-2 m-4" id="permission-container">
<div class="m-4 flex items-center gap-2" id="permission-container">
<Checkbox id="input-select-all" size="tiny" checked={allItemsSelected} onCheckedChange={onCheckedAllChange} />
<Label label={$t('select_all')} for="input-select-all" />
</div>
@@ -30,8 +30,8 @@
);
</script>
<div class="h-full flex flex-col">
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
<div class="flex h-full flex-col">
<div class="flex h-16 w-full items-center justify-between border-b px-4 py-2 md:px-2">
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
{#if enabledActions.length > 0}
+1 -1
View File
@@ -1,6 +1,6 @@
import { fireEvent, render } from '@testing-library/svelte';
import Image from '$lib/components/Image.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { fireEvent, render } from '@testing-library/svelte';
vi.mock('$lib/utils/sw-messaging', () => ({
cancelImageUrl: vi.fn(),
+1 -1
View File
@@ -36,7 +36,7 @@
onLoad={() => adaptiveImageLoader.onLoad(quality)}
onError={() => adaptiveImageLoader.onError(quality)}
bind:ref
class="h-full w-full bg-transparent pointer-events-auto"
class="pointer-events-auto size-full bg-transparent"
{alt}
{role}
draggable={false}
@@ -35,7 +35,7 @@
const setSelectedDate = (value: DateTime | undefined) => {
selectedPresetValue = null; // Clear preset when manually setting date
expiresAt = value ? value.toISO() : null;
expiresAt = value ? value.toUTC().toISO() : null;
};
const selectPreset = (value: number) => {
@@ -44,8 +44,8 @@
expiresAt = null;
return;
}
const newDate = DateTime.now().plus(value);
expiresAt = newDate.toISO();
const newDate = DateTime.now().plus({ milliseconds: value });
expiresAt = newDate.toUTC().toISO();
};
const isSelected = (value: number) => {
@@ -58,7 +58,7 @@
<DatePicker bind:value={getSelectedDate, setSelectedDate} />
</Field>
<div class="flex flex-wrap gap-2 mt-2">
<div class="mt-2 flex flex-wrap gap-2">
{#each expiredDateOptions as option (option.value)}
<Button
size="tiny"
@@ -1,5 +1,5 @@
import { renderWithTooltips } from '$tests/helpers';
import userEvent from '@testing-library/user-event';
import { renderWithTooltips } from '$tests/helpers';
import SharedLinkFormFields from './SharedLinkFormFields.svelte';
describe('SharedLinkFormFields component', () => {
@@ -30,7 +30,7 @@
});
</script>
<div class="flex flex-col gap-4 mt-4">
<div class="mt-4 flex flex-col gap-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
@@ -2,8 +2,8 @@
import SupportedDatetimePanel from '$lib/components/admin-settings/SupportedDatetimePanel.svelte';
import SupportedVariablesPanel from '$lib/components/admin-settings/SupportedVariablesPanel.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingInputField from '$lib/components/shared-components/settings/SettingInputField.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/SettingSwitch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
@@ -106,7 +106,7 @@
});
</script>
<section class="dark:text-immich-dark-fg mt-2">
<section class="mt-2 dark:text-immich-dark-fg">
<div in:fade={{ duration }} class="mx-4 flex flex-col gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.storage_template_more_details">
@@ -164,7 +164,7 @@
<SupportedVariablesPanel />
</section>
<div class="flex flex-col mt-2">
<div class="mt-2 flex flex-col">
<!-- <h3 class="text-base font-medium text-primary">{$t('template')}</h3> -->
<Heading size="tiny" color="primary">
{$t('template')}
@@ -199,20 +199,20 @@
</FormatMessage>
</p>
<p class="p-4 py-2 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
<p class="mt-2 rounded-lg bg-gray-200 p-4 py-2 text-xs dark:bg-gray-700 dark:text-immich-dark-fg">
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
>UPLOAD_LOCATION/library/{authManager.user.storageLabel || authManager.user.id}</span
>/{parsedTemplate()}.jpg
</p>
<form autocomplete="off" class="flex flex-col" onsubmit={preventDefault(bubble('submit'))}>
<div class="flex flex-col my-2">
<div class="my-2 flex flex-col">
{#if templateOptions}
<label class="font-medium text-primary text-sm" for="preset-select">
<label class="text-sm font-medium text-primary" for="preset-select">
{$t('preset')}
</label>
<select
class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
class="mt-2 immich-form-input rounded-lg bg-slate-200 p-2 text-sm hover:cursor-pointer dark:bg-gray-600"
disabled={disabled || !configToEdit.storageTemplate.enabled}
name="presets"
id="preset-select"
@@ -27,7 +27,7 @@
<Text size="small">{$t('date_and_time')}</Text>
<Card class="mt-2 text-sm bg-light-50 shadow-none">
<Card class="mt-2 bg-light-50 text-sm shadow-none">
<CardHeader>
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
<Text color="primary"
@@ -5,7 +5,7 @@
<Text size="small">{$t('other_variables')}</Text>
<Card class="mt-2 text-sm bg-light-50 shadow-none">
<Card class="mt-2 bg-light-50 text-sm shadow-none">
<CardBody>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div>
@@ -1,9 +1,9 @@
<script lang="ts">
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
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';
import { type AlbumResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -34,13 +34,13 @@
</script>
<div
class="group relative rounded-2xl border border-transparent p-5 hover:bg-gray-100 hover:border-gray-200 dark:hover:border-gray-800 dark:hover:bg-gray-900"
class="group relative rounded-2xl border border-transparent p-5 hover:border-gray-200 hover:bg-gray-100 dark:hover:border-gray-800 dark:hover:bg-gray-900"
data-testid="album-card"
>
{#if onShowContextMenu}
<div
id="icon-{album.id}"
class="absolute end-6 top-6 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
class="absolute inset-e-6 top-6 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
data-testid="context-button-parent"
>
<IconButton
@@ -60,7 +60,7 @@
<div class="mt-4">
<p
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-primary"
class="line-clamp-2 w-full text-lg/6 font-semibold text-black group-hover:text-primary dark:text-white"
data-testid="album-name"
title={album.albumName}
>
@@ -68,7 +68,7 @@
</p>
{#if showDateRange && album.startDate && album.endDate}
<p class="flex text-sm dark:text-immich-dark-fg capitalize">
<p class="flex text-sm capitalize dark:text-immich-dark-fg">
{getShortDateRange(album.startDate, album.endDate)}
</p>
{/if}
@@ -85,12 +85,13 @@
{/if}
{#if showOwner}
{#if authManager.user.id === album.ownerId}
{@const owner = album.albumUsers[0].user}
{#if owner.id === authManager.user.id}
<p>{$t('owned')}</p>
{:else if album.owner}
<p>{$t('shared_by_user', { values: { user: album.owner.name } })}</p>
{:else}
<p>{$t('shared')}</p>
<p>
{$t('shared_by_user', { values: { user: owner.name } })}
</p>
{/if}
{:else if album.shared}
<p>{$t('shared')}</p>
@@ -1,5 +1,5 @@
<script lang="ts">
import AlbumCard from '$lib/components/album-page/album-card.svelte';
import AlbumCard from '$lib/components/album-page/AlbumCard.svelte';
import { Route } from '$lib/route';
import { albumViewSettings } from '$lib/stores/preferences.store';
import { type AlbumGroup, isAlbumGroupCollapsed, toggleAlbumGroupCollapsing } from '$lib/utils/album-utils';
@@ -48,11 +48,11 @@
<button
type="button"
onclick={() => toggleAlbumGroupCollapsing(group.id)}
class="w-full text-start mt-2 pt-2 pe-2 pb-2 rounded-md transition-colors cursor-pointer dark:text-immich-dark-fg hover:text-primary hover:bg-subtle dark:hover:bg-immich-dark-gray"
class="mt-2 w-full cursor-pointer rounded-md py-2 pe-2 text-start transition-colors hover:bg-subtle hover:text-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray"
aria-expanded={!isCollapsed}
>
<Icon icon={mdiChevronRight} size="24" class="inline-block -mt-2.5 transition-all duration-250 {iconRotation}" />
<span class="font-bold text-3xl text-black dark:text-white">{group.name}</span>
<Icon icon={mdiChevronRight} size="24" class="-mt-2.5 inline-block transition-all duration-250 {iconRotation}" />
<span class="text-3xl font-bold text-black dark:text-white">{group.name}</span>
<span class="ms-1.5">({$t('albums_count', { values: { count: albums.length } })})</span>
</button>
<hr class="dark:border-immich-dark-gray" />
@@ -1,6 +1,6 @@
<script lang="ts">
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
import AssetCover from '$lib/components/sharedlinks-page/covers/AssetCover.svelte';
import NoCover from '$lib/components/sharedlinks-page/covers/NoCover.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { type AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
@@ -34,7 +34,7 @@
const { ViewQrCode, Copy, Delete } = $derived(getSharedLinkActions($t, sharedLink));
</script>
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<Text size="small">{sharedLink.description || album.albumName}</Text>
<Text size="tiny" color="muted">{getShareProperties()}</Text>
@@ -1,6 +1,6 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
@@ -20,9 +20,9 @@
import { ActionButton, IconButton, Logo } from '@immich/ui';
import { mdiDownload, mdiFileImagePlusOutline, mdiPresentationPlay } from '@mdi/js';
import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ThemeButton from '../shared-components/theme-button.svelte';
import AlbumSummary from './album-summary.svelte';
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import ThemeButton from '../shared-components/ThemeButton.svelte';
import AlbumSummary from './AlbumSummary.svelte';
interface Props {
sharedLink: SharedLinkResponseDto;
@@ -70,11 +70,11 @@
}}
/>
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
<main class="relative h-dvh overflow-hidden px-2 pt-(--navbar-height) max-md:pt-(--navbar-height-md) md:px-6">
<Timeline enableRouting={true} {album} bind:timelineManager {options} assetInteraction={assetMultiSelectManager}>
<section class="pt-8 md:pt-24 px-2 md:px-0">
<section class="px-2 pt-8 md:px-0 md:pt-24">
<!-- ALBUM TITLE -->
<h1 class="text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all">
<h1 class="text-2xl text-primary transition-all outline-none md:text-4xl lg:text-6xl">
{album.albumName}
</h1>
@@ -85,7 +85,7 @@
<!-- ALBUM DESCRIPTION -->
{#if album.description}
<p
class="whitespace-pre-line mb-12 mt-6 w-full pb-2 text-start font-medium text-base text-black dark:text-gray-300"
class="mt-6 mb-12 w-full pb-2 text-start text-base font-medium whitespace-pre-line text-black dark:text-gray-300"
>
{album.description}
</p>
@@ -1,9 +1,9 @@
<script lang="ts">
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
import AlbumCardGroup from '$lib/components/album-page/AlbumCardGroup.svelte';
import AlbumsTable from '$lib/components/album-page/AlbumsTable.svelte';
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 MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import RightClickContextMenu from '$lib/components/shared-components/context-menu/RightClickContextMenu.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
@@ -20,7 +20,7 @@
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';
import { type AlbumResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { AlbumUserRole, type AlbumResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
import { groupBy } from 'lodash-es';
@@ -98,24 +98,26 @@
/** Group by owner */
[AlbumGroupBy.Owner]: (order, albums): AlbumGroup[] => {
const currentUserId = authManager.user.id;
const groupedByOwnerIds = groupBy(albums, 'ownerId');
const groupedByOwnerIds = groupBy(albums, (album) => album.albumUsers[0].user.id);
const sortSign = order === SortOrder.Desc ? -1 : 1;
const sortedByOwnerNames = Object.entries(groupedByOwnerIds).sort(([ownerA, albumsA], [ownerB, albumsB]) => {
const sortedByOwnerNames = Object.entries(groupedByOwnerIds).sort(([ownerIdA, albumsA], [ownerIdB, albumsB]) => {
// We make sure owned albums stay either at the beginning or the end
// of the list
if (ownerA === currentUserId) {
if (ownerIdA === currentUserId) {
return -sortSign;
} else if (ownerB === currentUserId) {
} else if (ownerIdB === currentUserId) {
return sortSign;
} else {
return albumsA[0].owner.name.localeCompare(albumsB[0].owner.name, $locale) * sortSign;
const ownerA = albumsA[0].albumUsers[0].user;
const ownerB = albumsB[0].albumUsers[0].user;
return ownerA.name.localeCompare(ownerB.name, $locale) * sortSign;
}
});
return sortedByOwnerNames.map(([ownerId, albums]) => ({
id: ownerId,
name: ownerId === currentUserId ? $t('my_albums') : albums[0].owner.name,
name: ownerId === currentUserId ? $t('my_albums') : albums[0].albumUsers[0].user.name,
albums,
}));
},
@@ -130,7 +132,10 @@
return sharedAlbums;
}
default: {
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== authManager.user.id);
const nonOwnedAlbums = sharedAlbums.filter(
(album) =>
album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role !== AlbumUserRole.Owner,
);
return nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
}
}
@@ -167,7 +172,9 @@
albumGroupIds = groupedAlbums.map(({ id }) => id);
});
let showFullContextMenu = $derived(allowEdit && selectedAlbum && selectedAlbum.ownerId === authManager.user.id);
let showFullContextMenu = $derived(
allowEdit && selectedAlbum && selectedAlbum.albumUsers[0].user.id === authManager.user.id,
);
onMount(async () => {
if (allowEdit) {
@@ -1,6 +1,6 @@
<script lang="ts">
import AlbumTableHeader from '$lib/components/album-page/albums-table-header.svelte';
import AlbumTableRow from '$lib/components/album-page/albums-table-row.svelte';
import AlbumTableHeader from '$lib/components/album-page/AlbumsTableHeader.svelte';
import AlbumTableRow from '$lib/components/album-page/AlbumsTableRow.svelte';
import { AlbumGroupBy, albumViewSettings } from '$lib/stores/preferences.store';
import {
isAlbumGroupCollapsed,
@@ -45,20 +45,20 @@
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4"
class="mt-4 block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
<tr
class="flex w-full place-items-center p-2 md:ps-5 md:pe-5 md:pt-3 md:pb-3"
class="flex w-full place-items-center p-2 md:py-3 md:ps-5 md:pe-5"
onclick={() => toggleAlbumGroupCollapsing(albumGroup.id)}
aria-expanded={!isCollapsed}
>
<td class="text-md text-start -mb-1">
<td class="text-md -mb-1 text-start">
<Icon
icon={mdiChevronRight}
size="20"
class="inline-block -mt-2 transition-all duration-250 {iconRotation}"
class="-mt-2 inline-block transition-all duration-250 {iconRotation}"
/>
<span class="font-bold text-2xl">{albumGroup.name}</span>
<span class="text-2xl font-bold">{albumGroup.name}</span>
<span class="ms-1.5">
({$t('albums_count', { values: { count: albumGroup.albums.length } })})
</span>
@@ -67,7 +67,7 @@
</tbody>
{#if !isCollapsed}
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4"
class="mt-4 block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
transition:slide={{ duration: 300 }}
>
{#each albumGroup.albums as album (album.id)}
@@ -5,7 +5,7 @@
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import type { AlbumResponseDto } from '@immich/sdk';
import { AlbumUserRole, type AlbumResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -32,40 +32,42 @@
</script>
<tr
class="flex w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:px-5 md:py-2"
class="flex w-full place-items-center border-3 border-transparent p-2 text-center odd:bg-subtle/80 even:bg-subtle/20 hover:cursor-pointer hover:border-immich-primary/75 md:px-5 md:py-2 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75"
onclick={() => goto(Route.viewAlbum(album))}
{oncontextmenu}
>
<td class="text-md text-ellipsis text-start w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center">
<td class="text-md w-8/12 items-center text-start text-ellipsis sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]">
{album.albumName}
{#if album.shared}
<Icon
icon={mdiShareVariantOutline}
size="16"
class="inline ms-1 opacity-70"
title={album.ownerId === authManager.user.id
class="ms-1 inline opacity-70"
title={album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role === AlbumUserRole.Owner
? $t('shared_by_you')
: $t('shared_by_user', { values: { user: album.owner.name } })}
: $t('shared_by_user', {
values: { user: album.albumUsers[0].user.name },
})}
/>
{/if}
</td>
<td class="text-md text-ellipsis text-center sm:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]">
<td class="text-md text-center text-ellipsis sm:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]">
{$t('items_count', { values: { count: album.assetCount } })}
</td>
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]">
<td class="text-md hidden w-3/12 text-center text-ellipsis sm:block xl:w-[15%] 2xl:w-[12%]">
{dateLocaleString(album.updatedAt)}
</td>
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]">
<td class="text-md hidden w-3/12 text-center text-ellipsis sm:block xl:w-[15%] 2xl:w-[12%]">
{dateLocaleString(album.createdAt)}
</td>
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
<td class="text-md hidden text-center text-ellipsis xl:block xl:w-[15%] 2xl:w-[12%]">
{#if album.endDate}
{dateLocaleString(album.endDate)}
{:else}
-
{/if}
</td>
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
<td class="text-md hidden text-center text-ellipsis xl:block xl:w-[15%] 2xl:w-[12%]">
{#if album.startDate}
{dateLocaleString(album.startDate)}
{:else}
@@ -1,11 +1,11 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { renderWithTooltips } from '$tests/helpers';
import { albumFactory } from '@test-data/factories/album-factory';
import '@testing-library/jest-dom';
import { render, waitFor, type RenderResult } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { init, register, waitLocale } from 'svelte-i18n';
import AlbumCard from '../album-card.svelte';
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { renderWithTooltips } from '$tests/helpers';
import { albumFactory } from '@test-data/factories/album-factory';
import AlbumCard from '../AlbumCard.svelte';
const onShowContextMenu = vi.fn();
@@ -1,7 +1,7 @@
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import { render } from '@testing-library/svelte';
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { albumFactory } from '@test-data/factories/album-factory';
import { render } from '@testing-library/svelte';
vi.mock('$lib/utils');
@@ -16,7 +16,7 @@
let { isLiked, numberOfComments, numberOfLikes, disabled, onFavorite }: Props = $props();
</script>
<div class="flex p-1 items-center justify-center rounded-full gap-1 bg-subtle/70 border">
<div class="flex items-center justify-center gap-1 rounded-full border bg-subtle/70 p-1">
<Button
{disabled}
onclick={onFavorite}
@@ -1,7 +1,7 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
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 ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
@@ -12,13 +12,13 @@
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 } from '@immich/sdk';
import { ReactionType, type ActivityResponseDto, type AlbumUserResponseDto, 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';
import { t } from 'svelte-i18n';
import { fromAction } from 'svelte/attachments';
import UserAvatar from '../shared-components/user-avatar.svelte';
import UserAvatar from '../shared-components/UserAvatar.svelte';
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
@@ -43,11 +43,11 @@
assetId?: string | undefined;
albumId: string;
assetType?: AssetTypeEnum | undefined;
albumOwnerId: string;
albumUsers: AlbumUserResponseDto[];
disabled: boolean;
}
let { assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled }: Props = $props();
let { assetId = undefined, albumId, assetType = undefined, albumUsers, disabled }: Props = $props();
let innerHeight: number = $state(0);
let activityHeight: number = $state(0);
@@ -56,6 +56,7 @@
let previousAssetId: string | undefined = $state(assetId);
let message = $state('');
let isSendingMessage = $state(false);
const isAlbumOwner = $derived(albumUsers[0].user.id === authManager.user.id);
const timeOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
@@ -109,9 +110,9 @@
};
</script>
<div class="overflow-y-hidden relative h-full border-l border-subtle bg-subtle" bind:offsetHeight={innerHeight}>
<div class="w-full h-full">
<div class="flex w-full h-fit dark:text-immich-dark-fg p-2 bg-subtle" bind:clientHeight={activityHeight}>
<div class="relative h-full overflow-y-hidden border-l border-subtle bg-subtle" bind:offsetHeight={innerHeight}>
<div class="size-full">
<div class="flex h-fit w-full bg-subtle p-2 dark:text-immich-dark-fg" bind:clientHeight={activityHeight}>
<div class="flex place-items-center gap-2">
<IconButton
shape="round"
@@ -127,27 +128,27 @@
</div>
{#if innerHeight}
<div
class="overflow-y-auto immich-scrollbar relative w-full px-2"
class="relative w-full overflow-y-auto px-2 immich-scrollbar"
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
>
{#each activityManager.activities as reaction, index (reaction.id)}
{#if reaction.type === ReactionType.Comment}
<div class="flex dark:bg-gray-800 bg-gray-200 py-3 ps-3 mt-3 rounded-lg gap-4 justify-start">
<div class="mt-3 flex justify-start gap-4 rounded-lg bg-gray-200 py-3 ps-3 dark:bg-gray-800">
<div class="flex items-center">
<UserAvatar user={reaction.user} size="sm" />
</div>
<div class="w-full leading-4 overflow-hidden self-center wrap-break-word text-sm">{reaction.comment}</div>
<div class="w-full self-center overflow-hidden text-sm/4 wrap-break-word">{reaction.comment}</div>
{#if assetId === undefined && reaction.assetId}
<a class="aspect-square w-19 h-19" href={Route.viewAlbumAsset({ albumId, assetId: reaction.assetId })}>
<a class="aspect-square size-19" href={Route.viewAlbumAsset({ albumId, assetId: reaction.assetId })}>
<img
class="rounded-lg w-19 h-19 object-cover"
class="size-19 rounded-lg object-cover"
src={getAssetMediaUrl({ id: reaction.assetId })}
alt="Profile picture of {reaction.user.name}, who commented on this asset"
/>
</a>
{/if}
{#if reaction.user.id === authManager.user.id || albumOwnerId === authManager.user.id}
{#if reaction.user.id === authManager.user.id || isAlbumOwner}
<div class="me-4">
<ButtonContextMenu
icon={mdiDotsVertical}
@@ -169,7 +170,7 @@
{#if (index != activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
<div
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
class="w-full px-2 pt-1 text-right text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
>
{timeSince(luxon.DateTime.fromISO(reaction.createdAt, { locale: $locale }))}
@@ -177,7 +178,7 @@
{/if}
{:else if reaction.type === ReactionType.Like}
<div class="relative">
<div class="flex py-3 ps-3 mt-3 gap-4 items-center text-sm">
<div class="mt-3 flex items-center gap-4 py-3 ps-3 text-sm">
<div class="text-primary"><Icon icon={mdiThumbUp} size="20" /></div>
<div class="w-full" title={`${reaction.user.name} (${reaction.user.email})`}>
@@ -189,18 +190,15 @@
})}
</div>
{#if assetId === undefined && reaction.assetId}
<a
class="aspect-square w-19 h-19"
href={Route.viewAlbumAsset({ albumId, assetId: reaction.assetId })}
>
<a class="aspect-square size-19" href={Route.viewAlbumAsset({ albumId, assetId: reaction.assetId })}>
<img
class="rounded-lg w-19 h-19 object-cover"
class="size-19 rounded-lg object-cover"
src={getAssetMediaUrl({ id: reaction.assetId })}
alt="Profile picture of {reaction.user.name}, who liked this asset"
/>
</a>
{/if}
{#if reaction.user.id === authManager.user.id || albumOwnerId === authManager.user.id}
{#if reaction.user.id === authManager.user.id || isAlbumOwner}
<div class="me-4">
<ButtonContextMenu
icon={mdiDotsVertical}
@@ -221,7 +219,7 @@
</div>
{#if (index != activityManager.activities.length - 1 && isTenMinutesApart(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
<div
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
class="w-full px-2 pt-1 text-right text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(navigator.language, timeOptions)}
>
{timeSince(luxon.DateTime.fromISO(reaction.createdAt, { locale: $locale }))}
@@ -234,13 +232,13 @@
{/if}
</div>
<div class="absolute w-full bottom-0">
<div class="absolute bottom-0 w-full">
<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 class="flex h-fit w-full gap-4 rounded-3xl bg-gray-200 p-2 text-immich-dark-gray">
<div>
<UserAvatar user={authManager.user} size="md" noTitle />
</div>
<form class="flex w-full items-center max-h-56 gap-1" {onsubmit}>
<form class="flex max-h-56 w-full items-center gap-1" {onsubmit}>
<Textarea
{disabled}
bind:value={message}
@@ -253,16 +251,16 @@
}))}
class="{disabled
? 'cursor-not-allowed'
: ''} ring-0! w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200 dark:bg-gray-200"
: ''} max-h-56 w-full resize-none items-center overflow-y-auto bg-gray-200 pe-2 leading-4 ring-0! outline-none dark:bg-gray-200"
/>
{#if isSendingMessage}
<div class="flex place-items-center pb-2 ms-0">
<div class="ms-0 flex place-items-center pb-2">
<div class="flex w-full place-items-center">
<LoadingSpinner size="large" />
</div>
</div>
{:else if message}
<div class="flex items-center w-fit ms-0 light">
<div class="light ms-0 flex w-fit items-center">
<IconButton
shape="round"
aria-label={$t('send_message')}
@@ -7,7 +7,7 @@
import { Icon } from '@immich/ui';
import { mdiCheckCircle } from '@mdi/js';
import type { Action } from 'svelte/action';
import AlbumListItemDetails from './album-list-item-details.svelte';
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
interface Props {
album: AlbumResponseDto;
@@ -116,7 +116,7 @@
<div
role="group"
class={[
'relative flex w-full text-start justify-between transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl my-2 hover:cursor-pointer',
'relative my-2 flex w-full justify-between rounded-xl text-start transition-colors hover:cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700',
{ 'bg-primary/10 hover:bg-primary/10': multiSelected },
]}
onmouseenter={onMouseEnter}
@@ -126,26 +126,24 @@
type="button"
onclick={onAlbumClick}
use:scrollIntoViewIfSelected
class="flex w-full gap-4 px-2 py-2 text-start"
class="flex w-full gap-4 p-2 text-start"
class:bg-gray-200={selected}
class:dark:bg-gray-700={selected}
use:longPress={{ onLongPress: () => handleMultiSelectClicked() }}
>
<span class="h-16 w-16 shrink-0 rounded-xl bg-slate-300">
<span class="size-16 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetMediaUrl({ id: album.albumThumbnailAssetId })}
alt={album.albumName}
class={['h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
class={['size-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
data-testid="album-image"
draggable="false"
/>
{/if}
</span>
<span class="flex h-full flex-col items-start justify-center overflow-hidden">
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
>
<span class="w-full shrink truncate">{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span>
<span class="flex gap-1 text-sm">
<AlbumListItemDetails {album} />
</span>
@@ -156,7 +154,7 @@
<button
type="button"
onclick={handleMultiSelectClicked}
class="absolute right-0 top-4 p-3 focus:outline-none hover:cursor-pointer"
class="absolute top-4 right-0 p-3 hover:cursor-pointer focus:outline-none"
role="checkbox"
tabindex={-1}
aria-checked={selected}
@@ -1,14 +1,14 @@
import { updateAsset } from '@immich/sdk';
import { fireEvent, waitFor } from '@testing-library/svelte';
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 { renderWithTooltips } from '$tests/helpers';
import { updateAsset } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { userAdminFactory } from '@test-data/factories/user-factory';
import { fireEvent, waitFor } from '@testing-library/svelte';
import AssetViewer from './asset-viewer.svelte';
import AssetViewer from './AssetViewer.svelte';
vi.mock('$lib/managers/feature-flags-manager.svelte', () => ({
featureFlagsManager: {
@@ -51,7 +51,7 @@ describe('AssetViewer', () => {
vi.restoreAllMocks();
});
it('updates the top bar favorite action after pressing favorite', async () => {
it.skip('updates the top bar favorite action after pressing favorite', async () => {
const ownerId = 'owner-id';
const user = userAdminFactory.build({ id: ownerId });
const asset = assetFactory.build({ ownerId, isFavorite: false, isTrashed: false });
@@ -1,10 +1,11 @@
<script lang="ts">
import { browser } from '$app/environment';
import { focusTrap } from '$lib/actions/focus-trap';
import { shortcuts } from '$lib/actions/shortcut';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
import NextAssetAction from '$lib/components/asset-viewer/actions/NextAssetAction.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/PreviousAssetAction.svelte';
import AssetViewerNavBar from '$lib/components/asset-viewer/AssetViewerNavBar.svelte';
import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
@@ -38,17 +39,17 @@
import type { SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
import DetailPanel from './detail-panel.svelte';
import EditorPanel from './editor/editor-panel.svelte';
import CropArea from './editor/transform-tool/crop-area.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
import OcrButton from './ocr-button.svelte';
import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
import Thumbnail from '../assets/thumbnail/Thumbnail.svelte';
import ActivityStatus from './ActivityStatus.svelte';
import ActivityViewer from './ActivityViewer.svelte';
import DetailPanel from './DetailPanel.svelte';
import EditorPanel from './editor/EditorPanel.svelte';
import CropArea from './editor/transform-tool/CropArea.svelte';
import ImagePanoramaViewer from './ImagePanoramaViewer.svelte';
import OcrButton from './OcrButton.svelte';
import PhotoViewer from './PhotoViewer.svelte';
import SlideshowBar from './SlideshowBar.svelte';
import VideoViewer from './VideoWrapperViewer.svelte';
export type AssetCursor = {
current: AssetResponseDto;
@@ -67,7 +68,7 @@
preAction?: PreAction;
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void;
onClose?: (assetId: string) => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>;
}
@@ -179,7 +180,7 @@
});
const closeViewer = () => {
onClose?.(asset);
onClose?.(asset.id);
};
const closeEditor = async () => {
@@ -246,6 +247,22 @@
}, $t('error_while_navigating'));
};
const navigateStack = (direction: 'previous' | 'next') => {
if (!stack || !withStacked || assetViewerManager.isShowEditor) {
return;
}
const assets = stack.assets;
const currentIndex = assets.findIndex(({ id }) => id === asset.id);
if (currentIndex === -1) {
return;
}
const nextIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1;
if (nextIndex < 0 || nextIndex >= assets.length) {
return;
}
cursor.current = assets[nextIndex];
};
/**
* Slide show mode
*/
@@ -457,8 +474,12 @@
<section
id="immich-asset-viewer"
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
class="fixed inset-s-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
use:focusTrap
use:shortcuts={[
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => navigateStack('previous') },
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => navigateStack('next') },
]}
bind:this={assetViewerHtmlElement}
>
<!-- Top navigation bar -->
@@ -474,7 +495,7 @@
onAction={handleAction}
{onUndoDelete}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(asset) : undefined}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
{onRemoveFromAlbum}
{playOriginalVideo}
{setPlayOriginalVideo}
@@ -496,13 +517,13 @@
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<div class="col-span-1 col-start-1 row-span-full row-start-1 my-auto justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
{/if}
<!-- Asset Viewer -->
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
<div data-viewer-content class="relative z-[-1] col-span-4 col-start-1 row-span-full row-start-1">
{#if viewerKind === 'StackVideoViewer'}
<VideoViewer
asset={previewStackedAsset!}
@@ -540,6 +561,7 @@
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
extendedControls
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
@@ -550,7 +572,7 @@
{/if}
{#if showActivityStatus}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<div class="absolute inset-e-0 bottom-0 me-8 mb-20">
<ActivityStatus
disabled={!album?.isActivityEnabled}
isLiked={activityManager.isLiked}
@@ -562,14 +584,14 @@
{/if}
{#if showOcrButton}
<div class="absolute bottom-0 end-0 mb-6 me-6 drop-shadow-[0_0_1px_rgba(0,0,0,0.4)]">
<div class="absolute inset-e-0 bottom-0 me-6 mb-6 drop-shadow-[0_0_1px_rgba(0,0,0,0.4)]">
<OcrButton />
</div>
{/if}
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<div class="col-span-1 col-start-4 row-span-full row-start-1 my-auto justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}
@@ -579,7 +601,7 @@
transition:fly={{ duration: 150 }}
id="detail-panel"
class={[
'row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light',
'row-span-4 row-start-1 overflow-y-auto bg-light transition-all dark:border-l dark:border-s-immich-dark-gray',
showDetailPanel ? 'w-90' : 'w-100',
]}
translate="yes"
@@ -594,11 +616,11 @@
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets}
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
<div id="stack-slideshow" class="pointer-events-none absolute bottom-0 col-span-4 col-start-1 w-full">
<div class="no-wrap horizontal-scrollbar relative flex flex-row overflow-x-auto overflow-y-hidden">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
<div
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
class={['pointer-events-auto relative inline-block px-1 pb-2 transition-all']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
>
<Thumbnail
@@ -618,8 +640,8 @@
/>
{#if stackedAsset.id === asset.id}
<div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
<div class="flex w-full place-content-center place-items-center">
<div class="mt-0.5 flex size-2 rounded-full bg-white"></div>
</div>
{/if}
</div>
@@ -632,13 +654,13 @@
<div
transition:fly={{ duration: 150 }}
id="activity-panel"
class="row-start-1 row-span-5 w-90 md:w-115 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
class="row-span-5 row-start-1 w-90 overflow-y-auto transition-all md:w-115 dark:border-l dark:border-s-immich-dark-gray"
translate="yes"
>
<ActivityViewer
disabled={!album.isActivityEnabled}
assetType={asset.type}
albumOwnerId={album.ownerId}
albumUsers={album.albumUsers}
albumId={album.id}
assetId={asset.id}
/>
@@ -1,11 +1,11 @@
import '@testing-library/jest-dom';
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
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';
import { userAdminFactory } from '@test-data/factories/user-factory';
import '@testing-library/jest-dom';
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
import AssetViewerNavBar from './AssetViewerNavBar.svelte';
describe('AssetViewerNavBar component', () => {
const additionalProps = {
@@ -2,22 +2,22 @@
import { goto } from '$app/navigation';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
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 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';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import AddToStackAction from '$lib/components/asset-viewer/actions/AddToStackAction.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/ArchiveAction.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/DeleteAction.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/KeepThisDeleteOthers.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/RatingAction.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/RestoreAction.svelte';
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/SetAlbumCoverAction.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/SetVisibilityAction.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/UnstackAction.svelte';
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 ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.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';
@@ -82,7 +82,7 @@
}: Props = $props();
const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id);
const isAlbumOwner = $derived(authManager.authenticated && album?.ownerId === authManager.user.id);
const isAlbumOwner = $derived(authManager.authenticated && album?.albumUsers[0].user.id === authManager.user.id);
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
@@ -103,14 +103,14 @@
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Close, Cast, ...Object.values(Actions)])} />
<div
class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200 drop-shadow-[0_0_1px_rgba(0,0,0,0.4)]"
class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 drop-shadow-[0_0_1px_rgba(0,0,0,0.4)] transition-transform duration-200"
>
<div class="dark">
<ActionButton action={Close} />
</div>
<div
class="flex p-1 -m-1 items-center gap-2 overflow-x-auto *:shrink-0 dark"
class="dark -m-1 flex items-center gap-2 overflow-x-auto p-1 *:shrink-0"
data-testid="asset-viewer-navbar-actions"
>
{#if assetViewerManager.isImageLoading}
@@ -0,0 +1,390 @@
<script lang="ts">
import { goto } from '$app/navigation';
import DetailPanelDate from '$lib/components/asset-viewer/DetailPanelDate.svelte';
import DetailPanelDescription from '$lib/components/asset-viewer/DetailPanelDescription.svelte';
import DetailPanelLocation from '$lib/components/asset-viewer/DetailPanelLocation.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/DetailPanelStarRating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/DetailPanelTags.svelte';
import { timeToLoadTheMap } from '$lib/constants';
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 { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl } 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 { getParentPath } from '$lib/utils/tree-utils';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition';
import PersonSidePanel from '../faces-page/PersonSidePanel.svelte';
import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/UserAvatar.svelte';
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
interface Props {
asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null;
}
let { asset, currentAlbum = null }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng) {
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
}
})(),
);
let previousId: string | undefined = $state();
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
const refreshAlbums = async () => {
if (authManager.isSharedLink) {
return [];
}
try {
return await getAllAlbums({ assetId: asset.id });
} catch (error) {
handleError(error, 'Error getting asset album membership');
return [];
}
};
let albums = $derived(refreshAlbums());
$effect(() => {
if (!previousId) {
previousId = asset.id;
return;
}
if (asset.id === previousId) {
return;
}
assetViewerManager.closeEditFacesPanel();
previousId = asset.id;
});
const getMegapixel = (width: number, height: number): number | undefined => {
const megapixel = Math.round((height * width) / 1_000_000);
if (megapixel) {
return megapixel;
}
return undefined;
};
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
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) });
};
onDestroy(() => {
assetViewerManager.closeEditFacesPanel();
});
</script>
<OnEvents onAlbumAddAssets={() => (albums = refreshAlbums())} />
{#if !assetViewerManager.isEditFacesPanelOpen}
<section class="relative p-2">
<div class="flex place-items-center gap-2">
<IconButton
icon={mdiClose}
aria-label={$t('close')}
onclick={() => assetViewerManager.closeDetailPanel()}
shape="round"
color="secondary"
variant="ghost"
/>
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p>
</div>
{#if asset.isOffline}
<section class="p-4">
<div role="alert">
<div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">
{$t('asset_offline')}
</div>
<div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
<p>
{#if authManager.authenticated && authManager.user.isAdmin}
{$t('admin.asset_offline_description')}
{:else}
{$t('asset_offline_description')}
{/if}
</p>
</div>
<div class="rounded-b bg-red-500 px-4 py-2 text-sm text-white">
<p>{asset.originalPath}</p>
</div>
</div>
</section>
{/if}
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
<DetailPanelPeople {asset} {isOwner} {previousRoute} />
<div class="p-4">
{#if asset.exifInfo}
<div class="flex h-10 w-full items-center justify-between text-sm">
<Text size="small" color="muted">{$t('details')}</Text>
</div>
{:else}
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
{/if}
<DetailPanelDate {asset} />
<div class="flex gap-4 py-4">
<div><Icon icon={mdiImageOutline} size="24" /></div>
<div>
<p class="flex place-items-center gap-2 break-all whitespace-pre-wrap">
{asset.originalFileName}
{#if isOwner}
<IconButton
icon={mdiInformationOutline}
aria-label={$t('show_file_location')}
size="small"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleAssetPath()}
/>
{/if}
</p>
{#if assetViewerManager.isShowAssetPath}
<p class="pb-2 text-xs break-all opacity-50 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">
{asset.originalPath}
</a>
</p>
{/if}
{#if (asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth) || asset.exifInfo?.fileSizeInByte}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth}
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
<p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{/if}
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
{#if asset.exifInfo?.fileSizeInByte}
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
{/if}
</div>
{/if}
</div>
</div>
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCamera} size="24" /></div>
<div>
{#if asset.exifInfo?.make || asset.exifInfo?.model}
<p>
<a
href={Route.search({
make: asset.exifInfo?.make ?? undefined,
model: asset.exifInfo?.model ?? undefined,
})}
title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}"
class="hover:text-primary"
>
{asset.exifInfo.make || ''}
{asset.exifInfo.model || ''}
</a>
</p>
{/if}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime} s`}</p>
{/if}
{#if asset.exifInfo.iso}
<p>{`ISO ${asset.exifInfo.iso}`}</p>
{/if}
</div>
</div>
</div>
{/if}
{#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCameraIris} size="24" /></div>
<div>
{#if asset.exifInfo?.lensModel}
<p>
<a
href={Route.search({ lensModel: asset.exifInfo.lensModel })}
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="line-clamp-1 hover:text-primary"
>
{asset.exifInfo.lensModel}
</a>
</p>
{/if}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo?.fNumber}
<p>ƒ/{asset.exifInfo.fNumber.toLocaleString($locale)}</p>
{/if}
{#if asset.exifInfo.focalLength}
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
{/if}
</div>
</div>
</div>
{/if}
<DetailPanelLocation {isOwner} {asset} />
</div>
</section>
{#if latlng && featureFlagsManager.value.map}
<div class="h-90">
{#await import('$lib/components/shared-components/map/Map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex size-full items-center justify-center">
<LoadingSpinner />
</div>
{/await}
{:then { default: Map }}
<Map
mapMarkers={[
{
lat: latlng.lat,
lon: latlng.lng,
id: asset.id,
city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null,
},
]}
center={latlng}
showSettings={false}
zoom={12.5}
simplified
useLocationPin
showSimpleControls={!assetViewerManager.isEditFacesPanelOpen}
onOpenInMapView={() => goto(Route.map({ ...latlng, zoom: 12.5 }))}
>
{#snippet popup({ marker })}
{@const { lat, lon } = marker}
<div class="flex flex-col items-center gap-1">
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
<a
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
target="_blank"
class="font-medium text-primary underline focus:outline-none"
>
{$t('open_in_openstreetmap')}
</a>
</div>
{/snippet}
</Map>
{/await}
</div>
{/if}
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
<section class="mt-4 px-6 dark:text-immich-dark-fg">
<Text size="small" color="muted">{$t('shared_by')}</Text>
<div class="flex gap-4 pt-4">
<div>
<UserAvatar user={asset.owner} size="md" />
</div>
<div class="my-auto">
<p>
{asset.owner.name}
</p>
</div>
</div>
</section>
{/if}
{#await albums then albums}
{#if albums.length > 0}
<section class="p-6 dark:text-immich-dark-fg">
<div class="pb-4">
<Text size="small" color="muted">{$t('appears_in')}</Text>
</div>
{#each albums as album (album.id)}
<a href={Route.viewAlbum(album)}>
<div class="flex items-center gap-4 pt-2 hover:cursor-pointer">
<div>
<img
alt={album.albumName}
class="size-12.5 rounded-sm object-cover"
src={album.albumThumbnailAssetId &&
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
/>
</div>
<div class="my-auto">
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex flex-col gap-0 text-sm">
<div>
<AlbumListItemDetails {album} />
</div>
</div>
</div>
</div>
</a>
{/each}
</section>
{/if}
{/await}
{#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>
{/if}
{/if}
{#if assetViewerManager.isEditFacesPanelOpen}
<PersonSidePanel
assetId={asset.id}
assetType={asset.type}
onClose={() => assetViewerManager.closeEditFacesPanel()}
onRefresh={handleRefreshPeople}
/>
{/if}
@@ -38,7 +38,7 @@
{#if dateTime}
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
class="flex w-full place-items-start justify-between gap-4 py-4 text-start"
onclick={handleChangeDate}
title={isOwner ? $t('edit_date') : ''}
class:hover:text-primary={isOwner}
@@ -75,7 +75,7 @@
{/if}
</button>
{:else if !dateTime && isOwner}
<div class="flex justify-between place-items-start gap-4 py-4">
<div class="flex place-items-start justify-between gap-4 py-4">
<div class="flex gap-4">
<Icon icon={mdiCalendar} size="24" />
</div>
@@ -1,8 +1,8 @@
import { assetFactory } from '@test-data/factories/asset-factory';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import DetailPanelDescription from './detail-panel-description.svelte';
import { assetFactory } from '@test-data/factories/asset-factory';
import DetailPanelDescription from './DetailPanelDescription.svelte';
describe('DetailPanelDescription', () => {
it('clears unsaved draft on asset change', async () => {
@@ -30,10 +30,10 @@
</script>
{#if isOwner}
<section class="px-4 mt-10">
<section class="mt-10 px-4">
<Textarea
bind:value={description}
class="max-h-40 pl-0 outline-none border-b border-gray-500 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
class="max-h-40 resize-none border-b border-gray-500 bg-transparent pl-0 ring-0 outline-none focus:border-b-2 focus:border-immich-primary focus:ring-0 dark:bg-transparent dark:focus:border-immich-dark-primary"
rows={1}
grow
shape="rectangle"
@@ -47,7 +47,7 @@
/>
</section>
{:else if description}
<section class="px-4 mt-6">
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
<section class="mt-6 px-4">
<p class="w-full text-base wrap-break-word whitespace-pre-line text-black dark:text-white">{description}</p>
</section>
{/if}
@@ -33,7 +33,7 @@
{#if asset.exifInfo?.country}
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
class="flex w-full place-items-start justify-between gap-4 py-4 text-start"
onclick={isOwner ? onAction : undefined}
title={isOwner ? $t('edit_location') : ''}
class:hover:text-primary={isOwner}
@@ -67,7 +67,7 @@
{:else if !asset.exifInfo?.city && isOwner}
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:text-primary"
class="flex w-full place-items-start justify-between gap-4 rounded-lg py-4 text-start hover:text-primary"
onclick={onAction}
title={$t('add_location')}
>
@@ -75,7 +75,7 @@
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
<p>{$t('add_a_location')}</p>
</div>
<div class="focus:outline-none p-1">
<div class="p-1 focus:outline-none">
<Icon icon={mdiPencil} size="20" />
</div>
</button>
@@ -0,0 +1,133 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.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 { getPeopleThumbnailUrl } from '$lib/utils';
import { type AssetResponseDto } from '@immich/sdk';
import { IconButton, Text } from '@immich/ui';
import { mdiEye, mdiEyeOff, mdiPencil, mdiPlus } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
asset: AssetResponseDto;
isOwner: boolean;
previousRoute: string;
};
const { asset, isOwner, previousRoute }: Props = $props();
const unassignedFaces = $derived(asset.unassignedFaces || []);
const people = $derived(asset.people || []);
const visiblePeople = $derived(
people
.filter((p) => assetViewerManager.isShowingHiddenPeople || !p.isHidden)
.map((person) => {
if (!person.birthDate) {
return { formattedBirthDate: undefined, formattedAge: undefined, ...person };
}
const personBirthDate = DateTime.fromISO(person.birthDate);
const ageInYears = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years);
const ageInMonths = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months);
let formattedAge;
if (ageInYears < 0) {
return { formattedBirthDate: undefined, formattedAge: undefined, ...person };
} else if (ageInMonths < 12) {
formattedAge = $t('age_months', { values: { months: ageInMonths } });
} else if (ageInMonths > 12 && ageInMonths < 24) {
formattedAge = $t('age_year_months', { values: { months: ageInMonths - 12 } });
} else {
formattedAge = $t('age_years', { values: { years: ageInYears } });
}
const formattedBirthDate = personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
);
return { formattedBirthDate, formattedAge, ...person };
}),
);
</script>
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
<div class="flex items-center gap-2">
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleHiddenPeople()}
/>
{/if}
<IconButton
aria-label={$t('tag_people')}
icon={mdiPlus}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleFaceEditMode()}
/>
{#if people.length > 0 || unassignedFaces.length > 0}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.openEditFacesPanel()}
/>
{/if}
</div>
</div>
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
{#each visiblePeople as person (person.id)}
{@const isHighlighted = person.faces.some((f) =>
assetViewerManager.highlightedFaces.some((b) => b.id === f.id),
)}
<a
class="group outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => assetViewerManager.setHighlightedFaces(person.faces)}
onblur={() => assetViewerManager.clearHighlightedFaces()}
onpointerenter={() => assetViewerManager.setHighlightedFaces(person.faces)}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
title={person.name}
widthStyle="100%"
hidden={person.isHidden}
highlighted={isHighlighted}
class="outline-offset-2 outline-immich-primary group-focus-visible:outline-2 dark:outline-immich-dark-primary"
/>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate && person.formattedAge}
<p class="font-light {visiblePeople.length > 6 ? 'text-xs' : ''}" title={person.formattedBirthDate!}>
{person.formattedAge}
</p>
{/if}
</a>
{/each}
</div>
</section>
{/if}
@@ -6,8 +6,7 @@
import { getAssetActions } from '$lib/services/asset.service';
import { removeTag } from '$lib/utils/asset-utils';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Badge, IconButton, Link, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { Badge, Link, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
@@ -38,27 +37,21 @@
<OnEvents {onAssetsTag} />
{#if isOwner && !authManager.isSharedLink}
<section class="px-4 mt-4">
<section class="mt-4 px-4">
<div class="flex h-10 w-full items-center justify-between text-sm">
<Text color="muted">{$t('tags')}</Text>
</div>
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
<section class="flex flex-wrap gap-1 pt-2" data-testid="detail-panel-tags">
{#each tags as tag (tag.id)}
<Badge size="small" class="items-center px-0" shape="round">
<Link
href={Route.tags({ path: tag.value })}
class="text-light no-underline rounded-full hover:bg-primary-400 px-2"
>
<Badge
onClose={() => handleRemove(tag.id)}
size="small"
shape="round"
translations={{ close: $t('remove_tag') }}
>
<Link href={Route.tags({ path: tag.value })} underline={false} class="px-2 font-light">
{tag.value}
</Link>
<IconButton
aria-label={$t('remove_tag')}
icon={mdiClose}
onclick={() => handleRemove(tag.id)}
size="tiny"
class="hover:bg-primary-400"
shape="round"
/>
</Badge>
{/each}
<HeaderActionButton action={Tag} />
@@ -20,8 +20,8 @@
};
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
<div transition:fade={{ duration: 150 }} class="flex h-full place-content-center place-items-center select-none">
{#await Promise.all([loadAssetData(assetId), import('./PhotoSphereViewerAdapter.svelte')])}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
@@ -12,7 +12,7 @@
<button
type="button"
class="my-auto mx-4 rounded-full p-3 text-gray-500 transition hover:bg-gray-500 hover:text-white"
class="mx-4 my-auto rounded-full p-3 text-gray-500 transition hover:bg-gray-500 hover:text-white"
aria-label={label}
onclick={onClick}
>
@@ -44,12 +44,12 @@
<div
class={[
'absolute left-0 top-0 flex items-center justify-center',
'border-2 border-blue-500 pointer-events-auto cursor-text',
'focus:z-1 focus:border-blue-600 focus:border-3 focus:outline-none',
'absolute top-0 left-0 flex items-center justify-center',
'pointer-events-auto cursor-text border-2 border-blue-500',
'focus:z-1 focus:border-3 focus:border-blue-600 focus:outline-none',
isTouch
? 'text-white bg-black/60 select-all'
: 'select-text text-transparent bg-blue-500/10 transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3',
? 'bg-black/60 text-white select-all'
: 'bg-blue-500/10 text-transparent transition-colors select-text hover:z-1 hover:border-3 hover:border-blue-600 hover:bg-black/60 hover:text-white',
ocrBox.verticalMode === 'none' ? 'px-2 py-1 whitespace-nowrap' : 'px-1 py-2',
]}
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0; touch-action: none; {verticalStyle}"
@@ -8,7 +8,7 @@
<IconButton
title={ocrManager.showOverlay ? $t('hide_text_recognition') : $t('show_text_recognition')}
icon={mdiTextRecognition}
class={"dark {ocrStore.showOverlay ? 'bg-immich-primary text-white dark' : 'dark'}"}
class={"dark {ocrStore.showOverlay ? 'bg-immich-primary dark' : 'dark'} text-white"}
color="secondary"
variant="ghost"
shape="round"
@@ -1,9 +1,8 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
import {
@@ -55,14 +54,9 @@
let viewer: Viewer;
let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = [];
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
// Debounce; don't do anything when the data didn't actually change.
if (faces === previousFaces) {
return;
}
previousFaces = faces;
$effect(() => {
const faces: Faces[] = assetViewerManager.highlightedFaces;
if (animationInProgress) {
animationInProgress.cancel();
@@ -105,7 +99,7 @@
textureX: x,
textureY: y,
zoom: Math.min(viewer.getZoomLevel(), 75),
speed: 500, // duration in ms
speed: 500,
});
}
});
@@ -247,7 +241,8 @@
if (viewer) {
viewer.destroy();
}
boundingBoxesUnsubscribe();
assetViewerManager.clearHighlightedFaces();
assetViewerManager.hideHiddenPeople();
assetViewerManager.zoom = 1;
});
</script>
@@ -255,7 +250,7 @@
<AssetViewerEvents {onZoom} />
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div>
<div class="mb-0 size-full" bind:this={container}></div>
<style>
/* Reset the default tooltip styling */
@@ -2,14 +2,13 @@
import { shortcuts } from '$lib/actions/shortcut';
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 FaceEditor from '$lib/components/asset-viewer/face-editor/FaceEditor.svelte';
import Thumbhash from '$lib/components/Thumbhash.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/OcrBoundingBox.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
@@ -22,7 +21,7 @@
import { onDestroy, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import type { AssetCursor } from './asset-viewer.svelte';
import type { AssetCursor } from './AssetViewer.svelte';
type Props = {
cursor: AssetCursor;
@@ -50,12 +49,13 @@
untrack(() => {
assetViewerManager.resetZoomState();
visibleImageReady = false;
$boundingBoxesArray = [];
assetViewerManager.clearHighlightedFaces();
});
});
onDestroy(() => {
$boundingBoxesArray = [];
assetViewerManager.clearHighlightedFaces();
assetViewerManager.hideHiddenPeople();
});
let containerWidth = $state(0);
@@ -74,15 +74,13 @@
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
});
const highlightedBoxes = $derived(getBoundingBox($boundingBoxesArray, overlaySize));
const highlightedBoxes = $derived(getBoundingBox(assetViewerManager.highlightedFaces, overlaySize));
const isHighlighting = $derived(highlightedBoxes.length > 0);
let visibleBoxes = $state<BoundingBox[]>([]);
let visibleBoundingBoxes = $state<Faces[]>([]);
$effect(() => {
if (isHighlighting) {
visibleBoxes = highlightedBoxes;
visibleBoundingBoxes = $boundingBoxesArray;
}
});
@@ -160,6 +158,9 @@
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>();
for (const person of asset.people ?? []) {
if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) {
continue;
}
for (const face of person.faces ?? []) {
map.set(face, person.name);
}
@@ -169,35 +170,31 @@
const faces = $derived(Array.from(faceToNameMap.keys()));
const handleImageMouseMove = (event: MouseEvent) => {
$boundingBoxesArray = [];
if (!assetViewerManager.imgRef || !element || assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
return;
const boundingBoxes = $derived.by(() => {
if (assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
return [];
}
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, container);
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const knownBoxes = getBoundingBox(faces, overlaySize);
const result = knownBoxes.map((box, index) => ({
...box,
face: faces[index],
name: faceToNameMap.get(faces[index]),
}));
const contentOffsetX = (container.width - scaled.width) / 2;
const contentOffsetY = (container.height - scaled.height) / 2;
const containerRect = element.getBoundingClientRect();
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
const faceBoxes = getBoundingBox(faces, overlaySize);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
$boundingBoxesArray.push(faces[index]);
}
if (assetViewerManager.highlightedFaces.length === 0) {
return result;
}
};
const handleImageMouseLeave = () => {
$boundingBoxesArray = [];
};
const knownIds = new Set(faces.map((f) => f.id));
const unassignedFaces = assetViewerManager.highlightedFaces.filter((f) => !knownIds.has(f.id));
const unassignedBoxes = getBoundingBox(unassignedFaces, overlaySize);
for (let i = 0; i < unassignedBoxes.length; i++) {
result.push({ ...unassignedBoxes[i], face: unassignedFaces[i], name: undefined });
}
return result;
});
</script>
<AssetViewerEvents {onCopy} {onZoom} {onFaceEditModeChange} />
@@ -213,13 +210,11 @@
<div
bind:this={element}
class="relative h-full w-full select-none"
class="relative size-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
role="presentation"
ondblclick={onZoom}
onmousemove={handleImageMouseMove}
onmouseleave={handleImageMouseLeave}
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
{...useSwipe((event) => onSwipe?.(event))}
>
@@ -242,15 +237,15 @@
>
{#snippet backdrop()}
{#if blurredSlideshow}
<Thumbhash base64ThumbHash={asset.thumbhash!} class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw" />
<Thumbhash base64ThumbHash={asset.thumbhash!} class="absolute inset-s-0 top-0 left-0 h-dvh w-dvw" />
{/if}
{/snippet}
{#snippet overlays()}
<div
class="absolute inset-0 pointer-events-none transition-opacity duration-150"
class="pointer-events-none absolute inset-0 transition-opacity duration-150"
style:opacity={isHighlighting ? 1 : 0}
>
<svg class="absolute inset-0 w-full h-full">
<svg class="absolute inset-0 size-full">
<defs>
<mask id="face-dim-mask">
<rect width="100%" height="100%" fill="white" />
@@ -261,22 +256,27 @@
</defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" />
</svg>
{#each visibleBoxes as boundingbox, index (boundingbox.id)}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{#if faceToNameMap.get(visibleBoundingBoxes[index])}
</div>
{#each boundingBoxes as boundingbox (boundingbox.id)}
{@const isActive = assetViewerManager.highlightedFaces.some((f) => f.id === boundingbox.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pointer-events-auto absolute rounded-lg {isActive && 'border-3 border-solid border-white'}"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
onpointerenter={() => assetViewerManager.setHighlightedFaces([boundingbox.face])}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
{#if isActive && boundingbox.name}
<div
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
boundingbox.width}px; transform: translateX(-100%);"
aria-hidden="true"
class="absolute rounded-sm bg-white/90 px-2 py-1 text-sm font-medium whitespace-nowrap text-black shadow-lg"
style="top: {boundingbox.height + 4}px; right: 0;"
>
{faceToNameMap.get(visibleBoundingBoxes[index])}
{boundingbox.name}
</div>
{/if}
{/each}
</div>
</div>
{/each}
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
@@ -1,7 +1,7 @@
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { loadImage } from '$lib/actions/image-loader.svelte';
import { getAssetUrls } from '$lib/utils';
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
type AssetCursor = {
current: AssetResponseDto;
@@ -1,6 +1,6 @@
<script lang="ts">
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import ProgressBar from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
import ProgressBar from '$lib/components/shared-components/progress-bar/ProgressBar.svelte';
import { ProgressBarStatus } from '$lib/constants';
import { languageManager } from '$lib/managers/language-manager.svelte';
import SlideshowSettingsModal from '$lib/modals/SlideshowSettingsModal.svelte';
@@ -172,7 +172,7 @@
{#if showControls}
<div
class="m-4 flex gap-2 dark"
class="dark m-4 flex gap-2"
onmouseenter={() => (isOverControls = true)}
onmouseleave={() => (isOverControls = false)}
transition:fly={{ duration: 150 }}
@@ -0,0 +1,338 @@
<script lang="ts">
import FaceEditor from '$lib/components/asset-viewer/face-editor/FaceEditor.svelte';
import VideoRemoteViewer from '$lib/components/asset-viewer/VideoRemoteViewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { autoPlayVideo, lang, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { Icon, LoadingSpinner } from '@immich/ui';
import {
mdiCheck,
mdiChevronLeft,
mdiChevronRight,
mdiFullscreen,
mdiFullscreenExit,
mdiPause,
mdiPlay,
mdiVolumeHigh,
mdiVolumeLow,
mdiVolumeMedium,
mdiVolumeMute,
} from '@mdi/js';
import 'media-chrome/media-control-bar';
import 'media-chrome/media-controller';
import 'media-chrome/media-fullscreen-button';
import 'media-chrome/media-mute-button';
import 'media-chrome/media-play-button';
import 'media-chrome/media-playback-rate-button';
import 'media-chrome/media-time-display';
import 'media-chrome/media-time-range';
import 'media-chrome/media-volume-range';
import 'media-chrome/menu/media-playback-rate-menu';
import 'media-chrome/menu/media-settings-menu';
import 'media-chrome/menu/media-settings-menu-button';
import 'media-chrome/menu/media-settings-menu-item';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
asset: AssetResponseDto;
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
extendedControls?: boolean;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
onVideoStarted?: () => void;
onClose?: () => void;
}
let {
asset,
assetId,
loopVideo,
cacheKey,
playOriginalVideo,
extendedControls = false,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
onVideoStarted = () => {},
onClose = () => {},
}: Props = $props();
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $derived(
playOriginalVideo
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
const aspectRatio = $derived(asset.width && asset.height ? `${asset.width} / ${asset.height}` : undefined);
let showVideo = $state(false);
let hasFocused = $state(false);
onMount(() => {
showVideo = true;
});
$effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
hasFocused = false;
videoPlayer?.load();
}
});
onDestroy(() => {
if (videoPlayer) {
videoPlayer.src = '';
}
});
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused) {
await video.play();
onVideoStarted();
}
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
await tryForceMutedPlay(video);
return;
}
// auto-play failed
} finally {
isLoading = false;
}
};
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
if (video.muted) {
return;
}
try {
video.muted = true;
await handleCanPlay(video);
} catch {
// muted auto-play failed
}
};
const onSwipe = (event: SwipeCustomEvent) => {
if (event.detail.direction === 'left') {
onNextAsset();
}
if (event.detail.direction === 'right') {
onPreviousAsset();
}
};
let containerWidth = $state(0);
let containerHeight = $state(0);
$effect(() => {
if (assetViewerManager.isFaceEditMode) {
videoPlayer?.pause();
}
});
</script>
{#if showVideo}
<div
transition:fade={{ duration: assetViewerFadeDuration }}
class="flex h-full place-content-center place-items-center select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
{#if castManager.isCasting}
<div class="h-full place-content-center place-items-center">
<VideoRemoteViewer
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
{onVideoStarted}
{onVideoEnded}
{assetFileUrl}
/>
</div>
{:else}
<!-- dir=ltr based on https://github.com/videojs/video.js/issues/949 -->
<media-controller
dir="ltr"
lang={$lang}
nohotkeys
class="dark h-full max-w-full"
style:aspect-ratio={aspectRatio}
defaultduration={asset.duration! / 1000}
>
<video
bind:this={videoPlayer}
slot="media"
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
disablePictureInPicture
playsinline
{...useSwipe(onSwipe)}
class="h-full object-contain"
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onplaying={(e) => {
if (!hasFocused) {
e.currentTarget.focus();
hasFocused = true;
}
}}
onclose={onClose}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
></video>
{#if extendedControls}
<media-settings-menu hidden anchor="auto" class="min-w-3xs rounded-xl border border-light-300 shadow-sm">
<Icon slot="checked-indicator" icon={mdiCheck} class="m-2" />
<media-settings-menu-item class="mx-1 rounded-lg p-1 ps-2">
{$t('media_chrome.playback_rate')}
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
<media-playback-rate-menu slot="submenu" hidden rates="0.5 1 1.5 2">
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
<span slot="title">{$t('media_chrome.playback_rate')}</span>
</media-playback-rate-menu>
</media-settings-menu-item>
</media-settings-menu>
{/if}
<div class="flex h-32 w-full flex-col justify-end bg-linear-to-b to-black/80 px-4">
<media-control-bar part="bottom" class="flex h-10 w-full gap-2">
<media-play-button class="shrink-0 rounded-full p-2 outline-none">
<Icon slot="play" icon={mdiPlay} />
<Icon slot="pause" icon={mdiPause} />
</media-play-button>
<media-time-display showduration class="rounded-lg p-2 outline-none"></media-time-display>
<span class="grow"></span>
<div
class="volume-wrapper shrink-0 rounded-full bg-light-100/0 transition-colors duration-400 hover:bg-light-100"
>
<media-volume-range class="h-full bg-none outline-none"></media-volume-range>
<media-mute-button class="bg-none p-2 outline-none">
<Icon slot="off" icon={mdiVolumeMute} />
<Icon slot="low" icon={mdiVolumeLow} />
<Icon slot="medium" icon={mdiVolumeMedium} />
<Icon slot="high" icon={mdiVolumeHigh} />
</media-mute-button>
</div>
{#if extendedControls}
<media-fullscreen-button class="shrink-0 rounded-full p-2 outline-none">
<Icon slot="enter" icon={mdiFullscreen} />
<Icon slot="exit" icon={mdiFullscreenExit} />
</media-fullscreen-button>
<media-settings-menu-button class="shrink-0 rounded-full p-2 outline-none"></media-settings-menu-button>
{/if}
</media-control-bar>
<media-time-range class="h-8 w-full rounded-lg px-2 pb-3 outline-none"></media-time-range>
</div>
</media-controller>
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
{#if assetViewerManager.isFaceEditMode}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
{/if}
</div>
{/if}
<style>
media-controller {
--media-control-background: none;
--media-control-hover-background: var(--immich-ui-light-100);
--media-focus-box-shadow: 0 0 0 2px var(--immich-ui-dark);
--media-font-family: var(--font-sans);
--media-font-size: var(--text-base);
--media-font-weight: var(--font-weight-medium);
--media-menu-border-radius: var(--radius-xl);
--media-menu-gap: var(--spacing);
--media-menu-item-hover-background: var(--immich-ui-light-200);
--media-menu-item-icon-height: 1em;
--media-menu-item-indicator-height: 1em;
--media-primary-color: var(--immich-ui-dark);
--media-time-range-buffered-color: var(--immich-ui-dark-400);
--media-time-range-hover-bottom: 0;
--media-time-range-hover-height: 100%;
--media-range-thumb-box-shadow: none;
--media-range-thumb-opacity: 0;
--media-range-thumb-transition: opacity 0.15s ease;
--media-range-track-border-radius: 2px;
--media-range-track-height: 3.5px;
--media-range-padding: 0;
--media-settings-menu-background: var(--immich-ui-light-100);
--media-text-content-height: var(--text-base--line-height);
--media-tooltip-arrow-display: none;
--media-tooltip-border-radius: var(--radius-lg);
--media-tooltip-background-color: var(--immich-ui-light-200);
--media-tooltip-distance: 8px;
--media-tooltip-padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3.5);
}
media-time-display {
font-variant-numeric: tabular-nums;
}
media-time-range,
media-volume-range {
--media-control-hover-background: none;
}
media-time-range:hover,
media-volume-range:hover {
--media-range-thumb-opacity: 1;
}
*::part(tooltip) {
--media-font-size: var(--text-xs);
--media-text-content-height: var(--text-xs--line-height);
color: white;
}
*[mediavolumeunavailable] {
--media-volume-range-display: none;
}
.volume-wrapper {
--media-control-hover-background: none;
}
media-volume-range:has(+ media-mute-button) {
padding: 0;
margin: 0;
width: 0;
overflow: hidden;
transition: width 0.4s ease-out;
}
/* Expand volume control in all relevant states */
.volume-wrapper:hover > media-volume-range,
media-volume-range:has(+ media-mute-button:hover),
media-volume-range:has(+ media-mute-button:focus),
media-volume-range:has(+ media-mute-button:focus-within),
media-volume-range:hover,
media-volume-range:focus,
media-volume-range:focus-within {
padding: 0 calc(var(--spacing) * 2);
margin-left: calc(var(--spacing) * 2);
width: 70px;
}
</style>
@@ -12,14 +12,14 @@
const { asset }: Props = $props();
const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
import('./PhotoSphereViewerAdapter.svelte').then((module) => module.default),
import('@photo-sphere-viewer/equirectangular-video-adapter').then((module) => module.EquirectangularVideoAdapter),
import('@photo-sphere-viewer/video-plugin').then((module) => module.VideoPlugin),
import('@photo-sphere-viewer/video-plugin/index.css'),
]);
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<div transition:fade={{ duration: 150 }} class="flex h-full place-content-center place-items-center select-none">
{#await modules}
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
@@ -68,12 +68,12 @@
}
</script>
<span class="flex items-center space-x-2 text-gray-200 text-2xl font-bold">
<span class="flex items-center space-x-2 text-2xl font-bold text-gray-200">
<Icon icon={mdiCastConnected} class="text-primary" size="36" />
<span>{$t('connected_to')} {castManager.receiverName}</span>
</span>
<img src={poster} alt="poster" class="rounded-xl m-4" />
<img src={poster} alt="poster" class="m-4 rounded-xl" />
<div class="flex place-content-center place-items-center">
{#if castManager.castState == CastState.BUFFERING}
@@ -97,6 +97,6 @@
max={castManager.duration}
value={castManager.currentTime ?? 0}
onchange={handleSeek}
class="w-full h-4 bg-primary"
class="h-4 w-full bg-primary"
/>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import VideoNativeViewer from '$lib/components/asset-viewer/VideoNativeViewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/VideoPanoramaViewer.svelte';
import { ProjectionType } from '$lib/constants';
import type { AssetResponseDto } from '@immich/sdk';
@@ -11,6 +11,7 @@
cacheKey: string | null;
loopVideo: boolean;
playOriginalVideo: boolean;
extendedControls?: boolean;
onClose?: () => void;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
@@ -25,6 +26,7 @@
cacheKey,
loopVideo,
playOriginalVideo,
extendedControls = false,
onPreviousAsset,
onClose,
onNextAsset,
@@ -41,8 +43,10 @@
<VideoNativeViewer
{loopVideo}
{cacheKey}
{asset}
assetId={effectiveAssetId}
{playOriginalVideo}
{extendedControls}
{onPreviousAsset}
{onNextAsset}
{onVideoEnded}
@@ -1,5 +1,5 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { createStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
@@ -1,7 +1,7 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { toggleArchive } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -1,8 +1,8 @@
import { renderWithTooltips } from '$tests/helpers';
import type { AssetResponseDto } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import '@testing-library/jest-dom';
import DeleteAction from './delete-action.svelte';
import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import DeleteAction from './DeleteAction.svelte';
let asset: AssetResponseDto;
@@ -1,5 +1,5 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -3,7 +3,7 @@
import { Icon } from '@immich/ui';
import { mdiChevronRight } from '@mdi/js';
import { t } from 'svelte-i18n';
import NavigationArea from '../navigation-area.svelte';
import NavigationArea from '../NavigationArea.svelte';
interface Props {
onNextAsset: () => void;
@@ -3,7 +3,7 @@
import { Icon } from '@immich/ui';
import { mdiChevronLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
import NavigationArea from '../navigation-area.svelte';
import NavigationArea from '../NavigationArea.svelte';
interface Props {
onPreviousAsset: () => void;
@@ -1,5 +1,5 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { removeAssetFromStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
@@ -1,5 +1,5 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -1,5 +1,5 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
@@ -1,5 +1,5 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { updatePerson, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
@@ -1,5 +1,5 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import type { AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
@@ -1,5 +1,5 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { updateStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
@@ -1,5 +1,5 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { handleError } from '$lib/utils/handle-error';
@@ -1,5 +1,5 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { deleteStack } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -1,6 +1,6 @@
import type { AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
import type { AssetAction } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
@@ -1,500 +0,0 @@
<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';
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
import { timeToLoadTheMap } from '$lib/constants';
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 { Route } from '$lib/route';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.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 { getParentPath } from '$lib/utils/tree-utils';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import {
mdiCamera,
mdiCameraIris,
mdiClose,
mdiEye,
mdiEyeOff,
mdiImageOutline,
mdiInformationOutline,
mdiPencil,
mdiPlus,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import PersonSidePanel from '../faces-page/person-side-panel.svelte';
import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import AlbumListItemDetails from './album-list-item-details.svelte';
interface Props {
asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null;
}
let { asset, currentAlbum = null }: Props = $props();
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 latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng) {
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
}
})(),
);
let previousId: string | undefined = $state();
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
const refreshAlbums = async () => {
if (authManager.isSharedLink) {
return [];
}
try {
return await getAllAlbums({ assetId: asset.id });
} catch (error) {
handleError(error, 'Error getting asset album membership');
return [];
}
};
let albums = $derived(refreshAlbums());
$effect(() => {
if (!previousId) {
previousId = asset.id;
return;
}
if (asset.id === previousId) {
return;
}
assetViewerManager.closeEditFacesPanel();
previousId = asset.id;
});
const getMegapixel = (width: number, height: number): number | undefined => {
const megapixel = Math.round((height * width) / 1_000_000);
if (megapixel) {
return megapixel;
}
return undefined;
};
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
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) });
};
</script>
<OnEvents onAlbumAddAssets={() => (albums = refreshAlbums())} />
<section class="relative p-2">
<div class="flex place-items-center gap-2">
<IconButton
icon={mdiClose}
aria-label={$t('close')}
onclick={() => assetViewerManager.closeDetailPanel()}
shape="round"
color="secondary"
variant="ghost"
/>
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p>
</div>
{#if asset.isOffline}
<section class="px-4 py-4">
<div role="alert">
<div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">
{$t('asset_offline')}
</div>
<div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
<p>
{#if authManager.authenticated && authManager.user.isAdmin}
{$t('admin.asset_offline_description')}
{:else}
{$t('asset_offline_description')}
{/if}
</p>
</div>
<div class="rounded-b bg-red-500 px-4 py-2 text-white text-sm">
<p>{asset.originalPath}</p>
</div>
</div>
</section>
{/if}
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
/>
{/if}
<IconButton
aria-label={$t('tag_people')}
icon={mdiPlus}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleFaceEditMode()}
/>
{#if people.length > 0 || unassignedFaces.length > 0}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.openEditFacesPanel()}
/>
{/if}
</div>
</div>
<div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
<a
class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
onmouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
title={person.name}
widthStyle="90px"
heightStyle="90px"
hidden={person.isHidden}
highlighted={isHighlighted}
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
/>
</div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate}
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
{@const age = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years)}
{@const ageInMonths = Math.floor(
DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months,
)}
{#if age >= 0}
<p
class="font-light"
title={personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
)}
>
{#if ageInMonths <= 11}
{$t('age_months', { values: { months: ageInMonths } })}
{:else if ageInMonths > 12 && ageInMonths <= 23}
{$t('age_year_months', { values: { months: ageInMonths - 12 } })}
{:else}
{$t('age_years', { values: { years: age } })}
{/if}
</p>
{/if}
{/if}
</a>
{/if}
{/each}
</div>
</section>
{/if}
<div class="px-4 py-4">
{#if asset.exifInfo}
<div class="flex h-10 w-full items-center justify-between text-sm">
<Text size="small" color="muted">{$t('details')}</Text>
</div>
{:else}
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
{/if}
<DetailPanelDate {asset} />
<div class="flex gap-4 py-4">
<div><Icon icon={mdiImageOutline} size="24" /></div>
<div>
<p class="break-all flex place-items-center gap-2 whitespace-pre-wrap">
{asset.originalFileName}
{#if isOwner}
<IconButton
icon={mdiInformationOutline}
aria-label={$t('show_file_location')}
size="small"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleAssetPath()}
/>
{/if}
</p>
{#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">
{asset.originalPath}
</a>
</p>
{/if}
{#if (asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth) || asset.exifInfo?.fileSizeInByte}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth}
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
<p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{/if}
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
{#if asset.exifInfo?.fileSizeInByte}
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
{/if}
</div>
{/if}
</div>
</div>
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCamera} size="24" /></div>
<div>
{#if asset.exifInfo?.make || asset.exifInfo?.model}
<p>
<a
href={Route.search({
make: asset.exifInfo?.make ?? undefined,
model: asset.exifInfo?.model ?? undefined,
})}
title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}"
class="hover:text-primary"
>
{asset.exifInfo.make || ''}
{asset.exifInfo.model || ''}
</a>
</p>
{/if}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime} s`}</p>
{/if}
{#if asset.exifInfo.iso}
<p>{`ISO ${asset.exifInfo.iso}`}</p>
{/if}
</div>
</div>
</div>
{/if}
{#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCameraIris} size="24" /></div>
<div>
{#if asset.exifInfo?.lensModel}
<p>
<a
href={Route.search({ lensModel: asset.exifInfo.lensModel })}
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:text-primary line-clamp-1"
>
{asset.exifInfo.lensModel}
</a>
</p>
{/if}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo?.fNumber}
<p>ƒ/{asset.exifInfo.fNumber.toLocaleString($locale)}</p>
{/if}
{#if asset.exifInfo.focalLength}
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
{/if}
</div>
</div>
</div>
{/if}
<DetailPanelLocation {isOwner} {asset} />
</div>
</section>
{#if latlng && featureFlagsManager.value.map}
<div class="h-90">
{#await import('$lib/components/shared-components/map/map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex items-center justify-center h-full w-full">
<LoadingSpinner />
</div>
{/await}
{:then { default: Map }}
<Map
mapMarkers={[
{
lat: latlng.lat,
lon: latlng.lng,
id: asset.id,
city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null,
},
]}
center={latlng}
showSettings={false}
zoom={12.5}
simplified
useLocationPin
showSimpleControls={!showEditFaces}
onOpenInMapView={() => goto(Route.map({ ...latlng, zoom: 12.5 }))}
>
{#snippet popup({ marker })}
{@const { lat, lon } = marker}
<div class="flex flex-col items-center gap-1">
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
<a
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
target="_blank"
class="font-medium text-primary underline focus:outline-none"
>
{$t('open_in_openstreetmap')}
</a>
</div>
{/snippet}
</Map>
{/await}
</div>
{/if}
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
<section class="px-6 dark:text-immich-dark-fg mt-4">
<Text size="small" color="muted">{$t('shared_by')}</Text>
<div class="flex gap-4 pt-4">
<div>
<UserAvatar user={asset.owner} size="md" />
</div>
<div class="mb-auto mt-auto">
<p>
{asset.owner.name}
</p>
</div>
</div>
</section>
{/if}
{#await albums then albums}
{#if albums.length > 0}
<section class="px-6 py-6 dark:text-immich-dark-fg">
<div class="pb-4">
<Text size="small" color="muted">{$t('appears_in')}</Text>
</div>
{#each albums as album (album.id)}
<a href={Route.viewAlbum(album)}>
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
<div>
<img
alt={album.albumName}
class="h-12.5 w-12.5 rounded object-cover"
src={album.albumThumbnailAssetId &&
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
/>
</div>
<div class="mb-auto mt-auto">
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex flex-col gap-0 text-sm">
<div>
<AlbumListItemDetails {album} />
</div>
</div>
</div>
</div>
</a>
{/each}
</section>
{/if}
{/await}
{#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>
{/if}
{#if showEditFaces}
<PersonSidePanel
assetId={asset.id}
assetType={asset.type}
onClose={() => assetViewerManager.closeEditFacesPanel()}
onRefresh={handleRefreshPeople}
/>
{/if}
@@ -54,8 +54,8 @@
]}
/>
<section class="relative flex flex-col h-full p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg dark pt-3">
<HStack class="justify-between me-4">
<section class="dark relative flex h-full flex-col p-2 pt-3 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<HStack class="me-4 justify-between">
<HStack>
<IconButton
shape="round"
@@ -65,7 +65,7 @@
aria-label={$t('close')}
onclick={closeEditor}
/>
<p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p>
<p class="text-lg text-immich-fg capitalize dark:text-immich-dark-fg">{$t('editor')}</p>
</HStack>
<Button shape="round" size="small" onclick={applyEdits} loading={editManager.isApplyingEdits}>{$t('save')}</Button>
</HStack>
@@ -60,9 +60,9 @@
});
</script>
<div class="flex flex-col items-center justify-center w-full h-full p-8" bind:this={canvasContainer}>
<div class="flex size-full flex-col items-center justify-center p-8" bind:this={canvasContainer}>
<div
class="crop-area max-w-full max-h-full transition-transform motion-reduce:transition-none"
class="crop-area max-h-full max-w-full transition-transform motion-reduce:transition-none"
class:rotated={transformManager.normalizedRotation % 180 > 0}
style:rotate={transformManager.imageRotation + 'deg'}
bind:this={transformManager.cropAreaEl}
@@ -72,12 +72,12 @@
draggable="false"
src={imageSrc}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full select-none transition-transform motion-reduce:transition-none"
class="h-full transition-transform select-none motion-reduce:transition-none"
style:transform={imageTransform}
/>
<div
class={[
'overlay w-full h-full absolute top-0 transition-colors motion-reduce:transition-none pointer-events-none',
'overlay pointer-events-none absolute top-0 size-full transition-colors motion-reduce:transition-none',
transformManager.isInteracting ? 'bg-black/30' : 'bg-black/56',
]}
bind:this={transformManager.overlayEl}
@@ -86,7 +86,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={[
'grid w-full h-full cursor-move transition-opacity motion-reduce:transition-none',
'grid size-full cursor-move transition-opacity motion-reduce:transition-none',
transformManager.isInteracting ? 'opacity-100' : 'opacity-0',
]}
onmousedown={(e) => transformManager.handleMouseDownOn(e, ResizeBoundary.None)}
@@ -77,7 +77,7 @@
/>
<div class="mt-3 px-4">
<div class="flex h-10 w-full items-center justify-between text-sm mt-2">
<div class="mt-2 flex h-10 w-full items-center justify-between text-sm">
<h2>{$t('editor_orientation')}</h2>
</div>
<HStack>
@@ -111,16 +111,16 @@
/>
</HStack>
<div class="flex h-10 w-full items-center justify-between text-sm mt-6">
<div class="mt-6 flex h-10 w-full items-center justify-between text-sm">
<h2>{$t('crop')}</h2>
</div>
<!-- Aspect Ratio Grid -->
<div class="grid grid-cols-2 mb-4">
<div class="mb-4 grid grid-cols-2">
{#each aspectRatios as ratio (ratio.value)}
<HStack>
<Button
class="w-14 h-14 m-2"
class="m-2 size-14"
shape="round"
onclick={() => selectAspectRatio(ratio)}
aria-label={ratio.label}
@@ -130,14 +130,14 @@
{#if ratio.isFree}
<!-- Free crop icon with dashed border -->
<div
class="w-6 h-6 border-2 border-dashed rounded-xs flex-shrink-0 {ratioSelected(ratio)
class="size-6 shrink-0 rounded-xs border-2 border-dashed {ratioSelected(ratio)
? 'border-black'
: 'border-white'}"
></div>
{:else}
<!-- Aspect ratio box -->
<div
class="border-2 rounded-xs flex-shrink-0 {ratioSelected(ratio) ? 'border-black' : 'border-white'}"
class="shrink-0 rounded-xs border-2 {ratioSelected(ratio) ? 'border-black' : 'border-white'}"
style="width: {ratio.width}px; height: {ratio.height}px;"
></div>
{/if}
@@ -1,6 +1,6 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
@@ -364,34 +364,34 @@
<div
id="face-editor-data"
class="absolute inset-s-0 top-0 z-5 h-full w-full overflow-hidden"
class="absolute inset-s-0 top-0 z-5 size-full overflow-hidden"
data-overlay-interactive
data-face-left={faceBoxPosition.left}
data-face-top={faceBoxPosition.top}
data-face-width={faceBoxPosition.width}
data-face-height={faceBoxPosition.height}
>
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 inset-s-0"></canvas>
<canvas bind:this={canvasEl} id="face-editor" class="absolute inset-s-0 top-0"></canvas>
<div
id="face-selector"
bind:this={faceSelectorEl}
class="absolute top-[calc(50%-250px)] inset-s-[calc(50%-125px)] max-w-62.5 w-62.5 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
class="absolute inset-s-[calc(50%-125px)] top-[calc(50%-250px)] w-62.5 max-w-62.5 rounded-xl border border-gray-200 bg-white px-2 py-4 backdrop-blur-sm transition-[top,left] duration-200 ease-out dark:border-gray-800 dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
<div class="my-3 relative">
<div class="relative my-3">
<Input placeholder={$t('search_people')} bind:value={searchTerm} bind:ref={searchInputEl} size="tiny" />
</div>
<div bind:this={scrollableListEl} class="h-62.5 overflow-y-auto mt-2">
<div bind:this={scrollableListEl} class="mt-2 h-62.5 overflow-y-auto">
{#if filteredCandidates.length > 0}
<div class="mt-2 rounded-lg">
{#each filteredCandidates as person (person.id)}
<button
onclick={() => tagFace(person)}
type="button"
class="w-full flex place-items-center gap-2 rounded-lg ps-1 pe-4 py-2 hover:bg-immich-primary/25"
class="flex w-full place-items-center gap-2 rounded-lg py-2 ps-1 pe-4 hover:bg-immich-primary/25"
>
<ImageThumbnail
curve
@@ -1,180 +0,0 @@
<script lang="ts">
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import {
autoPlayVideo,
loopVideo as loopVideoPreference,
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition';
interface Props {
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
onVideoStarted?: () => void;
onClose?: () => void;
}
let {
assetId,
loopVideo,
cacheKey,
playOriginalVideo,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
onVideoStarted = () => {},
onClose = () => {},
}: Props = $props();
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $derived(
playOriginalVideo
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
let isScrubbing = $state(false);
let showVideo = $state(false);
let hasFocused = $state(false);
onMount(() => {
// Show video after mount to ensure fading in.
showVideo = true;
});
$effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
hasFocused = false;
videoPlayer?.load();
}
});
onDestroy(() => {
if (videoPlayer) {
videoPlayer.src = '';
}
});
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
await video.play();
onVideoStarted();
}
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
await tryForceMutedPlay(video);
return;
}
// auto-play failed
} finally {
isLoading = false;
}
};
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
if (video.muted) {
return;
}
try {
video.muted = true;
await handleCanPlay(video);
} catch {
// muted auto-play failed
}
};
const onSwipe = (event: SwipeCustomEvent) => {
if (event.detail.direction === 'left') {
onNextAsset();
}
if (event.detail.direction === 'right') {
onPreviousAsset();
}
};
let containerWidth = $state(0);
let containerHeight = $state(0);
$effect(() => {
if (assetViewerManager.isFaceEditMode) {
videoPlayer?.pause();
}
});
</script>
{#if showVideo}
<div
transition:fade={{ duration: assetViewerFadeDuration }}
class="flex h-full select-none place-content-center place-items-center"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
{#if castManager.isCasting}
<div class="place-content-center h-full place-items-center">
<VideoRemoteViewer
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
{onVideoStarted}
{onVideoEnded}
{assetFileUrl}
/>
</div>
{:else}
<video
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
playsinline
controls
disablePictureInPicture
class="h-full object-contain"
{...useSwipe(onSwipe)}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
if (!hasFocused) {
e.currentTarget.focus();
hasFocused = true;
}
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
{#if assetViewerManager.isFaceEditMode}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
{/if}
</div>
{/if}
@@ -25,7 +25,7 @@
style:height
>
<div class="hidden @min-[75px]:block">
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
<Icon icon={mdiImageBrokenVariant} size="7em" class="min-h-6 max-w-full min-w-6" />
</div>
{#if !hideMessage}
<span class="text-center text-xs @min-[100px]:text-sm @min-[150px]:text-base">{$t('error_loading_image')}</span>
@@ -1,5 +1,5 @@
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { fireEvent, render } from '@testing-library/svelte';
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
vi.mock('$lib/utils/sw-messaging', () => ({
cancelImageUrl: vi.fn(),
@@ -1,5 +1,5 @@
<script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import BrokenAsset from '$lib/components/assets/BrokenAsset.svelte';
import Image from '$lib/components/Image.svelte';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
@@ -78,7 +78,7 @@
src={url}
onLoad={setLoaded}
onError={setErrored}
class={['object-cover bg-gray-300 dark:bg-gray-700', sharedClasses, imageClass]}
class={['bg-gray-300 object-cover dark:bg-gray-700', sharedClasses, imageClass]}
{style}
alt={loaded || errored ? altText : ''}
draggable={false}
@@ -88,7 +88,7 @@
{/if}
{#if hidden}
<div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
<div class="absolute inset-s-1/2 top-1/2 translate-[-50%] transform">
<!-- TODO fix `title` type -->
<Icon title={title ?? undefined} icon={mdiEyeOffOutline} size="2em" class={hiddenIconClass} />
</div>
@@ -5,7 +5,6 @@
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time';
import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { getAltText } from '$lib/utils/thumbnail-util';
@@ -26,8 +25,8 @@
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';
import ImageThumbnail from './ImageThumbnail.svelte';
import VideoThumbnail from './VideoThumbnail.svelte';
interface Props {
asset: TimelineAsset;
groupIndex?: number;
@@ -207,7 +206,7 @@
<div
class={[
'group focus-visible:outline-none flex overflow-hidden transition-[background-color,border-radius]',
'group flex overflow-hidden transition-[background-color,border-radius] focus-visible:outline-none',
backgroundColorClass,
{ 'rounded-xl': selected },
]}
@@ -238,20 +237,20 @@
role="link"
>
<div
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
class={['group absolute inset-y-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
style:width="inherit"
style:height="inherit"
>
<div
class={[
'absolute h-full w-full select-none bg-transparent transition-transform',
'absolute size-full bg-transparent transition-transform select-none',
{ 'scale-[0.85]': selected },
{ 'rounded-xl': selected },
]}
>
<ImageThumbnail
class={[
'absolute group-focus-visible:rounded-lg transition-[border-radius]',
'absolute transition-[border-radius] group-focus-visible:rounded-lg',
{ 'rounded-xl': selected },
imageClass,
]}
@@ -268,18 +267,18 @@
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
/>
{#if asset.isVideo}
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
<VideoThumbnail
class="group-focus-visible:rounded-lg"
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
durationInSeconds={asset.duration ? asset.duration / 1000 : 0}
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{:else if asset.isImage && asset.livePhotoVideoId}
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
<VideoThumbnail
class="group-focus-visible:rounded-lg"
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
@@ -293,7 +292,7 @@
</div>
{:else if asset.isImage && asset.duration && mouseOver}
<!-- GIF -->
<div class="absolute h-full w-full pointer-events-none">
<div class="pointer-events-none absolute size-full">
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
@@ -318,12 +317,12 @@
{/if}
<!-- icon overlay -->
<div class="z-2 absolute inset-0">
<div class="absolute inset-0 z-2">
<!-- Gradient overlay on hover -->
{#if !usingMobileDevice && !disabled && !asset.isVideo}
<div
class={[
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100 ',
'absolute size-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100',
{ 'rounded-xl group-focus-visible:rounded-lg': selected },
]}
></div>
@@ -333,36 +332,33 @@
{#if dimmed && !mouseOver}
<div
id="a"
class={[
'z-2 absolute h-full w-full bg-gray-700/40 group-focus-visible:rounded-lg',
{ 'rounded-xl': selected },
]}
class={['absolute z-2 size-full bg-gray-700/40 group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
></div>
{/if}
<!-- Favorite asset star -->
{#if !authManager.isSharedLink && asset.isFavorite}
<div class="z-2 absolute bottom-2 inset-s-2">
<div class="absolute inset-s-2 bottom-2 z-2">
<Icon data-icon-favorite icon={mdiHeart} size="24" class="text-white" />
</div>
{/if}
{#if !!assetOwner}
<div class="z-2 absolute bottom-1 inset-e-2 max-w-[50%]">
<p class="text-xs font-medium text-white drop-shadow-lg max-w-full truncate">
<div class="absolute inset-e-2 bottom-1 z-2 max-w-[50%]">
<p class="max-w-full truncate text-xs font-medium text-white drop-shadow-lg">
{assetOwner.name}
</p>
</div>
{/if}
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['z-2 absolute inset-s-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<div class={['absolute inset-s-2 z-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
</div>
{/if}
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="z-2 absolute inset-e-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<div class="absolute inset-e-0 top-0 z-2 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiRotate360} size="24" />
</span>
@@ -370,7 +366,7 @@
{/if}
{#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">
<div class="absolute inset-e-0 top-0 z-2 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" />
</span>
@@ -381,11 +377,11 @@
{#if asset.stack && showStackedIcon}
<div
class={[
'z-2 absolute flex place-items-center gap-1 text-xs font-medium text-white',
asset.isImage && !asset.livePhotoVideoId ? 'top-0 inset-e-0' : 'top-7 inset-e-1',
'absolute z-2 flex place-items-center gap-1 text-xs font-medium text-white',
asset.isImage && !asset.livePhotoVideoId ? 'inset-e-0 top-0' : 'inset-e-1 top-7',
]}
>
<span class="pe-2 pt-2 flex place-items-center gap-1">
<span class="flex place-items-center gap-1 pe-2 pt-2">
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
<Icon icon={mdiCameraBurst} size="24" />
</span>
@@ -396,7 +392,7 @@
<!-- lazy show the url on mouse over-->
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
<a
class="z-2 absolute w-full top-0 bottom-0"
class="absolute inset-y-0 z-2 w-full"
style:cursor="unset"
href={currentUrlReplaceAssetId(asset.id)}
onclick={(evt) => evt.preventDefault()}
@@ -409,7 +405,7 @@
{#if selectionCandidate}
<div
class={['z-2 absolute top-0 h-full w-full bg-immich-primary opacity-40', { 'rounded-xl': selected }]}
class={['absolute top-0 z-2 size-full bg-immich-primary opacity-40', { 'rounded-xl': selected }]}
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
></div>
@@ -447,7 +443,7 @@
e.preventDefault();
onPreview?.($state.snapshot(asset));
}}
class="absolute z-2 bottom-1 end-1 rounded-full bg-black/25 p-1.5 hover:bg-black/50 focus:outline-none transition-colors"
class="absolute inset-e-1 bottom-1 z-2 rounded-full bg-black/25 p-1.5 transition-colors hover:bg-black/50 focus:outline-none"
in:fade={{ duration: 100 }}
tabindex={-1}
aria-label="Preview asset"
@@ -459,7 +455,7 @@
<!-- Outline on focus -->
<div
class={[
'pointer-events-none absolute z-1 size-full outline-immich-primary dark:outline-immich-dark-primary group-focus-visible:outline-4 group-focus-visible:-outline-offset-4',
'pointer-events-none absolute z-1 size-full outline-immich-primary group-focus-visible:outline-4 group-focus-visible:-outline-offset-4 dark:outline-immich-dark-primary',
{ 'rounded-xl': selected },
]}
data-outline
@@ -94,10 +94,10 @@
{/if}
<div
class="@container absolute inset-x-0 top-0 flex justify-end place-items-center gap-1 text-xs font-medium text-white text-shadow-[1px_1px_6px_rgb(0_0_0)]"
class="@container absolute inset-x-0 top-0 flex place-items-center justify-end gap-1 text-xs font-medium text-white text-shadow-[1px_1px_6px_rgb(0_0_0)]"
>
{#if showTime}
<span class="hidden @min-[100px]:inline pt-2">
<span class="hidden pt-2 @min-[100px]:inline">
{#if remainingSeconds < 60}
{Duration.fromObject({ seconds: remainingSeconds }).toFormat('m:ss')}
{:else if remainingSeconds < 3600}
@@ -110,7 +110,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="pe-2 pt-2 @max-[99px]:scale-75 @max-[99px]:pe-1 @max-[99px]:pt-1 drop-shadow-[1px_1px_6px_rgb(0_0_0)]"
class="pe-2 pt-2 drop-shadow-[1px_1px_6px_rgb(0_0_0)] @max-[99px]:scale-75 @max-[99px]:pe-1 @max-[99px]:pt-1"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
>
@@ -1,8 +1,8 @@
import { render } from '@testing-library/svelte';
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { assetFactory } from '@test-data/factories/asset-factory';
import { render } from '@testing-library/svelte';
vi.hoisted(() => {
Object.defineProperty(globalThis, 'matchMedia', {
@@ -1,5 +1,5 @@
<script lang="ts">
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import SearchPeople from '$lib/components/faces-page/PeopleSearch.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
@@ -13,7 +13,7 @@
import { t } from 'svelte-i18n';
import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import ImageThumbnail from '../assets/thumbnail/ImageThumbnail.svelte';
interface Props {
editedFace: AssetFaceResponseDto;
@@ -73,7 +73,7 @@
<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"
class="absolute top-0 h-full w-90 overflow-x-hidden bg-light p-2 dark:text-immich-dark-fg"
>
<div class="flex place-items-center justify-between gap-2">
{#if !searchFaces}
@@ -123,7 +123,7 @@
aria-label={$t('back')}
onclick={onClose}
/>
<div class="w-full flex">
<div class="flex w-full">
<SearchPeople
type="input"
bind:searchName
@@ -146,14 +146,14 @@
/>
{/if}
</div>
<div class="px-4 py-4 text-sm">
<h2 class="mb-8 mt-4">{$t('all_people')}</h2>
<div class="p-4 text-sm">
<h2 class="mt-4 mb-8">{$t('all_people')}</h2>
{#if isShowLoadingPeople}
<div class="flex w-full justify-center">
<LoadingSpinner />
</div>
{:else}
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
<div class="mt-4 flex flex-wrap gap-2 overflow-y-auto immich-scrollbar">
{#each showPeople as person (person.id)}
{#if !editedFace.person || person.id !== editedFace.person.id}
<div class="w-fit">

Some files were not shown because too many files have changed in this diff Show More