mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge remote-tracking branch 'origin/main' into feat/yucca-integration
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
24.14.1
|
||||
24.15.0
|
||||
|
||||
+3
-2
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Vendored
+4
-4
@@ -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,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 }) => {
|
||||
|
||||
+2
-2
@@ -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,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,5 +1,5 @@
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
import { tick } from 'svelte';
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
|
||||
interface Options {
|
||||
/**
|
||||
|
||||
@@ -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,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 = {
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
+11
-10
@@ -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>
|
||||
+4
-4
@@ -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" />
|
||||
+2
-2
@@ -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';
|
||||
+1
-1
@@ -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>
|
||||
+8
-8
@@ -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>
|
||||
+20
-13
@@ -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) {
|
||||
+8
-8
@@ -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)}
|
||||
+13
-11
@@ -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}
|
||||
+4
-4
@@ -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();
|
||||
|
||||
+2
-2
@@ -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');
|
||||
|
||||
+1
-1
@@ -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}
|
||||
+28
-30
@@ -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
-9
@@ -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}
|
||||
+4
-4
@@ -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 });
|
||||
+53
-31
@@ -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}
|
||||
/>
|
||||
+2
-2
@@ -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 = {
|
||||
+18
-18
@@ -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}
|
||||
+2
-2
@@ -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>
|
||||
+2
-2
@@ -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 () => {
|
||||
+4
-4
@@ -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}
|
||||
+3
-3
@@ -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}
|
||||
+10
-17
@@ -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} />
|
||||
+2
-2
@@ -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 })} />
|
||||
+1
-1
@@ -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}
|
||||
>
|
||||
+5
-5
@@ -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}"
|
||||
+1
-1
@@ -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"
|
||||
+7
-12
@@ -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 */
|
||||
+52
-52
@@ -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;
|
||||
|
||||
+2
-2
@@ -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>
|
||||
+2
-2
@@ -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]}
|
||||
+3
-3
@@ -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>
|
||||
+6
-2
@@ -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
-1
@@ -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
-1
@@ -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';
|
||||
+3
-3
@@ -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
-1
@@ -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';
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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}
|
||||
+3
-3
@@ -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>
|
||||
+5
-5
@@ -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)}
|
||||
+6
-6
@@ -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}
|
||||
+7
-7
@@ -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}
|
||||
+1
-1
@@ -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
-1
@@ -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(),
|
||||
+3
-3
@@ -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>
|
||||
+26
-30
@@ -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
|
||||
+3
-3
@@ -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}
|
||||
>
|
||||
+2
-2
@@ -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', {
|
||||
+7
-7
@@ -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
Reference in New Issue
Block a user