mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
Feature - Implemented virtual scroll on web (#573)
This PR implemented a virtual scroll on the web, as seen in this article. [Building the Google Photos Web UI](https://medium.com/google-design/google-photos-45b714dfbed1)
This commit is contained in:
@@ -148,40 +148,40 @@ export interface AlbumResponseDto {
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AssetCountByTimeGroupDto
|
||||
* @interface AssetCountByTimeBucket
|
||||
*/
|
||||
export interface AssetCountByTimeGroupDto {
|
||||
export interface AssetCountByTimeBucket {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof AssetCountByTimeGroupDto
|
||||
* @memberof AssetCountByTimeBucket
|
||||
*/
|
||||
'timeGroup': string;
|
||||
'timeBucket': string;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetCountByTimeGroupDto
|
||||
* @memberof AssetCountByTimeBucket
|
||||
*/
|
||||
'count': number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AssetCountByTimeGroupResponseDto
|
||||
* @interface AssetCountByTimeBucketResponseDto
|
||||
*/
|
||||
export interface AssetCountByTimeGroupResponseDto {
|
||||
export interface AssetCountByTimeBucketResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetCountByTimeGroupResponseDto
|
||||
* @memberof AssetCountByTimeBucketResponseDto
|
||||
*/
|
||||
'totalAssets': number;
|
||||
'totalCount': number;
|
||||
/**
|
||||
*
|
||||
* @type {Array<AssetCountByTimeGroupDto>}
|
||||
* @memberof AssetCountByTimeGroupResponseDto
|
||||
* @type {Array<AssetCountByTimeBucket>}
|
||||
* @memberof AssetCountByTimeBucketResponseDto
|
||||
*/
|
||||
'groups': Array<AssetCountByTimeGroupDto>;
|
||||
'buckets': Array<AssetCountByTimeBucket>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@@ -761,13 +761,26 @@ export interface ExifResponseDto {
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface GetAssetCountByTimeGroupDto
|
||||
* @interface GetAssetByTimeBucketDto
|
||||
*/
|
||||
export interface GetAssetCountByTimeGroupDto {
|
||||
export interface GetAssetByTimeBucketDto {
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof GetAssetByTimeBucketDto
|
||||
*/
|
||||
'timeBucket': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface GetAssetCountByTimeBucketDto
|
||||
*/
|
||||
export interface GetAssetCountByTimeBucketDto {
|
||||
/**
|
||||
*
|
||||
* @type {TimeGroupEnum}
|
||||
* @memberof GetAssetCountByTimeGroupDto
|
||||
* @memberof GetAssetCountByTimeBucketDto
|
||||
*/
|
||||
'timeGroup': TimeGroupEnum;
|
||||
}
|
||||
@@ -2139,14 +2152,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {GetAssetCountByTimeGroupDto} getAssetCountByTimeGroupDto
|
||||
* @param {GetAssetByTimeBucketDto} getAssetByTimeBucketDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAssetCountByTimeGroup: async (getAssetCountByTimeGroupDto: GetAssetCountByTimeGroupDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'getAssetCountByTimeGroupDto' is not null or undefined
|
||||
assertParamExists('getAssetCountByTimeGroup', 'getAssetCountByTimeGroupDto', getAssetCountByTimeGroupDto)
|
||||
const localVarPath = `/asset/count-by-date`;
|
||||
getAssetByTimeBucket: async (getAssetByTimeBucketDto: GetAssetByTimeBucketDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'getAssetByTimeBucketDto' is not null or undefined
|
||||
assertParamExists('getAssetByTimeBucket', 'getAssetByTimeBucketDto', getAssetByTimeBucketDto)
|
||||
const localVarPath = `/asset/time-bucket`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
@@ -2154,7 +2167,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
@@ -2169,7 +2182,46 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(getAssetCountByTimeGroupDto, localVarRequestOptions, configuration)
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(getAssetByTimeBucketDto, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {GetAssetCountByTimeBucketDto} getAssetCountByTimeBucketDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAssetCountByTimeBucket: async (getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'getAssetCountByTimeBucketDto' is not null or undefined
|
||||
assertParamExists('getAssetCountByTimeBucket', 'getAssetCountByTimeBucketDto', getAssetCountByTimeBucketDto)
|
||||
const localVarPath = `/asset/count-by-time-bucket`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication bearer required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(getAssetCountByTimeBucketDto, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
@@ -2562,12 +2614,22 @@ export const AssetApiFp = function(configuration?: Configuration) {
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {GetAssetCountByTimeGroupDto} getAssetCountByTimeGroupDto
|
||||
* @param {GetAssetByTimeBucketDto} getAssetByTimeBucketDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getAssetCountByTimeGroup(getAssetCountByTimeGroupDto: GetAssetCountByTimeGroupDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetCountByTimeGroupResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetCountByTimeGroup(getAssetCountByTimeGroupDto, options);
|
||||
async getAssetByTimeBucket(getAssetByTimeBucketDto: GetAssetByTimeBucketDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetByTimeBucket(getAssetByTimeBucketDto, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {GetAssetCountByTimeBucketDto} getAssetCountByTimeBucketDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getAssetCountByTimeBucket(getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetCountByTimeBucketResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
@@ -2714,12 +2776,21 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {GetAssetCountByTimeGroupDto} getAssetCountByTimeGroupDto
|
||||
* @param {GetAssetByTimeBucketDto} getAssetByTimeBucketDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAssetCountByTimeGroup(getAssetCountByTimeGroupDto: GetAssetCountByTimeGroupDto, options?: any): AxiosPromise<AssetCountByTimeGroupResponseDto> {
|
||||
return localVarFp.getAssetCountByTimeGroup(getAssetCountByTimeGroupDto, options).then((request) => request(axios, basePath));
|
||||
getAssetByTimeBucket(getAssetByTimeBucketDto: GetAssetByTimeBucketDto, options?: any): AxiosPromise<Array<AssetResponseDto>> {
|
||||
return localVarFp.getAssetByTimeBucket(getAssetByTimeBucketDto, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {GetAssetCountByTimeBucketDto} getAssetCountByTimeBucketDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAssetCountByTimeBucket(getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto, options?: any): AxiosPromise<AssetCountByTimeBucketResponseDto> {
|
||||
return localVarFp.getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
@@ -2867,13 +2938,24 @@ export class AssetApi extends BaseAPI {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {GetAssetCountByTimeGroupDto} getAssetCountByTimeGroupDto
|
||||
* @param {GetAssetByTimeBucketDto} getAssetByTimeBucketDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof AssetApi
|
||||
*/
|
||||
public getAssetCountByTimeGroup(getAssetCountByTimeGroupDto: GetAssetCountByTimeGroupDto, options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getAssetCountByTimeGroup(getAssetCountByTimeGroupDto, options).then((request) => request(this.axios, this.basePath));
|
||||
public getAssetByTimeBucket(getAssetByTimeBucketDto: GetAssetByTimeBucketDto, options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getAssetByTimeBucket(getAssetByTimeBucketDto, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {GetAssetCountByTimeBucketDto} getAssetCountByTimeBucketDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof AssetApi
|
||||
*/
|
||||
public getAssetCountByTimeBucket(getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto, options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { AssetCountByTimeGroupResponseDto } from '@api';
|
||||
let _basePath = '/api';
|
||||
|
||||
export function getFileUrl(aid: string, did: string, isThumb?: boolean, isWeb?: boolean) {
|
||||
const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file`);
|
||||
|
||||
urlObj.searchParams.append('aid', aid);
|
||||
urlObj.searchParams.append('did', did);
|
||||
if (isThumb !== undefined && isThumb !== null) urlObj.searchParams.append('isThumb', `${isThumb}`);
|
||||
if (isWeb !== undefined && isWeb !== null) urlObj.searchParams.append('isWeb', `${isWeb}`);
|
||||
const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file`);
|
||||
|
||||
return urlObj.href;
|
||||
urlObj.searchParams.append('aid', aid);
|
||||
urlObj.searchParams.append('did', did);
|
||||
if (isThumb !== undefined && isThumb !== null)
|
||||
urlObj.searchParams.append('isThumb', `${isThumb}`);
|
||||
if (isWeb !== undefined && isWeb !== null) urlObj.searchParams.append('isWeb', `${isWeb}`);
|
||||
|
||||
return urlObj.href;
|
||||
}
|
||||
|
||||
@@ -26,11 +26,22 @@
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from '../shared-components/notification/notification';
|
||||
import { browser } from '$app/env';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
|
||||
let isShowAssetViewer = false;
|
||||
|
||||
let isShowAssetSelection = false;
|
||||
$: {
|
||||
if (browser) {
|
||||
if (isShowAssetSelection) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
}
|
||||
}
|
||||
let isShowShareUserSelection = false;
|
||||
let isEditingTitle = false;
|
||||
let isCreatingSharedAlbum = false;
|
||||
@@ -197,10 +208,12 @@
|
||||
}
|
||||
|
||||
const createAlbumHandler = async (event: CustomEvent) => {
|
||||
const { assets }: { assets: string[] } = event.detail;
|
||||
const { assets }: { assets: AssetResponseDto[] } = event.detail;
|
||||
|
||||
try {
|
||||
const { data } = await api.albumApi.addAssetsToAlbum(album.id, { assetIds: assets });
|
||||
const { data } = await api.albumApi.addAssetsToAlbum(album.id, {
|
||||
assetIds: assets.map((a) => a.id)
|
||||
});
|
||||
album = data;
|
||||
|
||||
isShowAssetSelection = false;
|
||||
@@ -456,8 +469,8 @@
|
||||
{#if isShowAssetViewer}
|
||||
<AssetViewer
|
||||
asset={selectedAsset}
|
||||
on:navigate-backward={navigateAssetBackward}
|
||||
on:navigate-forward={navigateAssetForward}
|
||||
on:navigate-previous={navigateAssetBackward}
|
||||
on:navigate-next={navigateAssetForward}
|
||||
on:close={closeViewer}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -2,30 +2,26 @@
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets';
|
||||
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
|
||||
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
|
||||
import moment from 'moment';
|
||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
||||
import { AssetResponseDto } from '@api';
|
||||
import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
|
||||
import { albumUploadAssetStore } from '$lib/stores/album-upload-asset';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import AssetGrid from '../photos-page/asset-grid.svelte';
|
||||
import {
|
||||
assetInteractionStore,
|
||||
assetsInAlbumStoreState,
|
||||
selectedAssets
|
||||
} from '$lib/stores/asset-interaction.store';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let assetsInAlbum: AssetResponseDto[];
|
||||
|
||||
let selectedAsset: Set<string> = new Set();
|
||||
let selectedGroup: Set<number> = new Set();
|
||||
let existingGroup: Set<number> = new Set();
|
||||
let groupWithAssetsInAlbum: Record<number, Set<string>> = {};
|
||||
|
||||
let uploadAssets: string[] = [];
|
||||
let uploadAssetsCount = 9999;
|
||||
|
||||
onMount(() => {
|
||||
scanForExistingSelectedGroup();
|
||||
$assetsInAlbumStoreState = assetsInAlbum;
|
||||
|
||||
albumUploadAssetStore.asset.subscribe((uploadedAsset) => {
|
||||
uploadAssets = uploadedAsset;
|
||||
@@ -60,127 +56,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
const selectAssetHandler = (assetId: string, groupIndex: number) => {
|
||||
const tempSelectedAsset = new Set(selectedAsset);
|
||||
|
||||
if (selectedAsset.has(assetId)) {
|
||||
tempSelectedAsset.delete(assetId);
|
||||
|
||||
const tempSelectedGroup = new Set(selectedGroup);
|
||||
tempSelectedGroup.delete(groupIndex);
|
||||
selectedGroup = tempSelectedGroup;
|
||||
} else {
|
||||
tempSelectedAsset.add(assetId);
|
||||
}
|
||||
|
||||
selectedAsset = tempSelectedAsset;
|
||||
|
||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||
if (!selectedGroup.has(groupIndex)) {
|
||||
const assetsInGroup = $assetsGroupByDate[groupIndex];
|
||||
let selectedAssetsInGroupCount = 0;
|
||||
|
||||
assetsInGroup.forEach((asset) => {
|
||||
if (selectedAsset.has(asset.id)) {
|
||||
selectedAssetsInGroupCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Taking into account of assets in group that are already in album
|
||||
if (groupWithAssetsInAlbum[groupIndex]) {
|
||||
selectedAssetsInGroupCount += groupWithAssetsInAlbum[groupIndex].size;
|
||||
}
|
||||
|
||||
// if all assets are selected in a group, add the group to selected group
|
||||
if (selectedAssetsInGroupCount == assetsInGroup.length) {
|
||||
selectedGroup = selectedGroup.add(groupIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectAssetGroupHandler = (groupIndex: number) => {
|
||||
if (existingGroup.has(groupIndex)) return;
|
||||
|
||||
let tempSelectedGroup = new Set(selectedGroup);
|
||||
let tempSelectedAsset = new Set(selectedAsset);
|
||||
|
||||
if (selectedGroup.has(groupIndex)) {
|
||||
tempSelectedGroup.delete(groupIndex);
|
||||
tempSelectedAsset.forEach((assetId) => {
|
||||
if ($assetsGroupByDate[groupIndex].find((a) => a.id == assetId)) {
|
||||
tempSelectedAsset.delete(assetId);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tempSelectedGroup.add(groupIndex);
|
||||
tempSelectedAsset = new Set([
|
||||
...selectedAsset,
|
||||
...$assetsGroupByDate[groupIndex].map((a) => a.id)
|
||||
]);
|
||||
}
|
||||
|
||||
// Remove existed assets in the date group
|
||||
if (groupWithAssetsInAlbum[groupIndex]) {
|
||||
tempSelectedAsset.forEach((assetId) => {
|
||||
if (groupWithAssetsInAlbum[groupIndex].has(assetId)) {
|
||||
tempSelectedAsset.delete(assetId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectedAsset = tempSelectedAsset;
|
||||
selectedGroup = tempSelectedGroup;
|
||||
};
|
||||
|
||||
const addSelectedAssets = async () => {
|
||||
dispatch('create-album', {
|
||||
assets: Array.from(selectedAsset)
|
||||
assets: Array.from($selectedAssets)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is used to scan for existing selected group in the album
|
||||
* and format it into the form of Record<any, Set<string>> to conditionally render and perform interaction
|
||||
* relationship between the noneselected assets/groups
|
||||
* with the existing assets/groups
|
||||
*/
|
||||
const scanForExistingSelectedGroup = () => {
|
||||
if (assetsInAlbum) {
|
||||
// Convert to each assetGroup to set of assetIds
|
||||
const distinctAssetGroup = $assetsGroupByDate.map((assetGroup) => {
|
||||
return new Set(assetGroup.map((asset) => asset.id));
|
||||
});
|
||||
|
||||
// Find the group that contains all existed assets with the same set of assetIds
|
||||
for (const assetInAlbum of assetsInAlbum) {
|
||||
distinctAssetGroup.forEach((group, index) => {
|
||||
if (group.has(assetInAlbum.id)) {
|
||||
groupWithAssetsInAlbum[index] = new Set(groupWithAssetsInAlbum[index] || []).add(
|
||||
assetInAlbum.id
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(groupWithAssetsInAlbum).forEach((key) => {
|
||||
if (distinctAssetGroup[parseInt(key)].size == groupWithAssetsInAlbum[parseInt(key)].size) {
|
||||
existingGroup = existingGroup.add(parseInt(key));
|
||||
}
|
||||
});
|
||||
}
|
||||
assetInteractionStore.clearMultiselect();
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
|
||||
class="absolute top-0 left-0 w-full h-full py-[160px] bg-immich-bg z-[9999]"
|
||||
class="absolute top-0 left-0 w-full h-full bg-immich-bg z-[9999]"
|
||||
>
|
||||
<ControlAppBar on:close-button-click={() => dispatch('go-back')}>
|
||||
<ControlAppBar
|
||||
on:close-button-click={() => {
|
||||
assetInteractionStore.clearMultiselect();
|
||||
dispatch('go-back');
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="leading">
|
||||
{#if selectedAsset.size == 0}
|
||||
{#if $selectedAssets.size == 0}
|
||||
<p class="text-lg">Add to album</p>
|
||||
{:else}
|
||||
<p class="text-lg">{selectedAsset.size} selected</p>
|
||||
<p class="text-lg">{$selectedAssets.size} selected</p>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
@@ -192,51 +91,14 @@
|
||||
Select from computer
|
||||
</button>
|
||||
<button
|
||||
disabled={selectedAsset.size === 0}
|
||||
disabled={$selectedAssets.size === 0}
|
||||
on:click={addSelectedAssets}
|
||||
class="immich-text-button border bg-immich-primary text-gray-50 hover:bg-immich-primary/75 px-6 text-sm disabled:opacity-25 disabled:bg-gray-500 disabled:cursor-not-allowed"
|
||||
><span class="px-2">Done</span></button
|
||||
>
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
|
||||
<section class="flex flex-wrap gap-14 px-20 overflow-y-auto">
|
||||
{#each $assetsGroupByDate as assetsInDateGroup, groupIndex}
|
||||
<!-- Asset Group By Date -->
|
||||
<div class="flex flex-col">
|
||||
<!-- Date group title -->
|
||||
<p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6">
|
||||
<span
|
||||
in:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||
out:fly={{ x: -24, duration: 200 }}
|
||||
class="inline-block px-2 hover:cursor-pointer"
|
||||
on:click={() => selectAssetGroupHandler(groupIndex)}
|
||||
>
|
||||
{#if selectedGroup.has(groupIndex)}
|
||||
<CheckCircle size="24" color="#4250af" />
|
||||
{:else if existingGroup.has(groupIndex)}
|
||||
<CheckCircle size="24" color="#757575" />
|
||||
{:else}
|
||||
<CircleOutline size="24" color="#757575" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{moment(assetsInDateGroup[0].createdAt).format('ddd, MMM DD YYYY')}
|
||||
</p>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div class="flex flex-wrap gap-[2px]">
|
||||
{#each assetsInDateGroup as asset}
|
||||
<ImmichThumbnail
|
||||
{asset}
|
||||
on:click={() => selectAssetHandler(asset.id, groupIndex)}
|
||||
{groupIndex}
|
||||
selected={selectedAsset.has(asset.id)}
|
||||
isExisted={assetsInAlbum.findIndex((a) => a.id == asset.id) != -1}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<section class="pt-[100px] pl-[70px] grid h-screen bg-immich-bg">
|
||||
<AssetGrid />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
</div>
|
||||
<div class="text-white flex gap-2">
|
||||
<CircleIconButton logo={CloudDownloadOutline} on:click={() => dispatch('download')} />
|
||||
<!-- <CircleIconButton logo={DotsVertical} on:click={() => console.log('Options')} /> -->
|
||||
<CircleIconButton logo={InformationOutline} on:click={() => dispatch('showDetail')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,12 +52,12 @@
|
||||
|
||||
const navigateAssetForward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-forward');
|
||||
dispatch('navigate-next');
|
||||
};
|
||||
|
||||
const navigateAssetBackward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-backward');
|
||||
dispatch('navigate-previous');
|
||||
};
|
||||
|
||||
const showDetailInfoHandler = () => {
|
||||
@@ -66,7 +66,6 @@
|
||||
|
||||
const downloadFile = async () => {
|
||||
try {
|
||||
console.log(asset.exifInfo);
|
||||
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
|
||||
const imageExtension = asset.originalPath.split('.')[1];
|
||||
const imageFileName = imageName + '.' + imageExtension;
|
||||
@@ -130,7 +129,7 @@
|
||||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4 "
|
||||
class="fixed h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4"
|
||||
>
|
||||
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
|
||||
<AsserViewerNavBar
|
||||
@@ -207,6 +206,10 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#immich-asset-viewer {
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.navigation-button-hover {
|
||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let once = false;
|
||||
export let top = 0;
|
||||
export let bottom = 0;
|
||||
export let left = 0;
|
||||
export let right = 0;
|
||||
export let root: HTMLElement | null = null;
|
||||
|
||||
let intersecting = false;
|
||||
let container: any;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(() => {
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
const rootMargin = `${bottom}px ${left}px ${top}px ${right}px`;
|
||||
|
||||
const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
intersecting = entries[0].isIntersecting;
|
||||
if (!intersecting) {
|
||||
dispatch('hidden', container);
|
||||
}
|
||||
|
||||
if (intersecting && once) {
|
||||
observer.unobserve(container);
|
||||
}
|
||||
|
||||
if (intersecting) {
|
||||
dispatch('intersected', container);
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin
|
||||
rootMargin,
|
||||
root
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@api';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { ImmichUser } from '../../models/immich-user';
|
||||
|
||||
export let user: ImmichUser;
|
||||
export let user: UserResponseDto;
|
||||
let error: string;
|
||||
let success: string;
|
||||
|
||||
|
||||
157
web/src/lib/components/photos-page/asset-date-group.svelte
Normal file
157
web/src/lib/components/photos-page/asset-date-group.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { assetStore } from '$lib/stores/assets.store';
|
||||
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
|
||||
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { AssetResponseDto } from '@api';
|
||||
import lodash from 'lodash-es';
|
||||
import moment from 'moment';
|
||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import {
|
||||
assetInteractionStore,
|
||||
assetsInAlbumStoreState,
|
||||
isMultiSelectStoreState,
|
||||
selectedAssets,
|
||||
selectedGroup
|
||||
} from '$lib/stores/asset-interaction.store';
|
||||
export let assets: AssetResponseDto[];
|
||||
export let bucketDate: string;
|
||||
export let bucketHeight: number;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let isMouseOverGroup = false;
|
||||
let actualBucketHeight: number;
|
||||
let hoveredDateGroup: string = '';
|
||||
$: assetsGroupByDate = lodash
|
||||
.chain(assets)
|
||||
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))
|
||||
.sortBy((group) => assets.indexOf(group[0]))
|
||||
.value();
|
||||
|
||||
$: {
|
||||
if (actualBucketHeight && actualBucketHeight != 0 && actualBucketHeight != bucketHeight) {
|
||||
assetStore.updateBucketHeight(bucketDate, actualBucketHeight);
|
||||
}
|
||||
}
|
||||
|
||||
const assetClickHandler = (
|
||||
asset: AssetResponseDto,
|
||||
assetsInDateGroup: AssetResponseDto[],
|
||||
dateGroupTitle: string
|
||||
) => {
|
||||
if ($isMultiSelectStoreState) {
|
||||
assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
|
||||
} else {
|
||||
assetInteractionStore.setViewingAsset(asset);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAssetGroupHandler = (
|
||||
selectAssetGroupHandler: AssetResponseDto[],
|
||||
dateGroupTitle: string
|
||||
) => {
|
||||
if ($selectedGroup.has(dateGroupTitle)) {
|
||||
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
|
||||
selectAssetGroupHandler.forEach((asset) => {
|
||||
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
||||
});
|
||||
} else {
|
||||
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
|
||||
selectAssetGroupHandler.forEach((asset) => {
|
||||
assetInteractionStore.addAssetToMultiselectGroup(asset);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const assetSelectHandler = (
|
||||
asset: AssetResponseDto,
|
||||
assetsInDateGroup: AssetResponseDto[],
|
||||
dateGroupTitle: string
|
||||
) => {
|
||||
if ($selectedAssets.has(asset)) {
|
||||
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
||||
} else {
|
||||
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++;
|
||||
}
|
||||
});
|
||||
|
||||
// if all assets are selected in a group, add the group to selected group
|
||||
if (selectedAssetsInGroupCount == assetsInDateGroup.length) {
|
||||
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
|
||||
} else {
|
||||
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
|
||||
}
|
||||
};
|
||||
|
||||
const assetMouseEventHandler = (dateGroupTitle: string) => {
|
||||
// Show multi select icon on hover on date group
|
||||
hoveredDateGroup = dateGroupTitle;
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
id="asset-group-by-date"
|
||||
class="flex flex-wrap gap-5 mt-5"
|
||||
bind:clientHeight={actualBucketHeight}
|
||||
>
|
||||
{#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)}
|
||||
{@const dateGroupTitle = moment(assetsInDateGroup[0].createdAt).format('ddd, MMM DD YYYY')}
|
||||
<!-- Asset Group By Date -->
|
||||
<div
|
||||
class="flex flex-col"
|
||||
on:mouseenter={() => (isMouseOverGroup = true)}
|
||||
on:mouseleave={() => (isMouseOverGroup = false)}
|
||||
>
|
||||
<!-- Date group title -->
|
||||
<p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6">
|
||||
{#if (hoveredDateGroup == dateGroupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroupTitle)}
|
||||
<div
|
||||
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||
class="inline-block px-2 hover:cursor-pointer"
|
||||
on:click={() => selectAssetGroupHandler(assetsInDateGroup, dateGroupTitle)}
|
||||
>
|
||||
{#if $selectedGroup.has(dateGroupTitle)}
|
||||
<CheckCircle size="24" color="#4250af" />
|
||||
{:else}
|
||||
<CircleOutline size="24" color="#757575" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span>
|
||||
{dateGroupTitle}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div class="flex flex-wrap gap-[2px]">
|
||||
{#each assetsInDateGroup as asset (asset.id)}
|
||||
<ImmichThumbnail
|
||||
{asset}
|
||||
{groupIndex}
|
||||
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
|
||||
on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)}
|
||||
on:mouse-event={() => assetMouseEventHandler(dateGroupTitle)}
|
||||
selected={$selectedAssets.has(asset)}
|
||||
disabled={$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#asset-group-by-date {
|
||||
contain: layout;
|
||||
}
|
||||
</style>
|
||||
119
web/src/lib/components/photos-page/asset-grid.svelte
Normal file
119
web/src/lib/components/photos-page/asset-grid.svelte
Normal file
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
|
||||
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
|
||||
import { api, TimeGroupEnum } from '@api';
|
||||
import AssetDateGroup from './asset-date-group.svelte';
|
||||
import Portal from '../shared-components/portal/portal.svelte';
|
||||
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
||||
import {
|
||||
assetInteractionStore,
|
||||
isViewingAssetStoreState,
|
||||
viewingAssetStoreState
|
||||
} from '$lib/stores/asset-interaction.store';
|
||||
|
||||
let viewportHeight = 0;
|
||||
let viewportWidth = 0;
|
||||
let assetGridElement: HTMLElement;
|
||||
|
||||
onMount(async () => {
|
||||
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
|
||||
timeGroup: TimeGroupEnum.Month
|
||||
});
|
||||
|
||||
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
|
||||
|
||||
// Get asset bucket if bucket height is smaller than viewport height
|
||||
let bucketsToFetchInitially: string[] = [];
|
||||
let initialBucketsHeight = 0;
|
||||
$assetGridState.buckets.every((bucket) => {
|
||||
if (initialBucketsHeight < viewportHeight) {
|
||||
initialBucketsHeight += bucket.bucketHeight;
|
||||
bucketsToFetchInitially.push(bucket.bucketDate);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
bucketsToFetchInitially.forEach((bucketDate) => {
|
||||
assetStore.getAssetsByBucket(bucketDate);
|
||||
});
|
||||
});
|
||||
|
||||
function intersectedHandler(event: CustomEvent) {
|
||||
const el = event.detail as HTMLElement;
|
||||
const target = el.firstChild as HTMLElement;
|
||||
|
||||
if (target) {
|
||||
const bucketDate = target.id.split('_')[1];
|
||||
assetStore.getAssetsByBucket(bucketDate);
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToPreviousAsset = () => {
|
||||
assetInteractionStore.navigateAsset('previous');
|
||||
};
|
||||
|
||||
const navigateToNextAsset = () => {
|
||||
assetInteractionStore.navigateAsset('next');
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
id="asset-grid"
|
||||
class="overflow-y-auto pl-4"
|
||||
bind:clientHeight={viewportHeight}
|
||||
bind:clientWidth={viewportWidth}
|
||||
bind:this={assetGridElement}
|
||||
>
|
||||
{#if assetGridElement}
|
||||
<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
|
||||
{#each $assetGridState.buckets as bucket, bucketIndex (bucketIndex)}
|
||||
<IntersectionObserver
|
||||
on:intersected={intersectedHandler}
|
||||
on:hidden={async () => {
|
||||
// If bucket is hidden and in loading state, cancel the request
|
||||
if ($loadingBucketState[bucket.bucketDate]) {
|
||||
await assetStore.cancelBucketRequest(bucket.cancelToken, bucket.bucketDate);
|
||||
}
|
||||
}}
|
||||
let:intersecting
|
||||
top={750}
|
||||
bottom={750}
|
||||
root={assetGridElement}
|
||||
>
|
||||
<div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
|
||||
{#if intersecting}
|
||||
<AssetDateGroup
|
||||
assets={bucket.assets}
|
||||
bucketDate={bucket.bucketDate}
|
||||
bucketHeight={bucket.bucketHeight}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</IntersectionObserver>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<Portal target="body">
|
||||
{#if $isViewingAssetStoreState}
|
||||
<AssetViewer
|
||||
asset={$viewingAssetStoreState}
|
||||
on:navigate-previous={navigateToPreviousAsset}
|
||||
on:navigate-next={navigateToNextAsset}
|
||||
on:close={() => {
|
||||
assetInteractionStore.setIsViewingAsset(false);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</Portal>
|
||||
|
||||
<style>
|
||||
#asset-grid {
|
||||
contain: layout;
|
||||
}
|
||||
</style>
|
||||
@@ -15,32 +15,19 @@
|
||||
export let thumbnailSize: number | undefined = undefined;
|
||||
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
||||
export let selected: boolean = false;
|
||||
export let isExisted: boolean = false;
|
||||
|
||||
export let disabled: boolean = false;
|
||||
let imageData: string;
|
||||
// let videoData: string;
|
||||
|
||||
let mouseOver: boolean = false;
|
||||
$: dispatch('mouseEvent', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
|
||||
let mouseOverIcon: boolean = false;
|
||||
let videoPlayerNode: HTMLVideoElement;
|
||||
let isThumbnailVideoPlaying = false;
|
||||
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
||||
let videoProgress = '00:00';
|
||||
// let videoAbortController: AbortController;
|
||||
let videoUrl: string;
|
||||
|
||||
const loadImageData = async () => {
|
||||
const { data } = await api.assetApi.getAssetThumbnail(asset.id, format, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
if (data instanceof Blob) {
|
||||
imageData = URL.createObjectURL(data);
|
||||
return imageData;
|
||||
}
|
||||
};
|
||||
|
||||
const loadVideoData = async () => {
|
||||
isThumbnailVideoPlaying = false;
|
||||
|
||||
@@ -117,7 +104,7 @@
|
||||
$: getThumbnailBorderStyle = () => {
|
||||
if (selected) {
|
||||
return 'border-[20px] border-immich-primary/20';
|
||||
} else if (isExisted) {
|
||||
} else if (disabled) {
|
||||
return 'border-[20px] border-gray-300';
|
||||
} else {
|
||||
return '';
|
||||
@@ -125,36 +112,38 @@
|
||||
};
|
||||
|
||||
$: getOverlaySelectorIconStyle = () => {
|
||||
if (selected || isExisted) {
|
||||
if (selected || disabled) {
|
||||
return '';
|
||||
} else {
|
||||
return 'bg-gradient-to-b from-gray-800/50';
|
||||
}
|
||||
};
|
||||
const thumbnailClickedHandler = () => {
|
||||
if (!isExisted) {
|
||||
if (!disabled) {
|
||||
dispatch('click', { asset });
|
||||
}
|
||||
};
|
||||
|
||||
const onIconClickedHandler = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch('select', { asset });
|
||||
if (!disabled) {
|
||||
dispatch('select', { asset });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<IntersectionObserver once={true} let:intersecting>
|
||||
<IntersectionObserver once={false} let:intersecting>
|
||||
<div
|
||||
style:width={`${thumbnailSize}px`}
|
||||
style:height={`${thumbnailSize}px`}
|
||||
class={`bg-gray-100 relative ${getSize()} ${
|
||||
isExisted ? 'cursor-not-allowed' : 'hover:cursor-pointer'
|
||||
disabled ? 'cursor-not-allowed' : 'hover:cursor-pointer'
|
||||
}`}
|
||||
on:mouseenter={handleMouseOverThumbnail}
|
||||
on:mouseleave={handleMouseLeaveThumbnail}
|
||||
on:click={thumbnailClickedHandler}
|
||||
>
|
||||
{#if mouseOver || selected || isExisted}
|
||||
{#if mouseOver || selected || disabled}
|
||||
<div
|
||||
in:fade={{ duration: 200 }}
|
||||
class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}
|
||||
@@ -167,7 +156,7 @@
|
||||
>
|
||||
{#if selected}
|
||||
<CheckCircle size="24" color="#4250af" />
|
||||
{:else if isExisted}
|
||||
{:else if disabled}
|
||||
<CheckCircle size="24" color="#252525" />
|
||||
{:else}
|
||||
<CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} />
|
||||
@@ -212,12 +201,13 @@
|
||||
<!-- Thumbnail -->
|
||||
{#if intersecting}
|
||||
<img
|
||||
id={asset.id}
|
||||
style:width={`${thumbnailSize}px`}
|
||||
style:height={`${thumbnailSize}px`}
|
||||
in:fade={{ duration: 250 }}
|
||||
in:fade={{ duration: 150 }}
|
||||
src={`/api/asset/thumbnail/${asset.id}?format=${format}`}
|
||||
alt={asset.id}
|
||||
class={`object-cover ${getSize()} transition-all duration-100 z-0 ${getThumbnailBorderStyle()}`}
|
||||
class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script context="module" lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
|
||||
/**
|
||||
* Usage: <div use:portal={'css selector'}> or <div use:portal={document.body}>
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
* @param {HTMLElement|string} target DOM Element or CSS Selector
|
||||
*/
|
||||
export function portal(el: any, target: any = 'body') {
|
||||
let targetEl;
|
||||
async function update(newTarget: any) {
|
||||
target = newTarget;
|
||||
if (typeof target === 'string') {
|
||||
targetEl = document.querySelector(target);
|
||||
if (targetEl === null) {
|
||||
await tick();
|
||||
targetEl = document.querySelector(target);
|
||||
}
|
||||
if (targetEl === null) {
|
||||
throw new Error(`No element found matching css selector: "${target}"`);
|
||||
}
|
||||
} else if (target instanceof HTMLElement) {
|
||||
targetEl = target;
|
||||
} else {
|
||||
throw new TypeError(
|
||||
`Unknown portal target type: ${
|
||||
target === null ? 'null' : typeof target
|
||||
}. Allowed types: string (CSS selector) or HTMLElement.`
|
||||
);
|
||||
}
|
||||
targetEl.appendChild(el);
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
update(target);
|
||||
return {
|
||||
update,
|
||||
destroy
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* DOM Element or CSS Selector
|
||||
* @type { HTMLElement|string}
|
||||
*/
|
||||
export let target = 'body';
|
||||
</script>
|
||||
|
||||
<div use:portal={target} hidden>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SegmentScrollbarLayout } from './segment-scrollbar-layout';
|
||||
|
||||
export let scrollTop = 0;
|
||||
export let viewportWidth = 0;
|
||||
export let scrollbarHeight = 0;
|
||||
|
||||
let timelineHeight = 0;
|
||||
let segmentScrollbarLayout: SegmentScrollbarLayout[] = [];
|
||||
let isHover = false;
|
||||
let hoveredDate: Date;
|
||||
let currentMouseYLocation: number = 0;
|
||||
let scrollbarPosition = 0;
|
||||
|
||||
$: {
|
||||
scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight;
|
||||
}
|
||||
|
||||
$: {
|
||||
// let result: SegmentScrollbarLayout[] = [];
|
||||
// for (const [i, segment] of assetStoreState.entries()) {
|
||||
// let segmentLayout = new SegmentScrollbarLayout();
|
||||
// segmentLayout.count = segmentData.groups[i].count;
|
||||
// segmentLayout.height =
|
||||
// segment.assets.length == 0
|
||||
// ? getSegmentHeight(segmentData.groups[i].count)
|
||||
// : Math.round((segment.segmentHeight / timelineHeight) * scrollbarHeight);
|
||||
// segmentLayout.timeGroup = segment.segmentDate;
|
||||
// result.push(segmentLayout);
|
||||
// }
|
||||
// segmentScrollbarLayout = result;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// segmentScrollbarLayout = getLayoutDistance();
|
||||
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const getSegmentHeight = (groupCount: number) => {
|
||||
// if (segmentData.groups.length > 0) {
|
||||
// const percentage = (groupCount * 100) / segmentData.totalAssets;
|
||||
// return Math.round((percentage * scrollbarHeight) / 100);
|
||||
// } else {
|
||||
// return 0;
|
||||
// }
|
||||
};
|
||||
|
||||
const getLayoutDistance = () => {
|
||||
// let result: SegmentScrollbarLayout[] = [];
|
||||
// for (const segment of segmentData.groups) {
|
||||
// let segmentLayout = new SegmentScrollbarLayout();
|
||||
// segmentLayout.count = segment.count;
|
||||
// segmentLayout.height = getSegmentHeight(segment.count);
|
||||
// segmentLayout.timeGroup = segment.timeGroup;
|
||||
// result.push(segmentLayout);
|
||||
// }
|
||||
// return result;
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent, currentDate: Date) => {
|
||||
currentMouseYLocation = e.clientY - 71 - 30;
|
||||
|
||||
hoveredDate = new Date(currentDate.toISOString().slice(0, -1));
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="immich-scubbable-scrollbar"
|
||||
class="fixed right-0 w-[60px] h-full bg-immich-bg z-[9999] hover:cursor-row-resize"
|
||||
on:mouseenter={() => (isHover = true)}
|
||||
on:mouseleave={() => (isHover = false)}
|
||||
>
|
||||
{#if isHover}
|
||||
<div
|
||||
class="border-b-2 border-immich-primary w-[100px] right-0 pr-6 py-1 text-sm pl-1 font-medium absolute bg-white z-50 pointer-events-none rounded-tl-md shadow-lg"
|
||||
style:top={currentMouseYLocation + 'px'}
|
||||
>
|
||||
{hoveredDate?.toLocaleString('default', { month: 'short' })}
|
||||
{hoveredDate?.getFullYear()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scroll Position Indicator Line -->
|
||||
<div
|
||||
class="absolute right-0 w-10 h-[2px] bg-immich-primary"
|
||||
style:top={scrollbarPosition + 'px'}
|
||||
/>
|
||||
|
||||
<!-- Time Segment -->
|
||||
{#each segmentScrollbarLayout as segment, index (segment.timeGroup)}
|
||||
{@const groupDate = new Date(segment.timeGroup)}
|
||||
|
||||
<div
|
||||
class="relative "
|
||||
style:height={segment.height + 'px'}
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
on:mousemove={(e) => handleMouseMove(e, groupDate)}
|
||||
>
|
||||
{#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 pr-3 z-10 text-xs font-medium"
|
||||
>
|
||||
{groupDate.getFullYear()}
|
||||
</div>
|
||||
{:else if segment.count > 5}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 rounded-full h-[4px] w-[4px] mr-3 bg-gray-300 block"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#immich-scubbable-scrollbar {
|
||||
contain: layout;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
export class SegmentScrollbarLayout {
|
||||
height!: number;
|
||||
timeGroup!: string;
|
||||
count!: number;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
|
||||
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
||||
import type { UploadAsset } from '$lib/models/upload-asset';
|
||||
import { getAssetsInfo } from '$lib/stores/assets';
|
||||
// import { getAssetsInfo } fro$lib/stores/assets.storeets';
|
||||
let showDetail = true;
|
||||
|
||||
let uploadLength = 0;
|
||||
@@ -83,7 +83,9 @@
|
||||
<div
|
||||
in:fade={{ duration: 250 }}
|
||||
out:fade={{ duration: 250, delay: 1000 }}
|
||||
on:outroend={() => getAssetsInfo()}
|
||||
on:outroend={() => {
|
||||
// getAssetsInfo()
|
||||
}}
|
||||
class="absolute right-6 bottom-6 z-[10000]"
|
||||
>
|
||||
{#if showDetail}
|
||||
|
||||
40
web/src/lib/models/asset-grid-state.ts
Normal file
40
web/src/lib/models/asset-grid-state.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AssetResponseDto } from '@api';
|
||||
|
||||
export class AssetBucket {
|
||||
/**
|
||||
* The DOM height of the bucket in pixel
|
||||
* This value is first estimated by the number of asset and later is corrected as the user scroll
|
||||
*/
|
||||
bucketHeight!: number;
|
||||
bucketDate!: string;
|
||||
assets!: AssetResponseDto[];
|
||||
cancelToken!: AbortController;
|
||||
}
|
||||
|
||||
export class AssetGridState {
|
||||
/**
|
||||
* The total height of the timeline in pixel
|
||||
* This value is first estimated by the number of asset and later is corrected as the user scroll
|
||||
*/
|
||||
timelineHeight: number = 0;
|
||||
|
||||
/**
|
||||
* The fixed viewport height in pixel
|
||||
*/
|
||||
viewportHeight: number = 0;
|
||||
|
||||
/**
|
||||
* The fixed viewport width in pixel
|
||||
*/
|
||||
viewportWidth: number = 0;
|
||||
|
||||
/**
|
||||
* List of bucket information
|
||||
*/
|
||||
buckets: AssetBucket[] = [];
|
||||
|
||||
/**
|
||||
* Total assets that have been loaded
|
||||
*/
|
||||
assets: AssetResponseDto[] = [];
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type ImmichUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isAdmin: boolean;
|
||||
profileImagePath: string;
|
||||
shouldChangePassword: boolean;
|
||||
};
|
||||
150
web/src/lib/stores/asset-interaction.store.ts
Normal file
150
web/src/lib/stores/asset-interaction.store.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { AssetGridState } from '$lib/models/asset-grid-state';
|
||||
import { api, AssetResponseDto } from '@api';
|
||||
import { derived, writable } from 'svelte/store';
|
||||
import { assetGridState, assetStore } from './assets.store';
|
||||
import _ from 'lodash-es';
|
||||
|
||||
// Asset Viewer
|
||||
export const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||
export const isViewingAssetStoreState = writable<boolean>(false);
|
||||
|
||||
// Multi-Selection mode
|
||||
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
|
||||
);
|
||||
|
||||
function createAssetInteractionStore() {
|
||||
let _assetGridState = new AssetGridState();
|
||||
let _viewingAssetStoreState: AssetResponseDto;
|
||||
let _selectedAssets: Set<AssetResponseDto>;
|
||||
let _selectedGroup: Set<string>;
|
||||
let _assetsInAblums: AssetResponseDto[];
|
||||
let savedAssetLength = 0;
|
||||
let assetSortedByDate: AssetResponseDto[] = [];
|
||||
|
||||
// Subscriber
|
||||
assetGridState.subscribe((state) => {
|
||||
_assetGridState = state;
|
||||
});
|
||||
|
||||
viewingAssetStoreState.subscribe((asset) => {
|
||||
_viewingAssetStoreState = asset;
|
||||
});
|
||||
|
||||
selectedAssets.subscribe((assets) => {
|
||||
_selectedAssets = assets;
|
||||
});
|
||||
|
||||
selectedGroup.subscribe((group) => {
|
||||
_selectedGroup = group;
|
||||
});
|
||||
|
||||
assetsInAlbumStoreState.subscribe((assets) => {
|
||||
_assetsInAblums = assets;
|
||||
});
|
||||
|
||||
// Methods
|
||||
|
||||
/**
|
||||
* Asset Viewer
|
||||
*/
|
||||
const setViewingAsset = async (asset: AssetResponseDto) => {
|
||||
const { data } = await api.assetApi.getAssetById(asset.id);
|
||||
viewingAssetStoreState.set(data);
|
||||
isViewingAssetStoreState.set(true);
|
||||
};
|
||||
|
||||
const setIsViewingAsset = (isViewing: boolean) => {
|
||||
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.createdAt);
|
||||
savedAssetLength = _assetGridState.assets.length;
|
||||
}
|
||||
|
||||
// Find the index of the current asset
|
||||
const currentIndex = assetSortedByDate.findIndex((a) => a.id === _viewingAssetStoreState.id);
|
||||
|
||||
// Get the next or previous asset
|
||||
const nextIndex = direction === 'previous' ? currentIndex + 1 : currentIndex - 1;
|
||||
|
||||
// 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
|
||||
|
||||
for (const bucket of _assetGridState.buckets) {
|
||||
if (bucket.assets.length === 0) {
|
||||
nextBucket = bucket.bucketDate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextBucket !== '') {
|
||||
await assetStore.getAssetsByBucket(nextBucket);
|
||||
navigateAsset(direction);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAsset = assetSortedByDate[nextIndex];
|
||||
setViewingAsset(nextAsset);
|
||||
};
|
||||
|
||||
/**
|
||||
* Multiselect
|
||||
*/
|
||||
const addAssetToMultiselectGroup = (asset: AssetResponseDto) => {
|
||||
// Not select if in album alreaady
|
||||
if (_assetsInAblums.find((a) => a.id === asset.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedAssets.add(asset);
|
||||
selectedAssets.set(_selectedAssets);
|
||||
};
|
||||
|
||||
const removeAssetFromMultiselectGroup = (asset: AssetResponseDto) => {
|
||||
_selectedAssets.delete(asset);
|
||||
selectedAssets.set(_selectedAssets);
|
||||
};
|
||||
|
||||
const addGroupToMultiselectGroup = (group: string) => {
|
||||
_selectedGroup.add(group);
|
||||
selectedGroup.set(_selectedGroup);
|
||||
};
|
||||
|
||||
const removeGroupFromMultiselectGroup = (group: string) => {
|
||||
_selectedGroup.delete(group);
|
||||
selectedGroup.set(_selectedGroup);
|
||||
};
|
||||
|
||||
const clearMultiselect = () => {
|
||||
_selectedAssets.clear();
|
||||
_selectedGroup.clear();
|
||||
_assetsInAblums = [];
|
||||
|
||||
selectedAssets.set(_selectedAssets);
|
||||
selectedGroup.set(_selectedGroup);
|
||||
assetsInAlbumStoreState.set(_assetsInAblums);
|
||||
};
|
||||
return {
|
||||
setViewingAsset,
|
||||
setIsViewingAsset,
|
||||
navigateAsset,
|
||||
addAssetToMultiselectGroup,
|
||||
removeAssetFromMultiselectGroup,
|
||||
addGroupToMultiselectGroup,
|
||||
removeGroupFromMultiselectGroup,
|
||||
clearMultiselect
|
||||
};
|
||||
}
|
||||
|
||||
export const assetInteractionStore = createAssetInteractionStore();
|
||||
139
web/src/lib/stores/assets.store.ts
Normal file
139
web/src/lib/stores/assets.store.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { writable, derived, readable } from 'svelte/store';
|
||||
import lodash from 'lodash-es';
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto } from '@api';
|
||||
import { AssetGridState } from '$lib/models/asset-grid-state';
|
||||
import { calculateViewportHeightByNumberOfAsset } from '$lib/utils/viewport-utils';
|
||||
|
||||
/**
|
||||
* The state that holds information about the asset grid
|
||||
*/
|
||||
export const assetGridState = writable<AssetGridState>(new AssetGridState());
|
||||
export const loadingBucketState = writable<{ [key: string]: boolean }>({});
|
||||
|
||||
function createAssetStore() {
|
||||
let _assetGridState = new AssetGridState();
|
||||
assetGridState.subscribe((state) => {
|
||||
_assetGridState = state;
|
||||
});
|
||||
|
||||
let _loadingBucketState: { [key: string]: boolean } = {};
|
||||
loadingBucketState.subscribe((state) => {
|
||||
_loadingBucketState = state;
|
||||
});
|
||||
/**
|
||||
* Set intial state
|
||||
* @param viewportHeight
|
||||
* @param viewportWidth
|
||||
* @param data
|
||||
*/
|
||||
const setInitialState = (
|
||||
viewportHeight: number,
|
||||
viewportWidth: number,
|
||||
data: AssetCountByTimeBucketResponseDto
|
||||
) => {
|
||||
assetGridState.set({
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
timelineHeight: calculateViewportHeightByNumberOfAsset(data.totalCount, viewportWidth),
|
||||
buckets: data.buckets.map((d) => ({
|
||||
bucketDate: d.timeBucket,
|
||||
bucketHeight: calculateViewportHeightByNumberOfAsset(d.count, viewportWidth),
|
||||
assets: [],
|
||||
cancelToken: new AbortController()
|
||||
})),
|
||||
assets: []
|
||||
});
|
||||
};
|
||||
|
||||
const getAssetsByBucket = async (bucket: string) => {
|
||||
try {
|
||||
const currentBucketData = _assetGridState.buckets.find((b) => b.bucketDate === bucket);
|
||||
if (currentBucketData?.assets && currentBucketData.assets.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingBucketState.set({
|
||||
..._loadingBucketState,
|
||||
[bucket]: true
|
||||
});
|
||||
const { data: assets } = await api.assetApi.getAssetByTimeBucket(
|
||||
{
|
||||
timeBucket: [bucket]
|
||||
},
|
||||
{ signal: currentBucketData?.cancelToken.signal }
|
||||
);
|
||||
loadingBucketState.set({
|
||||
..._loadingBucketState,
|
||||
[bucket]: false
|
||||
});
|
||||
|
||||
// Update assetGridState with assets by time bucket
|
||||
assetGridState.update((state) => {
|
||||
const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
|
||||
state.buckets[bucketIndex].assets = assets;
|
||||
state.assets = lodash.flatMap(state.buckets, (b) => b.assets);
|
||||
|
||||
return state;
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.name === 'CanceledError') {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to get asset for bucket ', bucket);
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAsset = (assetId: string) => {
|
||||
assetGridState.update((state) => {
|
||||
const bucketIndex = state.buckets.findIndex((b) => b.assets.some((a) => a.id === assetId));
|
||||
const assetIndex = state.buckets[bucketIndex].assets.findIndex((a) => a.id === assetId);
|
||||
state.buckets[bucketIndex].assets.splice(assetIndex, 1);
|
||||
|
||||
if (state.buckets[bucketIndex].assets.length === 0) {
|
||||
_removeBucket(state.buckets[bucketIndex].bucketDate);
|
||||
}
|
||||
state.assets = lodash.flatMap(state.buckets, (b) => b.assets);
|
||||
return state;
|
||||
});
|
||||
};
|
||||
|
||||
const _removeBucket = (bucketDate: string) => {
|
||||
assetGridState.update((state) => {
|
||||
const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucketDate);
|
||||
state.buckets.splice(bucketIndex, 1);
|
||||
state.assets = lodash.flatMap(state.buckets, (b) => b.assets);
|
||||
return state;
|
||||
});
|
||||
};
|
||||
|
||||
const updateBucketHeight = (bucket: string, height: number) => {
|
||||
assetGridState.update((state) => {
|
||||
const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
|
||||
state.buckets[bucketIndex].bucketHeight = height;
|
||||
return state;
|
||||
});
|
||||
};
|
||||
|
||||
const cancelBucketRequest = async (token: AbortController, bucketDate: string) => {
|
||||
token.abort();
|
||||
// set new abort controller for bucket
|
||||
assetGridState.update((state) => {
|
||||
const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucketDate);
|
||||
state.buckets[bucketIndex].cancelToken = new AbortController();
|
||||
return state;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
setInitialState,
|
||||
getAssetsByBucket,
|
||||
removeAsset,
|
||||
updateBucketHeight,
|
||||
cancelBucketRequest
|
||||
};
|
||||
}
|
||||
|
||||
export const assetStore = createAssetStore();
|
||||
@@ -1,35 +0,0 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import lodash from 'lodash-es';
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import { api, AssetResponseDto } from '@api';
|
||||
export const assets = writable<AssetResponseDto[]>([]);
|
||||
|
||||
export const assetsGroupByDate = derived(assets, ($assets) => {
|
||||
try {
|
||||
return lodash
|
||||
.chain($assets)
|
||||
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))
|
||||
.sortBy((group) => $assets.indexOf(group[0]))
|
||||
.value();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
export const flattenAssetGroupByDate = derived(assetsGroupByDate, ($assetsGroupByDate) => {
|
||||
return $assetsGroupByDate.flat();
|
||||
});
|
||||
|
||||
export const getAssetsInfo = async () => {
|
||||
try {
|
||||
const { data } = await api.assetApi.getAllAssets();
|
||||
assets.set(data);
|
||||
} catch (error) {
|
||||
console.log('Error [getAssetsInfo]');
|
||||
}
|
||||
};
|
||||
|
||||
export const setAssetInfo = (data: AssetResponseDto[]) => {
|
||||
assets.set(data);
|
||||
};
|
||||
13
web/src/lib/utils/viewport-utils.ts
Normal file
13
web/src/lib/utils/viewport-utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Glossary
|
||||
* 1. Section: Group of assets in a month
|
||||
*/
|
||||
|
||||
export function calculateViewportHeightByNumberOfAsset(assetCount: number, viewportWidth: number) {
|
||||
const thumbnailHeight = 235;
|
||||
|
||||
const unwrappedWidth = (3 / 2) * assetCount * thumbnailHeight * (7 / 10);
|
||||
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
||||
const height = rows * thumbnailHeight;
|
||||
return height;
|
||||
}
|
||||
@@ -35,24 +35,24 @@
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#key $page.url}
|
||||
<div in:fade={{ duration: 100 }}>
|
||||
{#if showNavigationLoadingBar}
|
||||
<NavigationLoadingBar />
|
||||
{/if}
|
||||
<!-- {#key $page.url} -->
|
||||
<div in:fade={{ duration: 100 }}>
|
||||
{#if showNavigationLoadingBar}
|
||||
<NavigationLoadingBar />
|
||||
{/if}
|
||||
|
||||
<slot />
|
||||
<slot />
|
||||
|
||||
<DownloadPanel />
|
||||
<UploadPanel />
|
||||
<NotificationList />
|
||||
{#if shouldShowAnnouncement}
|
||||
<AnnouncementBox
|
||||
{localVersion}
|
||||
{remoteVersion}
|
||||
on:close={() => (shouldShowAnnouncement = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
<DownloadPanel />
|
||||
<UploadPanel />
|
||||
<NotificationList />
|
||||
{#if shouldShowAnnouncement}
|
||||
<AnnouncementBox
|
||||
{localVersion}
|
||||
{remoteVersion}
|
||||
on:close={() => (shouldShowAnnouncement = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- {/key} -->
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { serverApi } from './../../api/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
|
||||
@@ -9,11 +8,8 @@ export const load: PageServerLoad = async ({ parent }) => {
|
||||
throw error(400, 'Not logged in');
|
||||
}
|
||||
|
||||
const { data: assets } = await serverApi.assetApi.getAllAssets();
|
||||
|
||||
return {
|
||||
user,
|
||||
assets
|
||||
user
|
||||
};
|
||||
} catch (e) {
|
||||
throw redirect(302, '/auth/login');
|
||||
|
||||
@@ -1,191 +1,57 @@
|
||||
<script lang="ts">
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
|
||||
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import {
|
||||
assetsGroupByDate,
|
||||
flattenAssetGroupByDate,
|
||||
assets,
|
||||
setAssetInfo
|
||||
} from '$lib/stores/assets';
|
||||
import ImmichThumbnail from '$lib/components/shared-components/immich-thumbnail.svelte';
|
||||
import moment from 'moment';
|
||||
import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
|
||||
import { api, AssetResponseDto } from '@api';
|
||||
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
|
||||
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
|
||||
import CircleIconButton from '$lib/components/shared-components/circle-icon-button.svelte';
|
||||
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
|
||||
|
||||
import type { PageData } from './$types';
|
||||
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
|
||||
import { onMount } from 'svelte';
|
||||
import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket';
|
||||
import {
|
||||
assetInteractionStore,
|
||||
isMultiSelectStoreState,
|
||||
selectedAssets
|
||||
} from '$lib/stores/asset-interaction.store';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import CircleIconButton from '$lib/components/shared-components/circle-icon-button.svelte';
|
||||
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
|
||||
import { api } from '@api';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket';
|
||||
import { assetStore } from '$lib/stores/assets.store';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let selectedGroupThumbnail: number | null;
|
||||
let isMouseOverGroup: boolean;
|
||||
onMount(async () => {
|
||||
openWebsocketConnection();
|
||||
|
||||
let multiSelectedAssets = new Set<AssetResponseDto>();
|
||||
$: isMultiSelectionMode = multiSelectedAssets.size > 0;
|
||||
|
||||
let selectedGroup: Set<number> = new Set();
|
||||
let existingGroup: Set<number> = new Set();
|
||||
|
||||
$: if (isMouseOverGroup == false) {
|
||||
selectedGroupThumbnail = null;
|
||||
}
|
||||
|
||||
let isShowAssetViewer = false;
|
||||
let currentViewAssetIndex = 0;
|
||||
let selectedAsset: AssetResponseDto;
|
||||
|
||||
onMount(() => {
|
||||
setAssetInfo(data.assets);
|
||||
return () => {
|
||||
closeWebsocketConnection();
|
||||
};
|
||||
});
|
||||
|
||||
const thumbnailMouseEventHandler = (event: CustomEvent) => {
|
||||
const { selectedGroupIndex }: { selectedGroupIndex: number } = event.detail;
|
||||
|
||||
selectedGroupThumbnail = selectedGroupIndex;
|
||||
};
|
||||
|
||||
const viewAssetHandler = (event: CustomEvent) => {
|
||||
const { asset }: { asset: AssetResponseDto } = event.detail;
|
||||
|
||||
currentViewAssetIndex = $flattenAssetGroupByDate.findIndex((a) => a.id == asset.id);
|
||||
selectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex];
|
||||
isShowAssetViewer = true;
|
||||
pushState(selectedAsset.id);
|
||||
};
|
||||
|
||||
const navigateAssetForward = () => {
|
||||
try {
|
||||
if (currentViewAssetIndex < $flattenAssetGroupByDate.length - 1) {
|
||||
currentViewAssetIndex++;
|
||||
selectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex];
|
||||
pushState(selectedAsset.id);
|
||||
}
|
||||
} catch (e) {
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: 'You have reached the end'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const navigateAssetBackward = () => {
|
||||
try {
|
||||
if (currentViewAssetIndex > 0) {
|
||||
currentViewAssetIndex--;
|
||||
selectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex];
|
||||
pushState(selectedAsset.id);
|
||||
}
|
||||
} catch (e) {
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: 'You have reached the end'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const pushState = (assetId: string) => {
|
||||
// add a URL to the browser's history
|
||||
// changes the current URL in the address bar but doesn't perform any SvelteKit navigation
|
||||
history.pushState(null, '', `/photos/${assetId}`);
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
isShowAssetViewer = false;
|
||||
history.pushState(null, '', `/photos`);
|
||||
};
|
||||
|
||||
const selectAssetHandler = (asset: AssetResponseDto, groupIndex: number) => {
|
||||
let temp = new Set(multiSelectedAssets);
|
||||
|
||||
if (multiSelectedAssets.has(asset)) {
|
||||
temp.delete(asset);
|
||||
|
||||
const tempSelectedGroup = new Set(selectedGroup);
|
||||
tempSelectedGroup.delete(groupIndex);
|
||||
selectedGroup = tempSelectedGroup;
|
||||
} else {
|
||||
temp.add(asset);
|
||||
}
|
||||
|
||||
multiSelectedAssets = temp;
|
||||
|
||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||
if (!selectedGroup.has(groupIndex)) {
|
||||
const assetsInGroup = $assetsGroupByDate[groupIndex];
|
||||
let selectedAssetsInGroupCount = 0;
|
||||
|
||||
assetsInGroup.forEach((asset) => {
|
||||
if (multiSelectedAssets.has(asset)) {
|
||||
selectedAssetsInGroupCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// if all assets are selected in a group, add the group to selected group
|
||||
if (selectedAssetsInGroupCount == assetsInGroup.length) {
|
||||
selectedGroup = selectedGroup.add(groupIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearMultiSelectAssetAssetHandler = () => {
|
||||
multiSelectedAssets = new Set();
|
||||
selectedGroup = new Set();
|
||||
existingGroup = new Set();
|
||||
};
|
||||
|
||||
const selectAssetGroupHandler = (groupIndex: number) => {
|
||||
if (existingGroup.has(groupIndex)) return;
|
||||
|
||||
let tempSelectedGroup = new Set(selectedGroup);
|
||||
let tempSelectedAsset = new Set(multiSelectedAssets);
|
||||
|
||||
if (selectedGroup.has(groupIndex)) {
|
||||
tempSelectedGroup.delete(groupIndex);
|
||||
tempSelectedAsset.forEach((asset) => {
|
||||
if ($assetsGroupByDate[groupIndex].find((a) => a.id == asset.id)) {
|
||||
tempSelectedAsset.delete(asset);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tempSelectedGroup.add(groupIndex);
|
||||
tempSelectedAsset = new Set([...multiSelectedAssets, ...$assetsGroupByDate[groupIndex]]);
|
||||
}
|
||||
|
||||
multiSelectedAssets = tempSelectedAsset;
|
||||
selectedGroup = tempSelectedGroup;
|
||||
};
|
||||
|
||||
const deleteSelectedAssetHandler = async () => {
|
||||
try {
|
||||
if (
|
||||
window.confirm(
|
||||
`Caution! Are you sure you want to delete ${multiSelectedAssets.size} assets? This step also deletes assets in the album(s) to which they belong. You can not undo this action!`
|
||||
`Caution! Are you sure you want to delete ${$selectedAssets.size} assets? This step also deletes assets in the album(s) to which they belong. You can not undo this action!`
|
||||
)
|
||||
) {
|
||||
const { data: deletedAssets } = await api.assetApi.deleteAsset({
|
||||
ids: Array.from(multiSelectedAssets).map((a) => a.id)
|
||||
ids: Array.from($selectedAssets).map((a) => a.id)
|
||||
});
|
||||
|
||||
for (const asset of deletedAssets) {
|
||||
if (asset.status == 'SUCCESS') {
|
||||
$assets = $assets.filter((a) => a.id !== asset.id);
|
||||
assetStore.removeAsset(asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
clearMultiSelectAssetAssetHandler();
|
||||
assetInteractionStore.clearMultiselect();
|
||||
}
|
||||
} catch (e) {
|
||||
notificationController.show({
|
||||
@@ -195,18 +61,6 @@
|
||||
console.error('Error deleteSelectedAssetHandler', e);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
openWebsocketConnection();
|
||||
|
||||
const { data: assets } = await api.assetApi.getAllAssets();
|
||||
|
||||
setAssetInfo(assets);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
closeWebsocketConnection();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -214,14 +68,14 @@
|
||||
</svelte:head>
|
||||
|
||||
<section>
|
||||
{#if isMultiSelectionMode}
|
||||
{#if $isMultiSelectStoreState}
|
||||
<ControlAppBar
|
||||
on:close-button-click={clearMultiSelectAssetAssetHandler}
|
||||
on:close-button-click={() => assetInteractionStore.clearMultiselect()}
|
||||
backIcon={Close}
|
||||
tailwindClasses={'bg-white shadow-md'}
|
||||
>
|
||||
<svelte:fragment slot="leading">
|
||||
<p class="font-medium text-immich-primary">Selected {multiSelectedAssets.size}</p>
|
||||
<p class="font-medium text-immich-primary">Selected {$selectedAssets.size}</p>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="trailing">
|
||||
<CircleIconButton
|
||||
@@ -231,9 +85,7 @@
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
|
||||
{#if !isMultiSelectionMode}
|
||||
{:else}
|
||||
<NavigationBar
|
||||
user={data.user}
|
||||
on:uploadClicked={() => openFileUploadDialog(UploadType.GENERAL)}
|
||||
@@ -243,71 +95,5 @@
|
||||
|
||||
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">
|
||||
<SideBar />
|
||||
|
||||
<!-- Main Section -->
|
||||
<section class="overflow-y-auto relative immich-scrollbar">
|
||||
<section id="assets-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg">
|
||||
<section id="image-grid" class="flex flex-wrap gap-14">
|
||||
{#each $assetsGroupByDate as assetsInDateGroup, groupIndex}
|
||||
<!-- Asset Group By Date -->
|
||||
<div
|
||||
class="flex flex-col"
|
||||
on:mouseenter={() => (isMouseOverGroup = true)}
|
||||
on:mouseleave={() => (isMouseOverGroup = false)}
|
||||
>
|
||||
<!-- Date group title -->
|
||||
<p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6">
|
||||
{#if (selectedGroupThumbnail === groupIndex && isMouseOverGroup) || selectedGroup.has(groupIndex)}
|
||||
<div
|
||||
in:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||
out:fly={{ x: -24, duration: 200 }}
|
||||
class="inline-block px-2 hover:cursor-pointer"
|
||||
on:click={() => selectAssetGroupHandler(groupIndex)}
|
||||
>
|
||||
{#if selectedGroup.has(groupIndex)}
|
||||
<CheckCircle size="24" color="#4250af" />
|
||||
{:else if existingGroup.has(groupIndex)}
|
||||
<CheckCircle size="24" color="#757575" />
|
||||
{:else}
|
||||
<CircleOutline size="24" color="#757575" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{moment(assetsInDateGroup[0].createdAt).format('ddd, MMM DD YYYY')}
|
||||
</p>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div class="flex flex-wrap gap-[2px]">
|
||||
{#each assetsInDateGroup as asset}
|
||||
{#key asset.id}
|
||||
<ImmichThumbnail
|
||||
{asset}
|
||||
on:mouseEvent={thumbnailMouseEventHandler}
|
||||
on:click={(event) =>
|
||||
isMultiSelectionMode
|
||||
? selectAssetHandler(asset, groupIndex)
|
||||
: viewAssetHandler(event)}
|
||||
on:select={() => selectAssetHandler(asset, groupIndex)}
|
||||
selected={multiSelectedAssets.has(asset)}
|
||||
{groupIndex}
|
||||
/>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
<AssetGrid />
|
||||
</section>
|
||||
|
||||
<!-- Overlay Asset Viewer -->
|
||||
{#if isShowAssetViewer}
|
||||
<AssetViewer
|
||||
asset={selectedAsset}
|
||||
on:navigate-backward={navigateAssetBackward}
|
||||
on:navigate-forward={navigateAssetForward}
|
||||
on:close={closeViewer}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user