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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 3032 additions and 814 deletions

View file

@ -0,0 +1,80 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte';
import { AppRoute } from '$lib/constants';
import { isSharedLink } from '$lib/utils';
import { removeTag, tagAssets } from '$lib/utils/asset-utils';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
export let isOwner: boolean;
$: tags = asset.tags || [];
let isOpen = false;
const handleAdd = () => (isOpen = true);
const handleCancel = () => (isOpen = false);
const handleTag = async (tagIds: string[]) => {
const ids = await tagAssets({ tagIds, assetIds: [asset.id], showNotification: false });
if (ids) {
isOpen = false;
}
asset = await getAssetInfo({ id: asset.id });
};
const handleRemove = async (tagId: string) => {
const ids = await removeTag({ tagIds: [tagId], assetIds: [asset.id], showNotification: false });
if (ids) {
asset = await getAssetInfo({ id: asset.id });
}
};
</script>
{#if isOwner && !isSharedLink()}
<section class="px-4 mt-4">
<div class="flex h-10 w-full items-center justify-between text-sm">
<h2>{$t('tags').toUpperCase()}</h2>
</div>
<section class="flex flex-wrap pt-2 gap-1">
{#each tags as tag (tag.id)}
<div class="flex group transition-all">
<a
class="inline-block h-min whitespace-nowrap pl-3 pr-1 group-hover:pl-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 rounded-tl-full rounded-bl-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
href={encodeURI(`${AppRoute.TAGS}/?path=${tag.value}`)}
>
<p class="text-sm">
{tag.value}
</p>
</a>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title="Remove tag"
on:click={() => handleRemove(tag.id)}
>
<Icon path={mdiClose} />
</button>
</div>
{/each}
<button
type="button"
class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1"
title="Add tag"
on:click={handleAdd}
>
<span class="text-sm px-1 flex place-items-center place-content-center gap-1"><Icon path={mdiPlus} />Add</span>
</button>
</section>
</section>
{/if}
{#if isOpen}
<TagAssetForm onTag={(tagsIds) => handleTag(tagsIds)} onCancel={handleCancel} />
{/if}

View file

@ -43,6 +43,7 @@
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
import { t } from 'svelte-i18n';
import { goto } from '$app/navigation';
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
@ -157,7 +158,7 @@
<DetailPanelRating {asset} {isOwner} />
{#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
<section class="px-4 py-4 text-sm">
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<h2>{$t('people').toUpperCase()}</h2>
<div class="flex gap-2 items-center">
@ -472,11 +473,11 @@
{/if}
{#if albums.length > 0}
<section class="p-6 dark:text-immich-dark-fg">
<section class="px-6 pt-6 dark:text-immich-dark-fg">
<p class="pb-4 text-sm">{$t('appears_in').toUpperCase()}</p>
{#each albums as album}
<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
<div class="flex gap-4 py-2 hover:cursor-pointer items-center">
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
<div>
<img
alt={album.albumName}
@ -501,6 +502,10 @@
</section>
{/if}
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<DetailPanelTags {asset} {isOwner} />
</section>
{#if showEditFaces}
<PersonSidePanel
assetId={asset.id}

View file

@ -153,7 +153,7 @@
return;
}
if (dateGroup && assetStore) {
assetStore.taskManager.seperatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
assetStore.taskManager.separatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
} else {
intersecting = false;
}

View file

@ -0,0 +1,82 @@
<script lang="ts">
import { mdiClose, mdiTag } from '@mdi/js';
import { t } from 'svelte-i18n';
import Button from '../elements/buttons/button.svelte';
import Combobox, { type ComboBoxOption } from '../shared-components/combobox.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { onMount } from 'svelte';
import { getAllTags, type TagResponseDto } from '@immich/sdk';
import Icon from '$lib/components/elements/icon.svelte';
export let onTag: (tagIds: string[]) => void;
export let onCancel: () => void;
let allTags: TagResponseDto[] = [];
$: tagMap = Object.fromEntries(allTags.map((tag) => [tag.id, tag]));
let selectedIds = new Set<string>();
$: disabled = selectedIds.size === 0;
onMount(async () => {
allTags = await getAllTags();
});
const handleSubmit = () => onTag([...selectedIds]);
const handleSelect = (option?: ComboBoxOption) => {
if (!option) {
return;
}
selectedIds.add(option.value);
selectedIds = selectedIds;
};
const handleRemove = (tag: string) => {
selectedIds.delete(tag);
selectedIds = selectedIds;
};
</script>
<FullScreenModal title={$t('tag_assets')} icon={mdiTag} onClose={onCancel}>
<form on:submit|preventDefault={handleSubmit} autocomplete="off" id="create-tag-form">
<div class="my-4 flex flex-col gap-2">
<Combobox
on:select={({ detail: option }) => handleSelect(option)}
label={$t('tag')}
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
placeholder={$t('search_tags')}
/>
</div>
</form>
<section class="flex flex-wrap pt-2 gap-1">
{#each selectedIds as tagId (tagId)}
{@const tag = tagMap[tagId]}
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap pl-3 pr-1 group-hover:pl-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 rounded-tl-full rounded-bl-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
</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-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title="Remove tag"
on:click={() => handleRemove(tagId)}
>
<Icon path={mdiClose} />
</button>
</div>
{/if}
{/each}
</section>
<svelte:fragment slot="sticky-bottom">
<Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button>
<Button type="submit" fullwidth form="create-tag-form" {disabled}>{$t('tag_assets')}</Button>
</svelte:fragment>
</FullScreenModal>

View file

@ -35,12 +35,16 @@
</slot>
<section class="relative">
{#if title}
{#if title || $$slots.title || $$slots.buttons}
<div
class="absolute flex h-16 w-full place-items-center justify-between border-b p-4 dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
<div class="flex gap-2 items-center">
<div class="font-medium">{title}</div>
<slot name="title">
{#if title}
<div class="font-medium">{title}</div>
{/if}
</slot>
{#if description}
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
{/if}

View file

@ -0,0 +1,47 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { tagAssets } from '$lib/utils/asset-utils';
import { mdiTagMultipleOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte';
export let menuItem = false;
const text = $t('tag');
const icon = mdiTagMultipleOutline;
let loading = false;
let isOpen = false;
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleOpen = () => (isOpen = true);
const handleCancel = () => (isOpen = false);
const handleTag = async (tagIds: string[]) => {
const assets = [...getOwnedAssets()];
loading = true;
const ids = await tagAssets({ tagIds, assetIds: assets.map((asset) => asset.id) });
if (ids) {
clearSelect();
}
loading = false;
};
</script>
{#if menuItem}
<MenuOption {text} {icon} onClick={handleOpen} />
{/if}
{#if !menuItem}
{#if loading}
<CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
{:else}
<CircleIconButton title={text} {icon} on:click={handleOpen} />
{/if}
{/if}
{#if isOpen}
<TagAssetForm onTag={(tagIds) => handleTag(tagIds)} onCancel={handleCancel} />
{/if}

View file

@ -109,7 +109,7 @@
);
},
onSeparate: () => {
$assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () =>
$assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () =>
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
);
},
@ -186,9 +186,9 @@
<div
use:intersectionObserver={{
onIntersect: () => onAssetInGrid?.(asset),
top: `-${TITLE_HEIGHT}px`,
bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`,
right: `-${viewport.width - 1}px`,
top: `${-TITLE_HEIGHT}px`,
bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`,
right: `${-(viewport.width - 1)}px`,
root: assetGridElement,
}}
data-asset-id={asset.id}

View file

@ -498,21 +498,21 @@
}
};
function intersectedHandler(bucket: AssetBucket) {
function handleIntersect(bucket: AssetBucket) {
updateLastIntersectedBucketDate();
const intersectedTask = () => {
const task = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
void $assetStore.loadBucket(bucket.bucketDate);
};
$assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask);
$assetStore.taskManager.intersectedBucket(componentId, bucket, task);
}
function seperatedHandler(bucket: AssetBucket) {
const seperatedTask = () => {
function handleSeparate(bucket: AssetBucket) {
const task = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
bucket.cancel();
};
$assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask);
$assetStore.taskManager.separatedBucket(componentId, bucket, task);
}
const handlePrevious = async () => {
@ -809,8 +809,8 @@
<div
id="bucket"
use:intersectionObserver={{
onIntersect: () => intersectedHandler(bucket),
onSeparate: () => seperatedHandler(bucket),
onIntersect: () => handleIntersect(bucket),
onSeparate: () => handleSeparate(bucket),
top: BUCKET_INTERSECTION_ROOT_TOP,
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
root: element,

View file

@ -1,5 +1,6 @@
<script lang="ts" context="module">
export type ComboBoxOption = {
id?: string;
label: string;
value: string;
};
@ -32,7 +33,7 @@
export let label: string;
export let hideLabel = false;
export let options: ComboBoxOption[] = [];
export let selectedOption: ComboBoxOption | undefined;
export let selectedOption: ComboBoxOption | undefined = undefined;
export let placeholder = '';
/**
@ -237,7 +238,7 @@
{$t('no_results')}
</li>
{/if}
{#each filteredOptions as option, index (option.label)}
{#each filteredOptions as option, index (option.id || option.label)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
aria-selected={index === selectedIndex}

View file

@ -4,6 +4,7 @@
TEXT = 'text',
NUMBER = 'number',
PASSWORD = 'password',
COLOR = 'color',
}
</script>
@ -13,6 +14,7 @@
import { fly } from 'svelte/transition';
import PasswordField from '../password-field.svelte';
import { t } from 'svelte-i18n';
import { onMount, tick } from 'svelte';
export let inputType: SettingInputFieldType;
export let value: string | number;
@ -25,8 +27,11 @@
export let required = false;
export let disabled = false;
export let isEdited = false;
export let autofocus = false;
export let passwordAutocomplete: string = 'current-password';
let input: HTMLInputElement;
const handleChange: FormEventHandler<HTMLInputElement> = (e) => {
value = e.currentTarget.value;
@ -41,6 +46,14 @@
value = newValue;
}
};
onMount(() => {
if (autofocus) {
tick()
.then(() => input?.focus())
.catch((_) => {});
}
});
</script>
<div class="mb-4 w-full">
@ -69,22 +82,46 @@
{/if}
{#if inputType !== SettingInputFieldType.PASSWORD}
<input
class="immich-form-input w-full pb-2"
aria-describedby={desc ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}
type={inputType}
min={min.toString()}
max={max.toString()}
{step}
{required}
{value}
on:change={handleChange}
{disabled}
{title}
/>
<div class="flex place-items-center place-content-center">
{#if inputType === SettingInputFieldType.COLOR}
<input
bind:this={input}
class="immich-form-input w-full pb-2 rounded-none mr-1"
aria-describedby={desc ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}
type="text"
min={min.toString()}
max={max.toString()}
{step}
{required}
{value}
on:change={handleChange}
{disabled}
{title}
/>
{/if}
<input
bind:this={input}
class="immich-form-input w-full pb-2"
class:color-picker={inputType === SettingInputFieldType.COLOR}
aria-describedby={desc ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}
type={inputType}
min={min.toString()}
max={max.toString()}
{step}
{required}
{value}
on:change={handleChange}
{disabled}
{title}
/>
</div>
{:else}
<PasswordField
aria-describedby={desc ? `${label}-desc` : undefined}
@ -100,3 +137,28 @@
/>
{/if}
</div>
<style>
.color-picker {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 52px;
height: 52px;
background-color: transparent;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
}
.color-picker::-webkit-color-swatch {
border-radius: 14px;
border: none;
}
.color-picker::-moz-color-swatch {
border-radius: 14px;
border: none;
}
</style>

View file

@ -21,6 +21,7 @@
mdiToolbox,
mdiToolboxOutline,
mdiFolderOutline,
mdiTagMultipleOutline,
} from '@mdi/js';
import SideBarSection from './side-bar-section.svelte';
import SideBarLink from './side-bar-link.svelte';
@ -105,6 +106,8 @@
</svelte:fragment>
</SideBarLink>
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
<SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
<SideBarLink

View file

@ -1,17 +1,23 @@
<script lang="ts">
import Tree from '$lib/components/shared-components/tree/tree.svelte';
import type { RecursiveObject } from '$lib/utils/tree-utils';
import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils';
export let items: RecursiveObject;
export let parent = '';
export let active = '';
export let icons: { default: string; active: string };
export let getLink: (path: string) => string;
export let getColor: (path: string) => string | undefined = () => undefined;
</script>
<ul class="list-none ml-2">
{#each Object.entries(items) as [path, tree], index (index)}
<li>
<Tree {parent} value={path} {tree} {active} {getLink} />
</li>
{#each Object.entries(items) as [path, tree]}
{@const value = normalizeTreePath(`${parent}/${path}`)}
{@const key = value + getColor(value)}
{#key key}
<li>
<Tree {parent} value={path} {tree} {icons} {active} {getLink} {getColor} />
</li>
{/key}
{/each}
</ul>

View file

@ -2,18 +2,21 @@
import Icon from '$lib/components/elements/icon.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils';
import { mdiChevronDown, mdiChevronRight, mdiFolder, mdiFolderOutline } from '@mdi/js';
import { mdiChevronDown, mdiChevronRight } from '@mdi/js';
export let tree: RecursiveObject;
export let parent: string;
export let value: string;
export let active = '';
export let icons: { default: string; active: string };
export let getLink: (path: string) => string;
export let getColor: (path: string) => string | undefined;
$: path = normalizeTreePath(`${parent}/${value}`);
$: isActive = active.startsWith(path);
$: isOpen = isActive;
$: isTarget = active === path;
$: color = getColor(path);
</script>
<a
@ -21,13 +24,18 @@
title={value}
class={`flex flex-grow place-items-center pl-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`}
>
<button type="button" on:click|preventDefault={() => (isOpen = !isOpen)}>
<button
type="button"
on:click|preventDefault={() => (isOpen = !isOpen)}
class={Object.values(tree).length === 0 ? 'invisible' : ''}
>
<Icon path={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size={20} />
</button>
<div>
<Icon
path={isActive ? mdiFolder : mdiFolderOutline}
path={isActive ? icons.active : icons.default}
class={isActive ? 'text-immich-primary dark:text-immich-dark-primary' : 'text-gray-400'}
{color}
size={20}
/>
</div>
@ -35,5 +43,5 @@
</a>
{#if isOpen}
<TreeItems parent={path} items={tree} {active} {getLink} />
<TreeItems parent={path} items={tree} {icons} {active} {getLink} {getColor} />
{/if}