fix: shared-link-mapper (#24794)

This commit is contained in:
Jason Rasmussen
2026-01-05 10:03:35 -05:00
committed by GitHub
parent b7bb118c00
commit e4311da1a4
11 changed files with 152 additions and 304 deletions

View File

@@ -20,7 +20,6 @@ describe('/shared-links', () => {
let user1: LoginResponseDto; let user1: LoginResponseDto;
let user2: LoginResponseDto; let user2: LoginResponseDto;
let album: AlbumResponseDto; let album: AlbumResponseDto;
let metadataAlbum: AlbumResponseDto;
let deletedAlbum: AlbumResponseDto; let deletedAlbum: AlbumResponseDto;
let linkWithDeletedAlbum: SharedLinkResponseDto; let linkWithDeletedAlbum: SharedLinkResponseDto;
let linkWithPassword: SharedLinkResponseDto; let linkWithPassword: SharedLinkResponseDto;
@@ -41,18 +40,9 @@ describe('/shared-links', () => {
[asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]); [asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
[album, deletedAlbum, metadataAlbum] = await Promise.all([ [album, deletedAlbum] = await Promise.all([
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }), createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }), createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
createAlbum(
{
createAlbumDto: {
albumName: 'metadata album',
assetIds: [asset1.id],
},
},
{ headers: asBearerAuth(user1.accessToken) },
),
]); ]);
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] = [linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
@@ -75,14 +65,14 @@ describe('/shared-links', () => {
password: 'foo', password: 'foo',
}), }),
utils.createSharedLink(user1.accessToken, { utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album, type: SharedLinkType.Individual,
albumId: metadataAlbum.id, assetIds: [asset1.id],
showMetadata: true, showMetadata: true,
slug: 'metadata-album', slug: 'metadata-slug',
}), }),
utils.createSharedLink(user1.accessToken, { utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album, type: SharedLinkType.Individual,
albumId: metadataAlbum.id, assetIds: [asset1.id],
showMetadata: false, showMetadata: false,
}), }),
]); ]);
@@ -95,9 +85,7 @@ describe('/shared-links', () => {
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`); const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
expect(resp.status).toBe(200); expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html'); expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain( expect(resp.text).toContain(`<meta name="description" content="1 shared photos &amp; videos" />`);
`<meta name="description" content="${metadataAlbum.assets.length} shared photos &amp; videos" />`,
);
}); });
it('should have correct asset count in meta tag for empty album', async () => { it('should have correct asset count in meta tag for empty album', async () => {
@@ -144,9 +132,7 @@ describe('/shared-links', () => {
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`); const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
expect(resp.status).toBe(200); expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html'); expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain( expect(resp.text).toContain(`<meta name="description" content="1 shared photos &amp; videos" />`);
`<meta name="description" content="${metadataAlbum.assets.length} shared photos &amp; videos" />`,
);
}); });
}); });
@@ -271,12 +257,12 @@ describe('/shared-links', () => {
); );
}); });
it('should return metadata for album shared link', async () => { it('should return metadata for individual shared link', async () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key }); const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
expect(status).toBe(200); expect(status).toBe(200);
expect(body.assets).toHaveLength(0); expect(body.assets).toHaveLength(1);
expect(body.album).toBeDefined(); expect(body.album).not.toBeDefined();
}); });
it('should not return metadata for album shared link without metadata', async () => { it('should not return metadata for album shared link without metadata', async () => {
@@ -284,7 +270,7 @@ describe('/shared-links', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body.assets).toHaveLength(1); expect(body.assets).toHaveLength(1);
expect(body.album).toBeDefined(); expect(body.album).not.toBeDefined();
const asset = body.assets[0]; const asset = body.assets[0];
expect(asset).not.toHaveProperty('exifInfo'); expect(asset).not.toHaveProperty('exifInfo');

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator'; import { IsString } from 'class-validator';
import _ from 'lodash';
import { SharedLink } from 'src/database'; import { SharedLink } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators'; import { HistoryBuilder, Property } from 'src/decorators';
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto'; import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
@@ -118,10 +117,10 @@ export class SharedLinkResponseDto {
slug!: string | null; slug!: string | null;
} }
export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto { export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetadata: boolean }): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || []; const assets = sharedLink.assets || [];
return { const response = {
id: sharedLink.id, id: sharedLink.id,
description: sharedLink.description, description: sharedLink.description,
password: sharedLink.password, password: sharedLink.password,
@@ -130,35 +129,19 @@ export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
type: sharedLink.type, type: sharedLink.type,
createdAt: sharedLink.createdAt, createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt, expiresAt: sharedLink.expiresAt,
assets: linkAssets.map((asset) => mapAsset(asset)), assets: assets.map((asset) => mapAsset(asset, { stripMetadata: options.stripAssetMetadata })),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif,
slug: sharedLink.slug,
};
}
export function mapSharedLinkWithoutMetadata(sharedLink: SharedLink): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
return {
id: sharedLink.id,
description: sharedLink.description,
password: sharedLink.password,
userId: sharedLink.userId,
key: sharedLink.key.toString('base64url'),
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload, allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload, allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif, showMetadata: sharedLink.showExif,
slug: sharedLink.slug, slug: sharedLink.slug,
}; };
// unless we select sharedLink.album.sharedLinks this will be wrong
if (response.album) {
response.album.hasSharedLink = true;
response.album.shared = true;
}
return response;
} }

