mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 09:15:44 +03:00
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:
80
web/src/lib/components/asset-viewer/detail-panel-tags.svelte
Normal file
80
web/src/lib/components/asset-viewer/detail-panel-tags.svelte
Normal 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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
82
web/src/lib/components/forms/tag-asset-form.svelte
Normal file
82
web/src/lib/components/forms/tag-asset-form.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
47
web/src/lib/components/photos-page/actions/tag-action.svelte
Normal file
47
web/src/lib/components/photos-page/actions/tag-action.svelte
Normal 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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -47,6 +47,7 @@ export enum AppRoute {
|
||||
DUPLICATES = '/utilities/duplicates',
|
||||
|
||||
FOLDERS = '/folders',
|
||||
TAGS = '/tags',
|
||||
}
|
||||
|
||||
export enum ProjectionType {
|
||||
|
||||
@@ -440,6 +440,7 @@
|
||||
"close": "Close",
|
||||
"collapse": "Collapse",
|
||||
"collapse_all": "Collapse all",
|
||||
"color": "Color",
|
||||
"color_theme": "Color theme",
|
||||
"comment_deleted": "Comment deleted",
|
||||
"comment_options": "Comment options",
|
||||
@@ -473,6 +474,8 @@
|
||||
"create_new_person": "Create new person",
|
||||
"create_new_person_hint": "Assign selected assets to a new person",
|
||||
"create_new_user": "Create new user",
|
||||
"create_tag": "Create tag",
|
||||
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
|
||||
"create_user": "Create user",
|
||||
"created": "Created",
|
||||
"current_device": "Current device",
|
||||
@@ -496,6 +499,8 @@
|
||||
"delete_library": "Delete library",
|
||||
"delete_link": "Delete link",
|
||||
"delete_shared_link": "Delete shared link",
|
||||
"delete_tag": "Delete tag",
|
||||
"delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?",
|
||||
"delete_user": "Delete user",
|
||||
"deleted_shared_link": "Deleted shared link",
|
||||
"description": "Description",
|
||||
@@ -537,6 +542,7 @@
|
||||
"edit_location": "Edit location",
|
||||
"edit_name": "Edit name",
|
||||
"edit_people": "Edit people",
|
||||
"edit_tag": "Edit tag",
|
||||
"edit_title": "Edit Title",
|
||||
"edit_user": "Edit user",
|
||||
"edited": "Edited",
|
||||
@@ -1007,6 +1013,7 @@
|
||||
"removed_from_archive": "Removed from archive",
|
||||
"removed_from_favorites": "Removed from favorites",
|
||||
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
|
||||
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
|
||||
"rename": "Rename",
|
||||
"repair": "Repair",
|
||||
"repair_no_results_message": "Untracked and missing files will show up here",
|
||||
@@ -1055,6 +1062,7 @@
|
||||
"search_people": "Search people",
|
||||
"search_places": "Search places",
|
||||
"search_state": "Search state...",
|
||||
"search_tags": "Search tags...",
|
||||
"search_timezone": "Search timezone...",
|
||||
"search_type": "Search type",
|
||||
"search_your_photos": "Search your photos",
|
||||
@@ -1158,6 +1166,12 @@
|
||||
"sunrise_on_the_beach": "Sunrise on the beach",
|
||||
"swap_merge_direction": "Swap merge direction",
|
||||
"sync": "Sync",
|
||||
"tag": "Tag",
|
||||
"tag_assets": "Tag assets",
|
||||
"tag_created": "Created tag: {tag}",
|
||||
"tag_updated": "Updated tag: {tag}",
|
||||
"tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}",
|
||||
"tags": "Tags",
|
||||
"template": "Template",
|
||||
"theme": "Theme",
|
||||
"theme_selection": "Theme selection",
|
||||
@@ -1169,6 +1183,7 @@
|
||||
"to_change_password": "Change password",
|
||||
"to_favorite": "Favorite",
|
||||
"to_login": "Login",
|
||||
"to_root": "To root",
|
||||
"to_trash": "Trash",
|
||||
"toggle_settings": "Toggle settings",
|
||||
"toggle_theme": "Toggle dark theme",
|
||||
|
||||
@@ -256,9 +256,9 @@ export class AssetGridTaskManager {
|
||||
bucketTask.scheduleIntersected(componentId, task);
|
||||
}
|
||||
|
||||
seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) {
|
||||
separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(bucket);
|
||||
bucketTask.scheduleSeparated(componentId, seperated);
|
||||
bucketTask.scheduleSeparated(componentId, separated);
|
||||
}
|
||||
|
||||
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
|
||||
@@ -266,9 +266,9 @@ export class AssetGridTaskManager {
|
||||
bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
|
||||
}
|
||||
|
||||
seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) {
|
||||
separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
bucketTask.separatedDateGroup(componentId, dateGroup, seperated);
|
||||
bucketTask.separatedDateGroup(componentId, dateGroup, separated);
|
||||
}
|
||||
|
||||
intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) {
|
||||
@@ -277,16 +277,16 @@ export class AssetGridTaskManager {
|
||||
dateGroupTask.intersectedThumbnail(componentId, asset, intersected);
|
||||
}
|
||||
|
||||
seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) {
|
||||
separatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, separated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
|
||||
dateGroupTask.separatedThumbnail(componentId, asset, seperated);
|
||||
dateGroupTask.separatedThumbnail(componentId, asset, separated);
|
||||
}
|
||||
}
|
||||
|
||||
class IntersectionTask {
|
||||
internalTaskManager: InternalTaskManager;
|
||||
seperatedKey;
|
||||
separatedKey;
|
||||
intersectedKey;
|
||||
priority;
|
||||
|
||||
@@ -295,7 +295,7 @@ class IntersectionTask {
|
||||
|
||||
constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
|
||||
this.internalTaskManager = internalTaskManager;
|
||||
this.seperatedKey = keyPrefix + ':s:' + key;
|
||||
this.separatedKey = keyPrefix + ':s:' + key;
|
||||
this.intersectedKey = keyPrefix + ':i:' + key;
|
||||
this.priority = priority;
|
||||
}
|
||||
@@ -325,14 +325,14 @@ class IntersectionTask {
|
||||
this.separated = execTask;
|
||||
const cleanup = () => {
|
||||
this.separated = undefined;
|
||||
this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey);
|
||||
this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey);
|
||||
};
|
||||
return { task: execTask, cleanup };
|
||||
}
|
||||
|
||||
removePendingSeparated() {
|
||||
if (this.separated) {
|
||||
this.internalTaskManager.removeSeparateTask(this.seperatedKey);
|
||||
this.internalTaskManager.removeSeparateTask(this.separatedKey);
|
||||
}
|
||||
}
|
||||
removePendingIntersected() {
|
||||
@@ -368,7 +368,7 @@ class IntersectionTask {
|
||||
task,
|
||||
cleanup,
|
||||
componentId: componentId,
|
||||
taskId: this.seperatedKey,
|
||||
taskId: this.separatedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -448,9 +448,9 @@ class DateGroupTask extends IntersectionTask {
|
||||
thumbnailTask.scheduleIntersected(componentId, intersected);
|
||||
}
|
||||
|
||||
separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) {
|
||||
separatedThumbnail(componentId: string, asset: AssetResponseDto, separated: Task) {
|
||||
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
|
||||
thumbnailTask.scheduleSeparated(componentId, seperated);
|
||||
thumbnailTask.scheduleSeparated(componentId, separated);
|
||||
}
|
||||
}
|
||||
class ThumbnailTask extends IntersectionTask {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { preferences } from '$lib/stores/user.store';
|
||||
import { downloadRequest, getKey, withError } from '$lib/utils';
|
||||
import { createAlbum } from '$lib/utils/album-utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
addAssetsToAlbum as addAssets,
|
||||
createStack,
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
getBaseUrl,
|
||||
getDownloadInfo,
|
||||
getStack,
|
||||
tagAssets as tagAllAssets,
|
||||
untagAssets,
|
||||
updateAsset,
|
||||
updateAssets,
|
||||
type AlbumResponseDto,
|
||||
@@ -61,6 +64,54 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show
|
||||
}
|
||||
};
|
||||
|
||||
export const tagAssets = async ({
|
||||
assetIds,
|
||||
tagIds,
|
||||
showNotification = true,
|
||||
}: {
|
||||
assetIds: string[];
|
||||
tagIds: string[];
|
||||
showNotification?: boolean;
|
||||
}) => {
|
||||
for (const tagId of tagIds) {
|
||||
await tagAllAssets({ id: tagId, bulkIdsDto: { ids: assetIds } });
|
||||
}
|
||||
|
||||
if (showNotification) {
|
||||
const $t = await getFormatter();
|
||||
notificationController.show({
|
||||
message: $t('tagged_assets', { values: { count: assetIds.length } }),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
}
|
||||
|
||||
return assetIds;
|
||||
};
|
||||
|
||||
export const removeTag = async ({
|
||||
assetIds,
|
||||
tagIds,
|
||||
showNotification = true,
|
||||
}: {
|
||||
assetIds: string[];
|
||||
tagIds: string[];
|
||||
showNotification?: boolean;
|
||||
}) => {
|
||||
for (const tagId of tagIds) {
|
||||
await untagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } });
|
||||
}
|
||||
|
||||
if (showNotification) {
|
||||
const $t = await getFormatter();
|
||||
notificationController.show({
|
||||
message: $t('removed_tagged_assets', { values: { count: assetIds.length } }),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
}
|
||||
|
||||
return assetIds;
|
||||
};
|
||||
|
||||
export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => {
|
||||
const album = await createAlbum(albumName, assetIds);
|
||||
if (!album) {
|
||||
|
||||
Reference in New Issue
Block a user