mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat(web): timeline bucket for albums (4) (#3604)
* feat: server changes for album timeline * feat(web): album timeline view * chore: open api * chore: remove archive action * fix: favorite for non-owners
This commit is contained in:
parent
36dc7bd924
commit
5cd13227ad
47 changed files with 1014 additions and 757 deletions
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
|
||||
import { SharedLinkType } from '@api';
|
||||
import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
|
||||
|
|
@ -12,9 +11,5 @@
|
|||
<CircleIconButton title="Share" logo={ShareVariantOutline} on:click={() => (showModal = true)} />
|
||||
|
||||
{#if showModal}
|
||||
<CreateSharedLinkModal
|
||||
sharedAssets={Array.from(getAssets())}
|
||||
shareType={SharedLinkType.Individual}
|
||||
on:close={() => (showModal = false)}
|
||||
/>
|
||||
<CreateSharedLinkModal assetIds={Array.from(getAssets()).map(({ id }) => id)} on:close={() => (showModal = false)} />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
export let onRemove: ((assetIds: string[]) => void) | undefined = undefined;
|
||||
|
||||
const { getAssets, clearSelect } = getAssetControlContext();
|
||||
|
||||
|
|
@ -17,14 +18,17 @@
|
|||
|
||||
const removeFromAlbum = async () => {
|
||||
try {
|
||||
const ids = Array.from(getAssets()).map((a) => a.id);
|
||||
const { data: results } = await api.albumApi.removeAssetFromAlbum({
|
||||
id: album.id,
|
||||
bulkIdsDto: { ids: Array.from(getAssets()).map((a) => a.id) },
|
||||
bulkIdsDto: { ids },
|
||||
});
|
||||
|
||||
const { data } = await api.albumApi.getAlbumInfo({ id: album.id });
|
||||
album = data;
|
||||
|
||||
onRemove?.(ids);
|
||||
|
||||
const count = results.filter(({ success }) => success).length;
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
for (const bucket of assetGridState.buckets) {
|
||||
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
|
||||
for (const asset of bucket.assets) {
|
||||
assetInteractionStore.addAssetToMultiselectGroup(asset);
|
||||
assetInteractionStore.selectAsset(asset);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,10 +25,14 @@
|
|||
export let assetStore: AssetStore;
|
||||
export let assetInteractionStore: AssetInteractionStore;
|
||||
|
||||
const { selectedGroup, selectedAssets, assetsInAlbumState, assetSelectionCandidates, isMultiSelectState } =
|
||||
assetInteractionStore;
|
||||
const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: { title: string; assets: AssetResponseDto[] };
|
||||
selectAssets: AssetResponseDto;
|
||||
selectAssetCandidates: AssetResponseDto | null;
|
||||
shift: { heightDelta: number };
|
||||
}>();
|
||||
|
||||
let isMouseOverGroup = false;
|
||||
let actualBucketHeight: number;
|
||||
|
|
@ -86,64 +90,44 @@
|
|||
return width;
|
||||
};
|
||||
|
||||
const assetClickHandler = (
|
||||
asset: AssetResponseDto,
|
||||
assetsInDateGroup: AssetResponseDto[],
|
||||
dateGroupTitle: string,
|
||||
) => {
|
||||
const assetClickHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
|
||||
if (isSelectionMode || $isMultiSelectState) {
|
||||
assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
|
||||
assetSelectHandler(asset, assetsInDateGroup, groupTitle);
|
||||
return;
|
||||
}
|
||||
|
||||
assetViewingStore.setAssetId(asset.id);
|
||||
};
|
||||
|
||||
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 handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets });
|
||||
|
||||
const assetSelectHandler = (
|
||||
asset: AssetResponseDto,
|
||||
assetsInDateGroup: AssetResponseDto[],
|
||||
dateGroupTitle: string,
|
||||
) => {
|
||||
dispatch('selectAssets', { asset });
|
||||
const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
|
||||
dispatch('selectAssets', asset);
|
||||
|
||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||
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) {
|
||||
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
|
||||
assetInteractionStore.addGroupToMultiselectGroup(groupTitle);
|
||||
} else {
|
||||
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
|
||||
assetInteractionStore.removeGroupFromMultiselectGroup(groupTitle);
|
||||
}
|
||||
};
|
||||
|
||||
const assetMouseEventHandler = (dateGroupTitle: string, asset: AssetResponseDto | null) => {
|
||||
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
|
||||
// Show multi select icon on hover on date group
|
||||
hoveredDateGroup = dateGroupTitle;
|
||||
hoveredDateGroup = groupTitle;
|
||||
|
||||
if ($isMultiSelectState) {
|
||||
dispatch('selectAssetCandidates', { asset });
|
||||
dispatch('selectAssetCandidates', asset);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}>
|
||||
{#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)}
|
||||
{@const dateGroupTitle = formatGroupTitle(DateTime.fromISO(assetsInDateGroup[0].fileCreatedAt).startOf('day'))}
|
||||
{#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)}
|
||||
{@const groupTitle = formatGroupTitle(DateTime.fromISO(groupAssets[0].fileCreatedAt).startOf('day'))}
|
||||
<!-- Asset Group By Date -->
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
|
|
@ -151,11 +135,11 @@
|
|||
class="mt-5 flex flex-col"
|
||||
on:mouseenter={() => {
|
||||
isMouseOverGroup = true;
|
||||
assetMouseEventHandler(dateGroupTitle, null);
|
||||
assetMouseEventHandler(groupTitle, null);
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
isMouseOverGroup = false;
|
||||
assetMouseEventHandler(dateGroupTitle, null);
|
||||
assetMouseEventHandler(groupTitle, null);
|
||||
}}
|
||||
>
|
||||
<!-- Date group title -->
|
||||
|
|
@ -163,14 +147,14 @@
|
|||
class="mb-2 flex h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
|
||||
style="width: {geometry[groupIndex].containerWidth}px"
|
||||
>
|
||||
{#if !singleSelect && ((hoveredDateGroup == dateGroupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroupTitle))}
|
||||
{#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))}
|
||||
<div
|
||||
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||
class="inline-block px-2 hover:cursor-pointer"
|
||||
on:click={() => selectAssetGroupHandler(assetsInDateGroup, dateGroupTitle)}
|
||||
on:keydown={() => selectAssetGroupHandler(assetsInDateGroup, dateGroupTitle)}
|
||||
on:click={() => handleSelectGroup(groupTitle, groupAssets)}
|
||||
on:keydown={() => handleSelectGroup(groupTitle, groupAssets)}
|
||||
>
|
||||
{#if $selectedGroup.has(dateGroupTitle)}
|
||||
{#if $selectedGroup.has(groupTitle)}
|
||||
<CheckCircle size="24" color="#4250af" />
|
||||
{:else}
|
||||
<CircleOutline size="24" color="#757575" />
|
||||
|
|
@ -178,8 +162,8 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="truncate first-letter:capitalize" title={dateGroupTitle}>
|
||||
{dateGroupTitle}
|
||||
<span class="truncate first-letter:capitalize" title={groupTitle}>
|
||||
{groupTitle}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
|
@ -188,7 +172,7 @@
|
|||
class="relative"
|
||||
style="height: {geometry[groupIndex].containerHeight}px;width: {geometry[groupIndex].containerWidth}px"
|
||||
>
|
||||
{#each assetsInDateGroup as asset, index (asset.id)}
|
||||
{#each groupAssets as asset, index (asset.id)}
|
||||
{@const box = geometry[groupIndex].boxes[index]}
|
||||
<div
|
||||
class="absolute"
|
||||
|
|
@ -197,12 +181,12 @@
|
|||
<Thumbnail
|
||||
{asset}
|
||||
{groupIndex}
|
||||
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
|
||||
on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)}
|
||||
on:mouse-event={() => assetMouseEventHandler(dateGroupTitle, asset)}
|
||||
selected={$selectedAssets.has(asset) || $assetsInAlbumState.some(({ id }) => id === asset.id)}
|
||||
on:click={() => assetClickHandler(asset, groupAssets, groupTitle)}
|
||||
on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)}
|
||||
on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)}
|
||||
selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
|
||||
selectionCandidate={$assetSelectionCandidates.has(asset)}
|
||||
disabled={$assetsInAlbumState.some(({ id }) => id === asset.id)}
|
||||
disabled={$assetStore.albumAssets.has(asset.id)}
|
||||
thumbnailWidth={box.width}
|
||||
thumbnailHeight={box.height}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { BucketPosition, type AssetStore, type Viewport } from '$lib/stores/assets.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { isSearchEnabled } from '$lib/stores/search.store';
|
||||
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@api';
|
||||
import { DateTime } from 'luxon';
|
||||
|
|
@ -9,15 +15,8 @@
|
|||
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
|
||||
import Portal from '../shared-components/portal/portal.svelte';
|
||||
import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte';
|
||||
import AssetDateGroup from './asset-date-group.svelte';
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
import { BucketPosition, type AssetStore, type Viewport } from '$lib/stores/assets.store';
|
||||
import { isSearchEnabled } from '$lib/stores/search.store';
|
||||
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
||||
import AssetDateGroup from './asset-date-group.svelte';
|
||||
|
||||
export let isSelectionMode = false;
|
||||
export let singleSelect = false;
|
||||
|
|
@ -25,7 +24,8 @@
|
|||
export let assetInteractionStore: AssetInteractionStore;
|
||||
export let removeAction: AssetAction | null = null;
|
||||
|
||||
const { assetSelectionCandidates, assetSelectionStart, selectedAssets, isMultiSelectState } = assetInteractionStore;
|
||||
const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
|
||||
assetInteractionStore;
|
||||
const viewport: Viewport = { width: 0, height: 0 };
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
|
||||
let element: HTMLElement;
|
||||
|
|
@ -45,6 +45,10 @@
|
|||
if (browser) {
|
||||
document.removeEventListener('keydown', onKeyboardPress);
|
||||
}
|
||||
|
||||
if ($showAssetViewer) {
|
||||
$showAssetViewer = false;
|
||||
}
|
||||
});
|
||||
|
||||
const handleKeyboardPress = (event: KeyboardEvent) => {
|
||||
|
|
@ -71,6 +75,12 @@
|
|||
}
|
||||
};
|
||||
|
||||
const handleSelectAsset = (asset: AssetResponseDto) => {
|
||||
if (!assetStore.albumAssets.has(asset.id)) {
|
||||
assetInteractionStore.selectAsset(asset);
|
||||
}
|
||||
};
|
||||
|
||||
function intersectedHandler(event: CustomEvent) {
|
||||
const el = event.detail.container as HTMLElement;
|
||||
const target = el.firstChild as HTMLElement;
|
||||
|
|
@ -166,16 +176,28 @@
|
|||
selectAssetCandidates(lastAssetMouseEvent);
|
||||
}
|
||||
|
||||
const handleSelectAssetCandidates = (e: CustomEvent) => {
|
||||
const asset = e.detail.asset;
|
||||
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => {
|
||||
if (asset) {
|
||||
selectAssetCandidates(asset);
|
||||
}
|
||||
lastAssetMouseEvent = asset;
|
||||
};
|
||||
|
||||
const handleSelectAssets = async (e: CustomEvent) => {
|
||||
const asset = e.detail.asset as AssetResponseDto;
|
||||
const handleGroupSelect = (group: string, assets: AssetResponseDto[]) => {
|
||||
if ($selectedGroup.has(group)) {
|
||||
assetInteractionStore.removeGroupFromMultiselectGroup(group);
|
||||
for (const asset of assets) {
|
||||
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
||||
}
|
||||
} else {
|
||||
assetInteractionStore.addGroupToMultiselectGroup(group);
|
||||
for (const asset of assets) {
|
||||
handleSelectAsset(asset);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAssets = async (asset: AssetResponseDto) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -184,6 +206,7 @@
|
|||
|
||||
if (singleSelect) {
|
||||
element.scrollTop = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeSelection = $assetSelectionCandidates.size > 0;
|
||||
|
|
@ -197,9 +220,9 @@
|
|||
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
||||
} else {
|
||||
for (const candidate of $assetSelectionCandidates || []) {
|
||||
assetInteractionStore.addAssetToMultiselectGroup(candidate);
|
||||
handleSelectAsset(candidate);
|
||||
}
|
||||
assetInteractionStore.addAssetToMultiselectGroup(asset);
|
||||
handleSelectAsset(asset);
|
||||
}
|
||||
|
||||
assetInteractionStore.clearAssetSelectionCandidates();
|
||||
|
|
@ -224,7 +247,7 @@
|
|||
if (deselect) {
|
||||
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
||||
} else {
|
||||
assetInteractionStore.addAssetToMultiselectGroup(asset);
|
||||
handleSelectAsset(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -293,7 +316,7 @@
|
|||
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
|
||||
<section
|
||||
id="asset-grid"
|
||||
class="scrollbar-hidden ml-4 mr-[60px] h-full overflow-y-auto pb-4"
|
||||
class="scrollbar-hidden ml-4 mr-[60px] h-full overflow-y-auto pb-[60px]"
|
||||
bind:clientHeight={viewport.height}
|
||||
bind:clientWidth={viewport.width}
|
||||
bind:this={element}
|
||||
|
|
@ -318,9 +341,10 @@
|
|||
{assetInteractionStore}
|
||||
{isSelectionMode}
|
||||
{singleSelect}
|
||||
on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
|
||||
on:shift={handleScrollTimeline}
|
||||
on:selectAssetCandidates={handleSelectAssetCandidates}
|
||||
on:selectAssets={handleSelectAssets}
|
||||
on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
|
||||
on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
|
||||
assets={bucket.assets}
|
||||
bucketDate={bucket.bucketDate}
|
||||
bucketHeight={bucket.bucketHeight}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue