mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
Merge e0310ee462 into e7d6a066f8
This commit is contained in:
commit
4e7cbbe37c
13 changed files with 359 additions and 16 deletions
|
|
@ -1635,6 +1635,7 @@
|
||||||
"remote_assets": "Remote Assets",
|
"remote_assets": "Remote Assets",
|
||||||
"remote_media_summary": "Remote Media Summary",
|
"remote_media_summary": "Remote Media Summary",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
|
"remove_album": "Remove album",
|
||||||
"remove_assets_album_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from the album?",
|
"remove_assets_album_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from the album?",
|
||||||
"remove_assets_shared_link_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from this shared link?",
|
"remove_assets_shared_link_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from this shared link?",
|
||||||
"remove_assets_title": "Remove assets?",
|
"remove_assets_title": "Remove assets?",
|
||||||
|
|
|
||||||
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
|
|
@ -91,6 +91,7 @@ Class | Method | HTTP request | Description
|
||||||
*AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} |
|
*AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} |
|
||||||
*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics |
|
*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics |
|
||||||
*AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums |
|
*AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums |
|
||||||
|
*AlbumsApi* | [**getAllAlbumsSlim**](doc//AlbumsApi.md#getallalbumsslim) | **GET** /albums/slim |
|
||||||
*AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets |
|
*AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets |
|
||||||
*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} |
|
*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} |
|
||||||
*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} |
|
*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} |
|
||||||
|
|
|
||||||
68
mobile/openapi/lib/api/albums_api.dart
generated
68
mobile/openapi/lib/api/albums_api.dart
generated
|
|
@ -499,6 +499,74 @@ class AlbumsApi {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This endpoint requires the `album.read` permission.
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] assetId:
|
||||||
|
/// Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
|
||||||
|
///
|
||||||
|
/// * [bool] shared:
|
||||||
|
Future<Response> getAllAlbumsSlimWithHttpInfo({ String? assetId, bool? shared, }) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/albums/slim';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
if (assetId != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'assetId', assetId));
|
||||||
|
}
|
||||||
|
if (shared != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'shared', shared));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This endpoint requires the `album.read` permission.
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] assetId:
|
||||||
|
/// Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
|
||||||
|
///
|
||||||
|
/// * [bool] shared:
|
||||||
|
Future<List<AlbumResponseDto>?> getAllAlbumsSlim({ String? assetId, bool? shared, }) async {
|
||||||
|
final response = await getAllAlbumsSlimWithHttpInfo( assetId: assetId, shared: shared, );
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
final responseBody = await _decodeBodyBytes(response);
|
||||||
|
return (await apiClient.deserializeAsync(responseBody, 'List<AlbumResponseDto>') as List)
|
||||||
|
.cast<AlbumResponseDto>()
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// This endpoint requires the `albumAsset.delete` permission.
|
/// This endpoint requires the `albumAsset.delete` permission.
|
||||||
///
|
///
|
||||||
/// Note: This method returns the HTTP [Response].
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
|
|
||||||
|
|
@ -1001,6 +1001,62 @@
|
||||||
"description": "This endpoint requires the `albumAsset.create` permission."
|
"description": "This endpoint requires the `albumAsset.create` permission."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/albums/slim": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getAllAlbumsSlim",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "assetId",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "shared",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AlbumResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Albums"
|
||||||
|
],
|
||||||
|
"x-immich-permission": "album.read",
|
||||||
|
"description": "This endpoint requires the `album.read` permission."
|
||||||
|
}
|
||||||
|
},
|
||||||
"/albums/statistics": {
|
"/albums/statistics": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getAlbumStatistics",
|
"operationId": "getAlbumStatistics",
|
||||||
|
|
|
||||||
|
|
@ -1925,6 +1925,23 @@ export function addAssetsToAlbums({ key, slug, albumsAddAssetsDto }: {
|
||||||
body: albumsAddAssetsDto
|
body: albumsAddAssetsDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This endpoint requires the `album.read` permission.
|
||||||
|
*/
|
||||||
|
export function getAllAlbumsSlim({ assetId, shared }: {
|
||||||
|
assetId?: string;
|
||||||
|
shared?: boolean;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: AlbumResponseDto[];
|
||||||
|
}>(`/albums/slim${QS.query(QS.explode({
|
||||||
|
assetId,
|
||||||
|
shared
|
||||||
|
}))}`, {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This endpoint requires the `album.statistics` permission.
|
* This endpoint requires the `album.statistics` permission.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,25 @@ describe(AlbumController.name, () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /albums/slim', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).get('/albums/slim');
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject an invalid shared param', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).get('/albums/slim?shared=invalid');
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(factory.responses.badRequest(['shared must be a boolean value']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject an invalid assetId param', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).get('/albums/slim?assetId=invalid');
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(factory.responses.badRequest(['assetId must be a UUID']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('GET /albums/:id', () => {
|
describe('GET /albums/:id', () => {
|
||||||
it('should be an authenticated route', async () => {
|
it('should be an authenticated route', async () => {
|
||||||
await request(ctx.getHttpServer()).get(`/albums/${factory.uuid()}`);
|
await request(ctx.getHttpServer()).get(`/albums/${factory.uuid()}`);
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,12 @@ export class AlbumController {
|
||||||
return this.service.getAll(auth, query);
|
return this.service.getAll(auth, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('slim')
|
||||||
|
@Authenticated({ permission: Permission.AlbumRead })
|
||||||
|
getAllAlbumsSlim(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||||
|
return this.service.getAll(auth, query, true);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@Authenticated({ permission: Permission.AlbumCreate })
|
@Authenticated({ permission: Permission.AlbumCreate })
|
||||||
createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,11 @@ export class AlbumService extends BaseService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
async getAll(
|
||||||
|
{ user: { id: ownerId } }: AuthDto,
|
||||||
|
{ assetId, shared }: GetAlbumsDto,
|
||||||
|
slim: boolean = false,
|
||||||
|
): Promise<AlbumResponseDto[]> {
|
||||||
await this.albumRepository.updateThumbnails();
|
await this.albumRepository.updateThumbnails();
|
||||||
|
|
||||||
let albums: MapAlbumDto[];
|
let albums: MapAlbumDto[];
|
||||||
|
|
@ -55,20 +59,24 @@ export class AlbumService extends BaseService {
|
||||||
|
|
||||||
// Get asset count for each album. Then map the result to an object:
|
// Get asset count for each album. Then map the result to an object:
|
||||||
// { [albumId]: assetCount }
|
// { [albumId]: assetCount }
|
||||||
const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
|
|
||||||
const albumMetadata: Record<string, AlbumAssetCount> = {};
|
const albumMetadata: Record<string, AlbumAssetCount> = {};
|
||||||
for (const metadata of results) {
|
if (!slim) {
|
||||||
albumMetadata[metadata.albumId] = metadata;
|
const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
|
||||||
|
for (const metadata of results) {
|
||||||
|
albumMetadata[metadata.albumId] = metadata;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return albums.map((album) => ({
|
return albums.map((album) => ({
|
||||||
...mapAlbumWithoutAssets(album),
|
...mapAlbumWithoutAssets(album),
|
||||||
sharedLinks: undefined,
|
sharedLinks: undefined,
|
||||||
startDate: albumMetadata[album.id]?.startDate ?? undefined,
|
...(!slim && {
|
||||||
endDate: albumMetadata[album.id]?.endDate ?? undefined,
|
startDate: albumMetadata[album.id]?.startDate ?? undefined,
|
||||||
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
|
endDate: albumMetadata[album.id]?.endDate ?? undefined,
|
||||||
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
|
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
|
||||||
lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined,
|
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
|
||||||
|
lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
id?: string;
|
id?: string;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
thumbnail?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const asComboboxOptions = (values: string[]) =>
|
export const asComboboxOptions = (values: string[]) =>
|
||||||
|
|
@ -45,6 +46,7 @@
|
||||||
* select first matching option on enter key.
|
* select first matching option on enter key.
|
||||||
*/
|
*/
|
||||||
defaultFirstOption?: boolean;
|
defaultFirstOption?: boolean;
|
||||||
|
hasThumbnails?: boolean;
|
||||||
onSelect?: (option: ComboBoxOption | undefined) => void;
|
onSelect?: (option: ComboBoxOption | undefined) => void;
|
||||||
forceFocus?: boolean;
|
forceFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +60,7 @@
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
allowCreate = false,
|
allowCreate = false,
|
||||||
defaultFirstOption = false,
|
defaultFirstOption = false,
|
||||||
|
hasThumbnails = false,
|
||||||
onSelect = () => {},
|
onSelect = () => {},
|
||||||
forceFocus = false,
|
forceFocus = false,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
@ -391,12 +394,34 @@
|
||||||
<li
|
<li
|
||||||
aria-selected={index === selectedIndex}
|
aria-selected={index === selectedIndex}
|
||||||
bind:this={optionRefs[index]}
|
bind:this={optionRefs[index]}
|
||||||
class="text-start w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words"
|
|
||||||
id={`${listboxId}-${index}`}
|
id={`${listboxId}-${index}`}
|
||||||
onclick={() => handleSelect(option)}
|
onclick={() => handleSelect(option)}
|
||||||
role="option"
|
role="option"
|
||||||
>
|
>
|
||||||
{option.label}
|
{#if hasThumbnails}
|
||||||
|
<div
|
||||||
|
class="text-start flex w-full place-items-center gap-4 rounded-e-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-subtle hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary px-4"
|
||||||
|
>
|
||||||
|
{#if option.thumbnail}
|
||||||
|
<img
|
||||||
|
src={option.thumbnail}
|
||||||
|
alt={option.label}
|
||||||
|
class="h-6 w-6 bg-cover rounded hover:shadow-lg"
|
||||||
|
data-testid="album-image"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="h-6 w-6 bg-cover rounded hover:shadow-lg"></div>
|
||||||
|
{/if}
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="text-start w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||||
|
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||||
|
import { getAllAlbumsSlim, type AlbumResponseDto } from '@immich/sdk';
|
||||||
|
import { Icon } from '@immich/ui';
|
||||||
|
import { mdiClose } from '@mdi/js';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedAlbums: SvelteSet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { selectedAlbums = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
let allAlbums: AlbumResponseDto[] = $state([]);
|
||||||
|
let albumMap = $derived(Object.fromEntries(allAlbums.map((album) => [album.id, album])));
|
||||||
|
let selectedOption = $state(undefined);
|
||||||
|
|
||||||
|
let sortedSelectedAlbums = $derived(
|
||||||
|
Array.from(selectedAlbums).sort((a, b) => albumMap[b]?.albumName?.length - albumMap[a]?.albumName?.length),
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
allAlbums = await getAllAlbumsSlim({});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelect = (option?: ComboBoxOption) => {
|
||||||
|
if (!option || !option.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedAlbums.add(option.value);
|
||||||
|
|
||||||
|
selectedOption = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (album: string) => {
|
||||||
|
selectedAlbums.delete(album);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="location-selection">
|
||||||
|
<form autocomplete="off" id="create-album-form">
|
||||||
|
<div class="my-4 flex flex-col gap-2">
|
||||||
|
<Combobox
|
||||||
|
onSelect={handleSelect}
|
||||||
|
hasThumbnails
|
||||||
|
label={$t('albums').toUpperCase()}
|
||||||
|
defaultFirstOption
|
||||||
|
options={allAlbums.map((album) => ({
|
||||||
|
id: album.id,
|
||||||
|
label: album.albumName,
|
||||||
|
value: album.id,
|
||||||
|
thumbnail: album.albumThumbnailAssetId ? getAssetThumbnailUrl(album.albumThumbnailAssetId) : undefined,
|
||||||
|
}))}
|
||||||
|
bind:selectedOption
|
||||||
|
placeholder={$t('search_albums')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="flex flex-wrap pt-2 gap-1">
|
||||||
|
{#each sortedSelectedAlbums as albumId (albumId)}
|
||||||
|
{@const album = albumMap[albumId]}
|
||||||
|
{#if album}
|
||||||
|
<div class="album-chip-container flex group transition-all">
|
||||||
|
<span
|
||||||
|
class="album-chip inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||||
|
>
|
||||||
|
<p class="text-sm album-chip">
|
||||||
|
{album.albumName}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||||
|
title={$t('remove_album')}
|
||||||
|
onclick={() => handleRemove(albumId)}
|
||||||
|
>
|
||||||
|
<Icon icon={mdiClose} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.album-chip-container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.album-chip {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -10,12 +10,21 @@
|
||||||
import { Checkbox, Label } from '@immich/ui';
|
import { Checkbox, Label } from '@immich/ui';
|
||||||
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import type { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filters: SearchDisplayFilters;
|
filters: SearchDisplayFilters;
|
||||||
|
selectedAlbums: SvelteSet<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { filters = $bindable() }: Props = $props();
|
let { filters = $bindable(), selectedAlbums }: Props = $props();
|
||||||
|
|
||||||
|
//disable the filter if albums get selected
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedAlbums?.size > 0) {
|
||||||
|
filters.isNotInAlbum = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="display-options-selection">
|
<div id="display-options-selection">
|
||||||
|
|
@ -23,7 +32,12 @@
|
||||||
<legend class="uppercase immich-form-label">{$t('display_options')}</legend>
|
<legend class="uppercase immich-form-label">{$t('display_options')}</legend>
|
||||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Checkbox id="not-in-album-checkbox" size="tiny" bind:checked={filters.isNotInAlbum} />
|
<Checkbox
|
||||||
|
disabled={selectedAlbums?.size > 0}
|
||||||
|
id="not-in-album-checkbox"
|
||||||
|
size="tiny"
|
||||||
|
bind:checked={filters.isNotInAlbum}
|
||||||
|
/>
|
||||||
<Label label={$t('not_in_any_album')} for="not-in-album-checkbox" />
|
<Label label={$t('not_in_any_album')} for="not-in-album-checkbox" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
query: string;
|
query: string;
|
||||||
queryType: 'smart' | 'metadata' | 'description';
|
queryType: 'smart' | 'metadata' | 'description';
|
||||||
personIds: SvelteSet<string>;
|
personIds: SvelteSet<string>;
|
||||||
|
albumIds: SvelteSet<string>;
|
||||||
tagIds: SvelteSet<string> | null;
|
tagIds: SvelteSet<string> | null;
|
||||||
location: SearchLocationFilter;
|
location: SearchLocationFilter;
|
||||||
camera: SearchCameraFilter;
|
camera: SearchCameraFilter;
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import SearchAlbumsSection from '$lib/components/shared-components/search-bar/search-albums-section.svelte';
|
||||||
import SearchCameraSection, {
|
import SearchCameraSection, {
|
||||||
type SearchCameraFilter,
|
type SearchCameraFilter,
|
||||||
} from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
|
} from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
|
||||||
|
|
@ -76,6 +78,7 @@
|
||||||
query,
|
query,
|
||||||
queryType: defaultQueryType(),
|
queryType: defaultQueryType(),
|
||||||
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
|
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
|
||||||
|
albumIds: new SvelteSet('albumIds' in searchQuery ? searchQuery.albumIds : []),
|
||||||
tagIds:
|
tagIds:
|
||||||
'tagIds' in searchQuery
|
'tagIds' in searchQuery
|
||||||
? searchQuery.tagIds === null
|
? searchQuery.tagIds === null
|
||||||
|
|
@ -114,6 +117,7 @@
|
||||||
query: '',
|
query: '',
|
||||||
queryType: defaultQueryType(), // retain from localStorage or default
|
queryType: defaultQueryType(), // retain from localStorage or default
|
||||||
personIds: new SvelteSet(),
|
personIds: new SvelteSet(),
|
||||||
|
albumIds: new SvelteSet(),
|
||||||
tagIds: new SvelteSet(),
|
tagIds: new SvelteSet(),
|
||||||
location: {},
|
location: {},
|
||||||
camera: {},
|
camera: {},
|
||||||
|
|
@ -153,6 +157,7 @@
|
||||||
isFavorite: filter.display.isFavorite || undefined,
|
isFavorite: filter.display.isFavorite || undefined,
|
||||||
isNotInAlbum: filter.display.isNotInAlbum || undefined,
|
isNotInAlbum: filter.display.isNotInAlbum || undefined,
|
||||||
personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,
|
personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,
|
||||||
|
albumIds: filter.albumIds.size > 0 ? [...filter.albumIds] : undefined,
|
||||||
tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
|
tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
|
||||||
type,
|
type,
|
||||||
rating: filter.rating,
|
rating: filter.rating,
|
||||||
|
|
@ -188,8 +193,13 @@
|
||||||
<!-- TEXT -->
|
<!-- TEXT -->
|
||||||
<SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} />
|
<SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} />
|
||||||
|
|
||||||
<!-- TAGS -->
|
<div class="grid grid-auto-fit-40 gap-5">
|
||||||
<SearchTagsSection bind:selectedTags={filter.tagIds} />
|
<!-- ALBUMS -->
|
||||||
|
<SearchAlbumsSection bind:selectedAlbums={filter.albumIds} />
|
||||||
|
|
||||||
|
<!-- TAGS -->
|
||||||
|
<SearchTagsSection bind:selectedTags={filter.tagIds} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- LOCATION -->
|
<!-- LOCATION -->
|
||||||
<SearchLocationSection bind:filters={filter.location} />
|
<SearchLocationSection bind:filters={filter.location} />
|
||||||
|
|
@ -210,7 +220,7 @@
|
||||||
<SearchMediaSection bind:filteredMedia={filter.mediaType} />
|
<SearchMediaSection bind:filteredMedia={filter.mediaType} />
|
||||||
|
|
||||||
<!-- DISPLAY OPTIONS -->
|
<!-- DISPLAY OPTIONS -->
|
||||||
<SearchDisplaySection bind:filters={filter.display} />
|
<SearchDisplaySection bind:filters={filter.display} selectedAlbums={filter.albumIds} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
|
getAlbumInfo,
|
||||||
getPerson,
|
getPerson,
|
||||||
getTagById,
|
getTagById,
|
||||||
type MetadataSearchDto,
|
type MetadataSearchDto,
|
||||||
|
|
@ -201,6 +202,7 @@
|
||||||
model: $t('camera_model'),
|
model: $t('camera_model'),
|
||||||
lensModel: $t('lens_model'),
|
lensModel: $t('lens_model'),
|
||||||
personIds: $t('people'),
|
personIds: $t('people'),
|
||||||
|
albumIds: $t('albums'),
|
||||||
tagIds: $t('tags'),
|
tagIds: $t('tags'),
|
||||||
originalFileName: $t('file_name'),
|
originalFileName: $t('file_name'),
|
||||||
description: $t('description'),
|
description: $t('description'),
|
||||||
|
|
@ -225,6 +227,18 @@
|
||||||
return personNames.join(', ');
|
return personNames.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getAlbumNames(albumIds: string[]) {
|
||||||
|
const albumNames = await Promise.all(
|
||||||
|
albumIds.map(async (albumId) => {
|
||||||
|
const album = await getAlbumInfo({ id: albumId, withoutAssets: true });
|
||||||
|
|
||||||
|
return album.albumName;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return albumNames.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
async function getTagNames(tagIds: string[] | null) {
|
async function getTagNames(tagIds: string[] | null) {
|
||||||
if (tagIds === null) {
|
if (tagIds === null) {
|
||||||
return $t('untagged');
|
return $t('untagged');
|
||||||
|
|
@ -338,6 +352,10 @@
|
||||||
{#await getPersonName(value) then personName}
|
{#await getPersonName(value) then personName}
|
||||||
{personName}
|
{personName}
|
||||||
{/await}
|
{/await}
|
||||||
|
{:else if searchKey === 'albumIds' && Array.isArray(value)}
|
||||||
|
{#await getAlbumNames(value) then albumNames}
|
||||||
|
{albumNames}
|
||||||
|
{/await}
|
||||||
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
|
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
|
||||||
{#await getTagNames(value) then tagNames}
|
{#await getTagNames(value) then tagNames}
|
||||||
{tagNames}
|
{tagNames}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue