feat: tags (#11980)

* feat: tags

* fix: folder tree icons

* navigate to tag from detail panel

* delete tag

* Tag position and add tag button

* Tag asset in detail panel

* refactor form

* feat: navigate to tag page from clicking on a tag

* feat: delete tags from the tag page

* refactor: moving tag section in detail panel and add + tag button

* feat: tag asset action in detail panel

* refactor add tag form

* fdisable add tag button when there is no selection

* feat: tag bulk endpoint

* feat: tag colors

* chore: clean up

* chore: unit tests

* feat: write tags to sidecar

* Remove tag and auto focus on tag creation form opened

* chore: regenerate migration

* chore: linting

* add color picker to tag edit form

* fix: force render tags timeline on navigating back from asset viewer

* feat: read tags from keywords

* chore: clean up

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen
2024-08-29 12:14:03 -04:00
committed by GitHub
parent 682adaa334
commit d08a20bd57
68 changed files with 3032 additions and 814 deletions
@@ -13,7 +13,7 @@
import { foldersStore } from '$lib/stores/folders.store';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { type AssetResponseDto } from '@immich/sdk';
import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome } from '@mdi/js';
import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -60,7 +60,12 @@
<section>
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
<div class="h-full">
<TreeItems items={tree} active={currentPath} {getLink} />
<TreeItems
icons={{ default: mdiFolderOutline, active: mdiFolder }}
items={tree}
active={currentPath}
{getLink}
/>
</div>
</section>
</SideBarSection>
@@ -73,7 +78,7 @@
<div
class="flex place-items-center gap-2 bg-gray-50 dark:bg-immich-dark-gray/50 w-full py-2 px-4 rounded-2xl border border-gray-100 dark:border-gray-900"
>
<a href={`${AppRoute.FOLDERS}`} title="To root">
<a href={`${AppRoute.FOLDERS}`} title={$t('to_root')}>
<Icon path={mdiFolderHome} class="text-immich-primary dark:text-immich-dark-primary mr-2" size={28} />
</a>
{#each pathSegments as segment, index}
@@ -25,6 +25,7 @@
import { preferences, user } from '$lib/stores/user.store';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
let { isViewing: showAssetViewer } = assetViewingStore;
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
@@ -80,6 +81,7 @@
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
<TagAction menuItem />
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
<hr />
<AssetJobActions />
@@ -0,0 +1,251 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Button from '$lib/components/elements/buttons/button.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { AssetStore } from '$lib/stores/assets.store';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
import { mdiChevronRight, mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
export let data: PageData;
$: pathSegments = data.path ? data.path.split('/') : [];
$: currentPath = $page.url.searchParams.get(QueryParameter.PATH) || '';
const assetInteractionStore = createAssetInteractionStore();
const buildMap = (tags: TagResponseDto[]) => {
return Object.fromEntries(tags.map((tag) => [tag.value, tag]));
};
$: tags = data.tags;
$: tagsMap = buildMap(tags);
$: tag = currentPath ? tagsMap[currentPath] : null;
$: tree = buildTree(tags.map((tag) => tag.value));
const handleNavigation = async (tag: string) => {
await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`));
};
const handleBreadcrumbNavigation = async (targetPath: string) => {
await navigateToView(targetPath);
};
const getLink = (path: string) => {
const url = new URL(AppRoute.TAGS, window.location.href);
url.searchParams.set(QueryParameter.PATH, path);
return url.href;
};
const getColor = (path: string) => tagsMap[path]?.color;
const navigateToView = (path: string) => goto(getLink(path));
let isNewOpen = false;
let newTagValue = '';
const handleCreate = () => {
newTagValue = tag ? tag.value + '/' : '';
isNewOpen = true;
};
let isEditOpen = false;
let newTagColor = '';
const handleEdit = () => {
newTagColor = tag?.color ?? '';
isEditOpen = true;
};
const handleCancel = () => {
isNewOpen = false;
isEditOpen = false;
};
const handleSubmit = async () => {
if (tag && isEditOpen && newTagColor) {
await updateTag({ id: tag.id, tagUpdateDto: { color: newTagColor } });
notificationController.show({
message: $t('tag_updated', { values: { tag: tag.value } }),
type: NotificationType.Info,
});
tags = await getAllTags();
isEditOpen = false;
}
if (isNewOpen && newTagValue) {
const [newTag] = await upsertTags({ tagUpsertDto: { tags: [newTagValue] } });
notificationController.show({
message: $t('tag_created', { values: { tag: newTag.value } }),
type: NotificationType.Info,
});
tags = await getAllTags();
isNewOpen = false;
}
};
const handleDelete = async () => {
if (!tag) {
return;
}
const isConfirm = await dialogController.show({
title: $t('delete_tag'),
prompt: $t('delete_tag_confirmation_prompt', { values: { tagName: tag.value } }),
confirmText: $t('delete'),
cancelText: $t('cancel'),
});
if (!isConfirm) {
return;
}
await deleteTag({ id: tag.id });
tags = await getAllTags();
// navigate to parent
const parentPath = pathSegments.slice(0, -1).join('/');
await navigateToView(parentPath);
};
</script>
<UserPageLayout title={data.meta.title} scrollbar={false}>
<SideBarSection slot="sidebar">
<section>
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
<div class="h-full">
<TreeItems icons={{ default: mdiTag, active: mdiTag }} items={tree} active={currentPath} {getLink} {getColor} />
</div>
</section>
</SideBarSection>
<section slot="buttons">
<LinkButton on:click={handleCreate}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPlus} size="18" />
<p class="hidden md:block">{$t('create_tag')}</p>
</div>
</LinkButton>
<LinkButton on:click={handleEdit}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPencil} size="18" />
<p class="hidden md:block">{$t('edit_tag')}</p>
</div>
</LinkButton>
{#if pathSegments.length > 0 && tag}
<LinkButton on:click={handleDelete}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiTrashCanOutline} size="18" />
<p class="hidden md:block">{$t('delete_tag')}</p>
</div>
</LinkButton>
{/if}
</section>
<section
class="flex place-items-center gap-2 mt-2 text-immich-primary dark:text-immich-dark-primary rounded-2xl bg-gray-50 dark:bg-immich-dark-gray/50 w-full py-2 px-4 border border-gray-100 dark:border-gray-900"
>
<a href={`${AppRoute.TAGS}`} title={$t('to_root')}>
<Icon path={mdiTagMultiple} class="text-immich-primary dark:text-immich-dark-primary mr-2" size={28} />
</a>
{#each pathSegments as segment, index}
<button
class="text-sm font-mono underline hover:font-semibold"
on:click={() => handleBreadcrumbNavigation(pathSegments.slice(0, index + 1).join('/'))}
type="button"
>
{segment}
</button>
<p class="text-gray-500">
{#if index < pathSegments.length - 1}
<Icon path={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size={16} />
{/if}
</p>
{/each}
</section>
<section class="mt-2 h-full">
{#key $page.url.href}
{#if tag}
<AssetGrid
enableRouting={true}
assetStore={new AssetStore({ tagId: tag.id })}
{assetInteractionStore}
removeAction={AssetAction.UNARCHIVE}
>
<TreeItemThumbnails items={data.children} icon={mdiTag} onClick={handleNavigation} slot="empty" />
</AssetGrid>
{:else}
<TreeItemThumbnails items={Object.keys(tree)} icon={mdiTag} onClick={handleNavigation} />
{/if}
{/key}
</section>
</UserPageLayout>
{#if isNewOpen}
<FullScreenModal title={$t('create_tag')} icon={mdiTag} onClose={handleCancel}>
<div class="text-immich-primary dark:text-immich-dark-primary">
<p class="text-sm dark:text-immich-dark-fg">
{$t('create_tag_description')}
</p>
</div>
<form on:submit|preventDefault={handleSubmit} autocomplete="off" id="create-tag-form">
<div class="my-4 flex flex-col gap-2">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('tag').toUpperCase()}
bind:value={newTagValue}
required={true}
autofocus={true}
/>
</div>
</form>
<svelte:fragment slot="sticky-bottom">
<Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
<Button type="submit" fullwidth form="create-tag-form">{$t('create')}</Button>
</svelte:fragment>
</FullScreenModal>
{/if}
{#if isEditOpen}
<FullScreenModal title={$t('edit_tag')} icon={mdiTag} onClose={handleCancel}>
<form on:submit|preventDefault={handleSubmit} autocomplete="off" id="edit-tag-form">
<div class="my-4 flex flex-col gap-2">
<SettingInputField
inputType={SettingInputFieldType.COLOR}
label={$t('color').toUpperCase()}
bind:value={newTagColor}
/>
</div>
</form>
<svelte:fragment slot="sticky-bottom">
<Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
<Button type="submit" fullwidth form="edit-tag-form">{$t('save')}</Button>
</svelte:fragment>
</FullScreenModal>
{/if}
@@ -0,0 +1,32 @@
import { QueryParameter } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { getAllTags } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate();
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
const path = url.searchParams.get(QueryParameter.PATH);
const tags = await getAllTags();
const tree = buildTree(tags.map((tag) => tag.value));
let currentTree = tree;
const parts = normalizeTreePath(path || '').split('/');
for (const part of parts) {
currentTree = currentTree?.[part];
}
return {
tags,
asset,
path,
children: Object.keys(currentTree || {}),
meta: {
title: $t('tags'),
},
};
}) satisfies PageLoad;