mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: locked/private view (#18268)
* feat: locked/private view * feat: locked/private view * pr feedback * fix: redirect loop * pr feedback
This commit is contained in:
parent
4935f3e0bb
commit
b7b0b9b6d8
61 changed files with 1018 additions and 186 deletions
|
|
@ -13,6 +13,8 @@ type ActionMap = {
|
|||
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
|
||||
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
|
||||
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
|
||||
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: AssetResponseDto };
|
||||
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: AssetResponseDto };
|
||||
};
|
||||
|
||||
export type Action = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetVisibility, updateAssets, Visibility, type AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction, PreAction } from './action';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
preAction: PreAction;
|
||||
}
|
||||
|
||||
let { asset, onAction, preAction }: Props = $props();
|
||||
const isLocked = asset.visibility === Visibility.Locked;
|
||||
|
||||
const toggleLockedVisibility = async () => {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
title: isLocked ? $t('remove_from_locked_folder') : $t('move_to_locked_folder'),
|
||||
prompt: isLocked ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
|
||||
confirmText: $t('move'),
|
||||
confirmColor: isLocked ? 'danger' : 'primary',
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
preAction({
|
||||
type: isLocked ? AssetAction.SET_VISIBILITY_TIMELINE : AssetAction.SET_VISIBILITY_LOCKED,
|
||||
asset,
|
||||
});
|
||||
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids: [asset.id],
|
||||
visibility: isLocked ? AssetVisibility.Timeline : AssetVisibility.Locked,
|
||||
},
|
||||
});
|
||||
|
||||
onAction({
|
||||
type: isLocked ? AssetAction.SET_VISIBILITY_TIMELINE : AssetAction.SET_VISIBILITY_LOCKED,
|
||||
asset,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_settings'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption
|
||||
onClick={() => toggleLockedVisibility()}
|
||||
text={isLocked ? $t('move_off_locked_folder') : $t('add_to_locked_folder')}
|
||||
icon={isLocked ? mdiFolderMoveOutline : mdiEyeOffOutline}
|
||||
/>
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
|
||||
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
|
||||
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
|
||||
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
|
||||
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
|
||||
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
|
||||
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
import {
|
||||
AssetJobName,
|
||||
AssetTypeEnum,
|
||||
Visibility,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
type PersonResponseDto,
|
||||
|
|
@ -91,6 +93,7 @@
|
|||
const sharedLink = getSharedLink();
|
||||
let isOwner = $derived($user && asset.ownerId === $user?.id);
|
||||
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
|
||||
let isLocked = $derived(asset.visibility === Visibility.Locked);
|
||||
|
||||
// $: showEditorButton =
|
||||
// isOwner &&
|
||||
|
|
@ -112,7 +115,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto text-white" data-testid="asset-viewer-navbar-actions">
|
||||
{#if !asset.isTrashed && $user}
|
||||
{#if !asset.isTrashed && $user && !isLocked}
|
||||
<ShareAction {asset} />
|
||||
{/if}
|
||||
{#if asset.isOffline}
|
||||
|
|
@ -159,17 +162,20 @@
|
|||
<DeleteAction {asset} {onAction} {preAction} />
|
||||
|
||||
<ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
|
||||
{#if showSlideshow}
|
||||
{#if showSlideshow && !isLocked}
|
||||
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
||||
{/if}
|
||||
{#if showDownloadButton}
|
||||
<DownloadAction {asset} menuItem />
|
||||
{/if}
|
||||
{#if asset.isTrashed}
|
||||
<RestoreAction {asset} {onAction} />
|
||||
{:else}
|
||||
<AddToAlbumAction {asset} {onAction} />
|
||||
<AddToAlbumAction {asset} {onAction} shared />
|
||||
|
||||
{#if !isLocked}
|
||||
{#if asset.isTrashed}
|
||||
<RestoreAction {asset} {onAction} />
|
||||
{:else}
|
||||
<AddToAlbumAction {asset} {onAction} />
|
||||
<AddToAlbumAction {asset} {onAction} shared />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
|
|
@ -183,21 +189,28 @@
|
|||
{#if person}
|
||||
<SetFeaturedPhotoAction {asset} {person} />
|
||||
{/if}
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
{#if asset.type === AssetTypeEnum.Image && !isLocked}
|
||||
<SetProfilePictureAction {asset} />
|
||||
{/if}
|
||||
<ArchiveAction {asset} {onAction} {preAction} />
|
||||
<MenuOption
|
||||
icon={mdiUpload}
|
||||
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
|
||||
text={$t('replace_with_upload')}
|
||||
/>
|
||||
{#if !asset.isArchived && !asset.isTrashed}
|
||||
|
||||
{#if !isLocked}
|
||||
<ArchiveAction {asset} {onAction} {preAction} />
|
||||
<MenuOption
|
||||
icon={mdiImageSearch}
|
||||
onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
|
||||
text={$t('view_in_timeline')}
|
||||
icon={mdiUpload}
|
||||
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
|
||||
text={$t('replace_with_upload')}
|
||||
/>
|
||||
{#if !asset.isArchived && !asset.isTrashed}
|
||||
<MenuOption
|
||||
icon={mdiImageSearch}
|
||||
onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
|
||||
text={$t('view_in_timeline')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !asset.isTrashed}
|
||||
<SetVisibilityAction {asset} {onAction} {preAction} />
|
||||
{/if}
|
||||
<hr />
|
||||
<MenuOption
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
import { Card, CardBody, CardHeader, Heading, immichLogo, Logo, VStack } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
interface Props {
|
||||
title: string;
|
||||
title?: string;
|
||||
children?: Snippet;
|
||||
withHeader?: boolean;
|
||||
}
|
||||
|
||||
let { title, children }: Props = $props();
|
||||
let { title, children, withHeader = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="min-w-dvw flex min-h-dvh items-center justify-center relative">
|
||||
|
|
@ -18,12 +19,14 @@
|
|||
</div>
|
||||
|
||||
<Card color="secondary" class="w-full max-w-lg border m-2">
|
||||
<CardHeader class="mt-6">
|
||||
<VStack>
|
||||
<Logo variant="icon" size="giant" />
|
||||
<Heading size="large" class="font-semibold" color="primary" tag="h1">{title}</Heading>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
{#if withHeader}
|
||||
<CardHeader class="mt-6">
|
||||
<VStack>
|
||||
<Logo variant="icon" size="giant" />
|
||||
<Heading size="large" class="font-semibold" color="primary" tag="h1">{title}</Heading>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
{/if}
|
||||
|
||||
<CardBody class="p-8">
|
||||
{@render children?.()}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script lang="ts">
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { type OnDelete, deleteAssets } from '$lib/utils/actions';
|
||||
import { mdiDeleteForeverOutline, mdiDeleteOutline, 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 { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { mdiTimerSand, mdiDeleteOutline, mdiDeleteForeverOutline } from '@mdi/js';
|
||||
import { type OnDelete, deleteAssets } from '$lib/utils/actions';
|
||||
import DeleteAssetDialog from '../delete-asset-dialog.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onAssetDelete: OnDelete;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
<script lang="ts">
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
|
||||
import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
|
||||
import { Button } from '@immich/ui';
|
||||
import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
assetStore: AssetStore;
|
||||
assetInteraction: AssetInteraction;
|
||||
withText?: boolean;
|
||||
}
|
||||
|
||||
let { assetStore, assetInteraction }: Props = $props();
|
||||
let { assetStore, assetInteraction, withText = false }: Props = $props();
|
||||
|
||||
const handleSelectAll = async () => {
|
||||
await selectAllAssets(assetStore, assetInteraction);
|
||||
|
|
@ -22,8 +24,20 @@
|
|||
};
|
||||
</script>
|
||||
|
||||
{#if $isSelectingAllAssets}
|
||||
<CircleIconButton title={$t('unselect_all')} icon={mdiSelectRemove} onclick={handleCancel} />
|
||||
{#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}
|
||||
<CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
|
||||
<CircleIconButton
|
||||
title={$isSelectingAllAssets ? $t('unselect_all') : $t('select_all')}
|
||||
icon={$isSelectingAllAssets ? mdiSelectRemove : mdiSelectAll}
|
||||
onclick={$isSelectingAllAssets ? handleCancel : handleSelectAll}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
<script lang="ts">
|
||||
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
|
||||
import type { OnSetVisibility } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetVisibility, updateAssets } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { mdiEyeOffOutline, mdiFolderMoveOutline } 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',
|
||||
});
|
||||
|
||||
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('add_to_locked_folder')}
|
||||
icon={unlock ? mdiFolderMoveOutline : mdiEyeOffOutline}
|
||||
/>
|
||||
{:else}
|
||||
<Button
|
||||
leadingIcon={unlock ? mdiFolderMoveOutline : mdiEyeOffOutline}
|
||||
disabled={loading}
|
||||
size="medium"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={setLockedVisibility}
|
||||
>
|
||||
{unlock ? $t('move_off_locked_folder') : $t('add_to_locked_folder')}
|
||||
</Button>
|
||||
{/if}
|
||||
|
|
@ -39,7 +39,13 @@
|
|||
enableRouting: boolean;
|
||||
assetStore: AssetStore;
|
||||
assetInteraction: AssetInteraction;
|
||||
removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null;
|
||||
removeAction?:
|
||||
| AssetAction.UNARCHIVE
|
||||
| AssetAction.ARCHIVE
|
||||
| AssetAction.FAVORITE
|
||||
| AssetAction.UNFAVORITE
|
||||
| AssetAction.SET_VISIBILITY_TIMELINE
|
||||
| null;
|
||||
withStacked?: boolean;
|
||||
showArchiveIcon?: boolean;
|
||||
isShared?: boolean;
|
||||
|
|
@ -417,7 +423,9 @@
|
|||
case AssetAction.TRASH:
|
||||
case AssetAction.RESTORE:
|
||||
case AssetAction.DELETE:
|
||||
case AssetAction.ARCHIVE: {
|
||||
case AssetAction.ARCHIVE:
|
||||
case AssetAction.SET_VISIBILITY_LOCKED:
|
||||
case AssetAction.SET_VISIBILITY_TIMELINE: {
|
||||
// find the next asset to show or close the viewer
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
(await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset }));
|
||||
|
|
@ -445,6 +453,7 @@
|
|||
|
||||
case AssetAction.UNSTACK: {
|
||||
updateUnstackedAssetInTimeline(assetStore, action.assets);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@
|
|||
text: string;
|
||||
fullWidth?: boolean;
|
||||
src?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let { onClick = undefined, text, fullWidth = false, src = empty1Url }: Props = $props();
|
||||
let { onClick = undefined, text, fullWidth = false, src = empty1Url, title }: Props = $props();
|
||||
|
||||
let width = $derived(fullWidth ? 'w-full' : 'w-1/2');
|
||||
|
||||
|
|
@ -24,5 +25,9 @@
|
|||
class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
|
||||
>
|
||||
<img {src} alt="" width="500" draggable="false" />
|
||||
<p class="text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
|
||||
|
||||
{#if title}
|
||||
<h2 class="text-xl font-medium my-4">{title}</h2>
|
||||
{/if}
|
||||
<p class="text-immich-text-gray-500 dark:text-immich-dark-fg font-light">{text}</p>
|
||||
</svelte:element>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
mdiImageMultiple,
|
||||
mdiImageMultipleOutline,
|
||||
mdiLink,
|
||||
mdiLock,
|
||||
mdiLockOutline,
|
||||
mdiMagnify,
|
||||
mdiMap,
|
||||
mdiMapOutline,
|
||||
|
|
@ -40,6 +42,7 @@
|
|||
let isSharingSelected: boolean = $state(false);
|
||||
let isTrashSelected: boolean = $state(false);
|
||||
let isUtilitiesSelected: boolean = $state(false);
|
||||
let isLockedFolderSelected: boolean = $state(false);
|
||||
</script>
|
||||
|
||||
<Sidebar ariaLabel={$t('primary')}>
|
||||
|
|
@ -128,6 +131,13 @@
|
|||
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
|
||||
></SideBarLink>
|
||||
|
||||
<SideBarLink
|
||||
title={$t('locked_folder')}
|
||||
routeId="/(user)/locked"
|
||||
bind:isSelected={isLockedFolderSelected}
|
||||
icon={isLockedFolderSelected ? mdiLock : mdiLockOutline}
|
||||
></SideBarLink>
|
||||
|
||||
{#if $featureFlags.trash}
|
||||
<SideBarLink
|
||||
title={$t('trash')}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { changePinCode } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let currentPinCode = $state('');
|
||||
let newPinCode = $state('');
|
||||
let confirmPinCode = $state('');
|
||||
let isLoading = $state(false);
|
||||
let canSubmit = $derived(currentPinCode.length === 6 && confirmPinCode.length === 6 && newPinCode === confirmPinCode);
|
||||
|
||||
interface Props {
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
let { onChanged }: Props = $props();
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
await handleChangePinCode();
|
||||
};
|
||||
|
||||
const handleChangePinCode = async () => {
|
||||
isLoading = true;
|
||||
try {
|
||||
await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
|
||||
|
||||
resetForm();
|
||||
|
||||
notificationController.show({
|
||||
message: $t('pin_code_changed_successfully'),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
handleError(error, $t('unable_to_change_pin_code'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
currentPinCode = '';
|
||||
newPinCode = '';
|
||||
confirmPinCode = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
|
||||
<div class="flex flex-col gap-6 place-items-center place-content-center">
|
||||
<p class="text-dark">{$t('change_pin_code')}</p>
|
||||
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
|
||||
|
||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
|
||||
|
||||
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { setupPinCode } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onCreated?: (pinCode: string) => void;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
let { onCreated, showLabel = true }: Props = $props();
|
||||
|
||||
let newPinCode = $state('');
|
||||
let confirmPinCode = $state('');
|
||||
let isLoading = $state(false);
|
||||
let canSubmit = $derived(confirmPinCode.length === 6 && newPinCode === confirmPinCode);
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
await createPinCode();
|
||||
};
|
||||
|
||||
const createPinCode = async () => {
|
||||
isLoading = true;
|
||||
try {
|
||||
await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
|
||||
|
||||
notificationController.show({
|
||||
message: $t('pin_code_setup_successfully'),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
|
||||
onCreated?.(newPinCode);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
handleError(error, $t('unable_to_setup_pin_code'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
newPinCode = '';
|
||||
confirmPinCode = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<form autocomplete="off" onsubmit={handleSubmit}>
|
||||
<div class="flex flex-col gap-6 place-items-center place-content-center">
|
||||
{#if showLabel}
|
||||
<p class="text-dark">{$t('setup_pin_code')}</p>
|
||||
{/if}
|
||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
|
||||
|
||||
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={7} pinLength={6} />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
|
||||
{$t('create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -1,12 +1,25 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value?: string;
|
||||
pinLength?: number;
|
||||
tabindexStart?: number;
|
||||
autofocus?: boolean;
|
||||
onFilled?: (value: string) => void;
|
||||
type?: 'text' | 'password';
|
||||
}
|
||||
|
||||
let { label, value = $bindable(''), pinLength = 6, tabindexStart = 0 }: Props = $props();
|
||||
let {
|
||||
label,
|
||||
value = $bindable(''),
|
||||
pinLength = 6,
|
||||
tabindexStart = 0,
|
||||
autofocus = false,
|
||||
onFilled,
|
||||
type = 'text',
|
||||
}: Props = $props();
|
||||
|
||||
let pinValues = $state(Array.from({ length: pinLength }).fill(''));
|
||||
let pinCodeInputElements: HTMLInputElement[] = $state([]);
|
||||
|
|
@ -17,6 +30,12 @@
|
|||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (autofocus) {
|
||||
pinCodeInputElements[0]?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const focusNext = (index: number) => {
|
||||
pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus();
|
||||
};
|
||||
|
|
@ -48,6 +67,10 @@
|
|||
if (value && index < pinLength - 1) {
|
||||
focusNext(index);
|
||||
}
|
||||
|
||||
if (value.length === pinLength) {
|
||||
onFilled?.(value);
|
||||
}
|
||||
};
|
||||
|
||||
function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
|
||||
|
|
@ -97,13 +120,13 @@
|
|||
{#each { length: pinLength } as _, index (index)}
|
||||
<input
|
||||
tabindex={tabindexStart + index}
|
||||
type="text"
|
||||
{type}
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="1"
|
||||
bind:this={pinCodeInputElements[index]}
|
||||
id="pin-code-{index}"
|
||||
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 bg-transparent text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono"
|
||||
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 bg-transparent text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light"
|
||||
bind:value={pinValues[index]}
|
||||
onkeydown={handleKeydown}
|
||||
oninput={(event) => handleInput(event, index)}
|
||||
|
|
|
|||
|
|
@ -1,116 +1,26 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { changePinCode, getAuthStatus, setupPinCode } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import PinCodeChangeForm from '$lib/components/user-settings-page/PinCodeChangeForm.svelte';
|
||||
import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
|
||||
import { getAuthStatus } from '@immich/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let hasPinCode = $state(false);
|
||||
let currentPinCode = $state('');
|
||||
let newPinCode = $state('');
|
||||
let confirmPinCode = $state('');
|
||||
let isLoading = $state(false);
|
||||
let canSubmit = $derived(
|
||||
(hasPinCode ? currentPinCode.length === 6 : true) && confirmPinCode.length === 6 && newPinCode === confirmPinCode,
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
const authStatus = await getAuthStatus();
|
||||
hasPinCode = authStatus.pinCode;
|
||||
const { pinCode } = await getAuthStatus();
|
||||
hasPinCode = pinCode;
|
||||
});
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
await (hasPinCode ? handleChange() : handleSetup());
|
||||
};
|
||||
|
||||
const handleSetup = async () => {
|
||||
isLoading = true;
|
||||
try {
|
||||
await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
|
||||
|
||||
resetForm();
|
||||
|
||||
notificationController.show({
|
||||
message: $t('pin_code_setup_successfully'),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('unable_to_setup_pin_code'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
hasPinCode = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = async () => {
|
||||
isLoading = true;
|
||||
try {
|
||||
await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
|
||||
|
||||
resetForm();
|
||||
|
||||
notificationController.show({
|
||||
message: $t('pin_code_changed_successfully'),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('unable_to_change_pin_code'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
currentPinCode = '';
|
||||
newPinCode = '';
|
||||
confirmPinCode = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
|
||||
<div class="flex flex-col gap-6 place-items-center place-content-center">
|
||||
{#if hasPinCode}
|
||||
<p class="text-dark">{$t('change_pin_code')}</p>
|
||||
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
|
||||
|
||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
|
||||
|
||||
<PinCodeInput
|
||||
label={$t('confirm_new_pin_code')}
|
||||
bind:value={confirmPinCode}
|
||||
tabindexStart={13}
|
||||
pinLength={6}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-dark">{$t('setup_pin_code')}</p>
|
||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
|
||||
|
||||
<PinCodeInput
|
||||
label={$t('confirm_new_pin_code')}
|
||||
bind:value={confirmPinCode}
|
||||
tabindexStart={7}
|
||||
pinLength={6}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
|
||||
{hasPinCode ? $t('save') : $t('create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{#if hasPinCode}
|
||||
<div in:fade={{ duration: 200 }} class="mt-6">
|
||||
<PinCodeChangeForm />
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fade={{ duration: 200 }} class="mt-6">
|
||||
<PinCodeCreateForm onCreated={() => (hasPinCode = true)} />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue