- {#if mouseOver || selected || isExisted}
+ {#if mouseOver || selected || disabled}
{#if selected}
- {:else if isExisted}
+ {:else if disabled}
{:else}
@@ -212,12 +201,13 @@
{#if intersecting}
{/if}
diff --git a/web/src/lib/components/shared-components/portal/portal.svelte b/web/src/lib/components/shared-components/portal/portal.svelte
new file mode 100644
index 000000000..b3d3ece6f
--- /dev/null
+++ b/web/src/lib/components/shared-components/portal/portal.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
new file mode 100644
index 000000000..9a2172faf
--- /dev/null
+++ b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
@@ -0,0 +1,122 @@
+
+
+
+
+
diff --git a/web/src/lib/components/shared-components/scrollbar/segment-scrollbar-layout.ts b/web/src/lib/components/shared-components/scrollbar/segment-scrollbar-layout.ts
new file mode 100644
index 000000000..337ee47f0
--- /dev/null
+++ b/web/src/lib/components/shared-components/scrollbar/segment-scrollbar-layout.ts
@@ -0,0 +1,5 @@
+export class SegmentScrollbarLayout {
+ height!: number;
+ timeGroup!: string;
+ count!: number;
+}
diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte
index 9e1bcb8ac..31da7c00e 100644
--- a/web/src/lib/components/shared-components/upload-panel.svelte
+++ b/web/src/lib/components/shared-components/upload-panel.svelte
@@ -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 @@
getAssetsInfo()}
+ on:outroend={() => {
+ // getAssetsInfo()
+ }}
class="absolute right-6 bottom-6 z-[10000]"
>
{#if showDetail}
diff --git a/web/src/lib/models/asset-grid-state.ts b/web/src/lib/models/asset-grid-state.ts
new file mode 100644
index 000000000..dadf9c762
--- /dev/null
+++ b/web/src/lib/models/asset-grid-state.ts
@@ -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[] = [];
+}
diff --git a/web/src/lib/models/immich-user.ts b/web/src/lib/models/immich-user.ts
deleted file mode 100644
index c0bf78164..000000000
--- a/web/src/lib/models/immich-user.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export type ImmichUser = {
- id: string;
- email: string;
- firstName: string;
- lastName: string;
- isAdmin: boolean;
- profileImagePath: string;
- shouldChangePassword: boolean;
-};
diff --git a/web/src/lib/stores/asset-interaction.store.ts b/web/src/lib/stores/asset-interaction.store.ts
new file mode 100644
index 000000000..48193901c
--- /dev/null
+++ b/web/src/lib/stores/asset-interaction.store.ts
@@ -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
();
+export const isViewingAssetStoreState = writable(false);
+
+// Multi-Selection mode
+export const assetsInAlbumStoreState = writable([]);
+export const selectedAssets = writable>(new Set());
+export const selectedGroup = writable>(new Set());
+export const isMultiSelectStoreState = derived(
+ selectedAssets,
+ ($selectedAssets) => $selectedAssets.size > 0
+);
+
+function createAssetInteractionStore() {
+ let _assetGridState = new AssetGridState();
+ let _viewingAssetStoreState: AssetResponseDto;
+ let _selectedAssets: Set;
+ let _selectedGroup: Set;
+ 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();
diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts
new file mode 100644
index 000000000..aa2f4faae
--- /dev/null
+++ b/web/src/lib/stores/assets.store.ts
@@ -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(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();
diff --git a/web/src/lib/stores/assets.ts b/web/src/lib/stores/assets.ts
deleted file mode 100644
index 107a98763..000000000
--- a/web/src/lib/stores/assets.ts
+++ /dev/null
@@ -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([]);
-
-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);
-};
diff --git a/web/src/lib/utils/viewport-utils.ts b/web/src/lib/utils/viewport-utils.ts
new file mode 100644
index 000000000..b084f6d72
--- /dev/null
+++ b/web/src/lib/utils/viewport-utils.ts
@@ -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;
+}
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index a56d96c66..791de0994 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -35,24 +35,24 @@
- {#key $page.url}
-
- {#if showNavigationLoadingBar}
-
- {/if}
+
+
+ {#if showNavigationLoadingBar}
+
+ {/if}
-
+
-
-
-
- {#if shouldShowAnnouncement}
-
(shouldShowAnnouncement = false)}
- />
- {/if}
-
- {/key}
+
+
+
+ {#if shouldShowAnnouncement}
+
(shouldShowAnnouncement = false)}
+ />
+ {/if}
+
+
diff --git a/web/src/routes/photos/+page.server.ts b/web/src/routes/photos/+page.server.ts
index 79fb1cb10..82ac30b30 100644
--- a/web/src/routes/photos/+page.server.ts
+++ b/web/src/routes/photos/+page.server.ts
@@ -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');
diff --git a/web/src/routes/photos/+page.svelte b/web/src/routes/photos/+page.svelte
index aca0447ed..b38184ea3 100644
--- a/web/src/routes/photos/+page.svelte
+++ b/web/src/routes/photos/+page.svelte
@@ -1,191 +1,57 @@
@@ -214,14 +68,14 @@
- {#if isMultiSelectionMode}
+ {#if $isMultiSelectStoreState}
assetInteractionStore.clearMultiselect()}
backIcon={Close}
tailwindClasses={'bg-white shadow-md'}
>
- Selected {multiSelectedAssets.size}
+ Selected {$selectedAssets.size}
- {/if}
-
- {#if !isMultiSelectionMode}
+ {:else}
openFileUploadDialog(UploadType.GENERAL)}
@@ -243,71 +95,5 @@
-
-
-{#if isShowAssetViewer}
-
-{/if}
diff --git a/web/tsconfig.json b/web/tsconfig.json
index adbaaf380..4667f6c95 100644
--- a/web/tsconfig.json
+++ b/web/tsconfig.json
@@ -1,24 +1,33 @@
{
- "extends": "./.svelte-kit/tsconfig.json",
- "compilerOptions": {
- "allowJs": true,
- "checkJs": true,
- "esModuleInterop": true,
- "forceConsistentCasingInFileNames": true,
- "lib": ["es2020", "DOM"],
- "moduleResolution": "node",
- "module": "es2020",
- "resolveJsonModule": true,
- "skipLibCheck": true,
- "sourceMap": true,
- "strict": true,
- "target": "es2020",
- "importsNotUsedAsValues": "preserve",
- "preserveValueImports": false,
- "paths": {
- "$lib": ["src/lib"],
- "$lib/*": ["src/lib/*"],
- "@api": ["src/api"]
- }
- }
-}
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": [
+ "es2020",
+ "DOM"
+ ],
+ "moduleResolution": "node",
+ "module": "es2020",
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "target": "es2020",
+ "importsNotUsedAsValues": "preserve",
+ "preserveValueImports": false,
+ "paths": {
+ "$lib": [
+ "./src/lib"
+ ],
+ "$lib/*": [
+ "./src/lib/*"
+ ],
+ "@api": [
+ "./src/api"
+ ]
+ }
+ }
+}
\ No newline at end of file