mirror of
https://github.com/immich-app/immich.git
synced 2026-02-12 20:08:25 +03:00
Merge branch 'immich-app:main' into feat-web-set-asset-as-profile-image
This commit is contained in:
12
web/src/api/open-api/api.ts
generated
12
web/src/api/open-api/api.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.65.0
|
||||
* The version of the OpenAPI document: 1.66.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
@@ -1772,11 +1772,17 @@ export interface PersonResponseDto {
|
||||
*/
|
||||
export interface PersonUpdateDto {
|
||||
/**
|
||||
*
|
||||
* Person name.
|
||||
* @type {string}
|
||||
* @memberof PersonUpdateDto
|
||||
*/
|
||||
'name': string;
|
||||
'name'?: string;
|
||||
/**
|
||||
* Asset is used to get the feature face thumbnail.
|
||||
* @type {string}
|
||||
* @memberof PersonUpdateDto
|
||||
*/
|
||||
'featureFaceAssetId'?: string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
||||
2
web/src/api/open-api/base.ts
generated
2
web/src/api/open-api/base.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.65.0
|
||||
* The version of the OpenAPI document: 1.66.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
web/src/api/open-api/common.ts
generated
2
web/src/api/open-api/common.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.65.0
|
||||
* The version of the OpenAPI document: 1.66.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
web/src/api/open-api/configuration.ts
generated
2
web/src/api/open-api/configuration.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.65.0
|
||||
* The version of the OpenAPI document: 1.66.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
web/src/api/open-api/index.ts
generated
2
web/src/api/open-api/index.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.65.0
|
||||
* The version of the OpenAPI document: 1.66.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
import { timeToSeconds } from '$lib/utils/time-to-seconds';
|
||||
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
|
||||
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
|
||||
import Heart from 'svelte-material-icons/Heart.svelte';
|
||||
import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
|
||||
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
|
||||
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
||||
import Heart from 'svelte-material-icons/Heart.svelte';
|
||||
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import ImageThumbnail from './image-thumbnail.svelte';
|
||||
import VideoThumbnail from './video-thumbnail.svelte';
|
||||
import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -21,6 +22,7 @@
|
||||
export let thumbnailHeight: number | undefined = undefined;
|
||||
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
||||
export let selected = false;
|
||||
export let selectionCandidate = false;
|
||||
export let disabled = false;
|
||||
export let readonly = false;
|
||||
export let publicSharedKey: string | undefined = undefined;
|
||||
@@ -30,7 +32,7 @@
|
||||
|
||||
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
|
||||
$: [width, height] = (() => {
|
||||
$: [width, height] = ((): [number, number] => {
|
||||
if (thumbnailSize) {
|
||||
return [thumbnailSize, thumbnailSize];
|
||||
}
|
||||
@@ -42,12 +44,19 @@
|
||||
return [235, 235];
|
||||
})();
|
||||
|
||||
const thumbnailClickedHandler = () => {
|
||||
const thumbnailClickedHandler = (e: Event) => {
|
||||
if (!disabled) {
|
||||
e.preventDefault();
|
||||
dispatch('click', { asset });
|
||||
}
|
||||
};
|
||||
|
||||
const thumbnailKeyDownHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
thumbnailClickedHandler(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onIconClickedHandler = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
@@ -68,17 +77,17 @@
|
||||
on:mouseenter={() => (mouseOver = true)}
|
||||
on:mouseleave={() => (mouseOver = false)}
|
||||
on:click={thumbnailClickedHandler}
|
||||
on:keydown={thumbnailClickedHandler}
|
||||
on:keydown={thumbnailKeyDownHandler}
|
||||
>
|
||||
{#if intersecting}
|
||||
<div class="absolute w-full h-full z-20">
|
||||
<!-- Select asset button -->
|
||||
{#if !readonly}
|
||||
{#if !readonly && (mouseOver || selected || selectionCandidate)}
|
||||
<button
|
||||
on:click={onIconClickedHandler}
|
||||
class="absolute p-2 group-hover:block"
|
||||
class:group-hover:block={!disabled}
|
||||
class:hidden={!selected}
|
||||
on:keydown|preventDefault
|
||||
on:keyup|preventDefault
|
||||
class="absolute p-2"
|
||||
class:cursor-not-allowed={disabled}
|
||||
role="checkbox"
|
||||
aria-checked={selected}
|
||||
@@ -153,6 +162,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectionCandidate}
|
||||
<div
|
||||
class="absolute w-full h-full top-0 bg-immich-primary opacity-40"
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</IntersectionObserver>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { AssetResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import AssetSelectionViewer from '../shared-components/gallery-viewer/asset-selection-viewer.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let assets: AssetResponseDto[];
|
||||
|
||||
let selectedAsset: AssetResponseDto | undefined = undefined;
|
||||
|
||||
const handleSelectedAsset = async (event: CustomEvent) => {
|
||||
const { asset }: { asset: AssetResponseDto } = event.detail;
|
||||
selectedAsset = asset;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
dispatch('go-back', { selectedAsset });
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
|
||||
class="absolute top-0 left-0 w-full h-full bg-immich-bg dark:bg-immich-dark-bg z-[9999]"
|
||||
>
|
||||
<ControlAppBar on:close-button-click={onClose}>
|
||||
<svelte:fragment slot="leading">Select feature photo</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
<section class="pt-[100px] pl-[70px] bg-immich-bg dark:bg-immich-dark-bg">
|
||||
<AssetSelectionViewer {assets} on:select={handleSelectedAsset} />
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
assetInteractionStore,
|
||||
assetSelectionCandidates,
|
||||
assetsInAlbumStoreState,
|
||||
isMultiSelectStoreState,
|
||||
selectedAssets,
|
||||
@@ -11,12 +12,13 @@
|
||||
import type { AssetResponseDto } from '@api';
|
||||
import justifiedLayout from 'justified-layout';
|
||||
import lodash from 'lodash-es';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
|
||||
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { DateTime, Interval } from 'luxon';
|
||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let assets: AssetResponseDto[];
|
||||
export let bucketDate: string;
|
||||
@@ -130,18 +132,19 @@
|
||||
dateGroupTitle: string,
|
||||
) => {
|
||||
if ($selectedAssets.has(asset)) {
|
||||
for (const candidate of $assetSelectionCandidates || []) {
|
||||
assetInteractionStore.removeAssetFromMultiselectGroup(candidate);
|
||||
}
|
||||
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
||||
} else {
|
||||
for (const candidate of $assetSelectionCandidates || []) {
|
||||
assetInteractionStore.addAssetToMultiselectGroup(candidate);
|
||||
}
|
||||
assetInteractionStore.addAssetToMultiselectGroup(asset);
|
||||
}
|
||||
|
||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||
let selectedAssetsInGroupCount = 0;
|
||||
assetsInDateGroup.forEach((asset) => {
|
||||
if ($selectedAssets.has(asset)) {
|
||||
selectedAssetsInGroupCount++;
|
||||
}
|
||||
});
|
||||
let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length;
|
||||
|
||||
// if all assets are selected in a group, add the group to selected group
|
||||
if (selectedAssetsInGroupCount == assetsInDateGroup.length) {
|
||||
@@ -151,9 +154,48 @@
|
||||
}
|
||||
};
|
||||
|
||||
const assetMouseEventHandler = (dateGroupTitle: string) => {
|
||||
const assetMouseEventHandler = (dateGroupTitle: string, asset: AssetResponseDto | null) => {
|
||||
// Show multi select icon on hover on date group
|
||||
hoveredDateGroup = dateGroupTitle;
|
||||
|
||||
if ($isMultiSelectStoreState) {
|
||||
dispatch('selectAssetCandidates', { asset });
|
||||
}
|
||||
};
|
||||
|
||||
const formatGroupTitle = (date: DateTime): string => {
|
||||
const today = DateTime.now().startOf('day');
|
||||
|
||||
// Today
|
||||
if (today.hasSame(date, 'day')) {
|
||||
return 'Today';
|
||||
}
|
||||
|
||||
// Yesterday
|
||||
if (Interval.fromDateTimes(date, today).length('days') == 1) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
|
||||
// Last week
|
||||
if (Interval.fromDateTimes(date, today).length('weeks') < 1) {
|
||||
return date.toLocaleString({ weekday: 'long' });
|
||||
}
|
||||
|
||||
// This year
|
||||
if (today.hasSame(date, 'year')) {
|
||||
return date.toLocaleString({
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
return date.toLocaleString({
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -164,16 +206,19 @@
|
||||
bind:clientWidth={viewportWidth}
|
||||
>
|
||||
{#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)}
|
||||
{@const dateGroupTitle = new Date(assetsInDateGroup[0].fileCreatedAt).toLocaleDateString($locale, groupDateFormat)}
|
||||
{@const dateGroupTitle = formatGroupTitle(DateTime.fromISO(assetsInDateGroup[0].fileCreatedAt).startOf('day'))}
|
||||
<!-- Asset Group By Date -->
|
||||
|
||||
<div
|
||||
class="flex flex-col mt-5"
|
||||
on:mouseenter={() => {
|
||||
isMouseOverGroup = true;
|
||||
assetMouseEventHandler(dateGroupTitle);
|
||||
assetMouseEventHandler(dateGroupTitle, null);
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
isMouseOverGroup = false;
|
||||
assetMouseEventHandler(dateGroupTitle, null);
|
||||
}}
|
||||
on:mouseleave={() => (isMouseOverGroup = false)}
|
||||
>
|
||||
<!-- Date group title -->
|
||||
<p
|
||||
@@ -195,7 +240,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="truncate" title={dateGroupTitle}>
|
||||
<span class="first-letter:capitalize truncate" title={dateGroupTitle}>
|
||||
{dateGroupTitle}
|
||||
</span>
|
||||
</p>
|
||||
@@ -216,9 +261,10 @@
|
||||
{groupIndex}
|
||||
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
|
||||
on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)}
|
||||
on:mouse-event={() => assetMouseEventHandler(dateGroupTitle)}
|
||||
selected={$selectedAssets.has(asset) || $assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
|
||||
disabled={$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
|
||||
on:mouse-event={() => assetMouseEventHandler(dateGroupTitle, asset)}
|
||||
selected={$selectedAssets.has(asset) || $assetsInAlbumStoreState.some(({ id }) => id === asset.id)}
|
||||
selectionCandidate={$assetSelectionCandidates.has(asset)}
|
||||
disabled={$assetsInAlbumStoreState.some(({ id }) => id === asset.id)}
|
||||
thumbnailWidth={box.width}
|
||||
thumbnailHeight={box.height}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { BucketPosition } from '$lib/models/asset-grid-state';
|
||||
import {
|
||||
assetInteractionStore,
|
||||
isMultiSelectStoreState,
|
||||
isViewingAssetStoreState,
|
||||
selectedAssets,
|
||||
viewingAssetStoreState,
|
||||
} from '$lib/stores/asset-interaction.store';
|
||||
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
|
||||
import type { UserResponseDto } from '@api';
|
||||
import { AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum, api } from '@api';
|
||||
import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum } from '@api';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
||||
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
|
||||
@@ -16,7 +19,6 @@
|
||||
OnScrollbarDragDetail,
|
||||
} from '../shared-components/scrollbar/scrollbar.svelte';
|
||||
import AssetDateGroup from './asset-date-group.svelte';
|
||||
import { BucketPosition } from '$lib/models/asset-grid-state';
|
||||
import MemoryLane from './memory-lane.svelte';
|
||||
|
||||
export let user: UserResponseDto | undefined = undefined;
|
||||
@@ -111,8 +113,80 @@
|
||||
navigateToNextAsset();
|
||||
assetStore.removeAsset(asset.id);
|
||||
};
|
||||
|
||||
let lastAssetMouseEvent: AssetResponseDto | null = null;
|
||||
|
||||
$: if (!lastAssetMouseEvent) {
|
||||
assetInteractionStore.clearAssetSelectionCandidates();
|
||||
}
|
||||
|
||||
let shiftKeyIsDown = false;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
e.preventDefault();
|
||||
shiftKeyIsDown = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
e.preventDefault();
|
||||
shiftKeyIsDown = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (!shiftKeyIsDown) {
|
||||
assetInteractionStore.clearAssetSelectionCandidates();
|
||||
}
|
||||
|
||||
$: if (shiftKeyIsDown && lastAssetMouseEvent) {
|
||||
selectAssetCandidates(lastAssetMouseEvent);
|
||||
}
|
||||
|
||||
const getLastSelectedAsset = () => {
|
||||
let value;
|
||||
for (value of $selectedAssets);
|
||||
return value;
|
||||
};
|
||||
|
||||
const handleSelectAssetCandidates = (e: CustomEvent) => {
|
||||
const asset = e.detail.asset;
|
||||
if (asset) {
|
||||
selectAssetCandidates(asset);
|
||||
}
|
||||
lastAssetMouseEvent = asset;
|
||||
};
|
||||
|
||||
const selectAssetCandidates = (asset: AssetResponseDto) => {
|
||||
if (!shiftKeyIsDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSelectedAsset = getLastSelectedAsset();
|
||||
if (!lastSelectedAsset) {
|
||||
return;
|
||||
}
|
||||
|
||||
let start = $assetGridState.assets.indexOf(asset);
|
||||
let end = $assetGridState.assets.indexOf(lastSelectedAsset);
|
||||
|
||||
if (start > end) {
|
||||
[start, end] = [end, start];
|
||||
}
|
||||
|
||||
assetInteractionStore.setAssetSelectionCandidates($assetGridState.assets.slice(start, end + 1));
|
||||
};
|
||||
|
||||
const onSelectStart = (e: Event) => {
|
||||
if ($isMultiSelectStoreState && shiftKeyIsDown) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} />
|
||||
|
||||
{#if bucketInfo && viewportHeight && $assetGridState.timelineHeight > viewportHeight}
|
||||
<Scrollbar
|
||||
scrollbarHeight={viewportHeight}
|
||||
@@ -155,6 +229,7 @@
|
||||
<AssetDateGroup
|
||||
{isAlbumSelectionMode}
|
||||
on:shift={handleScrollTimeline}
|
||||
on:selectAssetCandidates={handleSelectAssetCandidates}
|
||||
assets={bucket.assets}
|
||||
bucketDate={bucket.bucketDate}
|
||||
bucketHeight={bucket.bucketHeight}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { getThumbnailSize } from '$lib/utils/thumbnail-util';
|
||||
import { AssetResponseDto, ThumbnailFormat } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
export let assets: AssetResponseDto[];
|
||||
export let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||
|
||||
let viewWidth: number;
|
||||
$: thumbnailSize = getThumbnailSize(assets.length, viewWidth);
|
||||
|
||||
let dispatch = createEventDispatcher();
|
||||
|
||||
const selectAssetHandler = (event: CustomEvent) => {
|
||||
const { asset }: { asset: AssetResponseDto } = event.detail;
|
||||
let temp = new Set(selectedAssets);
|
||||
if (selectedAssets.has(asset)) {
|
||||
temp.delete(asset);
|
||||
} else {
|
||||
temp.add(asset);
|
||||
}
|
||||
|
||||
selectedAssets = temp;
|
||||
dispatch('select', { asset, selectedAssets });
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if assets.length > 0}
|
||||
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
|
||||
{#each assets as asset (asset.id)}
|
||||
<div animate:flip={{ duration: 500 }}>
|
||||
<Thumbnail
|
||||
{asset}
|
||||
{thumbnailSize}
|
||||
format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
|
||||
on:click={selectAssetHandler}
|
||||
selected={selectedAssets.has(asset)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -10,6 +10,7 @@
|
||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { archivedAsset } from '$lib/stores/archived-asset.store';
|
||||
import { getThumbnailSize } from '$lib/utils/thumbnail-util';
|
||||
|
||||
export let assets: AssetResponseDto[];
|
||||
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
||||
@@ -24,22 +25,10 @@
|
||||
let currentViewAssetIndex = 0;
|
||||
|
||||
let viewWidth: number;
|
||||
let thumbnailSize = 300;
|
||||
$: thumbnailSize = getThumbnailSize(assets.length, viewWidth);
|
||||
|
||||
$: isMultiSelectionMode = selectedAssets.size > 0;
|
||||
|
||||
$: {
|
||||
if (assets.length < 6) {
|
||||
thumbnailSize = Math.min(320, Math.floor(viewWidth / assets.length - assets.length));
|
||||
} else {
|
||||
if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 7 - 7);
|
||||
else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6);
|
||||
else if (viewWidth > 300) thumbnailSize = Math.floor(viewWidth / 2 - 6);
|
||||
else if (viewWidth > 200) thumbnailSize = Math.floor(viewWidth / 2 - 6);
|
||||
else if (viewWidth > 100) thumbnailSize = Math.floor(viewWidth / 1 - 6);
|
||||
}
|
||||
}
|
||||
|
||||
const viewAssetHandler = (event: CustomEvent) => {
|
||||
const { asset }: { asset: AssetResponseDto } = event.detail;
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ export class AssetGridState {
|
||||
*/
|
||||
assets: AssetResponseDto[] = [];
|
||||
|
||||
/**
|
||||
* Total assets that have been loaded along with additional data
|
||||
*/
|
||||
loadedAssets: Record<string, number> = {};
|
||||
|
||||
/**
|
||||
* User that owns assets
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
|
||||
import { api, AssetResponseDto } from '@api';
|
||||
import { sortBy } from 'lodash-es';
|
||||
import { derived, writable } from 'svelte/store';
|
||||
import { assetGridState, assetStore } from './assets.store';
|
||||
|
||||
@@ -13,6 +12,7 @@ export const assetsInAlbumStoreState = writable<AssetResponseDto[]>([]);
|
||||
export const selectedAssets = writable<Set<AssetResponseDto>>(new Set());
|
||||
export const selectedGroup = writable<Set<string>>(new Set());
|
||||
export const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0);
|
||||
export const assetSelectionCandidates = writable<Set<AssetResponseDto>>(new Set());
|
||||
|
||||
function createAssetInteractionStore() {
|
||||
let _assetGridState = new AssetGridState();
|
||||
@@ -20,8 +20,7 @@ function createAssetInteractionStore() {
|
||||
let _selectedAssets: Set<AssetResponseDto>;
|
||||
let _selectedGroup: Set<string>;
|
||||
let _assetsInAlbums: AssetResponseDto[];
|
||||
let savedAssetLength = 0;
|
||||
let assetSortedByDate: AssetResponseDto[] = [];
|
||||
let _assetSelectionCandidates: Set<AssetResponseDto>;
|
||||
|
||||
// Subscriber
|
||||
assetGridState.subscribe((state) => {
|
||||
@@ -44,6 +43,10 @@ function createAssetInteractionStore() {
|
||||
_assetsInAlbums = assets;
|
||||
});
|
||||
|
||||
assetSelectionCandidates.subscribe((assets) => {
|
||||
_assetSelectionCandidates = assets;
|
||||
});
|
||||
|
||||
// Methods
|
||||
|
||||
/**
|
||||
@@ -63,41 +66,64 @@ function createAssetInteractionStore() {
|
||||
isViewingAssetStoreState.set(isViewing);
|
||||
};
|
||||
|
||||
const navigateAsset = async (direction: 'next' | 'previous') => {
|
||||
// Flatten and sort the asset by date if there are new assets
|
||||
if (assetSortedByDate.length === 0 || savedAssetLength !== _assetGridState.assets.length) {
|
||||
assetSortedByDate = sortBy(_assetGridState.assets, (a) => a.fileCreatedAt);
|
||||
savedAssetLength = _assetGridState.assets.length;
|
||||
const getNextAsset = async (currentBucketIndex: number, assetId: string): Promise<AssetResponseDto | null> => {
|
||||
const currentBucket = _assetGridState.buckets[currentBucketIndex];
|
||||
const assetIndex = currentBucket.assets.findIndex(({ id }) => id == assetId);
|
||||
if (assetIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the index of the current asset
|
||||
const currentIndex = assetSortedByDate.findIndex((a) => a.id === _viewingAssetStoreState.id);
|
||||
if (assetIndex + 1 < currentBucket.assets.length) {
|
||||
return currentBucket.assets[assetIndex + 1];
|
||||
}
|
||||
|
||||
// Get the next or previous asset
|
||||
const nextIndex = direction === 'previous' ? currentIndex + 1 : currentIndex - 1;
|
||||
const nextBucketIndex = currentBucketIndex + 1;
|
||||
if (nextBucketIndex >= _assetGridState.buckets.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Run out of asset, this might be because there is no asset in the next bucket.
|
||||
if (nextIndex == -1) {
|
||||
let nextBucket = '';
|
||||
// Find next bucket that doesn't have all assets loaded
|
||||
const nextBucket = _assetGridState.buckets[nextBucketIndex];
|
||||
await assetStore.getAssetsByBucket(nextBucket.bucketDate, BucketPosition.Unknown);
|
||||
|
||||
for (const bucket of _assetGridState.buckets) {
|
||||
if (bucket.assets.length === 0) {
|
||||
nextBucket = bucket.bucketDate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return nextBucket.assets[0] ?? null;
|
||||
};
|
||||
|
||||
if (nextBucket !== '') {
|
||||
await assetStore.getAssetsByBucket(nextBucket, BucketPosition.Below);
|
||||
navigateAsset(direction);
|
||||
}
|
||||
const getPrevAsset = async (currentBucketIndex: number, assetId: string): Promise<AssetResponseDto | null> => {
|
||||
const currentBucket = _assetGridState.buckets[currentBucketIndex];
|
||||
const assetIndex = currentBucket.assets.findIndex(({ id }) => id == assetId);
|
||||
if (assetIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (assetIndex > 0) {
|
||||
return currentBucket.assets[assetIndex - 1];
|
||||
}
|
||||
|
||||
const prevBucketIndex = currentBucketIndex - 1;
|
||||
if (prevBucketIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prevBucket = _assetGridState.buckets[prevBucketIndex];
|
||||
await assetStore.getAssetsByBucket(prevBucket.bucketDate, BucketPosition.Unknown);
|
||||
|
||||
return prevBucket.assets[prevBucket.assets.length - 1] ?? null;
|
||||
};
|
||||
|
||||
const navigateAsset = async (direction: 'next' | 'previous') => {
|
||||
const currentAssetId = _viewingAssetStoreState.id;
|
||||
const currentBucketIndex = _assetGridState.loadedAssets[currentAssetId];
|
||||
if (currentBucketIndex < 0 || currentBucketIndex >= _assetGridState.buckets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAsset = assetSortedByDate[nextIndex];
|
||||
if (nextAsset) {
|
||||
setViewingAsset(nextAsset);
|
||||
const asset =
|
||||
direction === 'next'
|
||||
? await getNextAsset(currentBucketIndex, currentAssetId)
|
||||
: await getPrevAsset(currentBucketIndex, currentAssetId);
|
||||
|
||||
if (asset) {
|
||||
setViewingAsset(asset);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -129,14 +155,26 @@ function createAssetInteractionStore() {
|
||||
selectedGroup.set(_selectedGroup);
|
||||
};
|
||||
|
||||
const setAssetSelectionCandidates = (assets: AssetResponseDto[]) => {
|
||||
_assetSelectionCandidates = new Set(assets);
|
||||
assetSelectionCandidates.set(_assetSelectionCandidates);
|
||||
};
|
||||
|
||||
const clearAssetSelectionCandidates = () => {
|
||||
_assetSelectionCandidates.clear();
|
||||
assetSelectionCandidates.set(_assetSelectionCandidates);
|
||||
};
|
||||
|
||||
const clearMultiselect = () => {
|
||||
_selectedAssets.clear();
|
||||
_selectedGroup.clear();
|
||||
_assetSelectionCandidates.clear();
|
||||
_assetsInAlbums = [];
|
||||
|
||||
selectedAssets.set(_selectedAssets);
|
||||
selectedGroup.set(_selectedGroup);
|
||||
assetsInAlbumStoreState.set(_assetsInAlbums);
|
||||
assetSelectionCandidates.set(_assetSelectionCandidates);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -148,6 +186,8 @@ function createAssetInteractionStore() {
|
||||
removeAssetFromMultiselectGroup,
|
||||
addGroupToMultiselectGroup,
|
||||
removeGroupFromMultiselectGroup,
|
||||
setAssetSelectionCandidates,
|
||||
clearAssetSelectionCandidates,
|
||||
clearMultiselect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
|
||||
import { api, AssetCountByTimeBucketResponseDto } from '@api';
|
||||
import { flatMap, sumBy } from 'lodash-es';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
@@ -31,6 +30,15 @@ function createAssetStore() {
|
||||
return height;
|
||||
};
|
||||
|
||||
const refreshLoadedAssets = (state: AssetGridState): void => {
|
||||
state.loadedAssets = {};
|
||||
state.buckets.forEach((bucket, bucketIndex) =>
|
||||
bucket.assets.map((asset) => {
|
||||
state.loadedAssets[asset.id] = bucketIndex;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set initial state
|
||||
* @param viewportHeight
|
||||
@@ -55,12 +63,13 @@ function createAssetStore() {
|
||||
position: BucketPosition.Unknown,
|
||||
})),
|
||||
assets: [],
|
||||
loadedAssets: {},
|
||||
userId,
|
||||
});
|
||||
|
||||
// Update timeline height based on calculated bucket height
|
||||
assetGridState.update((state) => {
|
||||
state.timelineHeight = sumBy(state.buckets, (d) => d.bucketHeight);
|
||||
state.timelineHeight = state.buckets.reduce((acc, b) => acc + b.bucketHeight, 0);
|
||||
return state;
|
||||
});
|
||||
};
|
||||
@@ -101,7 +110,8 @@ function createAssetStore() {
|
||||
const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
|
||||
state.buckets[bucketIndex].assets = assets;
|
||||
state.buckets[bucketIndex].position = position;
|
||||
state.assets = flatMap(state.buckets, (b) => b.assets);
|
||||
state.assets = state.buckets.flatMap((b) => b.assets);
|
||||
refreshLoadedAssets(state);
|
||||
return state;
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -123,7 +133,8 @@ function createAssetStore() {
|
||||
if (state.buckets[bucketIndex].assets.length === 0) {
|
||||
_removeBucket(state.buckets[bucketIndex].bucketDate);
|
||||
}
|
||||
state.assets = flatMap(state.buckets, (b) => b.assets);
|
||||
state.assets = state.buckets.flatMap((b) => b.assets);
|
||||
refreshLoadedAssets(state);
|
||||
return state;
|
||||
});
|
||||
};
|
||||
@@ -132,7 +143,8 @@ function createAssetStore() {
|
||||
assetGridState.update((state) => {
|
||||
const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucketDate);
|
||||
state.buckets.splice(bucketIndex, 1);
|
||||
state.assets = flatMap(state.buckets, (b) => b.assets);
|
||||
state.assets = state.buckets.flatMap((b) => b.assets);
|
||||
refreshLoadedAssets(state);
|
||||
return state;
|
||||
});
|
||||
};
|
||||
@@ -180,7 +192,8 @@ function createAssetStore() {
|
||||
const assetIndex = state.buckets[bucketIndex].assets.findIndex((a) => a.id === assetId);
|
||||
state.buckets[bucketIndex].assets[assetIndex].isFavorite = isFavorite;
|
||||
|
||||
state.assets = flatMap(state.buckets, (b) => b.assets);
|
||||
state.assets = state.buckets.flatMap((b) => b.assets);
|
||||
refreshLoadedAssets(state);
|
||||
return state;
|
||||
});
|
||||
};
|
||||
|
||||
19
web/src/lib/utils/thumbnail-util.ts
Normal file
19
web/src/lib/utils/thumbnail-util.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Calculate thumbnail size based on number of assets and viewport width
|
||||
* @param assetCount Number of assets in the view
|
||||
* @param viewWidth viewport width
|
||||
* @returns thumbnail size
|
||||
*/
|
||||
export function getThumbnailSize(assetCount: number, viewWidth: number): number {
|
||||
if (assetCount < 6) {
|
||||
return Math.min(320, Math.floor(viewWidth / assetCount - assetCount));
|
||||
} else {
|
||||
if (viewWidth > 600) return viewWidth / 7 - 7;
|
||||
else if (viewWidth > 400) return viewWidth / 4 - 6;
|
||||
else if (viewWidth > 300) return viewWidth / 2 - 6;
|
||||
else if (viewWidth > 200) return viewWidth / 2 - 6;
|
||||
else if (viewWidth > 100) return viewWidth / 1 - 6;
|
||||
}
|
||||
|
||||
return 300;
|
||||
}
|
||||
@@ -22,10 +22,17 @@
|
||||
import type { PageData } from './$types';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import SelectAll from 'svelte-material-icons/SelectAll.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import FaceThumbnailSelector from '$lib/components/faces-page/face-thumbnail-selector.svelte';
|
||||
import {
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let isEditName = false;
|
||||
let isEditingName = false;
|
||||
let isSelectingFace = false;
|
||||
let previousRoute: string = AppRoute.EXPLORE;
|
||||
let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||
$: isMultiSelectionMode = selectedAssets.size > 0;
|
||||
@@ -41,7 +48,7 @@
|
||||
|
||||
const handleNameChange = async (name: string) => {
|
||||
try {
|
||||
isEditName = false;
|
||||
isEditingName = false;
|
||||
data.person.name = name;
|
||||
await api.personApi.updatePerson({ id: data.person.id, personUpdateDto: { name } });
|
||||
} catch (error) {
|
||||
@@ -55,6 +62,25 @@
|
||||
const handleSelectAll = () => {
|
||||
selectedAssets = new Set(data.assets);
|
||||
};
|
||||
|
||||
const handleSelectFeaturePhoto = async (event: CustomEvent) => {
|
||||
isSelectingFace = false;
|
||||
|
||||
const { selectedAsset }: { selectedAsset: AssetResponseDto | undefined } = event.detail;
|
||||
|
||||
if (selectedAsset) {
|
||||
await api.personApi.updatePerson({
|
||||
id: data.person.id,
|
||||
personUpdateDto: { featureFaceAssetId: selectedAsset.id },
|
||||
});
|
||||
|
||||
// TODO: Replace by Websocket in the future
|
||||
notificationController.show({
|
||||
message: 'Feature photo updated, refresh page to see changes',
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isMultiSelectionMode}
|
||||
@@ -73,30 +99,39 @@
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
<ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(previousRoute)} />
|
||||
<ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(previousRoute)}>
|
||||
<svelte:fragment slot="trailing">
|
||||
<AssetSelectContextMenu icon={DotsVertical} title="Menu">
|
||||
<MenuOption text="Change feature photo" on:click={() => (isSelectingFace = true)} />
|
||||
</AssetSelectContextMenu>
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
|
||||
<!-- Face information block -->
|
||||
<section class="pt-24 px-4 sm:px-6 flex place-items-center">
|
||||
{#if isEditName}
|
||||
{#if isEditingName}
|
||||
<EditNameInput
|
||||
person={data.person}
|
||||
on:change={(event) => handleNameChange(event.detail)}
|
||||
on:cancel={() => (isEditName = false)}
|
||||
on:cancel={() => (isEditingName = false)}
|
||||
/>
|
||||
{:else}
|
||||
<ImageThumbnail
|
||||
circle
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(data.person.id)}
|
||||
altText={data.person.name}
|
||||
widthStyle="3.375rem"
|
||||
heightStyle="3.375rem"
|
||||
/>
|
||||
<button on:click={() => (isSelectingFace = true)}>
|
||||
<ImageThumbnail
|
||||
circle
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(data.person.id)}
|
||||
altText={data.person.name}
|
||||
widthStyle="3.375rem"
|
||||
heightStyle="3.375rem"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
title="Edit name"
|
||||
class="px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||
on:click={() => (isEditName = true)}
|
||||
on:click={() => (isEditingName = true)}
|
||||
>
|
||||
{#if data.person.name}
|
||||
<p class="font-medium py-2">{data.person.name}</p>
|
||||
@@ -109,10 +144,16 @@
|
||||
</section>
|
||||
|
||||
<!-- Gallery Block -->
|
||||
<section class="relative pt-8 sm:px-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg">
|
||||
<section class="overflow-y-auto relative immich-scrollbar">
|
||||
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
|
||||
<GalleryViewer assets={data.assets} viewFrom="search-page" showArchiveIcon={true} bind:selectedAssets />
|
||||
{#if !isSelectingFace}
|
||||
<section class="relative pt-8 sm:px-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg">
|
||||
<section class="overflow-y-auto relative immich-scrollbar">
|
||||
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
|
||||
<GalleryViewer assets={data.assets} viewFrom="search-page" showArchiveIcon={true} bind:selectedAssets />
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if isSelectingFace}
|
||||
<FaceThumbnailSelector assets={data.assets} on:go-back={handleSelectFeaturePhoto} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user