feat (server, web): Share with partner (#2388)

* feat(server, web): implement share with partner

* chore: regenerate api

* chore: regenerate api

* Pass userId to getAssetCountByTimeBucket and getAssetByTimeBucket

* chore: regenerate api

* Use AssetGrid to view partner's assets

* Remove disableNavBarActions flag

* Check access to buckets

* Apply suggestions from code review

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* Remove exception rethrowing

* Simplify partner access check

* Create new PartnerController

* chore api:generate

* Use partnerApi

* Remove id from PartnerResponseDto

* Refactor PartnerEntity

* Rename args

* Remove duplicate code in getAll

* Create composite primary keys for partners table

* Move asset access check into PartnerCore

* Remove redundant getUserAssets call

* Remove unused getUserAssets method

* chore: regenerate api

* Simplify getAll

* Replace ?? with ||

* Simplify PartnerRepository.create

* Introduce PartnerIds interface

* Replace two database migrations with one

* Simplify getAll

* Change PartnerResponseDto to include UserResponseDto

* Move partner sharing endpoints to PartnerController

* Rename ShareController to SharedLinkController

* chore: regenerate api after rebase

* refactor: shared link remove return type

* refactor: return user response dto

* chore: regenerate open api

* refactor: partner getAll

* refactor: partner settings event typing

* chore: remove unused code

* refactor: add partners modal trigger

* refactor: update url for viewing partner photos

* feat: update partner sharing title

* refactor: rename service method names

* refactor: http exception logic to service, PartnerIds interface

* chore: regenerate open api

* test: coverage for domain code

* fix: addPartner => createPartner

* fix: missed rename

* refactor: more code cleanup

* chore: alphabetize settings order

* feat: stop sharing confirmation modal

* Enhance contrast of the email in dark mode

* Replace button with CircleIconButton

* Fix linter warning

* Fix date types for PartnerEntity

* Fix PartnerEntity creation

* Reset assetStore state

* Change layout of the partner's assets page

* Add bulk download action for partner's assets

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Sergey Kondrikov
2023-05-15 20:30:53 +03:00
committed by GitHub
parent 4524aa0d06
commit 7f2fa23179
55 changed files with 1669 additions and 92 deletions

View File

@@ -8,6 +8,7 @@ import {
ConfigurationParameters,
JobApi,
OAuthApi,
PartnerApi,
SearchApi,
ServerInfoApi,
ShareApi,
@@ -20,34 +21,36 @@ import { DUMMY_BASE_URL, toPathString } from './open-api/common';
import type { ApiParams } from './types';
export class ImmichApi {
public userApi: UserApi;
public albumApi: AlbumApi;
public assetApi: AssetApi;
public authenticationApi: AuthenticationApi;
public oauthApi: OAuthApi;
public searchApi: SearchApi;
public serverInfoApi: ServerInfoApi;
public jobApi: JobApi;
public keyApi: APIKeyApi;
public systemConfigApi: SystemConfigApi;
public oauthApi: OAuthApi;
public partnerApi: PartnerApi;
public searchApi: SearchApi;
public serverInfoApi: ServerInfoApi;
public shareApi: ShareApi;
public systemConfigApi: SystemConfigApi;
public userApi: UserApi;
private config: Configuration;
constructor(params: ConfigurationParameters) {
this.config = new Configuration(params);
this.userApi = new UserApi(this.config);
this.albumApi = new AlbumApi(this.config);
this.assetApi = new AssetApi(this.config);
this.authenticationApi = new AuthenticationApi(this.config);
this.oauthApi = new OAuthApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
this.jobApi = new JobApi(this.config);
this.keyApi = new APIKeyApi(this.config);
this.oauthApi = new OAuthApi(this.config);
this.partnerApi = new PartnerApi(this.config);
this.searchApi = new SearchApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
this.shareApi = new ShareApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
this.userApi = new UserApi(this.config);
}
private createUrl(path: string, params?: Record<string, unknown>) {

View File

@@ -1210,6 +1210,12 @@ export interface GetAssetByTimeBucketDto {
* @memberof GetAssetByTimeBucketDto
*/
'timeBucket': Array<string>;
/**
*
* @type {string}
* @memberof GetAssetByTimeBucketDto
*/
'userId'?: string;
}
/**
*
@@ -1223,6 +1229,12 @@ export interface GetAssetCountByTimeBucketDto {
* @memberof GetAssetCountByTimeBucketDto
*/
'timeGroup': TimeGroupEnum;
/**
*
* @type {string}
* @memberof GetAssetCountByTimeBucketDto
*/
'userId'?: string;
}
@@ -7191,6 +7203,263 @@ export class OAuthApi extends BaseAPI {
}
/**
* PartnerApi - axios parameter creator
* @export
*/
export const PartnerApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createPartner: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('createPartner', 'id', id)
const localVarPath = `/partner/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {'shared-by' | 'shared-with'} direction
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPartners: async (direction: 'shared-by' | 'shared-with', options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'direction' is not null or undefined
assertParamExists('getPartners', 'direction', direction)
const localVarPath = `/partner`;
// 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 (direction !== undefined) {
localVarQueryParameter['direction'] = direction;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
removePartner: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('removePartner', 'id', id)
const localVarPath = `/partner/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// 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: 'DELETE', ...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)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* PartnerApi - functional programming interface
* @export
*/
export const PartnerApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = PartnerApiAxiosParamCreator(configuration)
return {
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createPartner(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createPartner(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {'shared-by' | 'shared-with'} direction
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getPartners(direction: 'shared-by' | 'shared-with', options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<UserResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPartners(direction, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async removePartner(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.removePartner(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* PartnerApi - factory interface
* @export
*/
export const PartnerApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = PartnerApiFp(configuration)
return {
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createPartner(id: string, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.createPartner(id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {'shared-by' | 'shared-with'} direction
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPartners(direction: 'shared-by' | 'shared-with', options?: any): AxiosPromise<Array<UserResponseDto>> {
return localVarFp.getPartners(direction, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
removePartner(id: string, options?: any): AxiosPromise<void> {
return localVarFp.removePartner(id, options).then((request) => request(axios, basePath));
},
};
};
/**
* PartnerApi - object-oriented interface
* @export
* @class PartnerApi
* @extends {BaseAPI}
*/
export class PartnerApi extends BaseAPI {
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PartnerApi
*/
public createPartner(id: string, options?: AxiosRequestConfig) {
return PartnerApiFp(this.configuration).createPartner(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {'shared-by' | 'shared-with'} direction
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PartnerApi
*/
public getPartners(direction: 'shared-by' | 'shared-with', options?: AxiosRequestConfig) {
return PartnerApiFp(this.configuration).getPartners(direction, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PartnerApi
*/
public removePartner(id: string, options?: AxiosRequestConfig) {
return PartnerApiFp(this.configuration).removePartner(id, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* SearchApi - axios parameter creator
* @export

View File

@@ -8,6 +8,7 @@
import { goto } from '$app/navigation';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import Button from '../elements/buttons/button.svelte';
import { AppRoute } from '$lib/constants';
export let album: AlbumResponseDto;
export let sharedUsersInAlbum: Set<UserResponseDto>;
@@ -138,7 +139,7 @@
{#if sharedLinks.length}
<button
class="flex flex-col gap-2 place-items-center place-content-center hover:cursor-pointer"
on:click={() => goto('/sharing/sharedlinks')}
on:click={() => goto(AppRoute.SHARED_LINKS)}
>
<ShareCircle size={24} />
<p class="text-sm">View links</p>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { UserResponseDto } from '@api';
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum } from '@api';
@@ -17,6 +18,7 @@
OnScrollbarDragDetail
} from '../shared-components/scrollbar/scrollbar.svelte';
export let user: UserResponseDto | undefined = undefined;
export let isAlbumSelectionMode = false;
let viewportHeight = 0;
@@ -26,11 +28,12 @@
onMount(async () => {
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
timeGroup: TimeGroupEnum.Month
timeGroup: TimeGroupEnum.Month,
userId: user?.id
});
bucketInfo = assetCountByTimebucket;
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket, user?.id);
// Get asset bucket if bucket height is smaller than viewport height
let bucketsToFetchInitially: string[] = [];
@@ -50,6 +53,10 @@
});
});
onDestroy(() => {
assetStore.setInitialState(0, 0, { totalCount: 0, buckets: [] }, undefined);
});
function intersectedHandler(event: CustomEvent) {
const el = event.detail as HTMLElement;
const target = el.firstChild as HTMLElement;

View File

@@ -31,7 +31,7 @@
id="immich-modal"
style:z-index={zIndex}
transition:fade={{ duration: 100, easing: quintOut }}
class="fixed top-0 w-full h-full bg-black/50 flex place-items-center place-content-center overflow-hidden"
class="fixed top-0 left-0 w-full h-full bg-black/50 flex place-items-center place-content-center overflow-hidden"
>
<div
use:clickOutside

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import BaseModal from '../shared-components/base-modal.svelte';
import CircleAvatar from '../shared-components/circle-avatar.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import Button from '../elements/buttons/button.svelte';
import { createEventDispatcher, onMount } from 'svelte';
let availableUsers: UserResponseDto[] = [];
let selectedUsers: UserResponseDto[] = [];
const dispatch = createEventDispatcher<{ close: void; 'add-users': UserResponseDto[] }>();
onMount(async () => {
// TODO: update endpoint to have a query param for deleted users
let { data: users } = await api.userApi.getAllUsers(false);
// remove soft deleted users
users = users.filter((user) => !user.deletedAt);
// exclude partners from the list of users available for selection
const { data: partners } = await api.partnerApi.getPartners('shared-by');
const partnerIds = partners.map((partner) => partner.id);
availableUsers = users.filter((user) => !partnerIds.includes(user.id));
});
const selectUser = (user: UserResponseDto) => {
if (selectedUsers.includes(user)) {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
} else {
selectedUsers = [...selectedUsers, user];
}
};
</script>
<BaseModal on:close={() => dispatch('close')}>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<ImmichLogo width={24} />
<p class="font-medium">Add partner</p>
</span>
</svelte:fragment>
<div class="max-h-[300px] overflow-y-auto immich-scrollbar">
{#if availableUsers.length > 0}
{#each availableUsers as user}
<button
on:click={() => selectUser(user)}
class="w-full flex place-items-center gap-4 py-4 px-5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"
>
{#if selectedUsers.includes(user)}
<span
class="bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-bg rounded-full w-12 h-12 border flex place-items-center place-content-center text-3xl dark:border-immich-dark-gray"
></span
>
{:else}
<CircleAvatar {user} />
{/if}
<div class="text-left">
<p class="text-immich-fg dark:text-immich-dark-fg">
{user.firstName}
{user.lastName}
</p>
<p class="text-xs ">
{user.email}
</p>
</div>
</button>
{/each}
{:else}
<p class="text-sm p-5">
Looks like you shared your photos with all users or you don't have any user to share with.
</p>
{/if}
{#if selectedUsers.length > 0}
<div class="flex place-content-end p-5 ">
<Button size="sm" rounded="lg" on:click={() => dispatch('add-users', selectedUsers)}>
Add
</Button>
</div>
{/if}
</div>
</BaseModal>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { UserResponseDto, api } from '@api';
import CircleAvatar from '../shared-components/circle-avatar.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import Button from '../elements/buttons/button.svelte';
import PartnerSelectionModal from './partner-selection-modal.svelte';
import { handleError } from '../../utils/handle-error';
import { onMount } from 'svelte';
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
let partners: UserResponseDto[] = [];
let createPartner = false;
let removePartner: UserResponseDto | null = null;
const refreshPartners = async () => {
const { data } = await api.partnerApi.getPartners('shared-by');
partners = data;
};
const handleRemovePartner = async () => {
if (!removePartner) {
return;
}
try {
await api.partnerApi.removePartner(removePartner.id);
removePartner = null;
await refreshPartners();
} catch (error) {
handleError(error, 'Unable to remove partner');
}
};
const handleCreatePartners = async (users: UserResponseDto[]) => {
try {
for (const user of users) {
await api.partnerApi.createPartner(user.id);
}
await refreshPartners();
createPartner = false;
} catch (error) {
handleError(error, 'Unable to add partners');
}
};
onMount(async () => {
await refreshPartners();
});
</script>
<section class="my-4">
{#if partners.length > 0}
<div class="flex flex-row gap-4">
{#each partners as partner}
<div class="flex rounded-lg gap-4 py-4 px-5 transition-all">
<CircleAvatar user={partner} />
<div class="text-left">
<p class="text-immich-fg dark:text-immich-dark-fg">
{partner.firstName}
{partner.lastName}
</p>
<p class="text-xs text-immich-fg/75 dark:text-immich-dark-fg/75">
{partner.email}
</p>
</div>
<CircleIconButton
on:click={() => (removePartner = partner)}
logo={Close}
size={'16'}
title="Remove partner"
/>
</div>
{/each}
</div>
{/if}
<div class="flex justify-end">
<Button size="sm" on:click={() => (createPartner = true)}>Add partner</Button>
</div>
</section>
{#if createPartner}
<PartnerSelectionModal
on:close={() => (createPartner = false)}
on:add-users={(event) => handleCreatePartners(event.detail)}
/>
{/if}
{#if removePartner}
<ConfirmDialogue
title="Stop sharing your photos?"
prompt="{removePartner.firstName} will no longer be able to access your photos."
on:cancel={() => (removePartner = null)}
on:confirm={() => handleRemovePartner()}
/>
{/if}

View File

@@ -7,6 +7,7 @@
import OAuthSettings from './oauth-settings.svelte';
import UserAPIKeyList from './user-api-key-list.svelte';
import DeviceList from './device-list.svelte';
import PartnerSettings from './partner-settings.svelte';
import UserProfileSettings from './user-profile-settings.svelte';
export let user: UserResponseDto;
@@ -51,3 +52,7 @@
<SettingAccordion title="Password" subtitle="Change your password">
<ChangePasswordSettings />
</SettingAccordion>
<SettingAccordion title="Sharing" subtitle="Manage sharing with partners">
<PartnerSettings />
</SettingAccordion>

View File

@@ -13,6 +13,7 @@ export enum AppRoute {
PHOTOS = '/photos',
EXPLORE = '/explore',
SHARING = '/sharing',
SHARED_LINKS = '/sharing/sharedlinks',
SEARCH = '/search',
MAP = '/map',

View File

@@ -37,4 +37,9 @@ export class AssetGridState {
* Total assets that have been loaded
*/
assets: AssetResponseDto[] = [];
/**
* User that owns assets
*/
userId: string | undefined;
}

View File

@@ -29,7 +29,8 @@ function createAssetStore() {
const setInitialState = (
viewportHeight: number,
viewportWidth: number,
data: AssetCountByTimeBucketResponseDto
data: AssetCountByTimeBucketResponseDto,
userId: string | undefined
) => {
assetGridState.set({
viewportHeight,
@@ -41,7 +42,8 @@ function createAssetStore() {
assets: [],
cancelToken: new AbortController()
})),
assets: []
assets: [],
userId
});
// Update timeline height based on calculated bucket height
@@ -64,7 +66,8 @@ function createAssetStore() {
});
const { data: assets } = await api.assetApi.getAssetByTimeBucket(
{
timeBucket: [bucket]
timeBucket: [bucket],
userId: _assetGridState.userId
},
{ signal: currentBucketData?.cancelToken.signal }
);

View File

@@ -0,0 +1,21 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { AppRoute } from '$lib/constants';
export const load: PageServerLoad = async ({ params, parent, locals: { api } }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, AppRoute.AUTH_LOGIN);
}
const { data: partner } = await api.userApi.getUserById(params['userId']);
return {
user,
partner,
meta: {
title: 'Partner'
}
};
};

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import type { PageData } from './$types';
import { AppRoute } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store';
import { goto } from '$app/navigation';
import { bulkDownload } from '$lib/utils/asset-utils';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import {
assetInteractionStore,
isMultiSelectStoreState,
selectedAssets
} from '$lib/stores/asset-interaction.store';
export let data: PageData;
const handleDownloadFiles = async () => {
await bulkDownload('immich', Array.from($selectedAssets), () => {
assetInteractionStore.clearMultiselect();
});
};
</script>
<main class="grid h-screen pt-[4.25rem] bg-immich-bg dark:bg-immich-dark-bg">
{#if $isMultiSelectStoreState}
<ControlAppBar
showBackButton
backIcon={Close}
on:close-button-click={() => assetInteractionStore.clearMultiselect()}
tailwindClasses={'bg-white shadow-md'}
>
<svelte:fragment slot="leading">
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">
Selected {$selectedAssets.size.toLocaleString($locale)}
</p>
</svelte:fragment>
<svelte:fragment slot="trailing">
<CircleIconButton
title="Download"
logo={CloudDownloadOutline}
on:click={handleDownloadFiles}
/>
</svelte:fragment>
</ControlAppBar>
{:else}
<ControlAppBar
showBackButton
backIcon={ArrowLeft}
on:close-button-click={() => goto(AppRoute.SHARING)}
>
<svelte:fragment slot="leading">
<p class="text-immich-fg dark:text-immich-dark-fg">
{data.partner.firstName}
{data.partner.lastName}'s photos
</p>
</svelte:fragment>
</ControlAppBar>
{/if}
<AssetGrid user={data.partner} />
</main>

View File

@@ -9,15 +9,18 @@ export const load = (async ({ locals: { api, user } }) => {
try {
const { data: sharedAlbums } = await api.albumApi.getAllAlbums(true);
const { data: partners } = await api.partnerApi.getPartners('shared-with');
return {
user,
sharedAlbums,
partners,
meta: {
title: 'Sharing'
}
};
} catch (e) {
console.log(e);
throw redirect(302, AppRoute.AUTH_LOGIN);
}
}) satisfies PageServerLoad;

View File

@@ -13,6 +13,8 @@
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import { flip } from 'svelte/animate';
import AlbumCard from '$lib/components/album-page/album-card.svelte';
import CircleAvatar from '$lib/components/shared-components/circle-avatar.svelte';
import { AppRoute } from '$lib/constants';
export let data: PageData;
@@ -43,7 +45,7 @@
</div>
</LinkButton>
<LinkButton on:click={() => goto('/sharing/sharedlinks')}>
<LinkButton on:click={() => goto(AppRoute.SHARED_LINKS)}>
<div class="flex place-items-center gap-x-1 text-sm flex-wrap justify-center">
<Link size="18" class="shrink-0" />
<span class="max-sm:text-xs leading-none">Shared links</span>
@@ -51,29 +53,69 @@
</LinkButton>
</div>
<section>
<div class="grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]">
{#each data.sharedAlbums as album (album.id)}
<a
data-sveltekit-preload-data="hover"
href={`albums/${album.id}`}
animate:flip={{ duration: 200 }}
>
<AlbumCard {album} user={data.user} isSharingView />
</a>
{/each}
</div>
<div class="mx-4 flex flex-col">
{#if data.partners.length > 0}
<div class="mb-6 mt-2">
<div>
<p class="mb-4 dark:text-immich-dark-fg font-medium">Partners</p>
</div>
<!-- Empty List -->
{#if data.sharedAlbums.length === 0}
<div
class="border dark:border-immich-dark-gray p-5 md:w-[500px] w-2/3 m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center dark:text-immich-dark-fg"
>
<img src={empty2Url} alt="Empty shared album" width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500">
Create a shared album to share photos and videos with people in your network
</p>
<div class="flex flex-row flex-wrap gap-4">
{#each data.partners as partner}
<button
on:click={() => goto(`/partners/${partner.id}`)}
class="flex rounded-lg gap-4 py-4 px-5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"
>
<CircleAvatar user={partner} />
<div class="text-left">
<p class="text-immich-fg dark:text-immich-dark-fg">
{partner.firstName}
{partner.lastName}
</p>
<p class="text-xs text-immich-fg/75 dark:text-immich-dark-fg/75">
{partner.email}
</p>
</div>
</button>
{/each}
</div>
</div>
<hr class="dark:border-immich-dark-gray mb-4" />
{/if}
</section>
<div class="mb-6 mt-2">
<div>
<p class="mb-4 dark:text-immich-dark-fg font-medium">Albums</p>
</div>
<div>
<!-- Share Album List -->
<div class="grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]">
{#each data.sharedAlbums as album (album.id)}
<a
data-sveltekit-preload-data="hover"
href={`albums/${album.id}`}
animate:flip={{ duration: 200 }}
>
<AlbumCard {album} user={data.user} isSharingView />
</a>
{/each}
</div>
<!-- Empty List -->
{#if data.sharedAlbums.length === 0}
<div
class="border dark:border-immich-dark-gray p-5 md:w-[500px] w-2/3 m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center dark:text-immich-dark-fg"
>
<img src={empty2Url} alt="Empty shared album" width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500">
Create a shared album to share photos and videos with people in your network
</p>
</div>
{/if}
</div>
</div>
</div>
</UserPageLayout>