merge main

This commit is contained in:
martabal
2024-05-28 00:04:45 +02:00
140 changed files with 2907 additions and 2197 deletions
+1
View File
@@ -51,6 +51,7 @@ module.exports = {
'unicorn/consistent-function-scoping': 'off',
'unicorn/prefer-top-level-await': 'off',
'unicorn/import-style': 'off',
'svelte/button-has-type': 'error',
// TODO: set recommended-type-checked and remove these rules
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
@@ -1,7 +1,7 @@
<script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '$lib/utils/handle-error';
import { deleteUser, type UserResponseDto } from '@immich/sdk';
import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk';
import { serverConfig } from '$lib/stores/server-config.store';
import { createEventDispatcher } from 'svelte';
import Checkbox from '$lib/components/elements/checkbox.svelte';
@@ -20,9 +20,9 @@
const handleDeleteUser = async () => {
try {
const { deletedAt } = await deleteUser({
const { deletedAt } = await deleteUserAdmin({
id: user.id,
deleteUserDto: { force: forceDelete },
userAdminDeleteDto: { force: forceDelete },
});
if (deletedAt == undefined) {
@@ -17,6 +17,7 @@
</script>
<button
type="button"
{disabled}
class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[
color
@@ -1,7 +1,7 @@
<script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '$lib/utils/handle-error';
import { restoreUser, type UserResponseDto } from '@immich/sdk';
import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
export let user: UserResponseDto;
@@ -14,7 +14,7 @@
const handleRestoreUser = async () => {
try {
const { deletedAt } = await restoreUser({ id: user.id });
const { deletedAt } = await restoreUserAdmin({ id: user.id });
if (deletedAt == undefined) {
dispatch('success');
} else {
@@ -102,7 +102,7 @@
min={0.001}
max={0.1}
desc="Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives."
disabled={disabled || $featureFlags.duplicateDetection}
disabled={disabled || !$featureFlags.duplicateDetection}
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
savedConfig.machineLearning.duplicateDetection.maxDistance}
/>
@@ -30,6 +30,7 @@
{#if group}
<div class="grid">
<button
type="button"
on:click={() => toggleAlbumGroupCollapsing(group.id)}
class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg"
aria-expanded={!isCollapsed}
@@ -75,7 +75,7 @@
<div class="py-2">
<div class="text-gray text-sm mb-3">PEOPLE</div>
<div class="p-2">
<button class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
<button type="button" class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
<div><Icon path={mdiPlus} size="25" /></div>
</div>
@@ -16,6 +16,7 @@
<th class="text-sm font-medium {option.columnStyle}">
<button
type="button"
class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
on:click={handleSort}
>
@@ -41,6 +41,7 @@
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
<button
type="button"
on:click={() => toggleAlbumGroupCollapsing(albumGroup.id)}
class="flex w-full mt-4 rounded-md"
aria-expanded={!isCollapsed}
@@ -1,6 +1,6 @@
<script lang="ts">
import {
getMyUserInfo,
getMyUser,
removeUserFromAlbum,
type AlbumResponseDto,
type UserResponseDto,
@@ -36,7 +36,7 @@
onMount(async () => {
try {
currentUser = await getMyUserInfo();
currentUser = await getMyUser();
} catch (error) {
handleError(error, 'Unable to refresh user');
}
@@ -141,6 +141,7 @@
</div>
{:else if user.id == currentUser?.id}
<button
type="button"
on:click={() => (selectedRemoveUser = user)}
class="text-sm font-medium text-immich-primary transition-colors hover:text-immich-primary/75 dark:text-immich-dark-primary"
>Leave</button
@@ -1,5 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Dropdown from '$lib/components/elements/dropdown.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
@@ -7,7 +6,7 @@
import {
AlbumUserRole,
getAllSharedLinks,
getAllUsers,
searchUsers,
type AlbumResponseDto,
type AlbumUserAddDto,
type SharedLinkResponseDto,
@@ -36,10 +35,10 @@
let sharedLinks: SharedLinkResponseDto[] = [];
onMount(async () => {
await getSharedLinks();
const data = await getAllUsers({ isAll: false });
const data = await searchUsers();
// remove invalid users
users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId));
// remove album owner
users = data.filter((user) => user.id !== album.ownerId);
// Remove the existed shared users from the album
for (const sharedUser of album.albumUsers) {
@@ -122,7 +121,11 @@
{#each users as user}
{#if !Object.keys(selectedUsers).includes(user.id)}
<div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl">
<button on:click={() => handleToggle(user)} class="flex w-full place-items-center gap-4 p-4">
<button
type="button"
on:click={() => handleToggle(user)}
class="flex w-full place-items-center gap-4 p-4"
>
<UserAvatar {user} size="md" />
<div class="text-left flex-grow">
<p class="text-immich-fg dark:text-immich-dark-fg">
@@ -160,6 +163,7 @@
<div id="shared-buttons" class="mt-4 flex place-content-center place-items-center justify-around">
<button
type="button"
class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer"
on:click={() => dispatch('share')}
>
@@ -168,13 +172,13 @@
</button>
{#if sharedLinks.length}
<button
<a
href={AppRoute.SHARED_LINKS}
class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer"
on:click={() => goto(AppRoute.SHARED_LINKS)}
>
<Icon path={mdiShareCircle} size={24} />
<p class="text-sm">View links</p>
</button>
</a>
{/if}
</div>
</FullScreenModal>
@@ -18,12 +18,12 @@
<div
class="w-full h-14 flex p-4 text-white items-center justify-center rounded-full gap-4 bg-immich-dark-bg bg-opacity-60"
>
<button class={disabled ? 'cursor-not-allowed' : ''} on:click={() => dispatch('favorite')} {disabled}>
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} on:click={() => dispatch('favorite')} {disabled}>
<div class="items-center justify-center">
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />
</div>
</button>
<button on:click={() => dispatch('openActivityTab')}>
<button type="button" on:click={() => dispatch('openActivityTab')}>
<div class="flex gap-2 items-center justify-center">
<Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} />
{#if numberOfComments}
@@ -200,6 +200,7 @@
<div>
{#if showDeleteReaction[index]}
<button
type="button"
class="absolute right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 py-3 px-6 text-left text-sm font-medium text-immich-fg hover:bg-red-300 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg dark:hover:bg-red-100 transition-colors"
use:clickOutside
on:outclick={() => (showDeleteReaction[index] = false)}
@@ -252,6 +253,7 @@
<div>
{#if showDeleteReaction[index]}
<button
type="button"
class="absolute right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 py-3 px-6 text-left text-sm font-medium text-immich-fg hover:bg-red-300 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg dark:hover:bg-red-100 transition-colors"
use:clickOutside
on:outclick={() => (showDeleteReaction[index] = false)}
@@ -28,6 +28,7 @@
</script>
<button
type="button"
on:click={() => dispatch('album')}
class="flex w-full gap-4 px-6 py-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
>
@@ -3,13 +3,11 @@
export let label: string;
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="my-auto group hover:cursor-pointer" on:click={onClick}>
<button
class="mx-4 rounded-full p-3 text-gray-500 transition group-hover:bg-gray-500 group-hover:text-white"
aria-label={label}
>
<slot />
</button>
</div>
<button
type="button"
class="my-auto mx-4 rounded-full p-3 text-gray-500 transition hover:bg-gray-500 hover:text-white"
aria-label={label}
on:click={onClick}
>
<slot />
</button>
@@ -106,6 +106,7 @@
<!-- Select asset button -->
{#if !readonly && (mouseOver || selected || selectionCandidate)}
<button
type="button"
on:click={onIconClickedHandler}
class="absolute p-2 focus:outline-none"
class:cursor-not-allowed={disabled}
@@ -93,6 +93,7 @@
{@const renderedOption = renderOption(option)}
{@const buttonStyle = renderedOption.disabled ? '' : 'transition-all hover:bg-gray-300 dark:hover:bg-gray-800'}
<button
type="button"
class="grid grid-cols-[36px,1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}"
disabled={renderedOption.disabled}
on:click={() => !renderedOption.disabled && handleSelectOption(option)}
@@ -7,6 +7,7 @@
<div class="flex bg-gray-200 dark:bg-immich-dark-gray rounded-2xl h-full">
{#each filters as filter, index}
<button
type="button"
class="text-sm px-4 {filter === selected
? 'dark:bg-gray-700 bg-gray-300'
: 'dark:hover:bg-gray-800 hover:bg-gray-300'} {index === 0 ? 'rounded-l-2xl' : ''} {index ===
@@ -100,7 +100,7 @@
{#each showPeople as person (person.id)}
{#if person.id !== editedPerson?.id}
<div class="w-fit">
<button class="w-[90px]" on:click={() => onReassign(person)}>
<button type="button" class="w-[90px]" on:click={() => onReassign(person)}>
<div class="relative">
<ImageThumbnail
curve
@@ -21,6 +21,7 @@
</script>
<button
type="button"
class="relative rounded-lg transition-all"
on:click={handleOnClicked}
disabled={!selectable}
@@ -51,6 +51,7 @@
</div>
<button
type="button"
disabled={potentialMergePeople.length === 0}
class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
on:click={() => {
@@ -71,13 +72,13 @@
{:else}
<div class="grid w-full grid-cols-1 gap-2">
<div class="px-2">
<button on:click={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button>
<button type="button" on:click={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button>
</div>
<div class="flex items-center justify-center">
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
{#each potentialMergePeople as person (person.id)}
<div class="h-24 w-24 md:h-28 md:w-28">
<button class="p-2 w-full" on:click={() => changePersonToMerge(person)}>
<button type="button" class="p-2 w-full" on:click={() => changePersonToMerge(person)}>
<ImageThumbnail
border={true}
circle
@@ -2,9 +2,8 @@
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import PasswordField from '../shared-components/password-field.svelte';
import { updateUser, type UserResponseDto } from '@immich/sdk';
import { updateMyUser } from '@immich/sdk';
export let user: UserResponseDto;
let errorMessage: string;
let success: string;
@@ -31,13 +30,7 @@
if (valid) {
errorMessage = '';
await updateUser({
updateUserDto: {
id: user.id,
password: String(password),
shouldChangePassword: false,
},
});
await updateMyUser({ userUpdateMeDto: { password: String(password) } });
dispatch('success');
}
@@ -2,7 +2,7 @@
import { serverInfo } from '$lib/stores/server-info.store';
import { convertToBytes } from '$lib/utils/byte-converter';
import { handleError } from '$lib/utils/handle-error';
import { createUser } from '@immich/sdk';
import { createUserAdmin } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import PasswordField from '../shared-components/password-field.svelte';
@@ -49,8 +49,8 @@
error = '';
try {
await createUser({
createUserDto: {
await createUserAdmin({
userAdminCreateDto: {
email,
password,
shouldChangePassword,
@@ -1,16 +1,16 @@
<script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { AppRoute } from '$lib/constants';
import { serverInfo } from '$lib/stores/server-info.store';
import { convertFromBytes, convertToBytes } from '$lib/utils/byte-converter';
import { handleError } from '$lib/utils/handle-error';
import { updateUser, type UserResponseDto } from '@immich/sdk';
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { mdiAccountEditOutline } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { mdiAccountEditOutline } from '@mdi/js';
export let user: UserResponseDto;
export let user: UserAdminResponseDto;
export let canResetPassword = true;
export let newPassword: string;
export let onClose: () => void;
@@ -36,9 +36,9 @@
const editUser = async () => {
try {
const { id, email, name, storageLabel } = user;
await updateUser({
updateUserDto: {
id,
await updateUserAdmin({
id,
userAdminUpdateDto: {
email,
name,
storageLabel: storageLabel || '',
@@ -56,9 +56,9 @@
try {
newPassword = generatePassword();
await updateUser({
updateUserDto: {
id: user.id,
await updateUserAdmin({
id: user.id,
userAdminUpdateDto: {
password: newPassword,
shouldChangePassword: true,
},
@@ -4,7 +4,7 @@
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { mdiFolderSync } from '@mdi/js';
import { onMount } from 'svelte';
import { getAllUsers } from '@immich/sdk';
import { searchUsersAdmin } from '@immich/sdk';
import { user } from '$lib/stores/user.store';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
@@ -13,7 +13,7 @@
let userOptions: { value: string; text: string }[] = [];
onMount(async () => {
const users = await getAllUsers({ isAll: true });
const users = await searchUsersAdmin({});
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
});
@@ -182,10 +182,9 @@
/>
{#each currentMemory.assets as _, index}
<button
<a
class="relative w-full py-2"
on:click={() =>
goto(`?${QueryParameter.MEMORY_INDEX}=${memoryIndex}&${QueryParameter.ASSET_INDEX}=${index}`)}
href="?{QueryParameter.MEMORY_INDEX}={memoryIndex}&{QueryParameter.ASSET_INDEX}={index}"
>
<span class="absolute left-0 h-[2px] w-full bg-gray-500" />
{#await resetPromise}
@@ -196,7 +195,7 @@
style:width={`${index < assetIndex ? 100 : index > assetIndex ? 0 : $progress * 100}%`}
/>
{/await}
</button>
</a>
{/each}
<div>
@@ -214,7 +213,11 @@
class:opacity-0={!galleryInView}
class:opacity-100={galleryInView}
>
<button on:click={() => memoryWrapper.scrollIntoView({ behavior: 'smooth' })} disabled={!galleryInView}>
<button
type="button"
on:click={() => memoryWrapper.scrollIntoView({ behavior: 'smooth' })}
disabled={!galleryInView}
>
<CircleIconButton title="Hide gallery" icon={mdiChevronUp} color="light" />
</button>
</div>
@@ -231,7 +234,12 @@
class:opacity-0={!previousMemory}
class:hover:opacity-70={previousMemory}
>
<button class="relative h-full w-full rounded-2xl" disabled={!previousMemory} on:click={toPreviousMemory}>
<button
type="button"
class="relative h-full w-full rounded-2xl"
disabled={!previousMemory}
on:click={toPreviousMemory}
>
{#if previousMemory}
<img
class="h-full w-full rounded-2xl object-cover"
@@ -304,7 +312,12 @@
class:opacity-0={!nextMemory}
class:hover:opacity-70={nextMemory}
>
<button class="relative h-full w-full rounded-2xl" on:click={toNextMemory} disabled={!nextMemory}>
<button
type="button"
class="relative h-full w-full rounded-2xl"
on:click={toNextMemory}
disabled={!nextMemory}
>
{#if nextMemory}
<img
class="h-full w-full rounded-2xl object-cover"
@@ -23,6 +23,7 @@
<div class="flex gap-4 mb-6">
<button
type="button"
class="w-1/2 aspect-square bg-immich-bg rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-dark-primary/80 border-immich-primary dark:border dark:border-transparent"
on:click={() => ($colorTheme.value = Theme.LIGHT)}
>
@@ -34,6 +35,7 @@
</div>
</button>
<button
type="button"
class="w-1/2 aspect-square bg-immich-dark-bg rounded-3xl dark:border-[3px] dark:border-immich-dark-primary/80 dark:border-immich-dark-primary border border-transparent"
on:click={() => ($colorTheme.value = Theme.DARK)}
>
@@ -1,5 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { memoryStore } from '$lib/stores/memory.store';
@@ -46,6 +45,7 @@
{#if canScrollLeft}
<div class="absolute left-4 top-[6rem] z-20" 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"
on:click={scrollLeft}
>
@@ -56,6 +56,7 @@
{#if canScrollRight}
<div class="absolute right-4 top-[6rem] z-20" 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"
on:click={scrollRight}
>
@@ -68,9 +69,9 @@
<div class="inline-block" bind:offsetWidth={innerWidth}>
{#each $memoryStore as memory, index (memory.yearsAgo)}
{#if memory.assets.length > 0}
<button
<a
class="memory-card relative mr-8 inline-block aspect-video h-[215px] rounded-xl"
on:click={() => goto(`${AppRoute.MEMORY}?${QueryParameter.MEMORY_INDEX}=${index}`)}
href="{AppRoute.MEMORY}?{QueryParameter.MEMORY_INDEX}={index}"
>
<img
class="h-full w-full rounded-xl object-cover"
@@ -84,7 +85,7 @@
<div
class="absolute left-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"
/>
</button>
</a>
{/if}
{/each}
</div>
@@ -77,6 +77,7 @@
/>
<div class="immich-scrollbar overflow-y-auto">
<button
type="button"
on:click={handleNew}
class="flex w-full items-center gap-4 px-6 py-2 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
>
@@ -117,7 +117,7 @@
use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }}
use:listNavigation={suggestionContainer}
>
<button class="w-full" on:click={() => (hideSuggestion = false)}>
<button type="button" class="w-full" on:click={() => (hideSuggestion = false)}>
<SearchBar
placeholder="Search places"
bind:name={searchWord}
@@ -133,6 +133,7 @@
{#if !hideSuggestion}
{#each suggestedPlaces as place, index}
<button
type="button"
class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
suggestedPlaces.length - 1
? 'rounded-b-lg border-b'
@@ -7,6 +7,7 @@
</script>
<button
type="button"
on:click
class="w-full bg-slate-100 p-4 text-left text-sm font-medium text-immich-fg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg"
role="menuitem"
@@ -22,6 +22,7 @@
<div id="immich-dropdown" class="relative">
<button
type="button"
{disabled}
on:click={toggle}
aria-expanded={isOpen}
@@ -52,6 +53,7 @@
<div class="absolute mt-2 flex w-full flex-col">
{#each options.options as option}
<button
type="button"
on:click={() => {
selected = option;
isOpen = false;
@@ -43,13 +43,16 @@
}
</script>
<FocusTrap>
<section
role="presentation"
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
class="fixed left-0 top-0 z-[9990] flex h-screen w-screen place-content-center place-items-center bg-black/40"
>
<section
role="presentation"
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
class="fixed left-0 top-0 z-[9990] flex h-screen w-screen place-content-center place-items-center bg-black/40"
on:keydown={(event) => {
event.stopPropagation();
}}
>
<FocusTrap>
<div
class="z-[9999] max-w-[95vw] max-h-[95vh] {modalWidth} overflow-y-auto rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar"
style="max-height: min(95vh, 900px);"
@@ -72,5 +75,5 @@
</div>
{/if}
</div>
</section>
</FocusTrap>
</FocusTrap>
</section>
@@ -4,7 +4,7 @@
import { AppRoute } from '$lib/constants';
import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { deleteProfileImage, updateUser, type UserAvatarColor } from '@immich/sdk';
import { deleteProfileImage, updateMyUser, type UserAvatarColor } from '@immich/sdk';
import { mdiCog, mdiLogout, mdiPencil } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
@@ -27,9 +27,8 @@
await deleteProfileImage();
}
$user = await updateUser({
updateUserDto: {
id: $user.id,
$user = await updateMyUser({
userUpdateMeDto: {
email: $user.email,
name: $user.name,
avatarColor: color,
@@ -93,6 +92,7 @@
<div class="mb-4 flex flex-col">
<button
type="button"
class="flex w-full place-content-center place-items-center gap-2 py-3 font-medium text-gray-500 hover:bg-immich-primary/10 dark:text-gray-300"
on:click={() => dispatch('logout')}
>
@@ -17,7 +17,7 @@
<div class="flex items-center justify-center mt-4">
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
{#each colors as color}
<button on:click={() => dispatch('choose', color)}>
<button type="button" on:click={() => dispatch('choose', color)}>
<UserAvatar label={color} {user} {color} size="xl" showProfileImage={false} />
</button>
{/each}
@@ -119,6 +119,7 @@
on:escape={() => (shouldShowAccountInfoPanel = false)}
>
<button
type="button"
class="flex"
on:mouseover={() => (shouldShowAccountInfo = true)}
on:focus={() => (shouldShowAccountInfo = true)}
@@ -101,6 +101,7 @@
{#if notification.button}
<p class="pl-[28px] mt-2.5 text-sm">
<button
type="button"
class="{buttonStyle[notification.type]} rounded px-3 pt-1.5 pb-1 transition-all duration-200"
on:click={handleButtonClick}
>
@@ -27,7 +27,12 @@
</script>
<div class="border-b-[1px] border-gray-200 py-4 dark:border-gray-700">
<button on:click={() => (isOpen = !isOpen)} class="flex w-full place-items-center justify-between text-left">
<button
type="button"
aria-expanded={isOpen}
on:click={() => (isOpen = !isOpen)}
class="flex w-full place-items-center justify-between text-left"
>
<div>
<h2 class="font-medium text-immich-primary dark:text-immich-dark-primary">
{title}
@@ -38,8 +43,7 @@
</slot>
</div>
<button
aria-expanded={isOpen}
<div
class="immich-circle-icon-button flex place-content-center place-items-center rounded-full p-3 transition-all hover:bg-immich-primary/10 dark:text-immich-dark-fg hover:dark:bg-immich-dark-primary/20"
>
<svg
@@ -55,7 +59,7 @@
>
<path d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</button>
{#if isOpen}
@@ -16,6 +16,7 @@
<div class="left">
{#if showResetToDefault}
<button
type="button"
on:click={() => dispatch('reset', { default: true })}
class="bg-none text-sm font-medium text-immich-primary hover:text-immich-primary/75 dark:text-immich-dark-primary hover:dark:text-immich-dark-primary/75"
>
@@ -26,6 +27,6 @@
<div class="right">
<Button {disabled} size="sm" color="gray" on:click={() => dispatch('reset', { default: false })}>Reset</Button>
<Button {disabled} size="sm" on:click={() => dispatch('save')}>Save</Button>
<Button type="submit" {disabled} size="sm" on:click={() => dispatch('save')}>Save</Button>
</div>
</div>
@@ -27,4 +27,25 @@ describe('SettingInputField component', () => {
await user.click(document.body);
expect(numberInput.value).toEqual('100');
});
it('allows emptying number inputs while editing', async () => {
const { getByRole } = render(SettingInputField, {
props: {
label: 'test-number-input',
inputType: SettingInputFieldType.NUMBER,
value: 5,
},
});
const user = userEvent.setup();
const numberInput = getByRole('spinbutton') as HTMLInputElement;
expect(numberInput.value).toEqual('5');
await user.click(numberInput);
await user.keyboard('{Backspace}');
expect(numberInput.value).toEqual('');
await user.click(document.body);
expect(numberInput.value).toEqual('0');
});
});
@@ -9,6 +9,7 @@
<script lang="ts">
import { quintOut } from 'svelte/easing';
import type { FormEventHandler } from 'svelte/elements';
import { fly } from 'svelte/transition';
import PasswordField from '../password-field.svelte';
@@ -25,7 +26,9 @@
export let isEdited = false;
export let passwordAutocomplete: string = 'current-password';
const validateInput = () => {
const handleChange: FormEventHandler<HTMLInputElement> = (e) => {
value = e.currentTarget.value;
if (inputType === SettingInputFieldType.NUMBER) {
let newValue = Number(value) || 0;
if (newValue < min) {
@@ -77,8 +80,7 @@
{step}
{required}
{value}
on:input={(e) => (value = e.currentTarget.value)}
on:blur={validateInput}
on:change={handleChange}
{disabled}
{title}
/>
@@ -84,10 +84,11 @@
</div>
{#if uploadAsset.state === UploadState.ERROR}
<div class="flex h-full flex-col place-content-evenly place-items-center justify-items-center pr-2">
<button on:click={() => handleRetry(uploadAsset)} title="Retry upload" class="flex text-sm">
<button type="button" on:click={() => handleRetry(uploadAsset)} title="Retry upload" class="flex text-sm">
<span class="text-immich-dark-gray dark:text-immich-dark-fg"><Icon path={mdiRefresh} size="20" /></span>
</button>
<button
type="button"
on:click={() => uploadAssetsStore.removeUploadAsset(uploadAsset.id)}
title="Dismiss error"
class="flex text-sm"
@@ -128,6 +128,7 @@
{:else}
<div class="rounded-full">
<button
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
on:click={() => (showDetail = true)}
class="absolute -left-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-primary p-5 text-xs text-gray-200"
@@ -136,6 +137,7 @@
</button>
{#if $hasError}
<button
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
on:click={() => (showDetail = true)}
class="absolute -right-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-error p-5 text-xs text-gray-200"
@@ -144,6 +146,7 @@
</button>
{/if}
<button
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
on:click={() => (showDetail = true)}
class="flex h-16 w-16 place-content-center place-items-center rounded-full bg-gray-200 p-5 text-sm text-immich-primary shadow-lg dark:bg-gray-600 dark:text-immich-gray"
@@ -3,23 +3,18 @@
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { updateUser, type UserResponseDto } from '@immich/sdk';
import { updateMyUser, type UserAdminResponseDto } from '@immich/sdk';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
import Button from '../elements/buttons/button.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import Button from '../elements/buttons/button.svelte';
export let user: UserResponseDto;
export let user: UserAdminResponseDto;
const handleSave = async () => {
try {
const data = await updateUser({
updateUserDto: {
id: user.id,
memoriesEnabled: user.memoriesEnabled,
},
});
const data = await updateMyUser({ userUpdateMeDto: { memoriesEnabled: user.memoriesEnabled } });
Object.assign(user, data);
@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { featureFlags } from '$lib/stores/server-config.store';
import { oauth } from '$lib/utils';
import { type UserResponseDto } from '@immich/sdk';
import { type UserAdminResponseDto } from '@immich/sdk';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
@@ -10,7 +10,7 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
export let user: UserResponseDto;
export let user: UserAdminResponseDto;
let loading = true;
@@ -1,5 +1,5 @@
<script lang="ts">
import { getAllUsers, getPartners, type UserResponseDto } from '@immich/sdk';
import { searchUsers, getPartners, type UserResponseDto } from '@immich/sdk';
import { createEventDispatcher, onMount } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
@@ -14,11 +14,10 @@
const dispatch = createEventDispatcher<{ 'add-users': UserResponseDto[] }>();
onMount(async () => {
// TODO: update endpoint to have a query param for deleted users
let users = await getAllUsers({ isAll: false });
let users = await searchUsers();
// remove invalid users
users = users.filter((_user) => !(_user.deletedAt || _user.id === user.id));
// remove current user
users = users.filter((_user) => _user.id !== user.id);
// exclude partners from the list of users available for selection
const partners = await getPartners({ direction: 'shared-by' });
@@ -38,6 +37,7 @@
{#if availableUsers.length > 0}
{#each availableUsers as user}
<button
type="button"
on:click={() => selectUser(user)}
class="flex w-full place-items-center gap-4 px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
>
@@ -3,23 +3,22 @@
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
import Button from '../elements/buttons/button.svelte';
import { user } from '$lib/stores/user.store';
import { cloneDeep } from 'lodash-es';
import { updateUser } from '@immich/sdk';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { user } from '$lib/stores/user.store';
import { updateMyUser } from '@immich/sdk';
import { cloneDeep } from 'lodash-es';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
import Button from '../elements/buttons/button.svelte';
let editedUser = cloneDeep($user);
const handleSaveProfile = async () => {
try {
const data = await updateUser({
updateUserDto: {
id: editedUser.id,
const data = await updateMyUser({
userUpdateMeDto: {
email: editedUser.email,
name: editedUser.name,
},
@@ -53,7 +53,7 @@
{@const assetData = JSON.stringify(asset, null, 2)}
<div class="relative">
<button on:click={() => onSelectAsset(asset)} class="block relative">
<button type="button" on:click={() => onSelectAsset(asset)} class="block relative">
<!-- THUMBNAIL-->
<img
src={getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
@@ -4,15 +4,13 @@
import { AppRoute } from '$lib/constants';
</script>
<a href={AppRoute.DUPLICATES}>
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="text-xs font-medium p-4">ORGANIZE YOUR LIBRARY</p>
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="text-xs font-medium p-4">ORGANIZE YOUR LIBRARY</p>
<button class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex gap-4 p-4">
<span
><Icon path={mdiContentDuplicate} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
Review duplicates
</button>
</div>
</a>
<a href={AppRoute.DUPLICATES} class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex gap-4 p-4">
<span
><Icon path={mdiContentDuplicate} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
Review duplicates
</a>
</div>
+3 -3
View File
@@ -1,12 +1,12 @@
import type { UserResponseDto } from '@immich/sdk';
import type { UserAdminResponseDto } from '@immich/sdk';
import { writable } from 'svelte/store';
export const user = writable<UserResponseDto>();
export const user = writable<UserAdminResponseDto>();
/**
* Reset the store to its initial undefined value. Make sure to
* only do this _after_ redirecting to an unauthenticated page.
*/
export const resetSavedUser = () => {
user.set(undefined as unknown as UserResponseDto);
user.set(undefined as unknown as UserAdminResponseDto);
};
+2 -3
View File
@@ -11,7 +11,6 @@ import {
startOAuth,
unlinkOAuthAccount,
type SharedLinkResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiImageRefreshOutline } from '@mdi/js';
@@ -264,7 +263,7 @@ export const oauth = {
login: (location: Location) => {
return finishOAuth({ oAuthCallbackDto: { url: location.href } });
},
link: (location: Location): Promise<UserResponseDto> => {
link: (location: Location) => {
return linkOAuthAccount({ oAuthCallbackDto: { url: location.href } });
},
unlink: () => {
@@ -292,4 +291,4 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
export const s = (count: number) => (count === 1 ? '' : 's');
export const memoryLaneTitle = (yearsAgo: number) => `year${s(yearsAgo)} ago`;
export const memoryLaneTitle = (yearsAgo: number) => `${yearsAgo} year${s(yearsAgo)} ago`;
+2 -2
View File
@@ -1,7 +1,7 @@
import { browser } from '$app/environment';
import { serverInfo } from '$lib/stores/server-info.store';
import { user } from '$lib/stores/user.store';
import { getMyUserInfo, getStorage } from '@immich/sdk';
import { getMyUser, getStorage } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { get } from 'svelte/store';
import { AppRoute } from '../constants';
@@ -15,7 +15,7 @@ export const loadUser = async () => {
try {
let loaded = get(user);
if (!loaded && hasAuthCookie()) {
loaded = await getMyUserInfo();
loaded = await getMyUser();
user.set(loaded);
}
return loaded;
@@ -510,6 +510,7 @@
<svelte:fragment slot="trailing">
<button
type="button"
on:click={handleSelectFromComputer}
class="rounded-lg px-6 py-2 text-sm font-medium text-immich-primary transition-all hover:bg-immich-primary/10 dark:text-immich-dark-primary dark:hover:bg-immich-dark-primary/25"
>
@@ -577,13 +578,13 @@
{/if}
<!-- owner -->
<button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<button type="button" on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<UserAvatar user={album.owner} size="md" />
</button>
<!-- users with write access (collaborators) -->
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)}
<button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<button type="button" on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<UserAvatar {user} size="md" />
</button>
{/each}
@@ -620,6 +621,7 @@
<div class="w-[300px]">
<p class="text-xs dark:text-immich-dark-fg">ADD PHOTOS</p>
<button
type="button"
on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
>
@@ -1,12 +1,12 @@
import { authenticate } from '$lib/utils/auth';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getUserById } from '@immich/sdk';
import { getUser } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
await authenticate();
const partner = await getUserById({ id: params.userId });
const partner = await getUser({ id: params.userId });
const asset = await getAssetInfoFromParam(params);
return {
asset,
@@ -502,6 +502,7 @@
<div class="w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-9 gap-1">
{#each people as person, index (person.id)}
<button
type="button"
class="relative"
on:click={() => (person.isHidden = !person.isHidden)}
on:mouseenter={() => (eyeColorMap[person.id] = 'black')}
@@ -464,6 +464,7 @@
{:else}
<div class="relative">
<button
type="button"
class="flex items-center justify-center"
title="Edit name"
on:click={() => (isEditingName = true)}
@@ -507,6 +508,7 @@
<div bind:this={suggestionContainer}>
{#each suggestedPeople as person, index (person.id)}
<button
type="button"
class="flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
suggestedPeople.length - 1
? 'rounded-b-lg border-b'
+6 -6
View File
@@ -68,10 +68,10 @@
rel="noopener noreferrer"
class="flex grow basis-0 justify-center p-4"
>
<button class="flex flex-col place-content-center place-items-center gap-2">
<div class="flex flex-col place-content-center place-items-center gap-2">
<Icon path={mdiMessage} size={24} />
<p class="text-sm">Get Help</p>
</button>
</div>
</a>
<a
@@ -80,10 +80,10 @@
rel="noopener noreferrer"
class="flex grow basis-0 justify-center p-4"
>
<button class="flex flex-col place-content-center place-items-center gap-2">
<div class="flex flex-col place-content-center place-items-center gap-2">
<Icon path={mdiPartyPopper} size={24} />
<p class="text-sm">Read Changelog</p>
</button>
</div>
</a>
<a
@@ -92,10 +92,10 @@
rel="noopener noreferrer"
class="flex grow basis-0 justify-center p-4"
>
<button class="flex flex-col place-content-center place-items-center gap-2">
<div class="flex flex-col place-content-center place-items-center gap-2">
<Icon path={mdiCodeTags} size={24} />
<p class="text-sm">Check Logs</p>
</button>
</div>
</a>
</div>
</div>
@@ -23,7 +23,7 @@
deleteLibrary,
getAllLibraries,
getLibraryStatistics,
getUserById,
getUserAdmin,
removeOfflineFiles,
scanLibrary,
updateLibrary,
@@ -99,7 +99,7 @@
const refreshStats = async (listIndex: number) => {
stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
owner[listIndex] = await getUserById({ id: libraries[listIndex].ownerId });
owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId });
photos[listIndex] = stats[listIndex].photos;
videos[listIndex] = stats[listIndex].videos;
totalCount[listIndex] = stats[listIndex].total;
@@ -1,11 +1,11 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getAllUsers } from '@immich/sdk';
import { searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate({ admin: true });
await requestServerInfo();
const allUsers = await getAllUsers({ isAll: false });
const allUsers = await searchUsersAdmin({ withDeleted: false });
return {
allUsers,
@@ -1,14 +1,15 @@
<script lang="ts">
import { page } from '$app/stores';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialogue.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import RestoreDialogue from '$lib/components/admin-page/restore-dialogue.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import {
NotificationType,
notificationController,
@@ -17,28 +18,27 @@
import { serverConfig } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { asByteUnitString } from '$lib/utils/byte-units';
import { copyToClipboard } from '$lib/utils';
import { UserStatus, getAllUsers, type UserResponseDto } from '@immich/sdk';
import { asByteUnitString } from '$lib/utils/byte-units';
import { UserStatus, searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { mdiClose, mdiContentCopy, mdiDeleteRestore, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
export let data: PageData;
let allUsers: UserResponseDto[] = [];
let allUsers: UserAdminResponseDto[] = [];
let shouldShowEditUserForm = false;
let shouldShowCreateUserForm = false;
let shouldShowPasswordResetSuccess = false;
let shouldShowDeleteConfirmDialog = false;
let shouldShowRestoreDialog = false;
let selectedUser: UserResponseDto;
let selectedUser: UserAdminResponseDto;
let newPassword: string;
const refresh = async () => {
allUsers = await getAllUsers({ isAll: false });
allUsers = await searchUsersAdmin({ withDeleted: true });
};
const onDeleteSuccess = (userId: string) => {
@@ -75,7 +75,7 @@
shouldShowCreateUserForm = false;
};
const editUserHandler = (user: UserResponseDto) => {
const editUserHandler = (user: UserAdminResponseDto) => {
selectedUser = user;
shouldShowEditUserForm = true;
};
@@ -91,7 +91,7 @@
shouldShowPasswordResetSuccess = true;
};
const deleteUserHandler = (user: UserResponseDto) => {
const deleteUserHandler = (user: UserAdminResponseDto) => {
selectedUser = user;
shouldShowDeleteConfirmDialog = true;
};
@@ -101,7 +101,7 @@
shouldShowDeleteConfirmDialog = false;
};
const restoreUserHandler = (user: UserResponseDto) => {
const restoreUserHandler = (user: UserAdminResponseDto) => {
selectedUser = user;
shouldShowRestoreDialog = true;
};
@@ -1,11 +1,11 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getAllUsers } from '@immich/sdk';
import { searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate({ admin: true });
await requestServerInfo();
const allUsers = await getAllUsers({ isAll: false });
const allUsers = await searchUsersAdmin({ withDeleted: true });
return {
allUsers,
@@ -25,5 +25,5 @@
enter the new password below.
</p>
<ChangePasswordForm user={$user} on:success={onSuccess} />
<ChangePasswordForm on:success={onSuccess} />
</FullscreenContainer>
+1 -12
View File
@@ -1,22 +1,11 @@
import { faker } from '@faker-js/faker';
import { UserAvatarColor, UserStatus, type UserResponseDto } from '@immich/sdk';
import { UserAvatarColor, type UserResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts';
export const userFactory = Sync.makeFactory<UserResponseDto>({
id: Sync.each(() => faker.string.uuid()),
email: Sync.each(() => faker.internet.email()),
name: Sync.each(() => faker.person.fullName()),
storageLabel: Sync.each(() => faker.string.alphanumeric()),
profileImagePath: '',
shouldChangePassword: Sync.each(() => faker.datatype.boolean()),
isAdmin: true,
createdAt: Sync.each(() => faker.date.past().toISOString()),
deletedAt: null,
updatedAt: Sync.each(() => faker.date.past().toISOString()),
memoriesEnabled: true,
oauthId: '',
avatarColor: UserAvatarColor.Primary,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: UserStatus.Active,
});