feat: header context menu (#24374)

This commit is contained in:
Jason Rasmussen
2025-12-04 11:09:38 -05:00
committed by GitHub
parent ba6687dde9
commit 31f2c7b505
22 changed files with 208 additions and 230 deletions
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HeaderButtonActionItem } from '$lib/types';
import { Button } from '@immich/ui';
type Props = {
action: HeaderButtonActionItem;
};
const { action }: Props = $props();
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<Button
variant="ghost"
size="small"
{color}
leadingIcon={icon}
onclick={() => onAction(action)}
title={action.data?.title}
>
{title}
</Button>
{/if}
@@ -1,17 +0,0 @@
<script lang="ts">
import { type ActionItem, Button, Text } from '@immich/ui';
type Props = {
action: ActionItem;
title?: string;
};
const { action, title: titleAttr }: Props = $props();
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<Button variant="ghost" size="small" {color} leadingIcon={icon} onclick={() => onAction(action)} title={titleAttr}>
<Text class="hidden md:block">{title}</Text>
</Button>
{/if}
@@ -1,19 +1,33 @@
<script lang="ts">
import PageContent from '$lib/components/layouts/PageContent.svelte';
import TitleLayout from '$lib/components/layouts/TitleLayout.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import AdminSidebar from '$lib/sidebars/AdminSidebar.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { AppShell, AppShellHeader, AppShellSidebar, Scrollable, type BreadcrumbItem } from '@immich/ui';
import type { HeaderButtonActionItem } from '$lib/types';
import {
AppShell,
AppShellHeader,
AppShellSidebar,
Breadcrumbs,
Button,
ContextMenuButton,
HStack,
MenuItemType,
Scrollable,
isMenuItemType,
type BreadcrumbItem,
} from '@immich/ui';
import { mdiSlashForward } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
breadcrumbs: BreadcrumbItem[];
buttons?: Snippet;
actions?: Array<HeaderButtonActionItem | MenuItemType>;
children?: Snippet;
};
let { breadcrumbs, buttons, children }: Props = $props();
let { breadcrumbs, actions = [], children }: Props = $props();
</script>
<AppShell>
@@ -24,11 +38,37 @@
<AdminSidebar />
</AppShellSidebar>
<TitleLayout {breadcrumbs} {buttons}>
<div class="h-full flex flex-col">
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
{#if actions.length > 0}
<div class="hidden md:block">
<HStack gap={0}>
{#each actions as action, i (i)}
{#if !isMenuItemType(action) && (action.$if?.() ?? true)}
<Button
variant="ghost"
size="small"
color={action.color ?? 'secondary'}
leadingIcon={action.icon}
onclick={() => action.onAction(action)}
title={action.data?.title}
>
{action.title}
</Button>
{/if}
{/each}
</HStack>
</div>
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
{/if}
</div>
<Scrollable class="grow">
<PageContent>
{@render children?.()}
</PageContent>
</Scrollable>
</TitleLayout>
</div>
</AppShell>
@@ -1,21 +0,0 @@
<script lang="ts">
import { Breadcrumbs, type BreadcrumbItem } from '@immich/ui';
import { mdiSlashForward } from '@mdi/js';
import type { Snippet } from 'svelte';
type Props = {
breadcrumbs: BreadcrumbItem[];
buttons?: Snippet;
children?: Snippet;
};
let { breadcrumbs, buttons, children }: Props = $props();
</script>
<div class="h-full flex flex-col">
<div class="flex h-16 w-full place-items-center justify-between border-b p-2">
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
{@render buttons?.()}
</div>
{@render children?.()}
</div>
+13 -20
View File
@@ -28,7 +28,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
title: $t('scan_all_libraries'),
type: $t('command'),
icon: mdiSync,
onAction: () => void handleScanAllLibraries(),
onAction: () => handleScanAllLibraries(),
shortcuts: { shift: true, key: 'r' },
$if: () => libraries.length > 0,
};
@@ -37,7 +37,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
title: $t('create_library'),
type: $t('command'),
icon: mdiPlusBoxOutline,
onAction: () => void handleCreateLibrary(),
onAction: () => handleCreateLibrary(),
shortcuts: { shift: true, key: 'n' },
};
@@ -49,7 +49,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
icon: mdiPencilOutline,
type: $t('command'),
title: $t('rename'),
onAction: () => void modalManager.show(LibraryRenameModal, { library }),
onAction: () => modalManager.show(LibraryRenameModal, { library }),
shortcuts: { key: 'r' },
};
@@ -58,7 +58,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
type: $t('command'),
title: $t('delete'),
color: 'danger',
onAction: () => void handleDeleteLibrary(library),
onAction: () => handleDeleteLibrary(library),
shortcuts: { key: 'Backspace' },
};
@@ -66,21 +66,21 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
icon: mdiPlusBoxOutline,
type: $t('command'),
title: $t('add'),
onAction: () => void modalManager.show(LibraryFolderAddModal, { library }),
onAction: () => modalManager.show(LibraryFolderAddModal, { library }),
};
const AddExclusionPattern: ActionItem = {
icon: mdiPlusBoxOutline,
type: $t('command'),
title: $t('add'),
onAction: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
onAction: () => modalManager.show(LibraryExclusionPatternAddModal, { library }),
};
const Scan: ActionItem = {
icon: mdiSync,
type: $t('command'),
title: $t('scan_library'),
onAction: () => void handleScanLibrary(library),
onAction: () => handleScanLibrary(library),
shortcuts: { shift: true, key: 'r' },
};
@@ -92,14 +92,14 @@ export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryRe
icon: mdiPencilOutline,
type: $t('command'),
title: $t('edit'),
onAction: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
onAction: () => modalManager.show(LibraryFolderEditModal, { folder, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
type: $t('command'),
title: $t('delete'),
onAction: () => void handleDeleteLibraryFolder(library, folder),
onAction: () => handleDeleteLibraryFolder(library, folder),
};
return { Edit, Delete };
@@ -114,14 +114,14 @@ export const getLibraryExclusionPatternActions = (
icon: mdiPencilOutline,
type: $t('command'),
title: $t('edit'),
onAction: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
onAction: () => modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
type: $t('command'),
title: $t('delete'),
onAction: () => void handleDeleteExclusionPattern(library, exclusionPattern),
onAction: () => handleDeleteExclusionPattern(library, exclusionPattern),
};
return { Edit, Delete };
@@ -273,7 +273,7 @@ const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: st
});
if (!confirmed) {
return false;
return;
}
try {
@@ -285,10 +285,7 @@ const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: st
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
export const handleAddLibraryExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
@@ -345,9 +342,8 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('admin.library_remove_exclusion_pattern_prompt') });
if (!confirmed) {
return false;
return;
}
try {
@@ -361,8 +357,5 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
+36 -14
View File
@@ -1,11 +1,20 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { queueManager } from '$lib/managers/queue-manager.svelte';
import JobCreateModal from '$lib/modals/JobCreateModal.svelte';
import { user } from '$lib/stores/user.store';
import type { HeaderButtonActionItem } from '$lib/types';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { emptyQueue, getQueue, QueueName, updateQueue, type QueueResponseDto } from '@immich/sdk';
import {
emptyQueue,
getQueue,
QueueCommand,
QueueName,
runQueueCommandLegacy,
updateQueue,
type QueueResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem, type IconLike } from '@immich/ui';
import {
mdiClose,
@@ -23,7 +32,6 @@ import {
mdiPlay,
mdiPlus,
mdiStateMachine,
mdiSync,
mdiTable,
mdiTagFaces,
mdiTrashCanOutline,
@@ -31,7 +39,6 @@ import {
mdiVideo,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
type QueueItem = {
icon: IconLike;
@@ -39,15 +46,17 @@ type QueueItem = {
subtitle?: string;
};
export const getQueuesActions = ($t: MessageFormatter) => {
const ViewQueues: ActionItem = {
title: $t('admin.queues'),
description: $t('admin.queues_page_description'),
icon: mdiSync,
type: $t('page'),
isGlobal: true,
$if: () => get(user)?.isAdmin,
onAction: () => goto(AppRoute.ADMIN_QUEUES),
export const getQueuesActions = ($t: MessageFormatter, queues: QueueResponseDto[] | undefined) => {
const pausedQueues = (queues ?? []).filter(({ isPaused }) => isPaused).map(({ name }) => name);
const ResumePaused: HeaderButtonActionItem = {
title: $t('resume_paused_jobs', { values: { count: pausedQueues.length } }),
$if: () => pausedQueues.length > 0,
icon: mdiPlay,
onAction: () => handleResumePausedJobs(pausedQueues),
data: {
title: pausedQueues.join(', '),
},
};
const CreateJob: ActionItem = {
@@ -68,7 +77,7 @@ export const getQueuesActions = ($t: MessageFormatter) => {
onAction: () => goto(`${AppRoute.ADMIN_SETTINGS}?isOpen=job`),
};
return { ViewQueues, ManageConcurrency, CreateJob };
return { ResumePaused, ManageConcurrency, CreateJob };
};
export const getQueueActions = ($t: MessageFormatter, queue: QueueResponseDto) => {
@@ -126,6 +135,19 @@ export const handleEmptyQueue = async (queue: QueueResponseDto) => {
}
};
const handleResumePausedJobs = async (queues: QueueName[]) => {
const $t = await getFormatter();
try {
for (const name of queues) {
await runQueueCommandLegacy({ name, queueCommandDto: { command: QueueCommand.Resume, force: false } });
}
await queueManager.refresh();
} catch (error) {
handleError(error, $t('admin.failed_job_command', { values: { command: 'resume', job: 'paused jobs' } }));
}
};
const handleRemoveFailedJobs = async (queue: QueueResponseDto) => {
const $t = await getFormatter();
+6 -8
View File
@@ -24,26 +24,26 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
const Edit: ActionItem = {
title: $t('edit_link'),
icon: mdiPencilOutline,
onAction: () => void goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
onAction: () => goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
};
const Delete: ActionItem = {
title: $t('delete_link'),
icon: mdiTrashCanOutline,
color: 'danger',
onAction: () => void handleDeleteSharedLink(sharedLink),
onAction: () => handleDeleteSharedLink(sharedLink),
};
const Copy: ActionItem = {
title: $t('copy_link'),
icon: mdiContentCopy,
onAction: () => void copyToClipboard(asUrl(sharedLink)),
onAction: () => copyToClipboard(asUrl(sharedLink)),
};
const ViewQrCode: ActionItem = {
title: $t('view_qr_code'),
icon: mdiQrcode,
onAction: () => void handleShowSharedLinkQrCode(sharedLink),
onAction: () => handleShowSharedLinkQrCode(sharedLink),
};
return { Edit, Delete, Copy, ViewQrCode };
@@ -88,7 +88,7 @@ export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto,
}
};
export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto): Promise<boolean> => {
const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto) => {
const $t = await getFormatter();
const success = await modalManager.showDialog({
title: $t('delete_shared_link'),
@@ -96,17 +96,15 @@ export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto):
confirmText: $t('delete'),
});
if (!success) {
return false;
return;
}
try {
await removeSharedLink({ id: sharedLink.id });
eventManager.emit('SharedLinkDelete', sharedLink);
toastManager.success($t('deleted_shared_link'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_shared_link'));
return false;
}
};
@@ -20,7 +20,7 @@ export const getSystemConfigActions = (
description: $t('admin.copy_config_to_clipboard_description'),
type: $t('command'),
icon: mdiContentCopy,
onAction: () => void handleCopyToClipboard(config),
onAction: () => handleCopyToClipboard(config),
shortcuts: { shift: true, key: 'c' },
};
+17 -12
View File
@@ -1,11 +1,13 @@
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
import UserEditModal from '$lib/modals/UserEditModal.svelte';
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
import { user as authUser } from '$lib/stores/user.store';
import type { HeaderButtonActionItem } from '$lib/types';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
@@ -28,6 +30,7 @@ import {
mdiPlusBoxOutline,
mdiTrashCanOutline,
} from '@mdi/js';
import { DateTime } from 'luxon';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
@@ -36,7 +39,7 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
title: $t('create_user'),
type: $t('command'),
icon: mdiPlusBoxOutline,
onAction: () => void modalManager.show(UserCreateModal, {}),
onAction: () => modalManager.show(UserCreateModal, {}),
shortcuts: { shift: true, key: 'n' },
};
@@ -60,11 +63,17 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
shortcuts: { key: 'Backspace' },
};
const Restore: ActionItem = {
const getDeleteDate = (deletedAt: string): Date =>
DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate();
const Restore: HeaderButtonActionItem = {
icon: mdiDeleteRestore,
title: $t('restore'),
type: $t('command'),
color: 'primary',
data: {
title: $t('admin.user_restore_scheduled_removal', { values: { date: getDeleteDate(user.deletedAt!) } }),
},
$if: () => !!user.deletedAt && user.status === UserStatus.Deleted,
onAction: () => modalManager.show(UserRestoreConfirmModal, { user }),
};
@@ -74,14 +83,14 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
title: $t('reset_password'),
type: $t('command'),
$if: () => get(authUser).id !== user.id,
onAction: () => void handleResetPasswordUserAdmin(user),
onAction: () => handleResetPasswordUserAdmin(user),
};
const ResetPinCode: ActionItem = {
icon: mdiLockSmart,
type: $t('command'),
title: $t('reset_pin_code'),
onAction: () => void handleResetPinCodeUserAdmin(user),
onAction: () => handleResetPinCodeUserAdmin(user),
};
return { Update, Delete, Restore, ResetPassword, ResetPinCode };
@@ -162,12 +171,12 @@ const generatePassword = (length: number = 16) => {
return generatedPassword;
};
export const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => {
const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => {
const $t = await getFormatter();
const prompt = $t('admin.confirm_user_password_reset', { values: { user: user.name } });
const success = await modalManager.showDialog({ prompt });
if (!success) {
return false;
return;
}
try {
@@ -176,28 +185,24 @@ export const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) =
eventManager.emit('UserAdminUpdate', response);
toastManager.success();
await modalManager.show(PasswordResetSuccessModal, { newPassword: dto.password });
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_reset_password'));
return false;
}
};
export const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => {
const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => {
const $t = await getFormatter();
const prompt = $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } });
const success = await modalManager.showDialog({ prompt });
if (!success) {
return false;
return;
}
try {
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
eventManager.emit('UserAdminUpdate', response);
toastManager.success($t('pin_code_reset_successfully'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_reset_pin_code'));
return false;
}
};
+3
View File
@@ -1,4 +1,5 @@
import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk';
import type { ActionItem } from '@immich/ui';
export interface ReleaseEvent {
isAvailable: boolean;
@@ -9,3 +10,5 @@ export interface ReleaseEvent {
}
export type QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] };
export type HeaderButtonActionItem = ActionItem & { data?: { title?: string } };