View File

@@ -55,7 +55,8 @@ describe(SharedLinkService.name, () => {
}, },
}); });
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); const response = await sut.getMine(authDto, {});
expect(response.assets[0]).toMatchObject({ hasMetadata: false });
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
}); });

View File

@@ -6,7 +6,6 @@ import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
mapSharedLink, mapSharedLink,
mapSharedLinkWithoutMetadata,
SharedLinkCreateDto, SharedLinkCreateDto,
SharedLinkEditDto, SharedLinkEditDto,
SharedLinkPasswordDto, SharedLinkPasswordDto,
@@ -22,7 +21,7 @@ export class SharedLinkService extends BaseService {
async getAll(auth: AuthDto, { id, albumId }: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> { async getAll(auth: AuthDto, { id, albumId }: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> {
return this.sharedLinkRepository return this.sharedLinkRepository
.getAll({ userId: auth.user.id, id, albumId }) .getAll({ userId: auth.user.id, id, albumId })
.then((links) => links.map((link) => mapSharedLink(link))); .then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false })));
} }
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> { async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
@@ -31,7 +30,7 @@ export class SharedLinkService extends BaseService {
} }
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id); const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
const response = this.mapToSharedLink(sharedLink, { withExif: sharedLink.showExif }); const response = mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
if (sharedLink.password) { if (sharedLink.password) {
response.token = this.validateAndRefreshToken(sharedLink, dto); response.token = this.validateAndRefreshToken(sharedLink, dto);
} }
@@ -41,7 +40,7 @@ export class SharedLinkService extends BaseService {
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> { async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
const sharedLink = await this.findOrFail(auth.user.id, id); const sharedLink = await this.findOrFail(auth.user.id, id);
return this.mapToSharedLink(sharedLink, { withExif: true }); return mapSharedLink(sharedLink, { stripAssetMetadata: false });
} }
async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> { async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> {
@@ -81,7 +80,7 @@ export class SharedLinkService extends BaseService {
slug: dto.slug || null, slug: dto.slug || null,
}); });
return this.mapToSharedLink(sharedLink, { withExif: true }); return mapSharedLink(sharedLink, { stripAssetMetadata: false });
} catch (error) { } catch (error) {
this.handleError(error); this.handleError(error);
} }
@@ -108,7 +107,7 @@ export class SharedLinkService extends BaseService {
showExif: dto.showMetadata, showExif: dto.showMetadata,
slug: dto.slug || null, slug: dto.slug || null,
}); });
return this.mapToSharedLink(sharedLink, { withExif: true }); return mapSharedLink(sharedLink, { stripAssetMetadata: false });
} catch (error) { } catch (error) {
this.handleError(error); this.handleError(error);
} }
@@ -214,10 +213,6 @@ export class SharedLinkService extends BaseService {
}; };
} }
private mapToSharedLink(sharedLink: SharedLink, { withExif }: { withExif: boolean }) {
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
}
private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string { private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string {
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`); const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
const sharedLinkTokens = dto.token?.split(',') || []; const sharedLinkTokens = dto.token?.split(',') || [];

View File

@@ -1,10 +1,7 @@
import { UserAdmin } from 'src/database'; import { UserAdmin } from 'src/database';
import { AlbumResponseDto } from 'src/dtos/album.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetResponseDto, MapAsset } from 'src/dtos/asset-response.dto';
import { ExifResponseDto } from 'src/dtos/exif.dto';
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { mapUser } from 'src/dtos/user.dto'; import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum';
import { AssetOrder, AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
@@ -20,89 +17,6 @@ const sharedLinkBytes = Buffer.from(
'hex', 'hex',
); );
const assetInfo: ExifResponseDto = {
make: 'camera-make',
model: 'camera-model',
exifImageWidth: 500,
exifImageHeight: 500,
fileSizeInByte: 100,
orientation: 'orientation',
dateTimeOriginal: today,
modifyDate: today,
timeZone: 'America/Los_Angeles',
lensModel: 'fancy',
fNumber: 100,
focalLength: 100,
iso: 100,
exposureTime: '1/16',
latitude: 100,
longitude: 100,
city: 'city',
state: 'state',
country: 'country',
description: 'description',
projectionType: null,
};
const assetResponse: AssetResponseDto = {
id: 'id_1',
createdAt: today,
deviceAssetId: 'device_asset_id_1',
ownerId: 'user_id_1',
deviceId: 'device_id_1',
type: AssetType.Video,
originalMimeType: 'image/jpeg',
originalPath: 'fake_path/jpeg',
originalFileName: 'asset_1.jpeg',
thumbhash: null,
fileModifiedAt: today,
isOffline: false,
fileCreatedAt: today,
localDateTime: today,
updatedAt: today,
isFavorite: false,
isArchived: false,
duration: '0:00:00.00000',
exifInfo: assetInfo,
livePhotoVideoId: null,
tags: [],
people: [],
checksum: 'ZmlsZSBoYXNo',
isTrashed: false,
libraryId: 'library-id',
hasMetadata: true,
visibility: AssetVisibility.Timeline,
};
const assetResponseWithoutMetadata = {
id: 'id_1',
type: AssetType.Video,
originalMimeType: 'image/jpeg',
thumbhash: null,
localDateTime: today,
duration: '0:00:00.00000',
livePhotoVideoId: null,
hasMetadata: false,
} as AssetResponseDto;
const albumResponse: AlbumResponseDto = {
albumName: 'Test Album',
description: '',
albumThumbnailAssetId: null,
createdAt: today,
updatedAt: today,
id: 'album-123',
ownerId: 'admin_id',
owner: mapUser(userStub.admin),
albumUsers: [],
shared: false,
hasSharedLink: false,
assets: [],
assetCount: 1,
isActivityEnabled: true,
order: AssetOrder.Desc,
};
export const sharedLinkStub = { export const sharedLinkStub = {
individual: Object.freeze({ individual: Object.freeze({
id: '123', id: '123',
@@ -161,7 +75,7 @@ export const sharedLinkStub = {
id: '123', id: '123',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
key: sharedLinkBytes, key: sharedLinkBytes,
type: SharedLinkType.Album, type: SharedLinkType.Individual,
createdAt: today, createdAt: today,
expiresAt: tomorrow, expiresAt: tomorrow,
allowUpload: false, allowUpload: false,
@@ -169,97 +83,80 @@ export const sharedLinkStub = {
showExif: false, showExif: false,
description: null, description: null,
password: null, password: null,
assets: [], assets: [
slug: null, {
albumId: 'album-123', id: 'id_1',
album: { status: AssetStatus.Active,
id: 'album-123', owner: undefined as unknown as UserAdmin,
updateId: '42', ownerId: 'user_id_1',
ownerId: authStub.admin.user.id, deviceAssetId: 'device_asset_id_1',
owner: userStub.admin, deviceId: 'device_id_1',
albumName: 'Test Album', type: AssetType.Video,
description: '', originalPath: 'fake_path/jpeg',
createdAt: today, checksum: Buffer.from('file hash', 'utf8'),
updatedAt: today, fileModifiedAt: today,
deletedAt: null, fileCreatedAt: today,
albumThumbnailAsset: null, localDateTime: today,
albumThumbnailAssetId: null, createdAt: today,
albumUsers: [], updatedAt: today,
sharedLinks: [], isFavorite: false,
isActivityEnabled: true, isArchived: false,
order: AssetOrder.Desc, isExternal: false,
assets: [ isOffline: false,
{ files: [],
id: 'id_1', thumbhash: null,
status: AssetStatus.Active, encodedVideoPath: '',
owner: undefined as unknown as UserAdmin, duration: null,
ownerId: 'user_id_1', livePhotoVideo: null,
deviceAssetId: 'device_asset_id_1', livePhotoVideoId: null,
deviceId: 'device_id_1', originalFileName: 'asset_1.jpeg',
type: AssetType.Video, exifInfo: {
originalPath: 'fake_path/jpeg', projectionType: null,
checksum: Buffer.from('file hash', 'utf8'), livePhotoCID: null,
fileModifiedAt: today, assetId: 'id_1',
fileCreatedAt: today, description: 'description',
localDateTime: today, exifImageWidth: 500,
createdAt: today, exifImageHeight: 500,
fileSizeInByte: 100,
orientation: 'orientation',
dateTimeOriginal: today,
modifyDate: today,
timeZone: 'America/Los_Angeles',
latitude: 100,
longitude: 100,
city: 'city',
state: 'state',
country: 'country',
make: 'camera-make',
model: 'camera-model',
lensModel: 'fancy',
fNumber: 100,
focalLength: 100,
iso: 100,
exposureTime: '1/16',
fps: 100,
profileDescription: 'sRGB',
bitsPerSample: 8,
colorspace: 'sRGB',
autoStackId: null,
rating: 3,
updatedAt: today, updatedAt: today,
isFavorite: false,
isArchived: false,
isExternal: false,
isOffline: false,
files: [],
thumbhash: null,
encodedVideoPath: '',
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
originalFileName: 'asset_1.jpeg',
exifInfo: {
projectionType: null,
livePhotoCID: null,
assetId: 'id_1',
description: 'description',
exifImageWidth: 500,
exifImageHeight: 500,
fileSizeInByte: 100,
orientation: 'orientation',
dateTimeOriginal: today,
modifyDate: today,
timeZone: 'America/Los_Angeles',
latitude: 100,
longitude: 100,
city: 'city',
state: 'state',
country: 'country',
make: 'camera-make',
model: 'camera-model',
lensModel: 'fancy',
fNumber: 100,
focalLength: 100,
iso: 100,
exposureTime: '1/16',
fps: 100,
profileDescription: 'sRGB',
bitsPerSample: 8,
colorspace: 'sRGB',
autoStackId: null,
rating: 3,
updatedAt: today,
updateId: '42',
},
sharedLinks: [],
faces: [],
sidecarPath: null,
deletedAt: null,
duplicateId: null,
updateId: '42', updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
}, },
], sharedLinks: [],
}, faces: [],
sidecarPath: null,
deletedAt: null,
duplicateId: null,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
},
],
albumId: null,
album: null,
slug: null,
}), }),
passwordRequired: Object.freeze({ passwordRequired: Object.freeze({
id: '123', id: '123',
@@ -312,20 +209,4 @@ export const sharedLinkResponseStub = {
userId: 'admin_id', userId: 'admin_id',
slug: null, slug: null,
}), }),
readonlyNoMetadata: Object.freeze<SharedLinkResponseDto>({
id: '123',
userId: 'admin_id',
key: sharedLinkBytes.toString('base64url'),
type: SharedLinkType.Album,
createdAt: today,
expiresAt: tomorrow,
description: null,
password: null,
allowUpload: false,
allowDownload: false,
showMetadata: false,
slug: null,
album: { ...albumResponse, startDate: assetResponse.localDateTime, endDate: assetResponse.localDateTime },
assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }],
}),
}; };

View File

@@ -13,8 +13,8 @@
AlbumGroupBy, AlbumGroupBy,
AlbumSortBy, AlbumSortBy,
AlbumViewMode, AlbumViewMode,
SortOrder,
locale, locale,
SortOrder,
type AlbumViewSettings, type AlbumViewSettings,
} from '$lib/stores/preferences.store'; } from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
@@ -23,7 +23,12 @@
import type { ContextMenuPosition } from '$lib/utils/context-menu'; import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { normalizeSearchString } from '$lib/utils/string-utils'; import { normalizeSearchString } from '$lib/utils/string-utils';
import { addUsersToAlbum, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk'; import {
addUsersToAlbum,
type AlbumResponseDto,
type AlbumUserAddDto,
type SharedLinkResponseDto,
} from '@immich/sdk';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js'; import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
import { groupBy } from 'lodash-es'; import { groupBy } from 'lodash-es';
@@ -208,12 +213,7 @@
} }
case 'sharedLink': { case 'sharedLink': {
const success = await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id }); await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id });
if (success) {
selectedAlbum.shared = true;
selectedAlbum.hasSharedLink = true;
onUpdate(selectedAlbum);
}
break; break;
} }
} }
@@ -274,9 +274,15 @@
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id); ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id); sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
}; };
const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => {
if (sharedLink.album) {
onUpdate(sharedLink.album);
}
};
</script> </script>
<OnEvents {onAlbumUpdate} {onAlbumDelete} /> <OnEvents {onAlbumUpdate} {onAlbumDelete} {onSharedLinkCreate} />
{#if albums.length > 0} {#if albums.length > 0}
{#if userSettings.view === AlbumViewMode.Cover} {#if userSettings.view === AlbumViewMode.Cover}

View File

@@ -1,26 +0,0 @@
<script lang="ts">
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import type { AssetResponseDto } from '@immich/sdk';
import { IconButton, modalManager } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
}
let { asset }: Props = $props();
const handleClick = async () => {
await modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] });
};
</script>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiShareVariantOutline}
onclick={handleClick}
aria-label={$t('share')}
/>

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import CastButton from '$lib/cast/cast-button.svelte'; import CastButton from '$lib/cast/cast-button.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte'; import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte'; import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
@@ -18,14 +19,13 @@
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte'; import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte'; import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte'; import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte'; import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte'; import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleReplaceAsset } from '$lib/services/asset.service'; import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store'; import { photoZoomState } from '$lib/stores/zoom-image.store';
@@ -113,6 +113,8 @@
let isLocked = $derived(asset.visibility === AssetVisibility.Locked); let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const { Share } = $derived(getAssetActions($t, asset));
// $: showEditorButton = // $: showEditorButton =
// isOwner && // isOwner &&
// asset.type === AssetTypeEnum.Image && // asset.type === AssetTypeEnum.Image &&
@@ -135,9 +137,7 @@
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions"> <div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
<CastButton /> <CastButton />
{#if !asset.isTrashed && $user && !isLocked} <ActionButton action={Share} />
<ShareAction {asset} />
{/if}
{#if asset.isOffline} {#if asset.isOffline}
<IconButton <IconButton
shape="round" shape="round"

View File

@@ -7,12 +7,12 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
onClose: (success?: boolean) => void; onClose: () => void;
albumId?: string; albumId?: string;
assetIds?: string[]; assetIds?: string[];
} }
let { onClose, albumId = $bindable(), assetIds = $bindable([]) }: Props = $props(); let { onClose, albumId, assetIds }: Props = $props();
let description = $state(''); let description = $state('');
let allowDownload = $state(true); let allowDownload = $state(true);
@@ -44,7 +44,7 @@
slug, slug,
}); });
if (success) { if (success) {
onClose(true); onClose();
} }
}; };
</script> </script>

View File

@@ -1,6 +1,23 @@
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser } from '$lib/stores/user.store';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { copyAsset, deleteAssets } from '@immich/sdk'; import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
const Share: ActionItem = {
title: $t('share'),
icon: mdiShareVariantOutline,
$if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
};
return { Share };
};
export const handleReplaceAsset = async (oldAssetId: string) => { export const handleReplaceAsset = async (oldAssetId: string) => {
const [newAssetId] = await openFileUploadDialog({ multiple: false }); const [newAssetId] = await openFileUploadDialog({ multiple: false });

View File

@@ -9,6 +9,7 @@ import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { import {
createSharedLink, createSharedLink,
getSharedLinkById,
removeSharedLink, removeSharedLink,
removeSharedLinkAssets, removeSharedLinkAssets,
updateSharedLink, updateSharedLink,
@@ -58,7 +59,11 @@ export const handleCreateSharedLink = async (dto: SharedLinkCreateDto) => {
const $t = await getFormatter(); const $t = await getFormatter();
try { try {
const sharedLink = await createSharedLink({ sharedLinkCreateDto: dto }); let sharedLink = await createSharedLink({ sharedLinkCreateDto: dto });
if (dto.albumId) {
// fetch album details, for event
sharedLink = await getSharedLinkById({ id: sharedLink.id });
}
eventManager.emit('SharedLinkCreate', sharedLink); eventManager.emit('SharedLinkCreate', sharedLink);