1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(web): Memory (#2759)

* Add on this day

* add query for x year

* dev: add query

* dev: front end

* dev: styling

* styling

* more styling

* add new page

* navigating

* navigate back and forth

* styling

* show gallery

* fix test

* fix test

* show previous and next title

* fix test

* show up down scrolling button

* more styling

* styling

* fix app bar

* fix height of next/previous

* autoplay

* auto play

* refactor

* refactor

* refactor

* show date

* Navigate

* finish

* pr feedback
This commit is contained in:
Alex 2023-06-14 20:47:18 -05:00 committed by GitHub
parent 408fa45c51
commit 43ec0b77a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 750 additions and 10 deletions

View File

@ -63,6 +63,7 @@ doc/LoginCredentialDto.md
doc/LoginResponseDto.md
doc/LogoutResponseDto.md
doc/MapMarkerResponseDto.md
doc/MemoryLaneResponseDto.md
doc/OAuthApi.md
doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
@ -194,6 +195,7 @@ lib/model/login_credential_dto.dart
lib/model/login_response_dto.dart
lib/model/logout_response_dto.dart
lib/model/map_marker_response_dto.dart
lib/model/memory_lane_response_dto.dart
lib/model/o_auth_callback_dto.dart
lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_dto.dart
@ -298,6 +300,7 @@ test/login_credential_dto_test.dart
test/login_response_dto_test.dart
test/logout_response_dto_test.dart
test/map_marker_response_dto_test.dart
test/memory_lane_response_dto_test.dart
test/o_auth_api_test.dart
test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1534,6 +1534,50 @@
]
}
},
"/asset/memory-lane": {
"get": {
"operationId": "getMemoryLane",
"parameters": [
{
"name": "timezone",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MemoryLaneResponseDto"
}
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/asset/search": {
"post": {
"operationId": "searchAsset",
@ -5709,6 +5753,24 @@
"lon"
]
},
"MemoryLaneResponseDto": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"assets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
}
}
},
"required": [
"title",
"assets"
]
},
"OAuthCallbackDto": {
"type": "object",
"properties": {

View File

@ -42,6 +42,7 @@ export enum WithProperty {
export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
getByDate(ownerId: string, date: Date): Promise<AssetEntity[]>;
getByIds(ids: string[]): Promise<AssetEntity[]>;
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity>;

View File

@ -2,7 +2,9 @@ import { Inject } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { IAssetRepository } from './asset.repository';
import { MapMarkerDto } from './dto/map-marker.dto';
import { MapMarkerResponseDto } from './response-dto';
import { MapMarkerResponseDto, mapAsset } from './response-dto';
import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto';
import { DateTime } from 'luxon';
export class AssetService {
constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
@ -10,4 +12,31 @@ export class AssetService {
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.assetRepository.getMapMarkers(authUser.id, options);
}
async getMemoryLane(authUser: AuthUserDto, timezone: string): Promise<MemoryLaneResponseDto[]> {
const result: MemoryLaneResponseDto[] = [];
const luxonDate = DateTime.fromISO(new Date().toISOString(), { zone: timezone });
const today = new Date(luxonDate.year, luxonDate.month - 1, luxonDate.day);
const years = Array.from({ length: 30 }, (_, i) => {
const year = today.getFullYear() - i - 1;
return new Date(year, today.getMonth(), today.getDate());
});
for (const year of years) {
const assets = await this.assetRepository.getByDate(authUser.id, year);
if (assets.length > 0) {
const yearGap = today.getFullYear() - year.getFullYear();
const memory = new MemoryLaneResponseDto();
memory.title = `${yearGap} year${yearGap > 1 && 's'} since...`;
memory.assets = assets.map((a) => mapAsset(a));
result.push(memory);
}
}
return result;
}
}

View File

@ -0,0 +1,7 @@
import { AssetResponseDto } from './asset-response.dto';
export class MemoryLaneResponseDto {
title!: string;
assets!: AssetResponseDto[];
}

View File

@ -5,6 +5,7 @@ import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto';
@ApiTags('Asset')
@Controller('asset')
@ -17,4 +18,12 @@ export class AssetController {
getMapMarkers(@GetAuthUser() authUser: AuthUserDto, @Query() options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.service.getMapMarkers(authUser, options);
}
@Get('memory-lane')
getMemoryLane(
@GetAuthUser() authUser: AuthUserDto,
@Query('timezone') timezone: string,
): Promise<MemoryLaneResponseDto[]> {
return this.service.getMemoryLane(authUser, timezone);
}
}

View File

@ -20,6 +20,40 @@ import { paginate } from '../utils/pagination.util';
export class AssetRepository implements IAssetRepository {
constructor(@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>) {}
getByDate(ownerId: string, date: Date): Promise<AssetEntity[]> {
// For reference of a correct approach althought slower
// let builder = this.repository
// .createQueryBuilder('asset')
// .leftJoin('asset.exifInfo', 'exifInfo')
// .where('asset.ownerId = :ownerId', { ownerId })
// .andWhere(
// `coalesce(date_trunc('day', asset."fileCreatedAt", "exifInfo"."timeZone") at TIME ZONE "exifInfo"."timeZone", date_trunc('day', asset."fileCreatedAt")) IN (:date)`,
// { date },
// )
// .andWhere('asset.isVisible = true')
// .andWhere('asset.isArchived = false')
// .orderBy('asset.fileCreatedAt', 'DESC');
// return builder.getMany();
const tomorrow = new Date(date.getTime() + 24 * 60 * 60 * 1000);
return this.repository.find({
where: {
ownerId,
isVisible: true,
isArchived: false,
fileCreatedAt: OptionalBetween(date, tomorrow),
},
relations: {
exifInfo: true,
},
order: {
fileCreatedAt: 'DESC',
},
});
}
getByIds(ids: string[]): Promise<AssetEntity[]> {
return this.repository.find({
where: { id: In(ids) },

View File

@ -2,6 +2,7 @@ import { IAssetRepository } from '@app/domain';
export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
return {
getByDate: jest.fn(),
getByIds: jest.fn().mockResolvedValue([]),
getWithout: jest.fn(),
getWith: jest.fn(),

View File

@ -1657,6 +1657,25 @@ export interface MapMarkerResponseDto {
*/
'lon': number;
}
/**
*
* @export
* @interface MemoryLaneResponseDto
*/
export interface MemoryLaneResponseDto {
/**
*
* @type {string}
* @memberof MemoryLaneResponseDto
*/
'title': string;
/**
*
* @type {Array<AssetResponseDto>}
* @memberof MemoryLaneResponseDto
*/
'assets': Array<AssetResponseDto>;
}
/**
*
* @export
@ -5493,6 +5512,51 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} timezone
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMemoryLane: async (timezone: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'timezone' is not null or undefined
assertParamExists('getMemoryLane', 'timezone', timezone)
const localVarPath = `/asset/memory-lane`;
// 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: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (timezone !== undefined) {
localVarQueryParameter['timezone'] = timezone;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -6091,6 +6155,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} timezone
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getMemoryLane(timezone: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MemoryLaneResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timezone, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
* Get all asset of a device that are in the database, ID only.
* @param {string} deviceId
@ -6370,6 +6444,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
getMapMarkers(isFavorite?: boolean, fileCreatedAfter?: string, fileCreatedBefore?: string, options?: any): AxiosPromise<Array<MapMarkerResponseDto>> {
return localVarFp.getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} timezone
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMemoryLane(timezone: string, options?: any): AxiosPromise<Array<MemoryLaneResponseDto>> {
return localVarFp.getMemoryLane(timezone, options).then((request) => request(axios, basePath));
},
/**
* Get all asset of a device that are in the database, ID only.
* @param {string} deviceId
@ -6767,6 +6850,20 @@ export interface AssetApiGetMapMarkersRequest {
readonly fileCreatedBefore?: string
}
/**
* Request parameters for getMemoryLane operation in AssetApi.
* @export
* @interface AssetApiGetMemoryLaneRequest
*/
export interface AssetApiGetMemoryLaneRequest {
/**
*
* @type {string}
* @memberof AssetApiGetMemoryLane
*/
readonly timezone: string
}
/**
* Request parameters for getUserAssetsByDeviceId operation in AssetApi.
* @export
@ -7199,6 +7296,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).getMapMarkers(requestParameters.isFavorite, requestParameters.fileCreatedAfter, requestParameters.fileCreatedBefore, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiGetMemoryLaneRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timezone, options).then((request) => request(this.axios, this.basePath));
}
/**
* Get all asset of a device that are in the database, ID only.
* @param {AssetApiGetUserAssetsByDeviceIdRequest} requestParameters Request parameters.

View File

@ -86,7 +86,7 @@
afterNavigate(({ from }) => {
backUrl = from?.url.pathname ?? '/albums';
if (from?.url.pathname === '/sharing') {
if (from?.url.pathname === '/sharing' && album.sharedUsers.length === 0) {
isCreatingSharedAlbum = true;
}
});

View File

@ -7,16 +7,17 @@
export let size = '24';
export let title = '';
export let isOpacity = false;
export let forceDark = false;
</script>
<button
{title}
style:background-color={backgroundColor}
style:--immich-icon-button-hover-color={hoverColor}
class="dark:text-immich-dark-fg rounded-full p-3 flex place-items-center place-content-center transition-all
{isOpacity
? 'hover:bg-immich-bg/30'
: 'immich-circle-icon-button hover:dark:text-immich-dark-gray'}"
class:dark:text-immich-dark-fg={!forceDark}
class="rounded-full p-3 flex place-items-center place-content-center transition-all
{isOpacity ? 'hover:bg-immich-bg/30' : 'immich-circle-icon-button hover:dark:text-immich-dark-gray'}
{forceDark && 'hover:text-black'}"
on:click
>
<svelte:component this={logo} {size} />

View File

@ -0,0 +1,354 @@
<script lang="ts">
import { memoryStore } from '$lib/stores/memory.store';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { MemoryLaneResponseDto, api } from '@api';
import { goto } from '$app/navigation';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import Play from 'svelte-material-icons/Play.svelte';
import Pause from 'svelte-material-icons/Pause.svelte';
import ChevronDown from 'svelte-material-icons/ChevronDown.svelte';
import ChevronUp from 'svelte-material-icons/ChevronUp.svelte';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import { AppRoute } from '$lib/constants';
import { page } from '$app/stores';
import noThumbnailUrl from '$lib/assets/no-thumbnail.png';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import { fade } from 'svelte/transition';
let currentIndex = 0;
let currentMemory: MemoryLaneResponseDto;
let nextMemory: MemoryLaneResponseDto;
let lastMemory: MemoryLaneResponseDto;
let lastIndex = 0;
let nextIndex = 0;
$: showNextMemory = nextIndex <= $memoryStore?.length - 1;
$: showPreviousMemory = currentIndex != 0;
let memoryGallery: HTMLElement;
let memoryWrapper: HTMLElement;
let galleryInView = false;
onMount(async () => {
if (!$memoryStore) {
const timezone = DateTime.local().zoneName;
const { data } = await api.assetApi.getMemoryLane({ timezone });
$memoryStore = data;
}
const queryIndex = $page.url.searchParams.get('index');
if (queryIndex != null) {
currentIndex = parseInt(queryIndex);
if (isNaN(currentIndex) || currentIndex > $memoryStore.length - 1) {
currentIndex = 0;
}
}
currentMemory = $memoryStore[currentIndex];
nextIndex = currentIndex + 1;
nextMemory = $memoryStore[nextIndex];
if (currentIndex > 0) {
lastMemory = $memoryStore[lastIndex];
}
});
const toNextMemory = (): boolean => {
if (showNextMemory) {
resetAutoPlay();
currentIndex++;
nextIndex = currentIndex + 1;
lastIndex = currentIndex - 1;
currentMemory = $memoryStore[currentIndex];
nextMemory = $memoryStore[nextIndex];
lastMemory = $memoryStore[lastIndex];
return true;
}
return false;
};
const toPreviousMemory = () => {
if (showPreviousMemory) {
resetAutoPlay();
currentIndex--;
nextIndex = currentIndex + 1;
lastIndex = currentIndex - 1;
currentMemory = $memoryStore[currentIndex];
nextMemory = $memoryStore[nextIndex];
lastMemory = $memoryStore[lastIndex];
}
};
let autoPlayInterval: NodeJS.Timeout;
let autoPlay = false;
let autoPlaySpeed = 5000;
let autoPlayProgress = 0;
let autoPlayIndex = 0;
let canPlayNext = true;
const toggleAutoPlay = () => {
autoPlay = !autoPlay;
if (autoPlay) {
autoPlayInterval = setInterval(() => {
if (!canPlayNext) return;
window.requestAnimationFrame(() => {
autoPlayProgress++;
});
if (autoPlayProgress > 100) {
autoPlayProgress = 0;
canPlayNext = false;
autoPlayTransition();
}
}, autoPlaySpeed / 100);
} else {
clearInterval(autoPlayInterval);
}
};
const autoPlayTransition = () => {
if (autoPlayIndex < currentMemory.assets.length - 1) {
autoPlayIndex++;
} else {
const canAdvance = toNextMemory();
if (!canAdvance) {
autoPlay = false;
clearInterval(autoPlayInterval);
return;
}
}
// Delay for nicer animation of the progress bar
setTimeout(() => {
canPlayNext = true;
}, 250);
};
const resetAutoPlay = () => {
autoPlayIndex = 0;
autoPlayProgress = 0;
};
const toNextCurrentAsset = () => {
autoPlayIndex++;
if (autoPlayIndex > currentMemory.assets.length - 1) {
toNextMemory();
}
};
const toPreviousCurrentAsset = () => {
autoPlayIndex--;
if (autoPlayIndex < 0) {
toPreviousMemory();
}
};
</script>
<section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}>
{#if currentMemory}
<ControlAppBar on:close-button-click={() => goto(AppRoute.PHOTOS)} forceDark>
<svelte:fragment slot="leading">
<p class="text-lg">
{currentMemory.title}
</p>
</svelte:fragment>
{#if !galleryInView}
<div class="flex place-items-center place-content-center overflow-hidden gap-2">
<CircleIconButton logo={autoPlay ? Pause : Play} forceDark on:click={toggleAutoPlay} />
<div class="relative w-full">
<span class="absolute left-0 w-full h-[2px] bg-gray-500" />
<span
class="absolute left-0 h-[2px] bg-white transition-all"
style:width={`${autoPlayProgress}%`}
/>
</div>
<div>
<p class="text-small">
{autoPlayIndex + 1}/{currentMemory.assets.length}
</p>
</div>
</div>
{/if}
</ControlAppBar>
{#if galleryInView}
<div
class="sticky top-20 flex place-content-center place-items-center z-30 transition-opacity"
class:opacity-0={!galleryInView}
class:opacity-100={galleryInView}
>
<button
on:click={() => memoryWrapper.scrollIntoView({ behavior: 'smooth' })}
disabled={!galleryInView}
>
<CircleIconButton logo={ChevronUp} backgroundColor="white" forceDark />
</button>
</div>
{/if}
<!-- Viewer -->
<section class="pt-20 overflow-hidden">
<div
class="flex w-[300%] h-[calc(100vh_-_180px)] items-center justify-center box-border ml-[-100%] gap-10 overflow-hidden"
>
<!-- PREVIOUS MEMORY -->
<div
class="rounded-2xl w-[20vw] h-1/2"
class:opacity-25={showPreviousMemory}
class:opacity-0={!showPreviousMemory}
class:hover:opacity-70={showPreviousMemory}
>
<button
class="rounded-2xl h-full w-full relative"
disabled={!showPreviousMemory}
on:click={toPreviousMemory}
>
<img
class="rounded-2xl h-full w-full object-cover"
src={showPreviousMemory && lastMemory
? api.getAssetThumbnailUrl(lastMemory.assets[0].id, 'JPEG')
: noThumbnailUrl}
alt=""
draggable="false"
/>
{#if showPreviousMemory}
<div class="absolute right-4 bottom-4 text-white text-left">
<p class="font-semibold text-xs text-gray-200">PREVIOUS</p>
<p class="text-xl">{lastMemory.title}</p>
</div>
{/if}
</button>
</div>
<!-- CURRENT MEMORY -->
<div
class="main-view rounded-2xl h-full relative w-[70vw] bg-black flex place-items-center place-content-center"
>
<div class="bg-black w-full h-full rounded-2xl">
<!-- CONTROL BUTTONS -->
<div class="absolute h-full flex justify-between w-full">
<div class="flex h-full flex-col place-content-center place-items-center ml-4">
<div class="inline-block">
<CircleIconButton
logo={ChevronLeft}
backgroundColor="#202123"
on:click={toPreviousCurrentAsset}
/>
</div>
</div>
<div class="flex h-full flex-col place-content-center place-items-center mr-4">
<div class="inline-block">
<CircleIconButton
logo={ChevronRight}
backgroundColor="#202123"
on:click={toNextCurrentAsset}
/>
</div>
</div>
</div>
{#key currentMemory.assets[autoPlayIndex].id}
<img
transition:fade|local
class="rounded-2xl w-full h-full object-contain transition-all"
src={api.getAssetThumbnailUrl(currentMemory.assets[autoPlayIndex].id, 'JPEG')}
alt=""
draggable="false"
/>
{/key}
<div class="absolute top-4 left-8 text-white text-sm font-medium">
<p>
{DateTime.fromISO(currentMemory.assets[0].fileCreatedAt).toLocaleString(
DateTime.DATE_FULL
)}
</p>
<p>
{currentMemory.assets[autoPlayIndex].exifInfo?.city || ''}
{currentMemory.assets[autoPlayIndex].exifInfo?.country || ''}
</p>
</div>
</div>
</div>
<!-- NEXT MEMORY -->
<div
class="rounded-xl w-[20vw] h-1/2"
class:opacity-25={showNextMemory}
class:opacity-0={!showNextMemory}
class:hover:opacity-70={showNextMemory}
>
<button
class="rounded-2xl h-full w-full relative"
on:click={toNextMemory}
disabled={!showNextMemory}
>
<img
class="rounded-2xl h-full w-full object-cover"
src={showNextMemory
? api.getAssetThumbnailUrl(nextMemory.assets[0].id, 'JPEG')
: noThumbnailUrl}
alt=""
draggable="false"
/>
{#if showNextMemory}
<div class="absolute left-4 bottom-4 text-white text-left">
<p class="font-semibold text-xs text-gray-200">UP NEXT</p>
<p class="text-xl">{nextMemory.title}</p>
</div>
{/if}
</button>
</div>
</div>
</section>
<!-- GALERY VIEWER -->
<section class="bg-immich-dark-gray pl-4">
<div
class="sticky flex place-content-center place-items-center mb-10 mt-4 transition-all"
class:opacity-0={galleryInView}
class:opacity-100={!galleryInView}
>
<button on:click={() => memoryGallery.scrollIntoView({ behavior: 'smooth' })}>
<CircleIconButton logo={ChevronDown} backgroundColor="white" forceDark />
</button>
</div>
<IntersectionObserver
once={false}
on:intersected={() => (galleryInView = true)}
on:hidden={() => (galleryInView = false)}
bottom={-200}
>
<div id="gallery-memory" bind:this={memoryGallery}>
<GalleryViewer assets={currentMemory.assets} viewFrom="album-page" />
</div>
</IntersectionObserver>
</section>
{/if}
</section>
<style>
.main-view {
box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.3), 0 8px 12px 6px rgba(0, 0, 0, 0.15);
}
</style>

View File

@ -17,6 +17,7 @@
} from '../shared-components/scrollbar/scrollbar.svelte';
import AssetDateGroup from './asset-date-group.svelte';
import { BucketPosition } from '$lib/models/asset-grid-state';
import MemoryLane from './memory-lane.svelte';
export let user: UserResponseDto | undefined = undefined;
export let isAlbumSelectionMode = false;
@ -130,6 +131,7 @@
on:scroll={handleTimelineScroll}
>
{#if assetGridElement}
<MemoryLane />
<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
{#each $assetGridState.buckets as bucket, bucketIndex (bucketIndex)}
<IntersectionObserver

View File

@ -0,0 +1,94 @@
<script lang="ts">
import { onMount } from 'svelte';
import { DateTime } from 'luxon';
import { MemoryLaneResponseDto, api } from '@api';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import { memoryStore } from '$lib/stores/memory.store';
import { goto } from '$app/navigation';
let memoryLane: MemoryLaneResponseDto[] = [];
$: shouldRender = memoryLane.length > 0;
onMount(async () => {
const timezone = DateTime.local().zoneName;
const { data } = await api.assetApi.getMemoryLane({ timezone });
memoryLane = data;
$memoryStore = data;
});
let memoryLaneElement: HTMLElement;
let offsetWidth = 0;
let innerWidth = 0;
$: isOverflow = offsetWidth < innerWidth;
function scrollLeft() {
memoryLaneElement.scrollTo({
left: memoryLaneElement.scrollLeft - 400,
behavior: 'smooth'
});
}
function scrollRight() {
memoryLaneElement.scrollTo({
left: memoryLaneElement.scrollLeft + 400,
behavior: 'smooth'
});
}
</script>
{#if shouldRender}
<section
id="memory-lane"
bind:this={memoryLaneElement}
class="relative overflow-x-hidden whitespace-nowrap mt-5 transition-all"
bind:offsetWidth
>
{#if isOverflow}
<div class="sticky left-0 z-20">
<div class="absolute right-0 top-[6rem] z-20">
<button
class="rounded-full opacity-50 hover:opacity-100 p-2 border border-gray-500 bg-gray-100 text-gray-500"
on:click={scrollRight}
>
<ChevronRight size="36" /></button
>
</div>
<div class="absolute left-0 top-[6rem] z-20">
<button
class="rounded-full opacity-50 hover:opacity-100 p-2 border border-gray-500 bg-gray-100 text-gray-500"
on:click={scrollLeft}><ChevronLeft size="36" /></button
>
</div>
</div>
{/if}
<div class="inline-block" bind:offsetWidth={innerWidth}>
{#each memoryLane as memory, i (memory.title)}
<button
class="memory-card relative inline-block mr-8 rounded-xl aspect-video h-[215px]"
on:click={() => goto(`/memory?index=${i}`)}
>
<img
class="rounded-xl h-full w-full object-cover"
src={api.getAssetThumbnailUrl(memory.assets[0].id, 'JPEG')}
alt={memory.title}
draggable="false"
/>
<p class="absolute bottom-2 left-4 text-lg text-white z-10">{memory.title}</p>
<div
class="absolute top-0 left-0 w-full h-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent z-0 hover:bg-black/20 transition-all"
/>
</button>
{/each}
</div>
</section>
{/if}
<style>
.memory-card {
box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px;
}
</style>

View File

@ -9,6 +9,7 @@
export let showBackButton = true;
export let backIcon = Close;
export let tailwindClasses = '';
export let forceDark = false;
let appBarBorder = 'bg-immich-bg border border-transparent';
@ -17,6 +18,10 @@
const onScroll = () => {
if (window.pageYOffset > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
if (forceDark) {
appBarBorder = 'border border-gray-600';
}
} else {
appBarBorder = 'bg-immich-bg border border-transparent';
}
@ -38,9 +43,11 @@
<div in:fly={{ y: 10, duration: 200 }} class="fixed top-0 w-full bg-transparent z-[100]">
<div
id="asset-selection-app-bar"
class={`flex justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray`}
class={`grid grid-cols-3 justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray ${
forceDark && 'bg-immich-dark-gray text-white'
}`}
>
<div class="flex place-items-center gap-6 dark:text-immich-dark-fg">
<div class="flex place-items-center gap-6 dark:text-immich-dark-fg justify-self-start">
{#if showBackButton}
<CircleIconButton
on:click={() => dispatch('close-button-click')}
@ -48,14 +55,17 @@
backgroundColor={'transparent'}
hoverColor={'#e2e7e9'}
size={'24'}
forceDark
/>
{/if}
<slot name="leading" />
</div>
<slot />
<div class="w-full">
<slot />
</div>
<div class="flex place-items-center gap-1 mr-4">
<div class="flex place-items-center gap-1 mr-4 justify-self-end">
<slot name="trailing" />
</div>
</div>

View File

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
import type { MemoryLaneResponseDto } from '../../api/open-api';
export const memoryStore = writable<MemoryLaneResponseDto[]>();

View File

@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { AppRoute } from '$lib/constants';
export const load = (async ({ locals: { user } }) => {
if (!user) {
throw redirect(302, AppRoute.AUTH_LOGIN);
}
return {
user,
meta: {
title: 'Memory'
}
};
}) satisfies PageServerLoad;

View File

@ -0,0 +1,5 @@
<script>
import MemoryViewer from '$lib/components/memory-page/memory-viewer.svelte';
</script>
<MemoryViewer />