refactor: rename timeline actions (#22086)
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import type { OnAddToAlbum } from '$lib/utils/actions';
|
||||
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
shared?: boolean;
|
||||
onAddToAlbum?: OnAddToAlbum;
|
||||
}
|
||||
|
||||
let { shared = false, onAddToAlbum = () => {} }: Props = $props();
|
||||
|
||||
const { getAssets } = getAssetControlContext();
|
||||
|
||||
const onClick = async () => {
|
||||
const albums = await modalManager.show(AlbumPickerModal, { shared });
|
||||
if (!albums || albums.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetIds = [...getAssets()].map(({ id }) => id);
|
||||
if (albums.length === 1) {
|
||||
const album = albums[0];
|
||||
await addAssetsToAlbum(album.id, assetIds);
|
||||
onAddToAlbum(assetIds, album.id);
|
||||
} else {
|
||||
await addAssetsToAlbums(
|
||||
albums.map(({ id }) => id),
|
||||
assetIds,
|
||||
);
|
||||
onAddToAlbum(assetIds, albums[0].id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption
|
||||
{onClick}
|
||||
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
|
||||
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
|
||||
shortcut={{ key: 'l', shift: shared }}
|
||||
/>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import type { OnArchive } from '$lib/utils/actions';
|
||||
import { archiveAssets } from '$lib/utils/asset-utils';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
interface Props {
|
||||
onArchive?: OnArchive;
|
||||
menuItem?: boolean;
|
||||
unarchive?: boolean;
|
||||
}
|
||||
|
||||
let { onArchive, menuItem = false, unarchive = false }: Props = $props();
|
||||
|
||||
let text = $derived(unarchive ? $t('unarchive') : $t('to_archive'));
|
||||
let icon = $derived(unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline);
|
||||
|
||||
let loading = $state(false);
|
||||
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const handleArchive = async () => {
|
||||
const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
|
||||
loading = true;
|
||||
const ids = await archiveAssets(assets, isArchived as AssetVisibility);
|
||||
if (ids) {
|
||||
onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
|
||||
clearSelect();
|
||||
}
|
||||
loading = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption {text} {icon} onClick={handleArchive} />
|
||||
{/if}
|
||||
|
||||
{#if !menuItem}
|
||||
{#if loading}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('loading')}
|
||||
icon={mdiTimerSand}
|
||||
onclick={() => {}}
|
||||
/>
|
||||
{:else}
|
||||
<IconButton shape="round" color="secondary" variant="ghost" aria-label={text} {icon} onclick={handleArchive} />
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import {
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetJobName, runAssetJobs } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
jobs?: AssetJobName[];
|
||||
}
|
||||
|
||||
let { jobs = [AssetJobName.RegenerateThumbnail, AssetJobName.RefreshMetadata, AssetJobName.TranscodeVideo] }: Props =
|
||||
$props();
|
||||
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const isAllVideos = $derived([...getOwnedAssets()].every((asset) => asset.isVideo));
|
||||
|
||||
const handleRunJob = async (name: AssetJobName) => {
|
||||
try {
|
||||
const ids = [...getOwnedAssets()].map(({ id }) => id);
|
||||
await runAssetJobs({ assetJobsDto: { assetIds: ids, name } });
|
||||
notificationController.show({ message: $getAssetJobMessage(name), type: NotificationType.Info });
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_submit_job'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#each jobs as job (job)}
|
||||
{#if isAllVideos || job !== AssetJobName.TranscodeVideo}
|
||||
<MenuOption text={$getAssetJobName(job)} icon={getAssetJobIcon(job)} onClick={() => handleRunJob(job)} />
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import ChangeDate, {
|
||||
type AbsoluteResult,
|
||||
type RelativeResult,
|
||||
} from '$lib/components/shared-components/change-date.svelte';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSelectedAssets } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util.js';
|
||||
import { updateAssets } from '@immich/sdk';
|
||||
import { mdiCalendarEditOutline } from '@mdi/js';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
interface Props {
|
||||
menuItem?: boolean;
|
||||
}
|
||||
|
||||
let { menuItem = false }: Props = $props();
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
let isShowChangeDate = $state(false);
|
||||
|
||||
let currentInterval = $derived.by(() => {
|
||||
if (isShowChangeDate) {
|
||||
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
||||
const assets = getOwnedAssets().filter((asset) => ids.includes(asset.id));
|
||||
const imageTimestamps = assets.map((asset) => {
|
||||
let localDateTime = fromTimelinePlainDateTime(asset.localDateTime);
|
||||
let fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt);
|
||||
let offsetMinutes = localDateTime.diff(fileCreatedAt, 'minutes').shiftTo('minutes').minutes;
|
||||
const timeZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`;
|
||||
return fileCreatedAt.setZone('utc', { keepLocalTime: true }).setZone(timeZone);
|
||||
});
|
||||
let minTimestamp = imageTimestamps[0];
|
||||
let maxTimestamp = imageTimestamps[0];
|
||||
for (let current of imageTimestamps) {
|
||||
if (current < minTimestamp) {
|
||||
minTimestamp = current;
|
||||
}
|
||||
|
||||
if (current > maxTimestamp) {
|
||||
maxTimestamp = current;
|
||||
}
|
||||
}
|
||||
return { start: minTimestamp, end: maxTimestamp };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const handleConfirm = async (result: AbsoluteResult | RelativeResult) => {
|
||||
isShowChangeDate = false;
|
||||
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
||||
|
||||
try {
|
||||
if (result.mode === 'absolute') {
|
||||
await updateAssets({ assetBulkUpdateDto: { ids, dateTimeOriginal: result.date } });
|
||||
} else if (result.mode === 'relative') {
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids,
|
||||
dateTimeRelative: result.duration,
|
||||
timeZone: result.timeZone,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_date'));
|
||||
}
|
||||
clearSelect();
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={() => (isShowChangeDate = true)} />
|
||||
{/if}
|
||||
{#if isShowChangeDate}
|
||||
<ChangeDate
|
||||
initialDate={DateTime.now()}
|
||||
{currentInterval}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => (isShowChangeDate = false)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import AssetUpdateDescriptionConfirmModal from '$lib/modals/AssetUpdateDescriptionConfirmModal.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSelectedAssets } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAssets } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiText } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
interface Props {
|
||||
menuItem?: boolean;
|
||||
}
|
||||
|
||||
let { menuItem = false }: Props = $props();
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const handleUpdateDescription = async () => {
|
||||
const description = await modalManager.show(AssetUpdateDescriptionConfirmModal);
|
||||
if (description) {
|
||||
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
||||
|
||||
try {
|
||||
await updateAssets({ assetBulkUpdateDto: { ids, description } });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_description'));
|
||||
}
|
||||
clearSelect();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text={$t('change_description')} icon={mdiText} onClick={() => handleUpdateDescription()} />
|
||||
{/if}
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSelectedAssets } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAssets } from '@immich/sdk';
|
||||
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
interface Props {
|
||||
menuItem?: boolean;
|
||||
}
|
||||
|
||||
let { menuItem = false }: Props = $props();
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
let isShowChangeLocation = $state(false);
|
||||
|
||||
async function handleConfirm(point?: { lng: number; lat: number }) {
|
||||
isShowChangeLocation = false;
|
||||
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
||||
|
||||
try {
|
||||
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_location'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption
|
||||
text={$t('change_location')}
|
||||
icon={mdiMapMarkerMultipleOutline}
|
||||
onClick={() => (isShowChangeLocation = true)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isShowChangeLocation}
|
||||
<ChangeLocation onClose={handleConfirm} />
|
||||
{/if}
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { makeSharedLinkUrl } from '$lib/utils';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
const { getAssets } = getAssetControlContext();
|
||||
|
||||
const handleClick = async () => {
|
||||
const sharedLink = await modalManager.show(SharedLinkCreateModal, {
|
||||
assetIds: [...getAssets()].map(({ id }) => id),
|
||||
});
|
||||
|
||||
if (sharedLink) {
|
||||
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('share')}
|
||||
icon={mdiShareVariantOutline}
|
||||
onclick={handleClick}
|
||||
/>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiDeleteForeverOutline, mdiDeleteOutline, mdiTimerSand } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
interface Props {
|
||||
onAssetDelete: OnDelete;
|
||||
onUndoDelete?: OnUndoDelete | undefined;
|
||||
menuItem?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
let { onAssetDelete, onUndoDelete = undefined, menuItem = false, force = !$featureFlags.trash }: Props = $props();
|
||||
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
let isShowConfirmation = $state(false);
|
||||
let loading = $state(false);
|
||||
|
||||
let label = $derived(force ? $t('permanently_delete') : $t('delete'));
|
||||
|
||||
const handleTrash = async () => {
|
||||
if (force) {
|
||||
isShowConfirmation = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDelete();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
loading = true;
|
||||
const assets = [...getOwnedAssets()];
|
||||
await deleteAssets(force, onAssetDelete, assets, onUndoDelete);
|
||||
clearSelect();
|
||||
isShowConfirmation = false;
|
||||
loading = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text={label} icon={mdiDeleteOutline} onClick={handleTrash} />
|
||||
{:else if loading}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('loading')}
|
||||
icon={mdiTimerSand}
|
||||
onclick={() => {}}
|
||||
/>
|
||||
{:else}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={label}
|
||||
icon={mdiDeleteForeverOutline}
|
||||
onclick={handleTrash}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isShowConfirmation}
|
||||
<DeleteAssetDialog
|
||||
size={getOwnedAssets().length}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => (isShowConfirmation = false)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
|
||||
import { getAssetInfo } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiDownload } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
interface Props {
|
||||
filename?: string;
|
||||
menuItem?: boolean;
|
||||
}
|
||||
|
||||
let { filename = 'immich.zip', menuItem = false }: Props = $props();
|
||||
|
||||
const { getAssets, clearSelect } = getAssetControlContext();
|
||||
|
||||
const handleDownloadFiles = async () => {
|
||||
const assets = [...getAssets()];
|
||||
if (assets.length === 1) {
|
||||
clearSelect();
|
||||
let asset = await getAssetInfo({ ...authManager.params, id: assets[0].id });
|
||||
await downloadFile(asset);
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelect();
|
||||
await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) });
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: handleDownloadFiles }} />
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text={$t('download')} icon={mdiDownload} onClick={handleDownloadFiles} />
|
||||
{:else}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('download')}
|
||||
icon={mdiDownload}
|
||||
onclick={handleDownloadFiles}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import {
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import type { OnFavorite } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAssets } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiHeartMinusOutline, mdiHeartOutline, mdiTimerSand } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onFavorite?: OnFavorite;
|
||||
menuItem?: boolean;
|
||||
removeFavorite: boolean;
|
||||
}
|
||||
|
||||
let { onFavorite, menuItem = false, removeFavorite }: Props = $props();
|
||||
|
||||
let text = $derived(removeFavorite ? $t('remove_from_favorites') : $t('to_favorite'));
|
||||
let icon = $derived(removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline);
|
||||
|
||||
let loading = $state(false);
|
||||
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const handleFavorite = async () => {
|
||||
const isFavorite = !removeFavorite;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const assets = [...getOwnedAssets()].filter((asset) => asset.isFavorite !== isFavorite);
|
||||
|
||||
const ids = assets.map(({ id }) => id);
|
||||
|
||||
if (ids.length > 0) {
|
||||
await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
asset.isFavorite = isFavorite;
|
||||
}
|
||||
|
||||
onFavorite?.(ids, isFavorite);
|
||||
|
||||
notificationController.show({
|
||||
message: isFavorite
|
||||
? $t('added_to_favorites_count', { values: { count: ids.length } })
|
||||
: $t('removed_from_favorites_count', { values: { count: ids.length } }),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: isFavorite } }));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption {text} {icon} onClick={handleFavorite} />
|
||||
{/if}
|
||||
|
||||
{#if !menuItem}
|
||||
{#if loading}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('loading')}
|
||||
icon={mdiTimerSand}
|
||||
onclick={() => {}}
|
||||
/>
|
||||
{:else}
|
||||
<IconButton shape="round" color="secondary" variant="ghost" aria-label={text} {icon} onclick={handleFavorite} />
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { OnLink, OnUnlink } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAssetInfo, updateAsset } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiLinkOff, mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
interface Props {
|
||||
onLink: OnLink;
|
||||
onUnlink: OnUnlink;
|
||||
menuItem?: boolean;
|
||||
unlink?: boolean;
|
||||
}
|
||||
|
||||
let { onLink, onUnlink, menuItem = false, unlink = false }: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
|
||||
let text = $derived(unlink ? $t('unlink_motion_video') : $t('link_motion_video'));
|
||||
let icon = $derived(unlink ? mdiLinkOff : mdiMotionPlayOutline);
|
||||
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const onClick = () => (unlink ? handleUnlink() : handleLink());
|
||||
|
||||
const handleLink = async () => {
|
||||
let [still, motion] = [...getOwnedAssets()];
|
||||
if ((still as TimelineAsset).isVideo) {
|
||||
[still, motion] = [motion, still];
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
|
||||
onLink({ still: toTimelineAsset(stillResponse), motion: motion as TimelineAsset });
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_link_motion_video'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlink = async () => {
|
||||
const [still] = [...getOwnedAssets()];
|
||||
if (!still) {
|
||||
return;
|
||||
}
|
||||
const motionId = still.livePhotoVideoId;
|
||||
if (!motionId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
loading = true;
|
||||
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } });
|
||||
const motionResponse = await getAssetInfo({ ...authManager.params, id: motionId });
|
||||
onUnlink({ still: toTimelineAsset(stillResponse), motion: toTimelineAsset(motionResponse) });
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_unlink_motion_video'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption {text} {icon} {onClick} />
|
||||
{/if}
|
||||
|
||||
{#if !menuItem}
|
||||
{#if loading}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('loading')}
|
||||
icon={mdiTimerSand}
|
||||
onclick={() => {}}
|
||||
/>
|
||||
{:else}
|
||||
<IconButton shape="round" color="secondary" variant="ghost" aria-label={text} {icon} onclick={onClick} />
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { getAlbumInfo, removeAssetFromAlbum, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiDeleteOutline, mdiImageRemoveOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
onRemove: ((assetIds: string[]) => void) | undefined;
|
||||
menuItem?: boolean;
|
||||
}
|
||||
|
||||
let { album = $bindable(), onRemove, menuItem = false }: Props = $props();
|
||||
|
||||
const { getAssets, clearSelect } = getAssetControlContext();
|
||||
|
||||
const removeFromAlbum = async () => {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
prompt: $t('remove_assets_album_confirmation', { values: { count: getAssets().length } }),
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ids = [...getAssets()].map((a) => a.id);
|
||||
const results = await removeAssetFromAlbum({
|
||||
id: album.id,
|
||||
bulkIdsDto: { ids },
|
||||
});
|
||||
|
||||
album = await getAlbumInfo({ id: album.id });
|
||||
|
||||
onRemove?.(ids);
|
||||
|
||||
const count = results.filter(({ success }) => success).length;
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('assets_removed_count', { values: { count } }),
|
||||
});
|
||||
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
console.error('Error [album-viewer] [removeAssetFromAlbum]', error);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: $t('errors.error_removing_assets_from_album'),
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text={$t('remove_from_album')} icon={mdiImageRemoveOutline} onClick={removeFromAlbum} />
|
||||
{:else}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('remove_from_album')}
|
||||
icon={mdiDeleteOutline}
|
||||
onclick={removeFromAlbum}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { removeSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiDeleteOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { NotificationType, notificationController } from '../../shared-components/notification/notification';
|
||||
|
||||
interface Props {
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
}
|
||||
|
||||
let { sharedLink = $bindable() }: Props = $props();
|
||||
|
||||
const { getAssets, clearSelect } = getAssetControlContext();
|
||||
|
||||
const handleRemove = async () => {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
title: $t('remove_assets_title'),
|
||||
prompt: $t('remove_assets_shared_link_confirmation', { values: { count: getAssets().length } }),
|
||||
confirmText: $t('remove'),
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await removeSharedLinkAssets({
|
||||
...authManager.params,
|
||||
id: sharedLink.id,
|
||||
assetIdsDto: {
|
||||
assetIds: [...getAssets()].map((asset) => asset.id),
|
||||
},
|
||||
});
|
||||
|
||||
for (const result of results) {
|
||||
if (!result.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== result.assetId);
|
||||
}
|
||||
|
||||
const count = results.filter((item) => item.success).length;
|
||||
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('assets_removed_count', { values: { count } }),
|
||||
});
|
||||
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_assets_from_shared_link'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('remove_from_shared_link')}
|
||||
onclick={handleRemove}
|
||||
icon={mdiDeleteOutline}
|
||||
/>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import type { OnRestore } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { restoreAssets } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { mdiHistory } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onRestore: OnRestore | undefined;
|
||||
}
|
||||
|
||||
let { onRestore }: Props = $props();
|
||||
|
||||
const { getAssets, clearSelect } = getAssetControlContext();
|
||||
|
||||
let loading = $state(false);
|
||||
|
||||
const handleRestore = async () => {
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const ids = [...getAssets()].map((a) => a.id);
|
||||
await restoreAssets({ bulkIdsDto: { ids } });
|
||||
onRestore?.(ids);
|
||||
|
||||
notificationController.show({
|
||||
message: $t('assets_restored_count', { values: { count: ids.length } }),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_restore_assets'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Button
|
||||
leadingIcon={mdiHistory}
|
||||
disabled={loading}
|
||||
size="medium"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={handleRestore}
|
||||
>
|
||||
{$t('restore')}
|
||||
</Button>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
|
||||
import { Button, IconButton } from '@immich/ui';
|
||||
import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
timelineManager: TimelineManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
withText?: boolean;
|
||||
}
|
||||
|
||||
let { timelineManager, assetInteraction, withText = false }: Props = $props();
|
||||
|
||||
const handleSelectAll = async () => {
|
||||
await selectAllAssets(timelineManager, assetInteraction);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelMultiselect(assetInteraction);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if withText}
|
||||
<Button
|
||||
leadingIcon={$isSelectingAllAssets ? mdiSelectRemove : mdiSelectAll}
|
||||
size="medium"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={$isSelectingAllAssets ? handleCancel : handleSelectAll}
|
||||
>
|
||||
{$isSelectingAllAssets ? $t('unselect_all') : $t('select_all')}
|
||||
</Button>
|
||||
{:else}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$isSelectingAllAssets ? $t('unselect_all') : $t('select_all')}
|
||||
icon={$isSelectingAllAssets ? mdiSelectRemove : mdiSelectAll}
|
||||
onclick={$isSelectingAllAssets ? handleCancel : handleSelectAll}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import type { OnSetVisibility } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetVisibility, updateAssets } from '@immich/sdk';
|
||||
import { Button, modalManager } from '@immich/ui';
|
||||
import { mdiLockOpenVariantOutline, mdiLockOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onVisibilitySet: OnSetVisibility;
|
||||
menuItem?: boolean;
|
||||
unlock?: boolean;
|
||||
}
|
||||
|
||||
let { onVisibilitySet, menuItem = false, unlock = false }: Props = $props();
|
||||
let loading = $state(false);
|
||||
const { getAssets } = getAssetControlContext();
|
||||
|
||||
const setLockedVisibility = async () => {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
title: unlock ? $t('remove_from_locked_folder') : $t('move_to_locked_folder'),
|
||||
prompt: unlock ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
|
||||
confirmText: $t('move'),
|
||||
confirmColor: unlock ? 'danger' : 'primary',
|
||||
icon: unlock ? mdiLockOpenVariantOutline : mdiLockOutline,
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const assetIds = getAssets().map(({ id }) => id);
|
||||
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids: assetIds,
|
||||
visibility: unlock ? AssetVisibility.Timeline : AssetVisibility.Locked,
|
||||
},
|
||||
});
|
||||
|
||||
onVisibilitySet(assetIds);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_settings'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption
|
||||
onClick={setLockedVisibility}
|
||||
text={unlock ? $t('move_off_locked_folder') : $t('move_to_locked_folder')}
|
||||
icon={unlock ? mdiLockOpenVariantOutline : mdiLockOutline}
|
||||
/>
|
||||
{:else}
|
||||
<Button
|
||||
leadingIcon={unlock ? mdiLockOpenVariantOutline : mdiLockOutline}
|
||||
disabled={loading}
|
||||
size="medium"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={setLockedVisibility}
|
||||
>
|
||||
{unlock ? $t('move_off_locked_folder') : $t('move_to_locked_folder')}
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import type { OnStack, OnUnstack } from '$lib/utils/actions';
|
||||
import { deleteStack, stackAssets } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { mdiImageMultipleOutline, mdiImageOffOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
unstack?: boolean;
|
||||
onStack: OnStack | undefined;
|
||||
onUnstack: OnUnstack | undefined;
|
||||
}
|
||||
|
||||
let { unstack = false, onStack, onUnstack }: Props = $props();
|
||||
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const handleStack = async () => {
|
||||
const selectedAssets = [...getOwnedAssets()];
|
||||
const result = await stackAssets(selectedAssets);
|
||||
onStack?.(result);
|
||||
clearSelect();
|
||||
};
|
||||
|
||||
const handleUnstack = async () => {
|
||||
const selectedAssets = [...getOwnedAssets()];
|
||||
if (selectedAssets.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const { stack } = selectedAssets[0];
|
||||
if (!stack) {
|
||||
return;
|
||||
}
|
||||
const unstackedAssets = await deleteStack([stack.id]);
|
||||
if (unstackedAssets) {
|
||||
onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a)));
|
||||
}
|
||||
clearSelect();
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if unstack}
|
||||
<MenuOption text={$t('unstack')} icon={mdiImageOffOutline} onClick={handleUnstack} />
|
||||
{:else}
|
||||
<MenuOption text={$t('stack')} icon={mdiImageMultipleOutline} onClick={handleStack} />
|
||||
{/if}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiTagMultipleOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
interface Props {
|
||||
menuItem?: boolean;
|
||||
}
|
||||
|
||||
let { menuItem = false }: Props = $props();
|
||||
|
||||
const text = $t('tag');
|
||||
const icon = mdiTagMultipleOutline;
|
||||
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const handleTagAssets = async () => {
|
||||
const assets = [...getOwnedAssets()];
|
||||
const success = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
|
||||
|
||||
if (success) {
|
||||
clearSelect();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleTagAssets }} />
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption {text} {icon} onClick={handleTagAssets} />
|
||||
{/if}
|
||||
|
||||
{#if !menuItem}
|
||||
<IconButton shape="round" color="secondary" variant="ghost" aria-label={text} {icon} onclick={handleTagAssets} />
|
||||
{/if}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
|
||||
const getFocusedThumb = () => {
|
||||
const current = document.activeElement as HTMLElement | undefined;
|
||||
if (current && current.dataset.thumbnailFocusContainer !== undefined) {
|
||||
return current;
|
||||
}
|
||||
};
|
||||
|
||||
export const focusNextAsset = () =>
|
||||
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
|
||||
|
||||
export const focusPreviousAsset = () =>
|
||||
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
|
||||
|
||||
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
|
||||
|
||||
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
|
||||
const scrolled = scrollToAsset(asset);
|
||||
if (scrolled) {
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||
element?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export const setFocusTo = async (
|
||||
scrollToAsset: (asset: TimelineAsset) => boolean,
|
||||
store: TimelineManager,
|
||||
direction: 'earlier' | 'later',
|
||||
interval: 'day' | 'month' | 'year' | 'asset',
|
||||
) => {
|
||||
if (tracker.isActive()) {
|
||||
// there are unfinished running invocations, so return early
|
||||
return;
|
||||
}
|
||||
const thumb = getFocusedThumb();
|
||||
if (!thumb) {
|
||||
return direction === 'earlier' ? focusNextAsset() : focusPreviousAsset();
|
||||
}
|
||||
|
||||
const invocation = tracker.startInvocation();
|
||||
const id = thumb.dataset.asset;
|
||||
if (!thumb || !id) {
|
||||
invocation.endInvocation();
|
||||
return;
|
||||
}
|
||||
|
||||
const asset =
|
||||
direction === 'earlier'
|
||||
? await store.getEarlierAsset({ id }, interval)
|
||||
: await store.getLaterAsset({ id }, interval);
|
||||
|
||||
if (!invocation.isStillValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
invocation.endInvocation();
|
||||
return;
|
||||
}
|
||||
|
||||
const scrolled = scrollToAsset(asset);
|
||||
if (scrolled) {
|
||||
await tick();
|
||||
if (!invocation.isStillValid()) {
|
||||
return;
|
||||
}
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||
element?.focus();
|
||||
}
|
||||
|
||||
invocation.endInvocation();
|
||||
};
|
||||
Reference in New Issue
Block a user