Implement album creation on web (#365)
* Added album creation button functionality * Added input for album title * Added select photos button * Added page to select assets * Show photo selection timeline * Implemented update album name mechanism: * Added selection mechanism * Added selection mechanism with existing assets in album * Refactored and added comments * Refactored and added comments - 2 * Refactor album app bar * Added modal for select user * Implemented choose users * Added additional share user button * Added rule to show add users button
This commit is contained in:
parent
02bde51caf
commit
1d34976dd0
14 changed files with 787 additions and 154 deletions
|
@ -18,8 +18,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
immichJwtService.extractJwtFromHeader,
|
|
||||||
immichJwtService.extractJwtFromCookie,
|
immichJwtService.extractJwtFromCookie,
|
||||||
|
immichJwtService.extractJwtFromHeader,
|
||||||
]),
|
]),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: jwtSecret,
|
secretOrKey: jwtSecret,
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
/* min-height: 100vh; */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: #f6f8fe;
|
background-color: #f6f8fe;
|
||||||
color: #5f6368;
|
color: #5f6368;
|
||||||
|
@ -18,14 +18,41 @@ body {
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.immich-form-input {
|
.immich-form-input {
|
||||||
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm
|
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.immich-form-label {
|
.immich-form-label {
|
||||||
@apply font-medium text-sm text-gray-500
|
@apply font-medium text-sm text-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.immich-btn-primary {
|
.immich-btn-primary {
|
||||||
@apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium
|
@apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.immich-text-button {
|
||||||
|
@apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* width */
|
||||||
|
.immich-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track */
|
||||||
|
.immich-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle */
|
||||||
|
.immich-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(85, 86, 87, 0.408);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle on hover */
|
||||||
|
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4250afad;
|
||||||
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
39
web/src/lib/components/album-page/album-app-bar.svelte
Normal file
39
web/src/lib/components/album-page/album-app-bar.svelte
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
|
import Close from 'svelte-material-icons/Close.svelte';
|
||||||
|
|
||||||
|
export let backIcon = Close;
|
||||||
|
let appBarBorder = '';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
onMount(() => {
|
||||||
|
window.onscroll = () => {
|
||||||
|
if (window.pageYOffset > 80) {
|
||||||
|
appBarBorder = 'border border-gray-200 bg-gray-50';
|
||||||
|
} else {
|
||||||
|
appBarBorder = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div 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`}
|
||||||
|
>
|
||||||
|
<div class="flex place-items-center gap-6">
|
||||||
|
<button
|
||||||
|
on:click={() => dispatch('close-button-click')}
|
||||||
|
id="immich-circle-icon-button"
|
||||||
|
class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`}
|
||||||
|
>
|
||||||
|
<svelte:component this={backIcon} size="24" />
|
||||||
|
</button>
|
||||||
|
<slot name="leading" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex place-items-center gap-6 mr-4">
|
||||||
|
<slot name="trailing" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,18 +1,30 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { AlbumResponseDto, AssetResponseDto, ThumbnailFormat } from '@api';
|
import { AlbumResponseDto, api, AssetResponseDto, ThumbnailFormat, UserResponseDto } from '@api';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
|
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
|
||||||
|
import Plus from 'svelte-material-icons/Plus.svelte';
|
||||||
import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
|
import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
|
||||||
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
||||||
import CircleAvatar from '../shared-components/circle-avatar.svelte';
|
import CircleAvatar from '../shared-components/circle-avatar.svelte';
|
||||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
||||||
|
import AssetSelection from './asset-selection.svelte';
|
||||||
|
import _ from 'lodash-es';
|
||||||
|
import { assets } from '$app/paths';
|
||||||
|
import UserSelection from './user-selection-modal.svelte';
|
||||||
|
import AlbumAppBar from './album-app-bar.svelte';
|
||||||
|
import UserSelectionModal from './user-selection-modal.svelte';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
export let album: AlbumResponseDto;
|
export let album: AlbumResponseDto;
|
||||||
|
|
||||||
let isShowAssetViewer = false;
|
let isShowAssetViewer = false;
|
||||||
|
let isShowAssetSelection = false;
|
||||||
|
let isShowShareUserSelection = false;
|
||||||
|
let isEditingTitle = false;
|
||||||
|
let isCreatingSharedAlbum = false;
|
||||||
|
|
||||||
let selectedAsset: AssetResponseDto;
|
let selectedAsset: AssetResponseDto;
|
||||||
let currentViewAssetIndex = 0;
|
let currentViewAssetIndex = 0;
|
||||||
|
|
||||||
|
@ -20,10 +32,20 @@
|
||||||
let thumbnailSize: number = 300;
|
let thumbnailSize: number = 300;
|
||||||
let border = '';
|
let border = '';
|
||||||
let backUrl = '/albums';
|
let backUrl = '/albums';
|
||||||
|
let currentAlbumName = '';
|
||||||
|
let currentUser: UserResponseDto;
|
||||||
|
let bodyElement: HTMLElement;
|
||||||
|
|
||||||
afterNavigate(({ from, to }) => {
|
$: isOwned = currentUser?.id == album.ownerId;
|
||||||
|
|
||||||
|
afterNavigate(({ from }) => {
|
||||||
backUrl = from?.pathname ?? '/albums';
|
backUrl = from?.pathname ?? '/albums';
|
||||||
|
|
||||||
|
if (from?.pathname === '/sharing') {
|
||||||
|
isCreatingSharedAlbum = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (album.assets.length < 6) {
|
if (album.assets.length < 6) {
|
||||||
thumbnailSize = Math.floor(viewWidth / album.assets.length - album.assets.length);
|
thumbnailSize = Math.floor(viewWidth / album.assets.length - album.assets.length);
|
||||||
|
@ -36,20 +58,18 @@
|
||||||
const startDate = new Date(album.assets[0].createdAt);
|
const startDate = new Date(album.assets[0].createdAt);
|
||||||
const endDate = new Date(album.assets[album.assets.length - 1].createdAt);
|
const endDate = new Date(album.assets[album.assets.length - 1].createdAt);
|
||||||
|
|
||||||
const startDateString = startDate.toLocaleDateString('us-EN', {
|
const timeFormatOption: Intl.DateTimeFormatOptions = {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
});
|
};
|
||||||
const endDateString = endDate.toLocaleDateString('us-EN', {
|
|
||||||
month: 'short',
|
const startDateString = startDate.toLocaleDateString('us-EN', timeFormatOption);
|
||||||
day: 'numeric',
|
const endDateString = endDate.toLocaleDateString('us-EN', timeFormatOption);
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
return `${startDateString} - ${endDateString}`;
|
return `${startDateString} - ${endDateString}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
window.onscroll = (event: Event) => {
|
window.onscroll = (event: Event) => {
|
||||||
if (window.pageYOffset > 80) {
|
if (window.pageYOffset > 80) {
|
||||||
border = 'border border-gray-200 bg-gray-50';
|
border = 'border border-gray-200 bg-gray-50';
|
||||||
|
@ -57,6 +77,15 @@
|
||||||
border = '';
|
border = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
currentAlbumName = album.albumName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.userApi.getMyUserInfo();
|
||||||
|
currentUser = data;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error [getMyUserInfo - album-viewer] ', e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewAsset = (event: CustomEvent) => {
|
const viewAsset = (event: CustomEvent) => {
|
||||||
|
@ -102,48 +131,123 @@
|
||||||
isShowAssetViewer = false;
|
isShowAssetViewer = false;
|
||||||
history.pushState(null, '', `${$page.url.pathname}`);
|
history.pushState(null, '', `${$page.url.pathname}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Update Album Name
|
||||||
|
$: {
|
||||||
|
if (!isEditingTitle && currentAlbumName != album.albumName && isOwned) {
|
||||||
|
api.albumApi
|
||||||
|
.updateAlbumInfo(album.id, {
|
||||||
|
ownerId: album.ownerId,
|
||||||
|
albumName: album.albumName
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
currentAlbumName = album.albumName;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log('Error [updateAlbumInfo] ', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAlbumHandler = async (event: CustomEvent) => {
|
||||||
|
const { assets }: { assets: string[] } = event.detail;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.albumApi.addAssetsToAlbum(album.id, { assetIds: assets });
|
||||||
|
album = data;
|
||||||
|
|
||||||
|
isShowAssetSelection = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error [createAlbumHandler] ', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUserHandler = async (event: CustomEvent) => {
|
||||||
|
const { selectedUsers }: { selectedUsers: UserResponseDto[] } = event.detail;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.albumApi.addUsersToAlbum(album.id, {
|
||||||
|
sharedUserIds: Array.from(selectedUsers).map((u) => u.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
album = data;
|
||||||
|
|
||||||
|
isShowShareUserSelection = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error [createAlbumHandler] ', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent scrolling when modal is open
|
||||||
|
$: {
|
||||||
|
if (isShowShareUserSelection == true) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="w-screen h-screen bg-immich-bg">
|
<svelte:body bind:this={bodyElement} />
|
||||||
<div class="fixed top-0 w-full bg-immich-bg z-[100]">
|
<section class="bg-immich-bg relative">
|
||||||
<div class={`flex justify-between rounded-lg ${border} p-2 mx-2 mt-2 transition-all`}>
|
<AlbumAppBar on:close-button-click={() => goto(backUrl)} backIcon={ArrowLeft}>
|
||||||
<a sveltekit:prefetch href={backUrl} title="Go Back">
|
<svelte:fragment slot="trailing">
|
||||||
|
{#if album.assets.length > 0}
|
||||||
<button
|
<button
|
||||||
id="immich-circle-icon-button"
|
id="immich-circle-icon-button"
|
||||||
class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`}
|
class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`}
|
||||||
>
|
on:click={() => (isShowAssetSelection = true)}
|
||||||
<ArrowLeft size="24" />
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
<div class="right-button-group" title="Add Photos">
|
|
||||||
<button
|
|
||||||
id="immich-circle-icon-button"
|
|
||||||
class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`}
|
|
||||||
on:click={() => dispatch('click')}
|
|
||||||
>
|
>
|
||||||
<FileImagePlusOutline size="24" />
|
<FileImagePlusOutline size="24" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="m-6 py-[72px] px-[160px]">
|
{#if isCreatingSharedAlbum && album.sharedUsers.length == 0}
|
||||||
<p class="text-6xl text-immich-primary">
|
<button
|
||||||
{album.albumName}
|
disabled={album.assets.length == 0}
|
||||||
</p>
|
on:click={() => (isShowShareUserSelection = true)}
|
||||||
|
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">Share</span></button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</svelte:fragment>
|
||||||
|
</AlbumAppBar>
|
||||||
|
|
||||||
|
<section class="m-auto my-[160px] w-[60%]">
|
||||||
|
<input
|
||||||
|
on:focus={() => (isEditingTitle = true)}
|
||||||
|
on:blur={() => (isEditingTitle = false)}
|
||||||
|
class={`transition-all text-6xl text-immich-primary w-[99%] border-b-2 border-transparent outline-none ${
|
||||||
|
isOwned ? 'hover:border-gray-400' : 'hover:border-transparent'
|
||||||
|
} focus:outline-none focus:border-b-2 focus:border-immich-primary bg-immich-bg`}
|
||||||
|
type="text"
|
||||||
|
bind:value={album.albumName}
|
||||||
|
disabled={!isOwned}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if album.assets.length > 0}
|
||||||
<p class="my-4 text-sm text-gray-500">{getDateRange()}</p>
|
<p class="my-4 text-sm text-gray-500">{getDateRange()}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if album.sharedUsers.length > 0}
|
{#if album.shared}
|
||||||
<div class="mb-4">
|
<div class="my-4 flex">
|
||||||
{#each album.sharedUsers as user}
|
{#each album.sharedUsers as user}
|
||||||
<span class="mr-1">
|
<span class="mr-1">
|
||||||
<CircleAvatar {user} />
|
<CircleAvatar {user} />
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<button
|
||||||
|
style:display={isOwned ? 'block' : 'none'}
|
||||||
|
on:click={() => (isShowShareUserSelection = true)}
|
||||||
|
title="Add more users"
|
||||||
|
class="h-12 w-12 border bg-white transition-colors hover:bg-gray-300 text-3xl flex place-items-center place-content-center rounded-full"
|
||||||
|
>+</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if album.assets.length > 0}
|
||||||
<div class="flex flex-wrap gap-1 w-full" bind:clientWidth={viewWidth}>
|
<div class="flex flex-wrap gap-1 w-full" bind:clientWidth={viewWidth}>
|
||||||
{#each album.assets as asset}
|
{#each album.assets as asset}
|
||||||
{#if album.assets.length < 7}
|
{#if album.assets.length < 7}
|
||||||
|
@ -151,13 +255,28 @@
|
||||||
{asset}
|
{asset}
|
||||||
{thumbnailSize}
|
{thumbnailSize}
|
||||||
format={ThumbnailFormat.Jpeg}
|
format={ThumbnailFormat.Jpeg}
|
||||||
on:viewAsset={viewAsset}
|
on:click={viewAsset}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<ImmichThumbnail {asset} {thumbnailSize} on:viewAsset={viewAsset} />
|
<ImmichThumbnail {asset} {thumbnailSize} on:click={viewAsset} />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Album is empty - Show asset selectection buttons -->
|
||||||
|
<section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">
|
||||||
|
<div class="w-[300px]">
|
||||||
|
<p class="text-xs">ADD PHOTOS</p>
|
||||||
|
<button
|
||||||
|
on:click={() => (isShowAssetSelection = true)}
|
||||||
|
class="w-full py-8 border bg-white rounded-md mt-5 flex place-items-center gap-6 px-8 transition-all hover:bg-gray-100 hover:text-immich-primary"
|
||||||
|
>
|
||||||
|
<span><Plus color="#4250af" size="24" /> </span>
|
||||||
|
<span class="text-lg text-immich-fg">Select photos</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@ -170,3 +289,19 @@
|
||||||
on:close={closeViewer}
|
on:close={closeViewer}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isShowAssetSelection}
|
||||||
|
<AssetSelection
|
||||||
|
assetsInAlbum={album.assets}
|
||||||
|
on:go-back={() => (isShowAssetSelection = false)}
|
||||||
|
on:create-album={createAlbumHandler}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isShowShareUserSelection}
|
||||||
|
<UserSelectionModal
|
||||||
|
on:close={() => (isShowShareUserSelection = false)}
|
||||||
|
on:add-user={addUserHandler}
|
||||||
|
sharedUsersInAlbum={new Set(album.sharedUsers)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
197
web/src/lib/components/album-page/asset-selection.svelte
Normal file
197
web/src/lib/components/album-page/asset-selection.svelte
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
<script lang="ts">
|
||||||
|
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 AlbumAppBar from './album-app-bar.svelte';
|
||||||
|
|
||||||
|
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>> = {};
|
||||||
|
|
||||||
|
onMount(() => scanForExistingSelectedGroup());
|
||||||
|
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
transition:fly={{ y: 1000, duration: 200, easing: quintOut }}
|
||||||
|
class="absolute top-0 left-0 w-full h-full bg-immich-bg z-[200]"
|
||||||
|
>
|
||||||
|
<AlbumAppBar on:close-button-click={() => dispatch('go-back')}>
|
||||||
|
<svelte:fragment slot="leading">
|
||||||
|
{#if selectedAsset.size == 0}
|
||||||
|
<p class="text-lg">Add to album</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-lg">{selectedAsset.size} selected</p>
|
||||||
|
{/if}
|
||||||
|
</svelte:fragment>
|
||||||
|
|
||||||
|
<svelte:fragment slot="trailing">
|
||||||
|
<button
|
||||||
|
disabled={selectedAsset.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>
|
||||||
|
</AlbumAppBar>
|
||||||
|
|
||||||
|
<section id="image-grid" class="flex flex-wrap gap-14 mt-[160px] px-20">
|
||||||
|
{#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>
|
||||||
|
</section>
|
117
web/src/lib/components/album-page/user-selection-modal.svelte
Normal file
117
web/src/lib/components/album-page/user-selection-modal.svelte
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
|
import { api, UserResponseDto } from '@api';
|
||||||
|
import BaseModal from '../shared-components/base-modal.svelte';
|
||||||
|
import CircleAvatar from '../shared-components/circle-avatar.svelte';
|
||||||
|
|
||||||
|
export let sharedUsersInAlbum: Set<UserResponseDto>;
|
||||||
|
let users: UserResponseDto[] = [];
|
||||||
|
let selectedUsers: Set<UserResponseDto> = new Set();
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const { data } = await api.userApi.getAllUsers(false);
|
||||||
|
|
||||||
|
users = data;
|
||||||
|
|
||||||
|
// Remove the existed shared users from the album
|
||||||
|
sharedUsersInAlbum.forEach((sharedUser) => {
|
||||||
|
users = users.filter((user) => user.id !== sharedUser.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectUser = (user: UserResponseDto) => {
|
||||||
|
const tempSelectedUsers = new Set(selectedUsers);
|
||||||
|
|
||||||
|
if (selectedUsers.has(user)) {
|
||||||
|
tempSelectedUsers.delete(user);
|
||||||
|
} else {
|
||||||
|
tempSelectedUsers.add(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedUsers = tempSelectedUsers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deselectUser = (user: UserResponseDto) => {
|
||||||
|
const tempSelectedUsers = new Set(selectedUsers);
|
||||||
|
|
||||||
|
tempSelectedUsers.delete(user);
|
||||||
|
|
||||||
|
selectedUsers = tempSelectedUsers;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BaseModal on:close={() => dispatch('close')}>
|
||||||
|
<svelte:fragment slot="title">
|
||||||
|
<span class="flex gap-2 place-items-center">
|
||||||
|
<img src="/immich-logo.svg" width="24" alt="Immich" />
|
||||||
|
<p class="font-medium text-immich-fg">Invite to album</p>
|
||||||
|
</span>
|
||||||
|
</svelte:fragment>
|
||||||
|
|
||||||
|
<div class="max-h-[400px] overflow-y-auto immich-scrollbar">
|
||||||
|
{#if selectedUsers.size > 0}
|
||||||
|
<div class="flex gap-4 py-2 px-5 overflow-x-auto place-items-center mb-2">
|
||||||
|
<p class="font-medium">To</p>
|
||||||
|
|
||||||
|
{#each Array.from(selectedUsers) as user}
|
||||||
|
<button
|
||||||
|
on:click={() => deselectUser(user)}
|
||||||
|
class="flex gap-1 place-items-center border border-gray-400 rounded-full p-1 hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<CircleAvatar size={28} {user} />
|
||||||
|
<p class="text-xs font-medium">{user.firstName} {user.lastName}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if users.length > 0}
|
||||||
|
<p class="text-xs font-medium px-5">SUGGESTIONS</p>
|
||||||
|
|
||||||
|
<div class="my-4">
|
||||||
|
{#each users as user}
|
||||||
|
<button
|
||||||
|
on:click={() => selectUser(user)}
|
||||||
|
class="w-full flex place-items-center gap-4 py-4 px-5 hover:bg-gray-200 transition-all"
|
||||||
|
>
|
||||||
|
{#if selectedUsers.has(user)}
|
||||||
|
<span
|
||||||
|
class="bg-immich-primary text-white rounded-full w-12 h-12 border flex place-items-center place-content-center text-3xl"
|
||||||
|
>✓</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<CircleAvatar {user} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="text-left">
|
||||||
|
<p class="text-immich-fg">
|
||||||
|
{user.firstName}
|
||||||
|
{user.lastName}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs ">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm px-5">
|
||||||
|
Looks like you have shared this album with all users or you don't have any user to share
|
||||||
|
with.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedUsers.size > 0}
|
||||||
|
<div class="flex place-content-end p-5 ">
|
||||||
|
<button
|
||||||
|
on:click={() => dispatch('add-user', { selectedUsers })}
|
||||||
|
class="text-white bg-immich-primary px-4 py-2 rounded-lg text-sm font-bold transition-colors hover:bg-immich-primary/75"
|
||||||
|
>Add</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</BaseModal>
|
31
web/src/lib/components/shared-components/base-modal.svelte
Normal file
31
web/src/lib/components/shared-components/base-modal.svelte
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
import { quintOut } from 'svelte/easing';
|
||||||
|
import Close from 'svelte-material-icons/Close.svelte';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="immich-modal"
|
||||||
|
transition:fly={{ y: 1000, duration: 200, easing: quintOut }}
|
||||||
|
class="absolute top-0 w-screen h-screen z-[9999] bg-black/50 flex place-items-center place-content-center"
|
||||||
|
>
|
||||||
|
<div class="bg-white w-[450px] min-h-[200px] max-h-[500px] rounded-lg shadow-md">
|
||||||
|
<div class="flex justify-between place-items-center p-5">
|
||||||
|
<div>
|
||||||
|
<slot name="title">
|
||||||
|
<p>Modal Title</p>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<button on:click={() => dispatch('close')}>
|
||||||
|
<Close size="24" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,9 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api, UserResponseDto } from '@api';
|
import { api, UserResponseDto } from '@api';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
export let user: UserResponseDto;
|
||||||
|
|
||||||
|
// Avatar Size In Pixel
|
||||||
|
export let size: number = 48;
|
||||||
|
|
||||||
const getUserAvatar = async () => {
|
const getUserAvatar = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.userApi.getProfileImage(user.id, {
|
const { data } = await api.userApi.getProfileImage(user.id, {
|
||||||
|
@ -20,12 +22,18 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#await getUserAvatar()}
|
{#await getUserAvatar()}
|
||||||
<div class="w-12 h-12 rounded-full bg-immich-primary/25" />
|
<div
|
||||||
|
style:width={`${size}px`}
|
||||||
|
style:height={`${size}px`}
|
||||||
|
class={` rounded-full bg-immich-primary/25`}
|
||||||
|
/>
|
||||||
{:then data}
|
{:then data}
|
||||||
<img
|
<img
|
||||||
src={data}
|
src={data}
|
||||||
alt="profile-img"
|
alt="profile-img"
|
||||||
class="inline rounded-full w-12 h-12 object-cover border shadow-md"
|
style:width={`${size}px`}
|
||||||
|
style:height={`${size}px`}
|
||||||
|
class={`inline rounded-full object-cover border shadow-md`}
|
||||||
title={user.email}
|
title={user.email}
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
|
|
|
@ -15,6 +15,8 @@
|
||||||
export let groupIndex = 0;
|
export let groupIndex = 0;
|
||||||
export let thumbnailSize: number | undefined = undefined;
|
export let thumbnailSize: number | undefined = undefined;
|
||||||
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
||||||
|
export let selected: boolean = false;
|
||||||
|
export let isExisted: boolean = false;
|
||||||
|
|
||||||
let imageData: string;
|
let imageData: string;
|
||||||
let videoData: string;
|
let videoData: string;
|
||||||
|
@ -27,6 +29,7 @@
|
||||||
let isThumbnailVideoPlaying = false;
|
let isThumbnailVideoPlaying = false;
|
||||||
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
||||||
let videoProgress = '00:00';
|
let videoProgress = '00:00';
|
||||||
|
let videoAbortController: AbortController;
|
||||||
|
|
||||||
const loadImageData = async () => {
|
const loadImageData = async () => {
|
||||||
if ($session.user) {
|
if ($session.user) {
|
||||||
|
@ -42,8 +45,8 @@
|
||||||
|
|
||||||
const loadVideoData = async () => {
|
const loadVideoData = async () => {
|
||||||
isThumbnailVideoPlaying = false;
|
isThumbnailVideoPlaying = false;
|
||||||
|
videoAbortController = new AbortController();
|
||||||
|
|
||||||
if ($session.user) {
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.assetApi.serveFile(
|
const { data } = await api.assetApi.serveFile(
|
||||||
asset.deviceAssetId,
|
asset.deviceAssetId,
|
||||||
|
@ -51,7 +54,8 @@
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
responseType: 'blob'
|
responseType: 'blob',
|
||||||
|
signal: videoAbortController.signal
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -62,7 +66,6 @@
|
||||||
videoData = URL.createObjectURL(data);
|
videoData = URL.createObjectURL(data);
|
||||||
|
|
||||||
videoPlayerNode.src = videoData;
|
videoPlayerNode.src = videoData;
|
||||||
// videoPlayerNode.src = videoData + '#t=0,5';
|
|
||||||
|
|
||||||
videoPlayerNode.load();
|
videoPlayerNode.load();
|
||||||
|
|
||||||
|
@ -87,7 +90,6 @@
|
||||||
|
|
||||||
return videoData;
|
return videoData;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVideoDurationInString = (currentTime: number) => {
|
const getVideoDurationInString = (currentTime: number) => {
|
||||||
|
@ -137,6 +139,11 @@
|
||||||
|
|
||||||
const handleMouseLeaveThumbnail = () => {
|
const handleMouseLeaveThumbnail = () => {
|
||||||
mouseOver = false;
|
mouseOver = false;
|
||||||
|
|
||||||
|
// Stop XHR download of video
|
||||||
|
videoAbortController?.abort();
|
||||||
|
|
||||||
|
// Stop video playback
|
||||||
URL.revokeObjectURL(videoData);
|
URL.revokeObjectURL(videoData);
|
||||||
|
|
||||||
clearInterval(calculateVideoDurationIntervalHandler);
|
clearInterval(calculateVideoDurationIntervalHandler);
|
||||||
|
@ -144,28 +151,59 @@
|
||||||
isThumbnailVideoPlaying = false;
|
isThumbnailVideoPlaying = false;
|
||||||
videoProgress = '00:00';
|
videoProgress = '00:00';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$: getThumbnailBorderStyle = () => {
|
||||||
|
if (selected) {
|
||||||
|
return 'border-[20px] border-immich-primary/20';
|
||||||
|
} else if (isExisted) {
|
||||||
|
return 'border-[20px] border-gray-300';
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$: getOverlaySelectorIconStyle = () => {
|
||||||
|
if (selected || isExisted) {
|
||||||
|
return '';
|
||||||
|
} else {
|
||||||
|
return 'bg-gradient-to-b from-gray-800/50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const thumbnailClickedHandler = () => {
|
||||||
|
if (!isExisted) {
|
||||||
|
dispatch('click', { assetId: asset.id, deviceId: asset.deviceId });
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<IntersectionObserver once={true} let:intersecting>
|
<IntersectionObserver once={true} let:intersecting>
|
||||||
<div
|
<div
|
||||||
style:width={`${thumbnailSize}px`}
|
style:width={`${thumbnailSize}px`}
|
||||||
style:height={`${thumbnailSize}px`}
|
style:height={`${thumbnailSize}px`}
|
||||||
class={`bg-gray-100 relative hover:cursor-pointer ${getSize()}`}
|
class={`bg-gray-100 relative ${getSize()} ${
|
||||||
|
isExisted ? 'cursor-not-allowed' : 'hover:cursor-pointer'
|
||||||
|
}`}
|
||||||
on:mouseenter={handleMouseOverThumbnail}
|
on:mouseenter={handleMouseOverThumbnail}
|
||||||
on:mouseleave={handleMouseLeaveThumbnail}
|
on:mouseleave={handleMouseLeaveThumbnail}
|
||||||
on:click={() => dispatch('viewAsset', { assetId: asset.id, deviceId: asset.deviceId })}
|
on:click={thumbnailClickedHandler}
|
||||||
>
|
>
|
||||||
{#if mouseOver}
|
{#if mouseOver || selected || isExisted}
|
||||||
<div
|
<div
|
||||||
in:fade={{ duration: 200 }}
|
in:fade={{ duration: 200 }}
|
||||||
class="w-full bg-gradient-to-b from-gray-800/50 via-white/0 to-white/0 absolute p-2 z-10"
|
class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
on:mouseenter={() => (mouseOverIcon = true)}
|
on:mouseenter={() => (mouseOverIcon = true)}
|
||||||
on:mouseleave={() => (mouseOverIcon = false)}
|
on:mouseleave={() => (mouseOverIcon = false)}
|
||||||
class="inline-block"
|
class="inline-block"
|
||||||
>
|
>
|
||||||
|
{#if selected}
|
||||||
|
<CheckCircle size="24" color="#4250af" />
|
||||||
|
{:else if isExisted}
|
||||||
|
<CheckCircle size="24" color="#252525" />
|
||||||
|
{:else}
|
||||||
<CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} />
|
<CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -220,7 +258,7 @@
|
||||||
in:fade={{ duration: 250 }}
|
in:fade={{ duration: 250 }}
|
||||||
src={imageData}
|
src={imageData}
|
||||||
alt={asset.id}
|
alt={asset.id}
|
||||||
class={`object-cover ${getSize()} transition-all duration-100 z-0`}
|
class={`object-cover ${getSize()} transition-all duration-100 z-0 ${getThumbnailBorderStyle()}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
|
|
|
@ -61,14 +61,17 @@
|
||||||
<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1>
|
<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex-1 ml-24">
|
<div class="flex-1 ml-24">
|
||||||
<input class="w-[50%] border rounded-md bg-gray-200 px-8 py-4" placeholder="Search - Coming soon" />
|
<input
|
||||||
|
class="w-[50%] border rounded-md bg-gray-200 px-8 py-4"
|
||||||
|
placeholder="Search - Coming soon"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<section class="flex gap-4 place-items-center">
|
<section class="flex gap-4 place-items-center">
|
||||||
{#if $page.url.pathname !== '/admin'}
|
{#if $page.url.pathname !== '/admin'}
|
||||||
<button
|
<button
|
||||||
in:fly={{ x: 50, duration: 250 }}
|
in:fly={{ x: 50, duration: 250 }}
|
||||||
on:click={() => dispatch('uploadClicked')}
|
on:click={() => dispatch('uploadClicked')}
|
||||||
class="flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium"
|
class="immich-text-button"
|
||||||
>
|
>
|
||||||
<TrayArrowUp size="20" />
|
<TrayArrowUp size="20" />
|
||||||
<span> Upload </span>
|
<span> Upload </span>
|
||||||
|
@ -158,7 +161,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<button class="border rounded-3xl px-6 py-2 hover:bg-gray-50" on:click={logOut}>Sign Out</button>
|
<button class="border rounded-3xl px-6 py-2 hover:bg-gray-50" on:click={logOut}
|
||||||
|
>Sign Out</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -79,9 +79,7 @@
|
||||||
isUploading = value;
|
isUploading = value;
|
||||||
|
|
||||||
if (isUploading == false) {
|
if (isUploading == false) {
|
||||||
if ($session.user) {
|
getAssetsInfo();
|
||||||
getAssetsInfo($session.user.accessToken);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -107,7 +105,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="upload-item-list" class="max-h-[400px] overflow-y-auto pr-2 rounded-lg">
|
<div class="max-h-[400px] overflow-y-auto pr-2 rounded-lg immich-scrollbar">
|
||||||
{#each $uploadAssetsStore as uploadAsset}
|
{#each $uploadAssetsStore as uploadAsset}
|
||||||
<div
|
<div
|
||||||
in:fade={{ duration: 250 }}
|
in:fade={{ duration: 250 }}
|
||||||
|
@ -136,7 +134,9 @@
|
||||||
<input
|
<input
|
||||||
disabled
|
disabled
|
||||||
class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
|
class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
|
||||||
value={`[${getSizeInHumanReadableFormat(uploadAsset.file.size)}] ${uploadAsset.file.name}`}
|
value={`[${getSizeInHumanReadableFormat(uploadAsset.file.size)}] ${
|
||||||
|
uploadAsset.file.name
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative">
|
<div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative">
|
||||||
|
@ -144,7 +144,9 @@
|
||||||
class="bg-immich-primary h-[15px] rounded-md transition-all"
|
class="bg-immich-primary h-[15px] rounded-md transition-all"
|
||||||
style={`width: ${uploadAsset.progress}%`}
|
style={`width: ${uploadAsset.progress}%`}
|
||||||
/>
|
/>
|
||||||
<p class="absolute h-full w-full text-center top-0 text-[10px] ">{uploadAsset.progress}/100</p>
|
<p class="absolute h-full w-full text-center top-0 text-[10px] ">
|
||||||
|
{uploadAsset.progress}/100
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -173,28 +175,3 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
|
||||||
/* width */
|
|
||||||
#upload-item-list::-webkit-scrollbar {
|
|
||||||
width: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track */
|
|
||||||
#upload-item-list::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Handle */
|
|
||||||
#upload-item-list::-webkit-scrollbar-thumb {
|
|
||||||
background: #4250af68;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Handle on hover */
|
|
||||||
#upload-item-list::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #4250afad;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -17,10 +17,10 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let allAlbums: AlbumResponseDto[] = [];
|
let albums: AlbumResponseDto[] = [];
|
||||||
try {
|
try {
|
||||||
const { data } = await api.albumApi.getAllAlbums();
|
const { data } = await api.albumApi.getAllAlbums();
|
||||||
allAlbums = data;
|
albums = data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Error [getAllAlbums] ', e);
|
console.log('Error [getAllAlbums] ', e);
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@
|
||||||
status: 200,
|
status: 200,
|
||||||
props: {
|
props: {
|
||||||
user: session.user,
|
user: session.user,
|
||||||
allAlbums: allAlbums
|
albums: albums
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -38,12 +38,47 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
export let user: ImmichUser;
|
export let user: ImmichUser;
|
||||||
export let allAlbums: AlbumResponseDto[];
|
export let albums: AlbumResponseDto[];
|
||||||
|
|
||||||
const showAlbum = (event: CustomEvent) => {
|
onMount(async () => {
|
||||||
goto('/albums/' + event.detail.id);
|
const { data } = await api.albumApi.getAllAlbums();
|
||||||
|
albums = data;
|
||||||
|
|
||||||
|
// Delete album that has no photos and is named 'Untitled'
|
||||||
|
for (const album of albums) {
|
||||||
|
if (album.albumName === 'Untitled' && album.assets.length === 0) {
|
||||||
|
const isDeleted = await deleteAlbum(album);
|
||||||
|
|
||||||
|
if (isDeleted) {
|
||||||
|
albums = albums.filter((a) => a.id !== album.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAlbum = async () => {
|
||||||
|
try {
|
||||||
|
const { data: newAlbum } = await api.albumApi.createAlbum({
|
||||||
|
albumName: 'Untitled'
|
||||||
|
});
|
||||||
|
|
||||||
|
goto('/albums/' + newAlbum.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error [createAlbum] ', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAlbum = async (album: AlbumResponseDto) => {
|
||||||
|
try {
|
||||||
|
await api.albumApi.deleteAlbum(album.id);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error [deleteAlbum] ', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -68,9 +103,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button on:click={createAlbum} class="immich-text-button text-sm">
|
||||||
class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700"
|
|
||||||
>
|
|
||||||
<span>
|
<span>
|
||||||
<PlusBoxOutline size="18" />
|
<PlusBoxOutline size="18" />
|
||||||
</span>
|
</span>
|
||||||
|
@ -85,8 +118,10 @@
|
||||||
|
|
||||||
<!-- Album Card -->
|
<!-- Album Card -->
|
||||||
<div class="flex flex-wrap gap-8">
|
<div class="flex flex-wrap gap-8">
|
||||||
{#each allAlbums as album}
|
{#each albums as album}
|
||||||
<a sveltekit:prefetch href={`albums/${album.id}`}> <AlbumCard {album} /></a>
|
<a sveltekit:prefetch href={`albums/${album.id}`}>
|
||||||
|
<AlbumCard {album} />
|
||||||
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -173,7 +173,7 @@
|
||||||
<ImmichThumbnail
|
<ImmichThumbnail
|
||||||
{asset}
|
{asset}
|
||||||
on:mouseEvent={thumbnailMouseEventHandler}
|
on:mouseEvent={thumbnailMouseEventHandler}
|
||||||
on:viewAsset={viewAssetHandler}
|
on:click={viewAssetHandler}
|
||||||
{groupIndex}
|
{groupIndex}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
@ -28,6 +28,28 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createSharedAlbum = async () => {
|
||||||
|
try {
|
||||||
|
const { data: newAlbum } = await api.albumApi.createAlbum({
|
||||||
|
albumName: 'Untitled'
|
||||||
|
});
|
||||||
|
|
||||||
|
goto('/albums/' + newAlbum.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error [createAlbum] ', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAlbum = async (album: AlbumResponseDto) => {
|
||||||
|
try {
|
||||||
|
await api.albumApi.deleteAlbum(album.id);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error [deleteAlbum] ', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
@ -36,6 +58,7 @@
|
||||||
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
|
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
|
||||||
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
||||||
import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte';
|
import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
export let user: UserResponseDto;
|
||||||
export let sharedAlbums: AlbumResponseDto[];
|
export let sharedAlbums: AlbumResponseDto[];
|
||||||
|
@ -62,6 +85,7 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
on:click={createSharedAlbum}
|
||||||
class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700"
|
class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
|
|
Loading…
Reference in a new issue