Compare commits
4 commits
main
...
feature/ui
Author | SHA1 | Date | |
---|---|---|---|
|
08f9674f4f | ||
|
7422319d9c | ||
|
659a754b98 | ||
|
1dd56acf8a |
11 changed files with 197 additions and 10 deletions
|
@ -30,7 +30,8 @@ class Asset {
|
|||
exifInfo =
|
||||
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
|
||||
isFavorite = remote.isFavorite,
|
||||
isArchived = remote.isArchived;
|
||||
isArchived = remote.isArchived,
|
||||
tags = remote.tags;
|
||||
|
||||
Asset.local(AssetEntity local, List<int> hash)
|
||||
: localId = local.id,
|
||||
|
@ -45,7 +46,8 @@ class Asset {
|
|||
updatedAt = local.modifiedDateTime,
|
||||
isFavorite = local.isFavorite,
|
||||
isArchived = false,
|
||||
fileCreatedAt = local.createDateTime {
|
||||
fileCreatedAt = local.createDateTime,
|
||||
tags = [] {
|
||||
if (fileCreatedAt.year == 1970) {
|
||||
fileCreatedAt = fileModifiedAt;
|
||||
}
|
||||
|
@ -74,6 +76,7 @@ class Asset {
|
|||
this.exifInfo,
|
||||
required this.isFavorite,
|
||||
required this.isArchived,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
@ignore
|
||||
|
@ -139,6 +142,8 @@ class Asset {
|
|||
|
||||
bool isArchived;
|
||||
|
||||
List<dynamic> tags;
|
||||
|
||||
@ignore
|
||||
ExifInfo? exifInfo;
|
||||
|
||||
|
@ -216,7 +221,8 @@ class Asset {
|
|||
livePhotoVideoId.hashCode ^
|
||||
isFavorite.hashCode ^
|
||||
isLocal.hashCode ^
|
||||
isArchived.hashCode;
|
||||
isArchived.hashCode ^
|
||||
tags.hashCode;
|
||||
|
||||
/// Returns `true` if this [Asset] can updated with values from parameter [a]
|
||||
bool canUpdate(Asset a) {
|
||||
|
@ -306,6 +312,7 @@ class Asset {
|
|||
String? livePhotoVideoId,
|
||||
bool? isFavorite,
|
||||
bool? isArchived,
|
||||
List<dynamic>? tags,
|
||||
ExifInfo? exifInfo,
|
||||
}) =>
|
||||
Asset(
|
||||
|
@ -326,6 +333,7 @@ class Asset {
|
|||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
isArchived: isArchived ?? this.isArchived,
|
||||
exifInfo: exifInfo ?? this.exifInfo,
|
||||
tags: tags ?? [],
|
||||
);
|
||||
|
||||
Future<void> put(Isar db) async {
|
||||
|
@ -365,15 +373,15 @@ class Asset {
|
|||
"remoteId": "${remoteId ?? "N/A"}",
|
||||
"localId": "${localId ?? "N/A"}",
|
||||
"checksum": "$checksum",
|
||||
"ownerId": $ownerId,
|
||||
"ownerId": $ownerId,
|
||||
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
|
||||
"fileCreatedAt": "$fileCreatedAt",
|
||||
"fileModifiedAt": "$fileModifiedAt",
|
||||
"updatedAt": "$updatedAt",
|
||||
"durationInSeconds": $durationInSeconds,
|
||||
"fileModifiedAt": "$fileModifiedAt",
|
||||
"updatedAt": "$updatedAt",
|
||||
"durationInSeconds": $durationInSeconds,
|
||||
"type": "$type",
|
||||
"fileName": "$fileName",
|
||||
"isFavorite": $isFavorite,
|
||||
"fileName": "$fileName",
|
||||
"isFavorite": $isFavorite,
|
||||
"isRemote: $isRemote,
|
||||
"storage": "$storage",
|
||||
"width": ${width ?? "N/A"},
|
||||
|
|
|
@ -203,6 +203,7 @@ void _assetSerialize(
|
|||
writer.writeByte(offsets[12], object.type.index);
|
||||
writer.writeDateTime(offsets[13], object.updatedAt);
|
||||
writer.writeInt(offsets[14], object.width);
|
||||
writer.writeString(offsets[15], jsonEncode(object.tags));
|
||||
}
|
||||
|
||||
Asset _assetDeserialize(
|
||||
|
@ -229,6 +230,7 @@ Asset _assetDeserialize(
|
|||
AssetType.other,
|
||||
updatedAt: reader.readDateTime(offsets[13]),
|
||||
width: reader.readIntOrNull(offsets[14]),
|
||||
tags: jsonDecode(reader.readString(offsets[15])),
|
||||
);
|
||||
return object;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ export const ITagRepository = 'ITagRepository';
|
|||
export interface ITagRepository {
|
||||
getById(userId: string, tagId: string): Promise<TagEntity | null>;
|
||||
getAll(userId: string): Promise<TagEntity[]>;
|
||||
getByName(userId: string, name: string): Promise<TagEntity>;
|
||||
create(tag: Partial<TagEntity>): Promise<TagEntity>;
|
||||
update(tag: Partial<TagEntity>): Promise<TagEntity>;
|
||||
remove(tag: TagEntity): Promise<void>;
|
||||
|
|
|
@ -27,6 +27,10 @@ export class TagRepository implements ITagRepository {
|
|||
return this.repository.find({ where: { userId } });
|
||||
}
|
||||
|
||||
getByName(userId: string, name: string): Promise<TagEntity> {
|
||||
return this.repository.findOneOrFail({ where: { userId, name }});
|
||||
}
|
||||
|
||||
create(tag: Partial<TagEntity>): Promise<TagEntity> {
|
||||
return this.save(tag);
|
||||
}
|
||||
|
|
|
@ -4,13 +4,14 @@ import {
|
|||
IEntityJob,
|
||||
IGeocodingRepository,
|
||||
IJobRepository,
|
||||
ITagRepository,
|
||||
JobName,
|
||||
JOBS_ASSET_PAGINATION_SIZE,
|
||||
QueueName,
|
||||
usePagination,
|
||||
WithoutProperty,
|
||||
} from '@app/domain';
|
||||
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
||||
import { AssetEntity, AssetType, ExifEntity, TagEntity, TagType } from '@app/infra/entities';
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
@ -41,6 +42,7 @@ export class MetadataExtractionProcessor {
|
|||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IGeocodingRepository) private geocodingRepository: IGeocodingRepository,
|
||||
@Inject(ITagRepository) private tagRepository: ITagRepository,
|
||||
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
|
||||
|
||||
configService: ConfigService,
|
||||
|
@ -200,6 +202,23 @@ export class MetadataExtractionProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
// Retrieve tags from exif and create / associate them to the asset
|
||||
const tags = getExifProperty('TagsList') ?? []
|
||||
if (tags.length > 0) {
|
||||
for (const name of tags) {
|
||||
let tag;
|
||||
try {
|
||||
tag = await this.tagRepository.getByName(asset.ownerId, name);
|
||||
} catch (error: any) {
|
||||
tag = await this.tagRepository.create({ name, userId: asset.ownerId, type: TagType.CUSTOM })
|
||||
}
|
||||
|
||||
asset.tags.push({ id: tag.id } as TagEntity);
|
||||
}
|
||||
|
||||
await this.assetRepository.save(asset)
|
||||
}
|
||||
|
||||
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
|
||||
await this.assetRepository.save({ id: asset.id, fileCreatedAt: fileCreatedAt || undefined });
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
SystemConfigApi,
|
||||
UserApi,
|
||||
UserApiFp,
|
||||
TagApi,
|
||||
} from './open-api';
|
||||
import { BASE_PATH } from './open-api/base';
|
||||
import { DUMMY_BASE_URL, toPathString } from './open-api/common';
|
||||
|
@ -36,6 +37,7 @@ export class ImmichApi {
|
|||
public personApi: PersonApi;
|
||||
public systemConfigApi: SystemConfigApi;
|
||||
public userApi: UserApi;
|
||||
public tagApi: TagApi;
|
||||
|
||||
private config: Configuration;
|
||||
|
||||
|
@ -55,6 +57,7 @@ export class ImmichApi {
|
|||
this.personApi = new PersonApi(this.config);
|
||||
this.systemConfigApi = new SystemConfigApi(this.config);
|
||||
this.userApi = new UserApi(this.config);
|
||||
this.tagApi = new TagApi(this.config);
|
||||
}
|
||||
|
||||
private createUrl(path: string, params?: Record<string, unknown>) {
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
|
||||
import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
|
||||
import TagOutline from 'svelte-material-icons/TagOutline.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { AssetResponseDto, AlbumResponseDto, api, ThumbnailFormat } from '@api';
|
||||
import { asByteUnitString } from '../../utils/byte-units';
|
||||
|
@ -236,6 +237,21 @@
|
|||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.tags.length > 0}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><TagOutline size="24" /></div>
|
||||
<div class="container mx-auto flex flex-row">
|
||||
{#each asset.tags as tag}
|
||||
<a href="/tags/{tag.id}" on:click={() => dispatch('close-viewer')}>
|
||||
<div class="flex justify-center items-center m-1 px-2 py-1 border border-gray-300 rounded-full text-base dark:text-immich-dark-fg font-medium">
|
||||
<div class="flex-initial max-w-full leading-none text-xs font-normal text-center">{tag.name}</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
22
web/src/routes/(user)/tags/[tagId]/+page.server.ts
Normal file
22
web/src/routes/(user)/tags/[tagId]/+page.server.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = (async ({ locals, parent, params }) => {
|
||||
const { user } = await parent();
|
||||
if (!user) {
|
||||
throw redirect(302, AppRoute.AUTH_LOGIN);
|
||||
}
|
||||
|
||||
const { data: tag } = await locals.api.tagApi.getTagById({ id: params.tagId });
|
||||
const { data: assets } = await locals.api.tagApi.getTagAssets({ id: params.tagId });
|
||||
|
||||
return {
|
||||
user,
|
||||
assets,
|
||||
tag,
|
||||
meta: {
|
||||
title: tag.name || 'Tag',
|
||||
},
|
||||
};
|
||||
}) satisfies PageServerLoad;
|
98
web/src/routes/(user)/tags/[tagId]/+page.svelte
Normal file
98
web/src/routes/(user)/tags/[tagId]/+page.svelte
Normal file
|
@ -0,0 +1,98 @@
|
|||
<script lang="ts">
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
|
||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
||||
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
|
||||
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
|
||||
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
|
||||
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
||||
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetResponseDto, TagResponseDto, api } from '@api';
|
||||
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
|
||||
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
|
||||
import Plus from 'svelte-material-icons/Plus.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import SelectAll from 'svelte-material-icons/SelectAll.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import FaceThumbnailSelector from '$lib/components/faces-page/face-thumbnail-selector.svelte';
|
||||
import {
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import TagOutline from 'svelte-material-icons/TagOutline.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
export let tag: TagResponseDto;
|
||||
|
||||
let isSelectingFace = false;
|
||||
let previousRoute: string = AppRoute.EXPLORE;
|
||||
let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||
$: isMultiSelectionMode = selectedAssets.size > 0;
|
||||
$: isAllArchive = Array.from(selectedAssets).every((asset) => asset.isArchived);
|
||||
$: isAllFavorite = Array.from(selectedAssets).every((asset) => asset.isFavorite);
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
// Prevent setting previousRoute to the current page.
|
||||
if (from && from.route.id !== $page.route.id) {
|
||||
previousRoute = from.url.href;
|
||||
}
|
||||
});
|
||||
|
||||
const onAssetDelete = (assetId: string) => {
|
||||
data.assets = data.assets.filter((asset: AssetResponseDto) => asset.id !== assetId);
|
||||
};
|
||||
const handleSelectAll = () => {
|
||||
selectedAssets = new Set(data.assets);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isMultiSelectionMode}
|
||||
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
|
||||
<CreateSharedLink />
|
||||
<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
|
||||
<AssetSelectContextMenu icon={Plus} title="Add">
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
<DeleteAssets {onAssetDelete} />
|
||||
<AssetSelectContextMenu icon={DotsVertical} title="Add">
|
||||
<DownloadAction menuItem filename="{data.tag.name || 'immich'}.zip" />
|
||||
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
|
||||
<ArchiveAction menuItem unarchive={isAllArchive} onAssetArchive={(asset) => onAssetDelete(asset.id)} />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
<ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(previousRoute)} />
|
||||
{/if}
|
||||
|
||||
<!-- Tag information block -->
|
||||
<section class="pt-24 px-4 sm:px-6 flex place-items-center">
|
||||
<div class="flex gap-4 py-2">
|
||||
<div>
|
||||
<TagOutline class="text-6xl text-immich-primary dark:text-immich-dark-primary w-[99%] border-b-2 border-transparent outline-none bg-immich-bg dark:bg-immich-dark-bg"/>
|
||||
</div>
|
||||
<div class="text-6xl text-immich-primary dark:text-immich-dark-primary w-[99%] border-b-2 border-transparent outline-none bg-immich-bg dark:bg-immich-dark-bg">
|
||||
{data.tag.name}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Gallery Block -->
|
||||
{#if !isSelectingFace}
|
||||
<section class="relative pt-8 sm:px-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg">
|
||||
<section class="overflow-y-auto relative immich-scrollbar">
|
||||
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
|
||||
<GalleryViewer assets={data.assets} viewFrom="search-page" showArchiveIcon={true} bind:selectedAssets />
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
{/if}
|
14
web/src/routes/(user)/tags/[tagId]/photos/[assetId]/+page.ts
Normal file
14
web/src/routes/(user)/tags/[tagId]/photos/[assetId]/+page.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
export const prerender = false;
|
||||
|
||||
export const load: PageLoad = async ({ params, parent }) => {
|
||||
const { user } = await parent();
|
||||
if (!user) {
|
||||
throw redirect(302, AppRoute.AUTH_LOGIN);
|
||||
}
|
||||
|
||||
const tagId = params['tagId'];
|
||||
throw redirect(302, `${AppRoute.TAG}/${tagId}`);
|
||||
};
|
Loading…
Reference in a new issue