mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
fix: z-index overuse (#18192)
This commit is contained in:
parent
48112d84a3
commit
989d9dbe51
@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { getShortDateRange } from '$lib/utils/date-time';
|
||||
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { getShortDateRange } from '$lib/utils/date-time';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@ -40,7 +40,7 @@
|
||||
{#if onShowContextMenu}
|
||||
<div
|
||||
id="icon-{album.id}"
|
||||
class="absolute end-6 top-6 z-10 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
|
||||
class="absolute end-6 top-6 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
|
||||
data-testid="context-button-parent"
|
||||
>
|
||||
<CircleIconButton
|
||||
|
@ -58,6 +58,32 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)]">
|
||||
<AssetGrid enableRouting={true} {album} {assetStore} {assetInteraction}>
|
||||
<section class="pt-8 md:pt-24 px-2 md:px-0">
|
||||
<!-- ALBUM TITLE -->
|
||||
<h1
|
||||
class="text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary"
|
||||
>
|
||||
{album.albumName}
|
||||
</h1>
|
||||
|
||||
{#if album.assetCount > 0}
|
||||
<AlbumSummary {album} />
|
||||
{/if}
|
||||
|
||||
<!-- ALBUM DESCRIPTION -->
|
||||
{#if album.description}
|
||||
<p
|
||||
class="whitespace-pre-line mb-12 mt-6 w-full pb-2 text-start font-medium text-base text-black dark:text-gray-300"
|
||||
>
|
||||
{album.description}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
</AssetGrid>
|
||||
</main>
|
||||
|
||||
<header>
|
||||
{#if assetInteraction.selectionActive}
|
||||
<AssetSelectControlBar
|
||||
@ -100,29 +126,3 @@
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)]">
|
||||
<AssetGrid enableRouting={true} {album} {assetStore} {assetInteraction}>
|
||||
<section class="pt-8 md:pt-24 px-2 md:px-0">
|
||||
<!-- ALBUM TITLE -->
|
||||
<h1
|
||||
class="text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary"
|
||||
>
|
||||
{album.albumName}
|
||||
</h1>
|
||||
|
||||
{#if album.assetCount > 0}
|
||||
<AlbumSummary {album} />
|
||||
{/if}
|
||||
|
||||
<!-- ALBUM DESCRIPTION -->
|
||||
{#if album.description}
|
||||
<p
|
||||
class="whitespace-pre-line mb-12 mt-6 w-full pb-2 text-start font-medium text-base text-black dark:text-gray-300"
|
||||
>
|
||||
{album.description}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
</AssetGrid>
|
||||
</main>
|
||||
|
@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { type AlbumResponseDto } from '@immich/sdk';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils.js';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
import type { Action } from 'svelte/action';
|
||||
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils.js';
|
||||
import { type AlbumResponseDto } from '@immich/sdk';
|
||||
import type { Action } from 'svelte/action';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
@ -52,7 +52,7 @@
|
||||
<img
|
||||
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
|
||||
alt={album.albumName}
|
||||
class="z-0 h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
|
||||
class="h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
|
@ -16,7 +16,7 @@
|
||||
{#if downloadManager.isDownloading}
|
||||
<div
|
||||
transition:fly={{ x: -100, duration: 350 }}
|
||||
class="fixed bottom-10 start-2 z-[10000] max-h-[270px] w-[315px] rounded-2xl border p-4 text-sm shadow-sm bg-light"
|
||||
class="fixed bottom-10 start-2 max-h-[270px] w-[315px] rounded-2xl border p-4 text-sm shadow-sm bg-light"
|
||||
>
|
||||
<p class="mb-2 text-xs text-gray-500">{$t('downloading').toUpperCase()}</p>
|
||||
<div class="my-2 mb-2 flex max-h-[200px] flex-col overflow-y-auto text-sm">
|
||||
|
@ -1,15 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { getAssetOriginalUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
import { imgElement, cropAreaEl, resetCropStore, overlayEl, isResizingOrDragging, cropFrame } from './crop-store';
|
||||
import { draw } from './drawing';
|
||||
import { onImageLoad, resizeCanvas } from './image-loading';
|
||||
import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers';
|
||||
import { recalculateCrop, animateCropChange } from './crop-settings';
|
||||
import {
|
||||
changedOriention,
|
||||
cropAspectRatio,
|
||||
@ -18,6 +13,11 @@
|
||||
rotateDegrees,
|
||||
} from '$lib/stores/asset-editor.store';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { animateCropChange, recalculateCrop } from './crop-settings';
|
||||
import { cropAreaEl, cropFrame, imgElement, isResizingOrDragging, overlayEl, resetCropStore } from './crop-store';
|
||||
import { draw } from './drawing';
|
||||
import { onImageLoad, resizeCanvas } from './image-loading';
|
||||
import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@ -169,7 +169,6 @@
|
||||
border: 2px solid white;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.corner {
|
||||
|
@ -214,7 +214,7 @@
|
||||
<img
|
||||
src={assetFileUrl}
|
||||
alt={$getAltText(asset)}
|
||||
class="absolute top-0 start-0 -z-10 object-cover h-full w-full blur-lg"
|
||||
class="absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
|
@ -5,8 +5,8 @@
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { mdiEyeOffOutline } from '@mdi/js';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
import type { ActionReturn } from 'svelte/action';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
|
@ -215,7 +215,7 @@
|
||||
slow: ??ms
|
||||
-->
|
||||
<div
|
||||
class={['group absolute top-[0px] bottom-[0px]', { 'curstor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||
class={['group absolute top-[0px] bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||
style:width="inherit"
|
||||
style:height="inherit"
|
||||
onmouseenter={onMouseEnter}
|
||||
@ -239,42 +239,6 @@
|
||||
tabindex={0}
|
||||
role="link"
|
||||
>
|
||||
<!-- Select asset button -->
|
||||
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
|
||||
<!-- lazy show the url on mouse over-->
|
||||
<a
|
||||
class={['absolute z-10 w-full top-0 bottom-0']}
|
||||
style:cursor="unset"
|
||||
href={currentUrlReplaceAssetId(asset.id)}
|
||||
onclick={(evt) => evt.preventDefault()}
|
||||
tabindex={-1}
|
||||
aria-label="Thumbnail URL"
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
{#if !readonly && (mouseOver || selected || selectionCandidate)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onIconClickedHandler}
|
||||
class={['absolute z-20 p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
|
||||
role="checkbox"
|
||||
tabindex={-1}
|
||||
onfocus={handleFocus}
|
||||
aria-checked={selected}
|
||||
{disabled}
|
||||
>
|
||||
{#if disabled}
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-zinc-800" />
|
||||
{:else if selected}
|
||||
<div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-immich-primary" />
|
||||
</div>
|
||||
{:else}
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={[
|
||||
'absolute h-full w-full select-none bg-transparent transition-transform',
|
||||
@ -284,6 +248,19 @@
|
||||
>
|
||||
<!-- icon overlay -->
|
||||
<div>
|
||||
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
|
||||
<!-- lazy show the url on mouse over-->
|
||||
<a
|
||||
class="absolute w-full top-0 bottom-0"
|
||||
style:cursor="unset"
|
||||
href={currentUrlReplaceAssetId(asset.id)}
|
||||
onclick={(evt) => evt.preventDefault()}
|
||||
tabindex={-1}
|
||||
aria-label="Thumbnail URL"
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Gradient overlay on hover -->
|
||||
{#if !usingMobileDevice && !disabled}
|
||||
<div
|
||||
@ -293,10 +270,10 @@
|
||||
]}
|
||||
></div>
|
||||
{/if}
|
||||
<!-- Dimmed support -->
|
||||
|
||||
<!-- Dimmed support -->
|
||||
{#if dimmed && !mouseOver}
|
||||
<div id="a" class={['absolute h-full w-full z-30 bg-gray-700/40', { 'rounded-xl': selected }]}></div>
|
||||
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
|
||||
{/if}
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
@ -308,19 +285,19 @@
|
||||
|
||||
<!-- Favorite asset star -->
|
||||
{#if !authManager.key && asset.isFavorite}
|
||||
<div class="absolute bottom-2 start-2 z-10">
|
||||
<div class="absolute bottom-2 start-2">
|
||||
<Icon path={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !authManager.key && showArchiveIcon && asset.isArchived}
|
||||
<div class={['absolute start-2 z-10', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
||||
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
||||
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<div class="absolute end-0 top-0 z-10 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon path={mdiRotate360} size="24" />
|
||||
</span>
|
||||
@ -331,7 +308,7 @@
|
||||
{#if asset.stack && showStackedIcon}
|
||||
<div
|
||||
class={[
|
||||
'absolute z-10 flex place-items-center gap-1 text-xs font-medium text-white',
|
||||
'absolute flex place-items-center gap-1 text-xs font-medium text-white',
|
||||
asset.type == AssetTypeEnum.Image && !asset.livePhotoVideoId ? 'top-0 end-0' : 'top-7 end-1',
|
||||
]}
|
||||
>
|
||||
@ -382,5 +359,29 @@
|
||||
out:fade={{ duration: 100 }}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Select asset button -->
|
||||
{#if !readonly && (mouseOver || selected || selectionCandidate)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onIconClickedHandler}
|
||||
class={['absolute p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
|
||||
role="checkbox"
|
||||
tabindex={-1}
|
||||
onfocus={handleFocus}
|
||||
aria-checked={selected}
|
||||
{disabled}
|
||||
>
|
||||
{#if disabled}
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-zinc-800" />
|
||||
{:else if selected}
|
||||
<div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-immich-primary" />
|
||||
</div>
|
||||
{:else}
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Duration } from 'luxon';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
@ -55,7 +55,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="absolute end-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
{#if showTime}
|
||||
<span class="pt-2">
|
||||
{#if remainingSeconds < 60}
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
import { t } from 'svelte-i18n';
|
||||
import Button from './button.svelte';
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@ -56,7 +56,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="absolute z-50 top-2 start-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
|
||||
<div class="absolute top-2 start-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
|
||||
<Button
|
||||
size="sm"
|
||||
rounded="none"
|
||||
|
@ -108,7 +108,7 @@
|
||||
{#if showMenu}
|
||||
<div
|
||||
transition:fly={{ y: -30, duration: 250 }}
|
||||
class="text-sm font-medium absolute z-50 flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className} {getAlignClass(
|
||||
class="text-sm font-medium z-[1] absolute flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className} {getAlignClass(
|
||||
position,
|
||||
)}"
|
||||
>
|
||||
|
@ -74,7 +74,7 @@
|
||||
|
||||
<section
|
||||
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
||||
class="absolute top-0 z-[2001] h-full w-[360px] overflow-x-hidden p-2 dark:text-immich-dark-fg"
|
||||
class="absolute top-0 h-full w-[360px] overflow-x-hidden p-2 dark:text-immich-dark-fg"
|
||||
>
|
||||
<div class="flex place-items-center justify-between gap-2">
|
||||
{#if !searchFaces}
|
||||
|
@ -112,7 +112,7 @@
|
||||
<svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
|
||||
|
||||
<div
|
||||
class="fixed top-0 z-10 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
|
||||
class="fixed top-0 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<CircleIconButton title={$t('close')} icon={mdiClose} onclick={onClose} />
|
||||
|
@ -96,7 +96,7 @@
|
||||
|
||||
<section
|
||||
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
|
||||
class="absolute start-0 top-0 z-[9999] h-full w-full bg-light"
|
||||
class="absolute start-0 top-0 h-full w-full bg-light"
|
||||
>
|
||||
<ControlAppBar onClose={onBack}>
|
||||
{#snippet leading()}
|
||||
|
@ -193,7 +193,7 @@
|
||||
|
||||
<section
|
||||
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
||||
class="absolute top-0 z-[2000] h-full w-[360px] overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
|
||||
class="absolute top-0 h-full w-[360px] overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
|
||||
>
|
||||
<div class="flex place-items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
@ -222,7 +222,7 @@
|
||||
{:else}
|
||||
{#each peopleWithFaces as face, index (face.id)}
|
||||
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
|
||||
<div class="relative z-[20001] h-[115px] w-[95px]">
|
||||
<div class="relative h-[115px] w-[95px]">
|
||||
<div
|
||||
role="button"
|
||||
tabindex={index}
|
||||
|
@ -120,7 +120,7 @@
|
||||
|
||||
<section
|
||||
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
|
||||
class="absolute start-0 top-0 z-[9999] h-full w-full bg-light"
|
||||
class="absolute start-0 top-0 h-full w-full bg-light"
|
||||
>
|
||||
<ControlAppBar {onClose}>
|
||||
{#snippet leading()}
|
||||
|
@ -11,11 +11,7 @@
|
||||
|
||||
<section class="min-w-dvw flex min-h-dvh items-center justify-center relative">
|
||||
<div class="absolute -z-10 w-full h-full flex place-items-center place-content-center">
|
||||
<img
|
||||
src={immichLogo}
|
||||
class="max-w-screen-md mx-auto h-full mb-2 antialiased -z-10 overflow-hidden"
|
||||
alt="Immich logo"
|
||||
/>
|
||||
<img src={immichLogo} class="max-w-screen-md mx-auto h-full mb-2 antialiased overflow-hidden" alt="Immich logo" />
|
||||
<div
|
||||
class="w-full h-[99%] absolute start-0 top-0 backdrop-blur-[200px] bg-transparent dark:bg-immich-dark-bg/20"
|
||||
></div>
|
||||
|
@ -4,11 +4,11 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import AdminSideBar from '$lib/components/shared-components/side-bar/admin-side-bar.svelte';
|
||||
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import type { Snippet } from 'svelte';
|
||||
import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte';
|
||||
import SideBar from '../shared-components/side-bar/side-bar.svelte';
|
||||
|
||||
interface Props {
|
||||
hideNavbar?: boolean;
|
||||
@ -51,18 +51,24 @@
|
||||
</header>
|
||||
<div
|
||||
tabindex="-1"
|
||||
class="relative grid grid-cols-[theme(spacing.0)_auto] overflow-hidden sidebar:grid-cols-[theme(spacing.64)_auto]
|
||||
class="relative z-0 grid grid-cols-[theme(spacing.0)_auto] overflow-hidden sidebar:grid-cols-[theme(spacing.64)_auto]
|
||||
{hideNavbar ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))]'}
|
||||
{hideNavbar ? 'pt-[var(--navbar-height)]' : ''}
|
||||
{hideNavbar ? 'max-md:pt-[var(--navbar-height-md)]' : ''}"
|
||||
>
|
||||
{#if sidebar}{@render sidebar()}{:else if admin}
|
||||
{#if sidebar}
|
||||
{@render sidebar()}
|
||||
{:else if admin}
|
||||
<AdminSideBar />
|
||||
{:else}
|
||||
<SideBar />
|
||||
{/if}
|
||||
|
||||
<main class="relative">
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto" use:useActions={use}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
{#if title || buttons}
|
||||
<div
|
||||
class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 dark:border-immich-dark-gray dark:text-immich-dark-fg"
|
||||
@ -78,9 +84,5 @@
|
||||
{@render buttons?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto" use:useActions={use}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
@ -305,7 +305,7 @@
|
||||
/>
|
||||
|
||||
{#if assetInteraction.selectionActive}
|
||||
<div class="sticky top-0 z-[90]">
|
||||
<div class="sticky top-0">
|
||||
<AssetSelectControlBar
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||
@ -380,7 +380,7 @@
|
||||
|
||||
{#if galleryInView}
|
||||
<div
|
||||
class="fixed top-20 z-30 start-1/2 -translate-x-1/2 transition-opacity"
|
||||
class="fixed top-20 start-1/2 -translate-x-1/2 transition-opacity"
|
||||
class:opacity-0={!galleryInView}
|
||||
class:opacity-100={galleryInView}
|
||||
>
|
||||
|
@ -127,7 +127,7 @@
|
||||
>
|
||||
<!-- Date group title -->
|
||||
<div
|
||||
class="flex z-[100] pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
|
||||
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
|
||||
style:width={dateGroup.width + 'px'}
|
||||
>
|
||||
{#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
|
||||
|
@ -5,6 +5,7 @@
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
||||
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
@ -26,7 +27,6 @@
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
import Portal from '../shared-components/portal/portal.svelte';
|
||||
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
||||
import AssetDateGroup from './asset-date-group.svelte';
|
||||
import DeleteAssetDialog from './delete-asset-dialog.svelte';
|
||||
|
||||
|
@ -44,9 +44,9 @@
|
||||
onscroll={onScroll}
|
||||
>
|
||||
{#if canScrollLeft || canScrollRight}
|
||||
<div class="sticky start-0 z-20">
|
||||
<div class="sticky start-0">
|
||||
{#if canScrollLeft}
|
||||
<div class="absolute start-4 top-[6rem] z-20" transition:fade={{ duration: 200 }}>
|
||||
<div class="absolute start-4 top-[6rem]" transition:fade={{ duration: 200 }}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100"
|
||||
@ -59,7 +59,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if canScrollRight}
|
||||
<div class="absolute end-4 top-[6rem] z-20" transition:fade={{ duration: 200 }}>
|
||||
<div class="absolute end-4 top-[6rem]" transition:fade={{ duration: 200 }}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100"
|
||||
@ -85,11 +85,11 @@
|
||||
alt={$t('memory_lane_title', { values: { title: $getAltText(memory.assets[0]) } })}
|
||||
draggable="false"
|
||||
/>
|
||||
<p class="absolute bottom-2 start-4 z-10 text-lg text-white max-md:text-sm">
|
||||
<p class="absolute bottom-2 start-4 text-lg text-white max-md:text-sm">
|
||||
{$memoryLaneTitle(memory)}
|
||||
</p>
|
||||
<div
|
||||
class="absolute start-0 top-0 z-0 h-full w-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
|
||||
class="absolute start-0 top-0 h-full w-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
|
||||
></div>
|
||||
</a>
|
||||
{/each}
|
||||
|
@ -9,7 +9,7 @@
|
||||
|
||||
<div class="overflow-clip" style:height={height + 'px'}>
|
||||
<div
|
||||
class="flex z-[100] pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
|
||||
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
@ -135,7 +135,7 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="absolute z-[99] w-full"
|
||||
class="absolute w-full"
|
||||
id="suggestion"
|
||||
bind:this={suggestionContainer}
|
||||
use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }}
|
||||
|
@ -20,16 +20,16 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import type { FormEventHandler } from 'svelte/elements';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { focusOutside } from '$lib/actions/focus-outside';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { mdiClose, mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { FormEventHandler } from 'svelte/elements';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
@ -341,7 +341,7 @@
|
||||
role="listbox"
|
||||
id={listboxId}
|
||||
transition:fly={{ duration: 250 }}
|
||||
class="fixed text-start text-sm w-full overflow-y-auto bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-900 z-[10000]"
|
||||
class="fixed z-[1] text-start text-sm w-full overflow-y-auto bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-900"
|
||||
class:rounded-b-xl={dropdownDirection === 'bottom'}
|
||||
class:rounded-t-xl={dropdownDirection === 'top'}
|
||||
class:shadow={dropdownDirection === 'bottom'}
|
||||
|
@ -59,7 +59,7 @@
|
||||
|
||||
<div
|
||||
bind:clientHeight={height}
|
||||
class="fixed z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
|
||||
class="fixed min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
|
||||
style:left="{left}px"
|
||||
style:top="{top}px"
|
||||
transition:slide={{ duration: 250, easing: quintOut }}
|
||||
|
@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { tick, type Snippet } from 'svelte';
|
||||
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
|
||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { tick, type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@ -91,7 +91,7 @@
|
||||
},
|
||||
]}
|
||||
>
|
||||
<section class="fixed start-0 top-0 z-10 flex h-dvh w-dvw" {oncontextmenu} role="presentation">
|
||||
<section class="fixed start-0 top-0 flex h-dvh w-dvw" {oncontextmenu} role="presentation">
|
||||
<ContextMenu
|
||||
{direction}
|
||||
{x}
|
||||
|
@ -66,7 +66,7 @@
|
||||
let buttonClass = $derived(forceDark ? 'hover:text-immich-dark-gray' : undefined);
|
||||
</script>
|
||||
|
||||
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
|
||||
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent z-[1]">
|
||||
<nav
|
||||
id="asset-selection-app-bar"
|
||||
class={[
|
||||
|
@ -161,7 +161,7 @@
|
||||
{#if dragStartTarget}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[1000] flex h-full w-full flex-col items-center justify-center bg-gray-100/90 text-immich-dark-gray dark:bg-immich-dark-bg/90 dark:text-immich-gray"
|
||||
class="fixed inset-0 flex h-full w-full flex-col items-center justify-center bg-gray-100/90 text-immich-dark-gray dark:bg-immich-dark-bg/90 dark:text-immich-gray"
|
||||
transition:fade={{ duration: 250 }}
|
||||
ondragover={onDragOver}
|
||||
>
|
||||
|
@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
import { fade } from 'svelte/transition';
|
||||
import ModalHeader from '$lib/components/shared-components/modal-header.svelte';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@ -77,14 +77,14 @@
|
||||
role="presentation"
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
class="fixed start-0 top-0 z-[9999] flex h-dvh w-dvw place-content-center place-items-center bg-black/40"
|
||||
class="fixed start-0 top-0 flex h-dvh w-dvw place-content-center place-items-center bg-black/40"
|
||||
onkeydown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
use:focusTrap
|
||||
>
|
||||
<div
|
||||
class="flex flex-col max-h-[min(95dvh,60rem)] z-[9999] max-w-[95vw] {modalWidth} overflow-hidden rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg pt-3 pb-4"
|
||||
class="flex flex-col max-h-[min(95dvh,60rem)] max-w-[95vw] {modalWidth} overflow-hidden rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg pt-3 pb-4"
|
||||
use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
|
||||
tabindex="-1"
|
||||
aria-modal="true"
|
||||
|
@ -25,7 +25,7 @@
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
id="account-info-panel"
|
||||
class="absolute end-[25px] top-[75px] z-[100] w-[min(360px,100vw-50px)] rounded-3xl bg-gray-200 shadow-lg dark:border dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
class="absolute z-[1] end-[25px] top-[75px] w-[min(360px,100vw-50px)] rounded-3xl bg-gray-200 shadow-lg dark:border dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
use:focusTrap
|
||||
>
|
||||
<div
|
||||
@ -33,7 +33,7 @@
|
||||
>
|
||||
<div class="relative">
|
||||
<UserAvatar user={$user} size="xl" />
|
||||
<div class="absolute z-10 bottom-0 end-0 rounded-full w-6 h-6">
|
||||
<div class="absolute bottom-0 end-0 rounded-full w-6 h-6">
|
||||
<CircleIconButton
|
||||
color="primary"
|
||||
icon={mdiPencil}
|
||||
|
@ -51,7 +51,7 @@
|
||||
|
||||
<nav
|
||||
id="dashboard-navbar"
|
||||
class="z-auto max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm overflow-hidden"
|
||||
class="max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm overflow-hidden"
|
||||
>
|
||||
<SkipLink text={$t('skip_to_content')} />
|
||||
<div
|
||||
|
@ -12,8 +12,8 @@
|
||||
import { Button, Scrollable, Stack, Text } from '@immich/ui';
|
||||
import { mdiBellOutline, mdiCheckAll } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const noUnreadNotifications = $derived(notificationManager.notifications.length === 0);
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
id="notification-panel"
|
||||
class="absolute right-[25px] top-[70px] z-[100] w-[min(360px,100vw-50px)] rounded-3xl bg-gray-100 border border-gray-200 shadow-lg dark:border dark:border-immich-dark-gray dark:bg-immich-dark-gray text-light"
|
||||
class="absolute right-[25px] top-[70px] z-[1] w-[min(360px,100vw-50px)] rounded-3xl bg-gray-100 border border-gray-200 shadow-lg dark:border dark:border-immich-dark-gray dark:bg-immich-dark-gray text-light"
|
||||
use:focusTrap
|
||||
>
|
||||
<Stack class="max-h-[500px]">
|
||||
|
@ -26,7 +26,7 @@
|
||||
</script>
|
||||
|
||||
{#if showing}
|
||||
<div class="absolute start-0 top-0 z-[999999999] h-[3px] w-dvw bg-white">
|
||||
<div class="absolute start-0 top-0 h-[3px] w-dvw bg-white">
|
||||
<span class="absolute h-[3px] bg-immich-primary" style:width={`${$progress}%`}></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -5,8 +5,8 @@ import { get } from 'svelte/store';
|
||||
import { NotificationType, notificationController } from '../notification';
|
||||
import NotificationList from '../notification-list.svelte';
|
||||
|
||||
function _getNotificationListElement(sut: RenderResult<NotificationList>): HTMLAnchorElement | null {
|
||||
return sut.container.querySelector('#notification-list');
|
||||
function _getNotificationListElement(): HTMLAnchorElement | null {
|
||||
return document.body.querySelector('#notification-list');
|
||||
}
|
||||
|
||||
describe('NotificationList component', () => {
|
||||
@ -23,7 +23,7 @@ describe('NotificationList component', () => {
|
||||
const status = await sut.findAllByRole('status');
|
||||
|
||||
expect(status).toHaveLength(1);
|
||||
expect(_getNotificationListElement(sut)).not.toBeInTheDocument();
|
||||
expect(_getNotificationListElement()).not.toBeInTheDocument();
|
||||
|
||||
notificationController.show({
|
||||
message: 'Notification',
|
||||
@ -31,11 +31,11 @@ describe('NotificationList component', () => {
|
||||
timeout: 1,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(_getNotificationListElement(sut)).toBeInTheDocument());
|
||||
await waitFor(() => expect(_getNotificationListElement(sut)?.children).toHaveLength(1));
|
||||
await waitFor(() => expect(_getNotificationListElement()).toBeInTheDocument());
|
||||
await waitFor(() => expect(_getNotificationListElement()?.children).toHaveLength(1));
|
||||
expect(get(notificationController.notificationList)).toHaveLength(1);
|
||||
|
||||
await waitFor(() => expect(_getNotificationListElement(sut)).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(_getNotificationListElement()).not.toBeInTheDocument());
|
||||
expect(get(notificationController.notificationList)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
@ -75,7 +75,7 @@
|
||||
transition:fade={{ duration: 250 }}
|
||||
style:background-color={backgroundColor[notification.type]}
|
||||
style:border-color={borderColor[notification.type]}
|
||||
class="border z-[999999] mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md {hoverStyle}"
|
||||
class="border mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md {hoverStyle}"
|
||||
onclick={handleClick}
|
||||
onkeydown={handleClick}
|
||||
>
|
||||
|
@ -1,22 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { notificationController } from './notification';
|
||||
import { fade } from 'svelte/transition';
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import NotificationCard from './notification-card.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { notificationController } from './notification';
|
||||
import NotificationCard from './notification-card.svelte';
|
||||
|
||||
const { notificationList } = notificationController;
|
||||
</script>
|
||||
|
||||
<div role="status" aria-relevant="additions" aria-label={$t('notifications')}>
|
||||
{#if $notificationList.length > 0}
|
||||
<section transition:fade={{ duration: 250 }} id="notification-list" class="fixed end-5 top-[80px] z-[99999999]">
|
||||
{#each $notificationList as notification (notification.id)}
|
||||
<div animate:flip={{ duration: 250, easing: quintOut }}>
|
||||
<NotificationCard {notification} />
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
<Portal>
|
||||
<div role="status" aria-relevant="additions" aria-label={$t('notifications')}>
|
||||
{#if $notificationList.length > 0}
|
||||
<section transition:fade={{ duration: 250 }} id="notification-list" class="fixed end-5 top-[80px]">
|
||||
{#each $notificationList as notification (notification.id)}
|
||||
<div animate:flip={{ duration: 250, easing: quintOut }}>
|
||||
<NotificationCard {notification} />
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</Portal>
|
||||
|
@ -464,7 +464,7 @@
|
||||
class={[
|
||||
{ 'border-b-2': isDragging },
|
||||
{ 'rounded-bl-md': !isDragging },
|
||||
'bg-light truncate opacity-85 pointer-events-none absolute end-0 z-[100] min-w-20 max-w-64 w-fit rounded-ss-md border-immich-primary py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:text-immich-dark-fg',
|
||||
'bg-light truncate opacity-85 pointer-events-none absolute end-0 min-w-20 max-w-64 w-fit rounded-ss-md border-immich-primary py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:text-immich-dark-fg',
|
||||
]}
|
||||
style:top="{hoverY + 2}px"
|
||||
>
|
||||
@ -506,7 +506,7 @@
|
||||
{#if assetStore.scrolling && scrollHoverLabel && !isHover}
|
||||
<p
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="truncate pointer-events-none absolute end-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-subtle/70 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:text-immich-dark-fg"
|
||||
class="truncate pointer-events-none absolute end-0 bottom-0 min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-subtle/70 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:text-immich-dark-fg"
|
||||
>
|
||||
{scrollHoverLabel}
|
||||
</p>
|
||||
@ -514,7 +514,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="relative z-10"
|
||||
class="relative"
|
||||
style:height={relativeTopOffset + 'px'}
|
||||
data-id="lead-in"
|
||||
data-time-segment-bucket-date={segments.at(0)?.date}
|
||||
@ -535,7 +535,7 @@
|
||||
>
|
||||
{#if !usingMobileDevice}
|
||||
{#if segment.hasLabel}
|
||||
<div class="absolute end-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono">
|
||||
<div class="absolute end-[1.25rem] top-[-16px] text-[12px] dark:text-immich-dark-fg font-immich-mono">
|
||||
{segment.date.year}
|
||||
</div>
|
||||
{/if}
|
||||
@ -547,6 +547,3 @@
|
||||
{/each}
|
||||
<div data-id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
@ -282,7 +282,7 @@
|
||||
class:end-28={isFocus && value.length > 0}
|
||||
>
|
||||
<p
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs z-10"
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs"
|
||||
>
|
||||
{getSearchTypeText()}
|
||||
</p>
|
||||
|
@ -119,7 +119,7 @@
|
||||
{#if showMessage}
|
||||
<dialog
|
||||
open
|
||||
class="hidden sidebar:block w-[500px] absolute bottom-[75px] start-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
|
||||
class="hidden sidebar:block w-[500px] absolute bottom-[75px] start-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl shadow-2xl px-8 py-6"
|
||||
transition:fade={{ duration: 150 }}
|
||||
onmouseover={() => (hoverMessage = true)}
|
||||
onmouseleave={() => (hoverMessage = false)}
|
||||
|
@ -37,7 +37,7 @@
|
||||
|
||||
<div class="relative">
|
||||
{#if hasDropdown}
|
||||
<span class="hidden md:block absolute start-1 z-50 h-full">
|
||||
<span class="hidden md:block absolute start-1 h-full">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$t('recent-albums')}
|
||||
|
@ -35,7 +35,7 @@
|
||||
id="sidebar"
|
||||
aria-label={ariaLabel}
|
||||
tabindex="-1"
|
||||
class="immich-scrollbar relative z-auto w-0 sidebar:w-[16rem] overflow-y-auto overflow-x-hidden pt-8 transition-all duration-200"
|
||||
class="immich-scrollbar relative z-[1] w-0 sidebar:w-[16rem] overflow-y-auto overflow-x-hidden pt-8 transition-all duration-200"
|
||||
class:shadow-2xl={isExpanded}
|
||||
class:dark:border-e-immich-dark-gray={isExpanded}
|
||||
class:border-r={isExpanded}
|
||||
|
@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { uploadExecutionQueue } from '$lib/utils/file-uploader';
|
||||
import { mdiCancel, mdiCloudUploadOutline, mdiCog, mdiWindowMinimize } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quartInOut } from 'svelte/easing';
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import { notificationController, NotificationType } from './notification/notification';
|
||||
import UploadAssetPreview from './upload-asset-preview.svelte';
|
||||
import { uploadExecutionQueue } from '$lib/utils/file-uploader';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import { mdiCog, mdiWindowMinimize, mdiCancel, mdiCloudUploadOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
|
||||
let showDetail = $state(false);
|
||||
let showOptions = $state(false);
|
||||
@ -48,7 +48,7 @@
|
||||
}
|
||||
uploadAssetsStore.reset();
|
||||
}}
|
||||
class="fixed bottom-6 end-16 z-[10000]"
|
||||
class="fixed bottom-6 end-16"
|
||||
>
|
||||
{#if showDetail}
|
||||
<div
|
||||
|
@ -1,37 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
|
||||
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
||||
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
|
||||
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
|
||||
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
|
||||
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
|
||||
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
|
||||
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
|
||||
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
||||
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
|
||||
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
|
||||
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
|
||||
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
||||
import { foldersStore } from '$lib/stores/folders.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
|
||||
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
|
||||
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
||||
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
|
||||
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
|
||||
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
||||
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
|
||||
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
|
||||
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@ -87,7 +87,7 @@
|
||||
</script>
|
||||
|
||||
{#if assetInteraction.selectionActive}
|
||||
<div class="fixed z-[910] top-0 start-0 w-full">
|
||||
<div class="fixed top-0 start-0 w-full">
|
||||
<AssetSelectControlBar
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||
|
@ -458,7 +458,7 @@
|
||||
<dialog
|
||||
open
|
||||
transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }}
|
||||
class="absolute start-0 top-0 z-[9999] h-full w-full bg-light"
|
||||
class="absolute start-0 top-0 h-full w-full bg-light"
|
||||
aria-modal="true"
|
||||
aria-labelledby="manage-visibility-title"
|
||||
use:focusTrap
|
||||
|
@ -401,91 +401,6 @@
|
||||
<MergeFaceSelector {person} onBack={handleGoBack} onMerge={handleMerge} />
|
||||
{/if}
|
||||
|
||||
<header>
|
||||
{#if assetInteraction.selectionActive}
|
||||
<AssetSelectControlBar
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||
>
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {assetStore} {assetInteraction} />
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) =>
|
||||
assetStore.updateAssetOperation(ids, (asset) => {
|
||||
asset.isFavorite = isFavorite;
|
||||
return { remove: false };
|
||||
})}
|
||||
/>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
||||
<MenuOption
|
||||
icon={mdiAccountMultipleCheckOutline}
|
||||
text={$t('fix_incorrect_match')}
|
||||
onClick={handleReassignAssets}
|
||||
/>
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
unarchive={assetInteraction.isAllArchived}
|
||||
onArchive={(assetIds) => assetStore.removeAssets(assetIds)}
|
||||
/>
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => handleDeleteAssets(assetIds)} />
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
{#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE || viewMode === PersonPageViewMode.BIRTH_DATE}
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
||||
{#snippet trailing()}
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<MenuOption
|
||||
text={$t('select_featured_photo')}
|
||||
icon={mdiAccountBoxOutline}
|
||||
onClick={() => (viewMode = PersonPageViewMode.SELECT_PERSON)}
|
||||
/>
|
||||
<MenuOption
|
||||
text={person.isHidden ? $t('unhide_person') : $t('hide_person')}
|
||||
icon={person.isHidden ? mdiEyeOutline : mdiEyeOffOutline}
|
||||
onClick={() => toggleHidePerson()}
|
||||
/>
|
||||
<MenuOption
|
||||
text={$t('set_date_of_birth')}
|
||||
icon={mdiCalendarEditOutline}
|
||||
onClick={() => (viewMode = PersonPageViewMode.BIRTH_DATE)}
|
||||
/>
|
||||
<MenuOption
|
||||
text={$t('merge_people')}
|
||||
icon={mdiAccountMultipleCheckOutline}
|
||||
onClick={() => (viewMode = PersonPageViewMode.MERGE_PEOPLE)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={person.isFavorite ? mdiHeartMinusOutline : mdiHeartOutline}
|
||||
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
|
||||
onClick={handleToggleFavorite}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
|
||||
{#if viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||
<ControlAppBar onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}>
|
||||
{#snippet leading()}
|
||||
{$t('select_featured_photo')}
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<main
|
||||
class="relative h-dvh overflow-hidden tall:ms-4 md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)]"
|
||||
use:scrollMemoryClearer={{
|
||||
@ -571,7 +486,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
{#if isEditingName}
|
||||
<div class="absolute z-[999] w-64 sm:w-96">
|
||||
<div class="absolute w-64 sm:w-96">
|
||||
{#if isSearchingPeople}
|
||||
<div
|
||||
class="flex border h-14 rounded-b-lg border-gray-400 dark:border-immich-dark-gray place-items-center bg-gray-200 p-2 dark:bg-gray-700"
|
||||
@ -611,3 +526,88 @@
|
||||
</AssetGrid>
|
||||
{/key}
|
||||
</main>
|
||||
|
||||
<header>
|
||||
{#if assetInteraction.selectionActive}
|
||||
<AssetSelectControlBar
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||
>
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {assetStore} {assetInteraction} />
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) =>
|
||||
assetStore.updateAssetOperation(ids, (asset) => {
|
||||
asset.isFavorite = isFavorite;
|
||||
return { remove: false };
|
||||
})}
|
||||
/>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
||||
<MenuOption
|
||||
icon={mdiAccountMultipleCheckOutline}
|
||||
text={$t('fix_incorrect_match')}
|
||||
onClick={handleReassignAssets}
|
||||
/>
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
unarchive={assetInteraction.isAllArchived}
|
||||
onArchive={(assetIds) => assetStore.removeAssets(assetIds)}
|
||||
/>
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => handleDeleteAssets(assetIds)} />
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
{#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE || viewMode === PersonPageViewMode.BIRTH_DATE}
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
||||
{#snippet trailing()}
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<MenuOption
|
||||
text={$t('select_featured_photo')}
|
||||
icon={mdiAccountBoxOutline}
|
||||
onClick={() => (viewMode = PersonPageViewMode.SELECT_PERSON)}
|
||||
/>
|
||||
<MenuOption
|
||||
text={person.isHidden ? $t('unhide_person') : $t('hide_person')}
|
||||
icon={person.isHidden ? mdiEyeOutline : mdiEyeOffOutline}
|
||||
onClick={() => toggleHidePerson()}
|
||||
/>
|
||||
<MenuOption
|
||||
text={$t('set_date_of_birth')}
|
||||
icon={mdiCalendarEditOutline}
|
||||
onClick={() => (viewMode = PersonPageViewMode.BIRTH_DATE)}
|
||||
/>
|
||||
<MenuOption
|
||||
text={$t('merge_people')}
|
||||
icon={mdiAccountMultipleCheckOutline}
|
||||
onClick={() => (viewMode = PersonPageViewMode.MERGE_PEOPLE)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={person.isFavorite ? mdiHeartMinusOutline : mdiHeartOutline}
|
||||
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
|
||||
onClick={handleToggleFavorite}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
|
||||
{#if viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||
<ControlAppBar onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}>
|
||||
{#snippet leading()}
|
||||
{$t('select_featured_photo')}
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
{/if}
|
||||
</header>
|
||||
|
@ -80,6 +80,24 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} showUploadButton scrollbar={false}>
|
||||
<AssetGrid
|
||||
enableRouting={true}
|
||||
{assetStore}
|
||||
{assetInteraction}
|
||||
removeAction={AssetAction.ARCHIVE}
|
||||
onEscape={handleEscape}
|
||||
withStacked
|
||||
>
|
||||
{#if $preferences.memories.enabled}
|
||||
<MemoryLane />
|
||||
{/if}
|
||||
{#snippet empty()}
|
||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} />
|
||||
{/snippet}
|
||||
</AssetGrid>
|
||||
</UserPageLayout>
|
||||
|
||||
{#if assetInteraction.selectionActive}
|
||||
<AssetSelectControlBar
|
||||
ownerId={$user.id}
|
||||
@ -129,21 +147,3 @@
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} showUploadButton scrollbar={false}>
|
||||
<AssetGrid
|
||||
enableRouting={true}
|
||||
{assetStore}
|
||||
{assetInteraction}
|
||||
removeAction={AssetAction.ARCHIVE}
|
||||
onEscape={handleEscape}
|
||||
withStacked
|
||||
>
|
||||
{#if $preferences.memories.enabled}
|
||||
<MemoryLane />
|
||||
{/if}
|
||||
{#snippet empty()}
|
||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} />
|
||||
{/snippet}
|
||||
</AssetGrid>
|
||||
</UserPageLayout>
|
||||
|
@ -251,7 +251,7 @@
|
||||
|
||||
<section>
|
||||
{#if assetInteraction.selectionActive}
|
||||
<div class="fixed z-[100] top-0 start-0 w-full">
|
||||
<div class="fixed top-0 start-0 w-full">
|
||||
<AssetSelectControlBar
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||
@ -289,9 +289,9 @@
|
||||
</AssetSelectControlBar>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="fixed z-[100] top-0 start-0 w-full">
|
||||
<div class="fixed top-0 start-0 w-full">
|
||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||
<div class="-z-[1] bg-light" style="position:absolute;top:0;left:0;right:0;bottom:0;"></div>
|
||||
<div class="absolute bg-light"></div>
|
||||
<div class="w-full flex-1 ps-4">
|
||||
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
|
||||
</div>
|
||||
|
@ -58,17 +58,6 @@
|
||||
<meta name="description" content={description} />
|
||||
</svelte:head>
|
||||
{#if passwordRequired}
|
||||
<header>
|
||||
<ControlAppBar showBackButton={false}>
|
||||
{#snippet leading()}
|
||||
<ImmichLogoSmallLink />
|
||||
{/snippet}
|
||||
|
||||
{#snippet trailing()}
|
||||
<ThemeButton />
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
</header>
|
||||
<main
|
||||
class="relative h-dvh overflow-hidden px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] sm:px-12 md:px-24 lg:px-40"
|
||||
>
|
||||
@ -85,6 +74,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<header>
|
||||
<ControlAppBar showBackButton={false}>
|
||||
{#snippet leading()}
|
||||
<ImmichLogoSmallLink />
|
||||
{/snippet}
|
||||
|
||||
{#snippet trailing()}
|
||||
<ThemeButton />
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Album}
|
||||
|
Loading…
x
Reference in New Issue
Block a user