Merge remote-tracking branch 'origin/main' into keynav_timeline

This commit is contained in:
Min Idzelis 2025-05-13 02:01:47 +00:00
commit b3752cc533
86 changed files with 912 additions and 541 deletions

View File

@ -362,6 +362,7 @@
"user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.",
"user_delete_immediately": "<b>{user}</b>'s account and assets will be queued for permanent deletion <b>immediately</b>.",
"user_delete_immediately_checkbox": "Queue user and assets for immediate deletion",
"user_details": "User Details",
"user_management": "User Management",
"user_password_has_been_reset": "The user's password has been reset:",
"user_password_reset_description": "Please provide the temporary password to the user and inform them they will need to change the password at their next login.",
@ -1290,6 +1291,7 @@
"notification_permission_list_tile_enable_button": "Enable Notifications",
"notification_permission_list_tile_title": "Notification Permission",
"notification_toggle_setting_description": "Enable email notifications",
"email_notifications": "Email notifications",
"notifications": "Notifications",
"notifications_setting_description": "Manage notifications",
"oauth": "OAuth",
@ -1394,6 +1396,7 @@
"previous_or_next_photo": "Previous or next photo",
"primary": "Primary",
"privacy": "Privacy",
"profile": "Profile",
"profile_drawer_app_logs": "Logs",
"profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.",
"profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.",
@ -1753,6 +1756,7 @@
"storage": "Storage space",
"storage_label": "Storage label",
"storage_usage": "{used} of {available} used",
"storage_quota": "Storage Quota",
"submit": "Submit",
"suggestions": "Suggestions",
"sunrise_on_the_beach": "Sunrise on the beach",
@ -1857,6 +1861,7 @@
"upload_success": "Upload success, refresh the page to see new upload assets.",
"upload_to_immich": "Upload to Immich ({count})",
"uploading": "Uploading",
"id": "ID",
"url": "URL",
"usage": "Usage",
"use_current_connection": "use current connection",
@ -1864,6 +1869,8 @@
"user": "User",
"user_id": "User ID",
"user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}",
"created_at": "Created",
"updated_at": "Updated",
"user_purchase_settings": "Purchase",
"user_purchase_settings_description": "Manage your purchase",
"user_role_set": "Set {user} as {role}",

View File

@ -253,6 +253,7 @@ Class | Method | HTTP request | Description
*UsersAdminApi* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} |
*UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /admin/users/{id} |
*UsersAdminApi* | [**getUserPreferencesAdmin**](doc//UsersAdminApi.md#getuserpreferencesadmin) | **GET** /admin/users/{id}/preferences |
*UsersAdminApi* | [**getUserStatisticsAdmin**](doc//UsersAdminApi.md#getuserstatisticsadmin) | **GET** /admin/users/{id}/statistics |
*UsersAdminApi* | [**restoreUserAdmin**](doc//UsersAdminApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore |
*UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users |
*UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} |

View File

@ -211,6 +211,76 @@ class UsersAdminApi {
return null;
}
/// Performs an HTTP 'GET /admin/users/{id}/statistics' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [AssetVisibility] visibility:
Future<Response> getUserStatisticsAdminWithHttpInfo(String id, { bool? isFavorite, bool? isTrashed, AssetVisibility? visibility, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/users/{id}/statistics'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isTrashed != null) {
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
}
if (visibility != null) {
queryParams.addAll(_queryParams('', 'visibility', visibility));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [AssetVisibility] visibility:
Future<AssetStatsResponseDto?> getUserStatisticsAdmin(String id, { bool? isFavorite, bool? isTrashed, AssetVisibility? visibility, }) async {
final response = await getUserStatisticsAdminWithHttpInfo(id, isFavorite: isFavorite, isTrashed: isTrashed, visibility: visibility, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetStatsResponseDto',) as AssetStatsResponseDto;
}
return null;
}
/// Performs an HTTP 'POST /admin/users/{id}/restore' operation and returns the [Response].
/// Parameters:
///
@ -262,8 +332,10 @@ class UsersAdminApi {
/// Performs an HTTP 'GET /admin/users' operation and returns the [Response].
/// Parameters:
///
/// * [String] id:
///
/// * [bool] withDeleted:
Future<Response> searchUsersAdminWithHttpInfo({ bool? withDeleted, }) async {
Future<Response> searchUsersAdminWithHttpInfo({ String? id, bool? withDeleted, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/users';
@ -274,6 +346,9 @@ class UsersAdminApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (withDeleted != null) {
queryParams.addAll(_queryParams('', 'withDeleted', withDeleted));
}
@ -294,9 +369,11 @@ class UsersAdminApi {
/// Parameters:
///
/// * [String] id:
///
/// * [bool] withDeleted:
Future<List<UserAdminResponseDto>?> searchUsersAdmin({ bool? withDeleted, }) async {
final response = await searchUsersAdminWithHttpInfo( withDeleted: withDeleted, );
Future<List<UserAdminResponseDto>?> searchUsersAdmin({ String? id, bool? withDeleted, }) async {
final response = await searchUsersAdminWithHttpInfo( id: id, withDeleted: withDeleted, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@ -345,6 +345,15 @@
"get": {
"operationId": "searchUsersAdmin",
"parameters": [
{
"name": "id",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "withDeleted",
"required": false,
@ -701,6 +710,72 @@
]
}
},
"/admin/users/{id}/statistics": {
"get": {
"operationId": "getUserStatisticsAdmin",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isTrashed",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "visibility",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetVisibility"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetStatsResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Users (admin)"
]
}
},
"/albums": {
"get": {
"operationId": "getAllAlbums",

View File

@ -224,6 +224,11 @@ export type UserPreferencesUpdateDto = {
sharedLinks?: SharedLinksUpdate;
tags?: TagsUpdate;
};
export type AssetStatsResponseDto = {
images: number;
total: number;
videos: number;
};
export type AlbumUserResponseDto = {
role: AlbumUserRole;
user: UserResponseDto;
@ -462,11 +467,6 @@ export type AssetJobsDto = {
assetIds: string[];
name: AssetJobName;
};
export type AssetStatsResponseDto = {
images: number;
total: number;
videos: number;
};
export type UpdateAssetDto = {
dateTimeOriginal?: string;
description?: string;
@ -1502,13 +1502,15 @@ export function sendTestEmailAdmin({ systemConfigSmtpDto }: {
body: systemConfigSmtpDto
})));
}
export function searchUsersAdmin({ withDeleted }: {
export function searchUsersAdmin({ id, withDeleted }: {
id?: string;
withDeleted?: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserAdminResponseDto[];
}>(`/admin/users${QS.query(QS.explode({
id,
withDeleted
}))}`, {
...opts
@ -1596,6 +1598,23 @@ export function restoreUserAdmin({ id }: {
method: "POST"
}));
}
export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }: {
id: string;
isFavorite?: boolean;
isTrashed?: boolean;
visibility?: AssetVisibility;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetStatsResponseDto;
}>(`/admin/users/${encodeURIComponent(id)}/statistics${QS.query(QS.explode({
isFavorite,
isTrashed,
visibility
}))}`, {
...opts
}));
}
export function getAllAlbums({ assetId, shared }: {
assetId?: string;
shared?: boolean;
@ -3552,6 +3571,11 @@ export enum UserStatus {
Removing = "removing",
Deleted = "deleted"
}
export enum AssetVisibility {
Archive = "archive",
Timeline = "timeline",
Hidden = "hidden"
}
export enum AlbumUserRole {
Editor = "editor",
Viewer = "viewer"
@ -3661,11 +3685,6 @@ export enum Permission {
AdminUserUpdate = "admin.user.update",
AdminUserDelete = "admin.user.delete"
}
export enum AssetVisibility {
Archive = "archive",
Timeline = "timeline",
Hidden = "hidden"
}
export enum AssetMediaStatus {
Created = "created",
Replaced = "replaced",

View File

@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import {
@ -57,6 +58,16 @@ export class UserAdminController {
return this.service.delete(auth, id, dto);
}
@Get(':id/statistics')
@Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true })
getUserStatisticsAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() dto: AssetStatsDto,
): Promise<AssetStatsResponseDto> {
return this.service.getStatistics(auth, id, dto);
}
@Get(':id/preferences')
@Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true })
getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserPreferencesResponseDto> {

View File

@ -4,7 +4,7 @@ import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from
import { User, UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types';
import { Optional, PinCode, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
import { Optional, PinCode, ValidateBoolean, ValidateUUID, toEmail, toSanitized } from 'src/validation';
export class UserUpdateMeDto {
@Optional()
@ -67,6 +67,9 @@ export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
export class UserAdminSearchDto {
@ValidateBoolean({ optional: true })
withDeleted?: boolean;
@ValidateUUID({ optional: true })
id?: string;
}
export class UserAdminCreateDto {

View File

@ -14,6 +14,7 @@ import { asUuid } from 'src/utils/database';
type Upsert = Insertable<DbUserMetadata>;
export interface UserListFilter {
id?: string;
withDeleted?: boolean;
}
@ -141,12 +142,13 @@ export class UserRepository {
{ name: 'with deleted', params: [{ withDeleted: true }] },
{ name: 'without deleted', params: [{ withDeleted: false }] },
)
getList({ withDeleted }: UserListFilter = {}) {
getList({ id, withDeleted }: UserListFilter = {}) {
return this.db
.selectFrom('users')
.select(columns.userAdmin)
.select(withMetadata)
.$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
.$if(!!id, (eb) => eb.where('users.id', '=', id!))
.orderBy('createdAt', 'desc')
.execute();
}

View File

@ -1,5 +1,6 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { SALT_ROUNDS } from 'src/constants';
import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import {
@ -18,7 +19,10 @@ import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/uti
@Injectable()
export class UserAdminService extends BaseService {
async search(auth: AuthDto, dto: UserAdminSearchDto): Promise<UserAdminResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: dto.withDeleted });
const users = await this.userRepository.getList({
id: dto.id,
withDeleted: dto.withDeleted,
});
return users.map((user) => mapUserAdmin(user));
}
@ -109,6 +113,11 @@ export class UserAdminService extends BaseService {
return mapUserAdmin(user);
}
async getStatistics(auth: AuthDto, id: string, dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
const stats = await this.assetRepository.getStatistics(auth.user.id, dto);
return mapStats(stats);
}
async getPreferences(auth: AuthDto, id: string): Promise<UserPreferencesResponseDto> {
await this.findOrFail(id, { withDeleted: true });
const metadata = await this.userRepository.getMetadata(id);

View File

@ -3,6 +3,7 @@
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { getJobName } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@ -20,10 +21,9 @@
mdiVideo,
} from '@mdi/js';
import type { Component } from 'svelte';
import { t } from 'svelte-i18n';
import JobTile from './job-tile.svelte';
import StorageMigrationDescription from './storage-migration-description.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
interface Props {
jobs: AllJobStatusResponseDto;
@ -45,7 +45,7 @@
const handleConfirmCommand = async (jobId: JobName, dto: JobCommandDto) => {
if (dto.force) {
const isConfirmed = await dialogController.show({
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_reprocess_all_faces'),
});

View File

@ -103,9 +103,7 @@
class="block max-h-[320px] w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
{#each stats.usageByUser as user (user.userId)}
<tr
class="flex h-[50px] w-full place-items-center text-center odd:bg-subtle even:bg-immich-bg odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50"
>
<tr class="flex h-[50px] w-full place-items-center text-center even:bg-subtle/20 odd:bg-subtle/80">
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.userName}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm"
>{user.photos.toLocaleString($locale)} ({getByteUnitString(user.usagePhotos, $locale, 0)})</td

View File

@ -1,12 +1,12 @@
<script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { OAuthTokenEndpointAuthMethod, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
@ -47,7 +47,7 @@
</script>
{#if isConfirmOpen}
<ConfirmDialog
<ConfirmModal
title={$t('admin.disable_login')}
onClose={(confirmed) => (confirmed ? handleSave(true) : (isConfirmOpen = false))}
>
@ -70,7 +70,7 @@
</p>
</div>
{/snippet}
</ConfirmDialog>
</ConfirmModal>
{/if}
<div>

View File

@ -2,10 +2,10 @@
import Icon from '$lib/components/elements/icon.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import {
AlbumUserRole,
@ -191,7 +191,7 @@
{/if}
{#if selectedRemoveUser}
<ConfirmDialog
<ConfirmModal
title={$t('album_remove_user')}
prompt={$t('album_remove_user_confirmation', { values: { user: selectedRemoveUser.name } })}
confirmText={$t('remove_user')}

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { updateAlbumInfo } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
import { shortcut } from '$lib/actions/shortcut';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk';
import { t } from 'svelte-i18n';
interface Props {
@ -40,7 +40,7 @@
onblur={handleUpdateName}
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned
? 'hover:border-gray-400'
: 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray"
: 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray"
type="text"
bind:value={newAlbumName}
disabled={!isOwned}

View File

@ -1,11 +1,18 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte';
import AssetGrid from '../photos-page/asset-grid.svelte';
@ -13,14 +20,7 @@
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
import ThemeButton from '../shared-components/theme-button.svelte';
import { shortcut } from '$lib/actions/shortcut';
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { handlePromiseError } from '$lib/utils';
import AlbumSummary from './album-summary.svelte';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import AlbumMap from '$lib/components/album-page/album-map.svelte';
interface Props {
sharedLink: SharedLinkResponseDto;
@ -101,14 +101,12 @@
{/if}
</header>
<main
class="relative h-dvh overflow-hidden bg-immich-bg px-2 md:px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg"
>
<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="bg-immich-bg text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:bg-immich-dark-bg dark:text-immich-dark-primary"
class="text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary"
>
{album.albumName}
</h1>

View File

@ -31,7 +31,7 @@
</script>
<tr
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center odd:bg-subtle even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
onclick={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
{oncontextmenu}
>

View File

@ -1,6 +1,13 @@
<script lang="ts">
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import {
AlbumUserRole,
getMyUser,
@ -13,10 +20,6 @@
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { handleError } from '../../utils/handle-error';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import UserAvatar from '../shared-components/user-avatar.svelte';
interface Props {
album: AlbumResponseDto;
@ -140,7 +143,7 @@
{/if}
{#if selectedRemoveUser && selectedRemoveUser?.id === currentUser?.id}
<ConfirmDialog
<ConfirmModal
title={$t('album_leave')}
prompt={$t('album_leave_confirmation', { values: { album: album.albumName } })}
confirmText={$t('leave')}
@ -149,7 +152,7 @@
{/if}
{#if selectedRemoveUser && selectedRemoveUser?.id !== currentUser?.id}
<ConfirmDialog
<ConfirmModal
title={$t('album_remove_user')}
prompt={$t('album_remove_user_confirmation', { values: { user: selectedRemoveUser.name } })}
confirmText={$t('remove_user')}

View File

@ -1,12 +1,12 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
import type { AssetResponseDto, StackResponseDto } from '@immich/sdk';
import { mdiPinOutline } from '@mdi/js';
import type { OnAction } from './action';
import { t } from 'svelte-i18n';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import type { OnAction } from './action';
interface Props {
stack: StackResponseDto;
@ -17,7 +17,7 @@
let { stack, asset, onAction }: Props = $props();
const handleKeepThisDeleteOthers = async () => {
const isConfirmed = await dialogController.show({
const isConfirmed = await modalManager.showDialog({
title: $t('keep_this_delete_others'),
prompt: $t('confirm_keep_this_delete_others'),
confirmText: $t('delete_others'),

View File

@ -529,7 +529,7 @@
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
translate="yes"
>
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} />
@ -540,7 +540,7 @@
<div
transition:fly={{ duration: 150 }}
id="editor-panel"
class="row-start-1 row-span-4 w-[400px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
class="row-start-1 row-span-4 w-[400px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
translate="yes"
>
<EditorPanel {asset} onUpdateSelectedType={handleUpdateSelectedEditType} onClose={closeEditor} />
@ -589,7 +589,7 @@
<div
transition:fly={{ duration: 150 }}
id="activity-panel"
class="row-start-1 row-span-5 w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
class="row-start-1 row-span-5 w-[360px] md:w-[460px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
translate="yes"
>
<ActivityViewer

View File

@ -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 bg-immich-bg p-4 text-sm shadow-sm"
class="fixed bottom-10 start-2 z-[10000] 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">

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store';
import { websocketEvents } from '$lib/stores/websocket';
import { type AssetResponseDto } from '@immich/sdk';
@ -67,11 +67,9 @@
</section>
{#if $showCancelConfirmDialog}
<ConfirmDialog
<ConfirmModal
title={$t('editor_close_without_save_title')}
prompt={$t('editor_close_without_save_prompt')}
cancelText={$t('no')}
cancelColor="secondary"
confirmColor="danger"
confirmText={$t('close')}
onClose={(confirmed) => (confirmed ? onConfirm() : ($showCancelConfirmDialog = false))}

View File

@ -1,7 +1,7 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { notificationController } from '$lib/components/shared-components/notification/notification';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
@ -284,10 +284,7 @@
return;
}
const isConfirmed = await dialogController.show({
prompt: `Do you want to tag this face as ${person.name}?`,
});
const isConfirmed = await modalManager.showDialog({ prompt: `Do you want to tag this face as ${person.name}?` });
if (!isConfirmed) {
return;
}

View File

@ -1,20 +1,20 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto, getAllPeople } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
import { AssetTypeEnum, getAllPeople, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
import { t } from 'svelte-i18n';
import { handleError } from '$lib/utils/handle-error';
import { onMount } from 'svelte';
interface Props {
editedFace: AssetFaceResponseDto;
@ -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 bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg"
class="absolute top-0 z-[2001] 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}

View File

@ -8,6 +8,7 @@
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { ToggleVisibility } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@ -15,7 +16,6 @@
import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
import { t } from 'svelte-i18n';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { ToggleVisibility } from '$lib/constants';
interface Props {
people: PersonResponseDto[];
@ -134,7 +134,7 @@
</div>
</div>
<div class="flex flex-wrap gap-1 bg-immich-bg p-2 pb-8 dark:bg-immich-dark-bg md:px-8 mt-16">
<div class="flex flex-wrap gap-1 p-2 pb-8 md:px-8 mt-16">
<PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}>
{#snippet children({ person })}
{@const hidden = personIsHidden[person.id]}

View File

@ -3,10 +3,12 @@
import { page } from '$app/state';
import Icon from '$lib/components/elements/icon.svelte';
import { ActionQueryParameterValue, AppRoute, QueryParameter } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getAllPeople, getPerson, mergePerson, type PersonResponseDto } from '@immich/sdk';
import { mdiCallMerge, mdiMerge, mdiSwapHorizontal } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
@ -16,8 +18,6 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import FaceThumbnail from './face-thumbnail.svelte';
import PeopleList from './people-list.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
interface Props {
person: PersonResponseDto;
@ -69,10 +69,7 @@
};
const handleMerge = async () => {
const isConfirm = await dialogController.show({
prompt: $t('merge_people_prompt'),
});
const isConfirm = await modalManager.showDialog({ prompt: $t('merge_people_prompt') });
if (!isConfirm) {
return;
}
@ -99,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-immich-bg dark:bg-immich-dark-bg"
class="absolute start-0 top-0 z-[9999] h-full w-full bg-light"
>
<ControlAppBar onClose={onBack}>
{#snippet leading()}
@ -117,7 +114,7 @@
>
{/snippet}
</ControlAppBar>
<section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg">
<section class="px-[70px] pt-[100px]">
<section id="merge-face-selector">
<div class="mb-10 h-[200px] place-content-center place-items-center">
<p class="mb-4 text-center uppercase dark:text-white">{$t('choose_matching_people_to_merge')}</p>

View File

@ -50,7 +50,7 @@
searchedPeopleLocal = $bindable(),
type,
numberPeopleToSearch = maximumLengthSearchPeople,
inputClass = 'w-full gap-2 bg-immich-bg dark:bg-immich-dark-bg',
inputClass = 'w-full gap-2',
showLoadingSpinner = $bindable(false),
placeholder = $t('name_or_nickname'),
onReset = () => {},

View File

@ -1,34 +1,34 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
import {
AssetTypeEnum,
createPerson,
deleteFace,
getFaces,
reassignFacesById,
AssetTypeEnum,
type AssetFaceResponseDto,
type PersonResponseDto,
deleteFace,
} from '@immich/sdk';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { t } from 'svelte-i18n';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
interface Props {
assetId: string;
@ -173,10 +173,9 @@
return;
}
const isConfirmed = await dialogController.show({
const isConfirmed = await modalManager.showDialog({
prompt: $t('confirm_delete_face', { values: { name: face.person.name } }),
});
if (!isConfirmed) {
return;
}
@ -194,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 bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg"
class="absolute top-0 z-[2000] 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">

View File

@ -11,6 +11,7 @@
} from '@immich/sdk';
import { mdiMerge, mdiPlus } from '@mdi/js';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import Button from '../elements/buttons/button.svelte';
@ -19,7 +20,6 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import FaceThumbnail from './face-thumbnail.svelte';
import PeopleList from './people-list.svelte';
import { t } from 'svelte-i18n';
interface Props {
assetIds: string[];
@ -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-immich-bg dark:bg-immich-dark-bg"
class="absolute start-0 top-0 z-[9999] h-full w-full bg-light"
>
<ControlAppBar {onClose}>
{#snippet leading()}
@ -161,7 +161,7 @@
{/snippet}
</ControlAppBar>
{@render merge?.()}
<section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg">
<section class="px-[70px] pt-[100px]">
<section id="merge-face-selector relative">
{#if selectedPerson !== null}
<div class="mb-10 h-[200px] place-content-center place-items-center">

View File

@ -177,9 +177,7 @@
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)}
<tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
listIndex % 2 == 0 ? 'bg-subtle' : 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
class="flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-1/8 text-ellipsis ps-8 text-sm">
{#if validatedPath.isValid}
@ -215,9 +213,7 @@
</tr>
{/each}
<tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
importPaths.length % 2 == 0 ? 'bg-subtle' : 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
class="flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-4/5 text-ellipsis px-4 text-sm">
{#if importPaths.length === 0}

View File

@ -127,9 +127,7 @@
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)}
<tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
listIndex % 2 == 0 ? 'bg-subtle' : 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
class="flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-3/4 text-ellipsis px-4 text-sm">{exclusionPattern}</td>
<td class="w-1/4 text-ellipsis flex justify-center">
@ -147,9 +145,7 @@
</tr>
{/each}
<tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
exclusionPatterns.length % 2 == 0 ? 'bg-subtle' : 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
class="flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-3/4 text-ellipsis px-4 text-sm">
{#if exclusionPatterns.length === 0}

View File

@ -1,7 +1,7 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { copyToClipboard } from '$lib/utils';
import { mdiCodeTags, mdiContentCopy, mdiMessage, mdiPartyPopper } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -22,7 +22,7 @@
</script>
<div class="h-dvh w-dvw">
<section class="bg-immich-bg dark:bg-immich-dark-bg">
<section>
<div class="flex place-items-center border-b px-6 py-4 dark:border-b-immich-dark-gray">
<a class="flex place-items-center gap-2 hover:cursor-pointer" href="/photos">
<ImmichLogo class="h-[50px]" />
@ -33,7 +33,7 @@
<div class="fixed top-0 flex h-full w-full place-content-center place-items-center overflow-hidden bg-black/50">
<div>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
class="w-[500px] max-w-[95vw] rounded-3xl border shadow-sm dark:border-immich-dark-gray dark:text-immich-dark-fg bg-subtle/80"
>
<div>
<div class="flex items-center justify-between gap-4 px-4 py-4">

View File

@ -51,7 +51,7 @@
</header>
<div
tabindex="-1"
class="relative grid grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]
class="relative 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)]' : ''}"

View File

@ -23,7 +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"
class="light w-1/2 aspect-square bg-light rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-dark-primary/80 border-immich-primary dark:border dark:border-transparent"
onclick={() => themeManager.setTheme(Theme.LIGHT)}
>
<div
@ -35,7 +35,7 @@
</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"
class="dark w-1/2 aspect-square bg-light rounded-3xl dark:border-[3px] dark:border-immich-dark-primary/80 dark:border-immich-dark-primary border border-transparent"
onclick={() => themeManager.setTheme(Theme.DARK)}
>
<div

View File

@ -4,12 +4,12 @@
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { getAlbumInfo, removeAssetFromAlbum, type AlbumResponseDto } from '@immich/sdk';
import { mdiDeleteOutline, mdiImageRemoveOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
interface Props {
album: AlbumResponseDto;
@ -22,7 +22,7 @@
const { getAssets, clearSelect } = getAssetControlContext();
const removeFromAlbum = async () => {
const isConfirmed = await dialogController.show({
const isConfirmed = await modalManager.showDialog({
prompt: $t('remove_assets_album_confirmation', { values: { count: getAssets().length } }),
});

View File

@ -1,7 +1,7 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { removeSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk';
import { mdiDeleteOutline } from '@mdi/js';
@ -18,7 +18,7 @@
const { getAssets, clearSelect } = getAssetControlContext();
const handleRemove = async () => {
const isConfirmed = await dialogController.show({
const isConfirmed = await modalManager.showDialog({
title: $t('remove_assets_title'),
prompt: $t('remove_assets_shared_link_confirmation', { values: { count: getAssets().length } }),
confirmText: $t('remove'),

View File

@ -1,20 +1,25 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import {
type AssetStore,
type AssetBucket,
assetSnapshot,
assetsSnapshot,
type AssetStore,
isSelectingAllAssets,
} from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
import { getDateLocaleString } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fly } from 'svelte/transition';
import { fly, scale } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
<<<<<<< HEAD
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { scale } from 'svelte/transition';
=======
>>>>>>> origin/main
import { flip } from 'svelte/animate';
import { uploadAssetsStore } from '$lib/stores/upload';
@ -126,7 +131,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 bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
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"
style:width={dateGroup.width + 'px'}
>
{#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}

View File

@ -1,9 +1,9 @@
<script lang="ts">
import Checkbox from '$lib/components/elements/checkbox.svelte';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { t } from 'svelte-i18n';
import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
interface Props {
size: number;
@ -23,7 +23,7 @@
};
</script>
<ConfirmDialog
<ConfirmModal
title={$t('permanently_delete_assets_count', { values: { count: size } })}
confirmText={$t('delete')}
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
@ -42,4 +42,4 @@
<Checkbox id="confirm-deletion-input" label={$t('do_not_show_again')} bind:checked />
</div>
{/snippet}
</ConfirmDialog>
</ConfirmModal>

View File

@ -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-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
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"
>
{title}
</div>

View File

@ -87,7 +87,7 @@
};
</script>
<section class="bg-immich-bg dark:bg-immich-dark-bg">
<section>
{#if sharedLink?.allowUpload || assets.length > 1}
{#if assetInteraction.selectionActive}
<AssetSelectControlBar

View File

@ -1,17 +1,17 @@
<script lang="ts">
import { type AlbumResponseDto, getAllAlbums } from '@immich/sdk';
import { onMount } from 'svelte';
import AlbumListItem from '../../asset-viewer/album-list-item.svelte';
import NewAlbumListItem from './new-album-list-item.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { initInput } from '$lib/actions/focus';
import { t } from 'svelte-i18n';
import { albumViewSettings } from '$lib/stores/preferences.store';
import {
AlbumModalRowConverter,
AlbumModalRowType,
isSelectableRowType,
} from '$lib/components/shared-components/album-selection/album-selection-utils';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { albumViewSettings } from '$lib/stores/preferences.store';
import { type AlbumResponseDto, getAllAlbums } from '@immich/sdk';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import AlbumListItem from '../../asset-viewer/album-list-item.svelte';
import NewAlbumListItem from './new-album-list-item.svelte';
let albums: AlbumResponseDto[] = $state([]);
let recentAlbums: AlbumResponseDto[] = $state([]);
@ -98,7 +98,7 @@
{/each}
{:else}
<input
class="border-b-4 border-immich-bg bg-immich-bg px-6 py-2 text-2xl focus:border-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:focus:border-immich-dark-primary"
class="border-b-4 border-immich-bg px-6 py-2 text-2xl focus:border-immich-primary dark:border-immich-dark-gray dark:focus:border-immich-dark-primary"
placeholder={$t('search')}
{onkeydown}
bind:value={search}

View File

@ -1,9 +1,9 @@
<script lang="ts">
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import DateInput from '../elements/date-input.svelte';
import Combobox, { type ComboBoxOption } from './combobox.svelte';
import ConfirmDialog from './dialog/confirm-dialog.svelte';
interface Props {
title?: string;
@ -142,7 +142,7 @@
let date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
<ConfirmDialog
<ConfirmModal
confirmColor="primary"
{title}
prompt="Please select a new date:"
@ -170,4 +170,4 @@
{/if}
</div>
{/snippet}
</ConfirmDialog>
</ConfirmModal>

View File

@ -1,20 +1,18 @@
<script lang="ts">
import { timeDebounceOnSearch } from '$lib/constants';
import { lastChosenLocation } from '$lib/stores/asset-editor.store';
import { handleError } from '$lib/utils/handle-error';
import ConfirmDialog from './dialog/confirm-dialog.svelte';
import { clickOutside } from '$lib/actions/click-outside';
import { listNavigation } from '$lib/actions/list-navigation';
import SearchBar from '$lib/components/elements/search-bar.svelte';
import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import type Map from '$lib/components/shared-components/map/map.svelte';
import { timeToLoadTheMap } from '$lib/constants';
import { timeDebounceOnSearch, timeToLoadTheMap } from '$lib/constants';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { lastChosenLocation } from '$lib/stores/asset-editor.store';
import { delay } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import SearchBar from '../elements/search-bar.svelte';
import LoadingSpinner from './loading-spinner.svelte';
interface Point {
lng: number;
lat: number;
@ -112,7 +110,7 @@
};
</script>
<ConfirmDialog
<ConfirmModal
confirmColor="primary"
title={$t('change_location')}
size="medium"
@ -208,4 +206,4 @@
</div>
</div>
{/snippet}
</ConfirmDialog>
</ConfirmModal>

View File

@ -32,7 +32,7 @@
trailing,
}: Props = $props();
let appBarBorder = $state('bg-immich-bg border border-transparent');
let appBarBorder = $state('bg-light border border-transparent');
const onScroll = () => {
if (window.scrollY > 80) {
@ -42,7 +42,7 @@
appBarBorder = 'border border-gray-600';
}
} else {
appBarBorder = 'bg-immich-bg border border-transparent';
appBarBorder = 'bg-light border border-transparent';
}
};

View File

@ -1,9 +0,0 @@
<script lang="ts">
import { dialogController } from './dialog';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
const { dialog } = dialogController;
</script>
{#if $dialog}
<ConfirmDialog {...$dialog} />
{/if}

View File

@ -1,42 +0,0 @@
import { writable } from 'svelte/store';
type DialogActions = {
onClose: (confirmed: boolean) => void;
};
type DialogOptions = {
title?: string;
prompt?: string;
confirmText?: string;
cancelText?: string;
hideCancelButton?: boolean;
disable?: boolean;
width?: 'wide' | 'narrow' | undefined;
};
export type Dialog = DialogOptions & DialogActions;
function createDialogWrapper() {
const dialog = writable<Dialog | undefined>();
async function show(options: DialogOptions) {
return new Promise<boolean>((resolve) => {
const newDialog: Dialog = {
...options,
onClose: (confirmed) => {
dialog.set(undefined);
resolve(confirmed);
},
};
dialog.set(newDialog);
});
}
return {
dialog,
show,
};
}
export const dialogController = createDialogWrapper();

View File

@ -5,15 +5,13 @@
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import AvatarEditModal from '$lib/modals/AvatarEditModal.svelte';
import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { deleteProfileImage, updateMyUser, type UserAvatarColor } from '@immich/sdk';
import { mdiCog, mdiLogout, mdiPencil, mdiWrench } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import { NotificationType, notificationController } from '../notification/notification';
import UserAvatar from '../user-avatar.svelte';
import AvatarSelector from './avatar-selector.svelte';
interface Props {
onLogout: () => void;
@ -21,26 +19,6 @@
}
let { onLogout, onClose = () => {} }: Props = $props();
let isShowSelectAvatar = $state(false);
const handleSaveProfile = async (color: UserAvatarColor) => {
try {
if ($user.profileImagePath !== '') {
await deleteProfileImage();
}
$user = await updateMyUser({ userUpdateMeDto: { avatarColor: color } });
isShowSelectAvatar = false;
notificationController.show({
message: $t('saved_profile'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_save_profile'));
}
};
</script>
<div
@ -63,7 +41,7 @@
class="border"
size="12"
padding="2"
onclick={() => (isShowSelectAvatar = true)}
onclick={() => modalManager.show(AvatarEditModal, {})}
/>
</div>
</div>
@ -83,7 +61,7 @@
</Button>
{#if $user.isAdmin}
<Button
href={AppRoute.ADMIN_USER_MANAGEMENT}
href={AppRoute.ADMIN_USERS}
onclick={onClose}
color="dark-gray"
size="sm"
@ -111,7 +89,3 @@
>
</div>
</div>
{#if isShowSelectAvatar}
<AvatarSelector user={$user} onClose={() => (isShowSelectAvatar = false)} onChoose={handleSaveProfile} />
{/if}

View File

@ -1,28 +0,0 @@
<script lang="ts">
import { UserAvatarColor, type UserResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import FullScreenModal from '../full-screen-modal.svelte';
import UserAvatar from '../user-avatar.svelte';
interface Props {
user: UserResponseDto;
onClose: () => void;
onChoose: (color: UserAvatarColor) => void;
}
let { user, onClose, onChoose }: Props = $props();
const colors: UserAvatarColor[] = Object.values(UserAvatarColor);
</script>
<FullScreenModal title={$t('select_avatar_color')} width="auto" {onClose}>
<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 (color)}
<button type="button" onclick={() => onChoose(color)}>
<UserAvatar label={color} {user} {color} size="xl" showProfileImage={false} />
</button>
{/each}
</div>
</div>
</FullScreenModal>

View File

@ -55,7 +55,7 @@
>
<SkipLink text={$t('skip_to_content')} />
<div
class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]"
class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b py-2 dark:border-b-immich-dark-gray sidebar:grid-cols-[theme(spacing.64)_auto]"
>
<div class="flex flex-row gap-1 mx-4 items-center">
<IconButton

View File

@ -446,7 +446,7 @@
aria-valuemax={toScrollY(1)}
aria-valuemin={toScrollY(0)}
data-id="immich-scrubbable-scrollbar"
class="absolute end-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
class="absolute end-0 z-[1] select-none hover:cursor-row-resize"
style:padding-top={PADDING_TOP + 'px'}
style:padding-bottom={PADDING_BOTTOM + 'px'}
style:width
@ -464,7 +464,7 @@
class={[
{ 'border-b-2': isDragging },
{ 'rounded-bl-md': !isDragging },
'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 bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg',
'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',
]}
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-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg"
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"
>
{scrollHoverLabel}
</p>

View File

@ -8,7 +8,7 @@
</script>
<SideBarSection ariaLabel={$t('primary')}>
<SideBarLink title={$t('users')} routeId={AppRoute.ADMIN_USER_MANAGEMENT} icon={mdiAccountMultipleOutline} />
<SideBarLink title={$t('users')} routeId={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
<SideBarLink title={$t('jobs')} routeId={AppRoute.ADMIN_JOBS} icon={mdiSync} />
<SideBarLink title={$t('settings')} routeId={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<SideBarLink title={$t('external_libraries')} routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />

View File

@ -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 bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg"
class="immich-scrollbar relative z-auto 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}

View File

@ -40,11 +40,7 @@
};
</script>
<div
in:fade={{ duration: 250 }}
out:fade={{ duration: 100 }}
class="flex flex-col rounded-lg bg-immich-bg text-xs dark:bg-immich-dark-bg p-2 gap-1"
>
<div in:fade={{ duration: 250 }} out:fade={{ duration: 100 }} class="flex flex-col rounded-lg text-xs p-2 gap-1">
<div class="flex items-center gap-2">
<div class="flex items-center justify-center">
{#if uploadAsset.state === UploadState.PENDING}

View File

@ -1,11 +1,11 @@
<script lang="ts">
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
import { handleError } from '../../utils/handle-error';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import DeviceCard from './device-card.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
import { Button } from '@immich/ui';
interface Props {
devices: SessionResponseDto[];
@ -19,10 +19,7 @@
let otherDevices = $derived(devices.filter((device) => !device.current));
const handleDelete = async (device: SessionResponseDto) => {
const isConfirmed = await dialogController.show({
prompt: $t('logout_this_device_confirmation'),
});
const isConfirmed = await modalManager.showDialog({ prompt: $t('logout_this_device_confirmation') });
if (!isConfirmed) {
return;
}
@ -38,7 +35,7 @@
};
const handleDeleteAll = async () => {
const isConfirmed = await dialogController.show({ prompt: $t('logout_all_device_confirmation') });
const isConfirmed = await modalManager.showDialog({ prompt: $t('logout_all_device_confirmation') });
if (!isConfirmed) {
return;
}

View File

@ -1,5 +1,4 @@
<script lang="ts">
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
@ -81,7 +80,7 @@
};
const handleRemovePartner = async (partner: PartnerResponseDto) => {
const isConfirmed = await dialogController.show({
const isConfirmed = await modalManager.showDialog({
title: $t('stop_photo_sharing'),
prompt: $t('stop_photo_sharing_description', { values: { partner: partner.name } }),
});

View File

@ -1,6 +1,5 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import ApiKeyModal from '$lib/modals/ApiKeyModal.svelte';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
@ -88,7 +87,7 @@
};
const handleDelete = async (key: ApiKeyResponseDto) => {
const isConfirmed = await dialogController.show({ prompt: $t('delete_api_key_prompt') });
const isConfirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') });
if (!isConfirmed) {
return;
}
@ -125,11 +124,9 @@
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each keys as key, index (key.id)}
{#each keys as key (key.id)}
<tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
index % 2 == 0 ? 'bg-subtle' : 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
class="flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-1/3 text-ellipsis px-4 text-sm">{key.name}</td>
<td class="w-1/3 text-ellipsis px-4 text-sm"

View File

@ -1,27 +1,27 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte';
import PurchaseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { purchaseStore } from '$lib/stores/purchase.store';
import { preferences, user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
import {
deleteServerLicense as deleteServerProductKey,
deleteUserLicense as deleteIndividualProductKey,
deleteServerLicense as deleteServerProductKey,
getAboutInfo,
getMyUser,
getServerLicense,
isHttpError,
type LicenseResponseDto,
} from '@immich/sdk';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiKey } from '@mdi/js';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { handleError } from '$lib/utils/handle-error';
import PurchaseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte';
import { t } from 'svelte-i18n';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
import { Button } from '@immich/ui';
import { mdiKey } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
const { isPurchased } = purchaseStore;
let isServerProduct = $state(false);
@ -62,11 +62,10 @@
const removeIndividualProductKey = async () => {
try {
const isConfirmed = await dialogController.show({
const isConfirmed = await modalManager.showDialog({
title: $t('purchase_remove_product_key'),
prompt: $t('purchase_remove_product_key_prompt'),
confirmText: $t('remove'),
cancelText: $t('cancel'),
});
if (!isConfirmed) {
@ -82,11 +81,10 @@
const removeServerProductKey = async () => {
try {
const isConfirmed = await dialogController.show({
const isConfirmed = await modalManager.showDialog({
title: $t('purchase_remove_server_product_key'),
prompt: $t('purchase_remove_server_product_key_prompt'),
confirmText: $t('remove'),
cancelText: $t('cancel'),
});
if (!isConfirmed) {

View File

@ -57,7 +57,7 @@
{#snippet row(viewName: string, stats: AssetStatsResponseDto)}
<tr
class="flex h-14 w-full place-items-center text-center dark:text-immich-dark-fg odd:bg-immich-bg even:bg-subtle odd:dark:bg-immich-dark-gray/50 even:dark:bg-immich-dark-gray/75"
class="flex h-14 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-1/4 px-4 text-sm">{viewName}</td>
<td class="w-1/4 px-4 text-sm">{stats.images.toLocaleString($locale)}</td>
@ -95,7 +95,7 @@
<div class="overflow-x-auto">
<table class="w-full text-start mt-4">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
class="mb-4 flex h-12 w-full rounded-md border text-immich-primary dark:border-immich-dark-gray bg-subtle dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center text-sm font-medium text-center">
<th class="w-1/2">{$t('owned')}</th>
@ -103,9 +103,7 @@
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
<tr
class="flex h-14 w-full place-items-center text-center dark:text-immich-dark-fg bg-immich-bg dark:bg-immich-dark-gray/50"
>
<tr class="flex h-14 w-full place-items-center text-center dark:text-immich-dark-fg bg-subtle/20">
<td class="w-1/2 px-4 text-sm">{albumStats.owned.toLocaleString($locale)}</td>
<td class="w-1/2 px-4 text-sm">{albumStats.shared.toLocaleString($locale)}</td>
</tr>

View File

@ -13,7 +13,7 @@ export enum AssetAction {
}
export enum AppRoute {
ADMIN_USER_MANAGEMENT = '/admin/user-management',
ADMIN_USERS = '/admin/users',
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
ADMIN_SETTINGS = '/admin/system-settings',
ADMIN_STATS = '/admin/server-status',

View File

@ -1,4 +1,4 @@
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { mount, unmount, type Component, type ComponentProps } from 'svelte';
type OnCloseData<T> = T extends { onClose: (data?: infer R) => void } ? R : never;
@ -34,8 +34,8 @@ class ModalManager {
};
}
openDialog(options: Omit<ComponentProps<typeof ConfirmDialog>, 'onClose'>) {
return this.show(ConfirmDialog, options);
showDialog(options: Omit<ComponentProps<typeof ConfirmModal>, 'onClose'>) {
return this.show(ConfirmModal, options);
}
}

View File

@ -0,0 +1,47 @@
<script lang="ts">
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { deleteProfileImage, updateMyUser, UserAvatarColor } from '@immich/sdk';
import { Modal, ModalBody } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
const colors: UserAvatarColor[] = Object.values(UserAvatarColor);
const onSave = async (color: UserAvatarColor) => {
try {
if ($user.profileImagePath !== '') {
await deleteProfileImage();
}
notificationController.show({ message: $t('saved_profile'), type: NotificationType.Info });
$user = await updateMyUser({ userUpdateMeDto: { avatarColor: color } });
onClose();
} catch (error) {
handleError(error, $t('errors.unable_to_save_profile'));
}
};
</script>
<Modal title={$t('select_avatar_color')} size="medium" {onClose}>
<ModalBody>
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
{#each colors as color (color)}
<button type="button" onclick={() => onSave(color)}>
<UserAvatar label={color} user={$user} {color} size="xl" showProfileImage={false} />
</button>
{/each}
</div>
</ModalBody>
</Modal>

View File

@ -8,9 +8,6 @@
prompt?: string;
confirmText?: string;
confirmColor?: Color;
cancelText?: string;
cancelColor?: Color;
hideCancelButton?: boolean;
disabled?: boolean;
size?: 'small' | 'medium';
onClose: (confirmed: boolean) => void;
@ -22,9 +19,6 @@
prompt = $t('are_you_sure_to_do_this'),
confirmText = $t('confirm'),
confirmColor = 'danger',
cancelText = $t('cancel'),
cancelColor = 'secondary',
hideCancelButton = false,
disabled = false,
size = 'small',
onClose,
@ -44,12 +38,10 @@
</ModalBody>
<ModalFooter>
<div class="flex gap-3 w-full my-3">
{#if !hideCancelButton}
<Button shape="round" color={cancelColor} fullWidth onclick={() => onClose(false)}>
{cancelText}
</Button>
{/if}
<div class="flex gap-3 w-full">
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>
{$t('cancel')}
</Button>
<Button shape="round" color={confirmColor} fullWidth onclick={handleConfirm} {disabled}>
{confirmText}
</Button>

View File

@ -1,10 +1,10 @@
<script lang="ts">
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { createJob, ManualJobName } from '@immich/sdk';
import { t } from 'svelte-i18n';
@ -44,7 +44,7 @@
};
</script>
<ConfirmDialog
<ConfirmModal
confirmColor="primary"
title={$t('admin.create_job')}
disabled={!selectedJob}
@ -62,4 +62,4 @@
</div>
</form>
{/snippet}
</ConfirmDialog>
</ConfirmModal>

View File

@ -1,8 +1,7 @@
<script lang="ts">
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import { copyToClipboard } from '$lib/utils';
import { Code, IconButton, Text } from '@immich/ui';
import { mdiContentCopy } from '@mdi/js';
import { Button, Code, IconButton, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiCheck, mdiContentCopy } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
@ -13,14 +12,14 @@
const { onClose, newPassword }: Props = $props();
</script>
<ConfirmDialog
<Modal
title={$t('password_reset_success')}
confirmText={$t('done')}
{onClose}
hideCancelButton={true}
confirmColor="success"
icon={mdiCheck}
onClose={() => onClose()}
size="small"
class="bg-light text-dark"
>
{#snippet promptSnippet()}
<ModalBody>
<div class="flex flex-col gap-4">
<Text>{$t('admin.user_password_has_been_reset')}</Text>
@ -39,5 +38,13 @@
<Text>{$t('admin.user_password_reset_description')}</Text>
</div>
{/snippet}
</ConfirmDialog>
</ModalBody>
<ModalFooter>
<div class="flex gap-3 w-full">
<Button shape="round" color="primary" fullWidth onclick={() => onClose()}>
{$t('done')}
</Button>
</div>
</ModalFooter>
</Modal>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import Checkbox from '$lib/components/elements/checkbox.svelte';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { serverConfig } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk';
@ -39,7 +39,7 @@
};
</script>
<ConfirmDialog
<ConfirmModal
title={$t('delete_user')}
confirmText={forceDelete ? $t('permanently_delete') : $t('delete')}
onClose={(confirmed) => (confirmed ? handleDeleteUser() : onClose())}
@ -98,4 +98,4 @@
{/if}
</div>
{/snippet}
</ConfirmDialog>
</ConfirmModal>

View File

@ -1,40 +1,32 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { userInteraction } from '$lib/stores/user.svelte';
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiAccountEditOutline, mdiLockSmart, mdiOnepassword } from '@mdi/js';
import { mdiAccountEditOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
user: UserAdminResponseDto;
canResetPassword?: boolean;
onClose: (
data?:
| { action: 'update'; data: UserAdminResponseDto }
| { action: 'resetPassword'; data: string }
| { action: 'resetPinCode' },
) => void;
onClose: (data?: UserAdminResponseDto) => void;
}
let { user, canResetPassword = true, onClose }: Props = $props();
let { user, onClose }: Props = $props();
let quotaSize = $state(user.quotaSizeInBytes === null ? null : convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB));
let newPassword = $state<string>('');
const previousQutoa = user.quotaSizeInBytes;
const previousQuota = user.quotaSizeInBytes;
let quotaSizeWarning = $derived(
previousQutoa !== convertToBytes(Number(quotaSize), ByteUnit.GiB) &&
previousQuota !== convertToBytes(Number(quotaSize), ByteUnit.GiB) &&
!!quotaSize &&
userInteraction.serverInfo &&
convertToBytes(Number(quotaSize), ByteUnit.GiB) > userInteraction.serverInfo.diskSizeRaw,
);
const editUser = async () => {
const handleEditUser = async () => {
try {
const { id, email, name, storageLabel } = user;
const newUser = await updateUserAdmin({
@ -47,76 +39,15 @@
},
});
onClose({ action: 'update', data: newUser });
onClose(newUser);
} catch (error) {
handleError(error, $t('errors.unable_to_update_user'));
}
};
const resetPassword = async () => {
const isConfirmed = await modalManager.openDialog({
prompt: $t('admin.confirm_user_password_reset', { values: { user: user.name } }),
});
if (!isConfirmed) {
return;
}
try {
newPassword = generatePassword();
await updateUserAdmin({
id: user.id,
userAdminUpdateDto: {
password: newPassword,
shouldChangePassword: true,
},
});
onClose({ action: 'resetPassword', data: newPassword });
} catch (error) {
handleError(error, $t('errors.unable_to_reset_password'));
}
};
const resetUserPincode = async () => {
const isConfirmed = await modalManager.openDialog({
prompt: $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }),
});
if (!isConfirmed) {
return;
}
try {
await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
onClose({ action: 'resetPinCode' });
} catch (error) {
handleError(error, $t('errors.unable_to_reset_pin_code'));
}
};
// TODO move password reset server-side
function generatePassword(length: number = 16) {
let generatedPassword = '';
const characterSet = '0123456789' + 'abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + ',.-{}+!#$%/()=?';
for (let i = 0; i < length; i++) {
let randomNumber = crypto.getRandomValues(new Uint32Array(1))[0];
randomNumber = randomNumber / 2 ** 32;
randomNumber = Math.floor(randomNumber * characterSet.length);
generatedPassword += characterSet[randomNumber];
}
return generatedPassword;
}
const onSubmit = async (event: Event) => {
event.preventDefault();
await editUser();
await handleEditUser();
};
</script>
@ -172,34 +103,11 @@
</ModalBody>
<ModalFooter>
<div class="w-full">
<div class="flex gap-3 w-full">
{#if canResetPassword}
<Button
shape="round"
color="warning"
variant="filled"
fullWidth
onclick={resetPassword}
leadingIcon={mdiOnepassword}
>
{$t('reset_password')}</Button
>
{/if}
<Button
shape="round"
color="warning"
variant="filled"
fullWidth
onclick={resetUserPincode}
leadingIcon={mdiLockSmart}>{$t('reset_pin_code')}</Button
>
</div>
<div class="w-full mt-4">
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
</div>
<div class="flex gap-3 w-full">
<Button shape="round" color="secondary" fullWidth form="edit-user-form" onclick={() => onClose()}
>{$t('cancel')}</Button
>
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
</div>
</ModalFooter>
</Modal>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
@ -22,7 +22,7 @@
};
</script>
<ConfirmDialog
<ConfirmModal
title={$t('restore_user')}
confirmText={$t('continue')}
confirmColor="success"
@ -37,4 +37,4 @@
</FormatMessage>
</p>
{/snippet}
</ConfirmDialog>
</ConfirmModal>

View File

@ -1,6 +1,6 @@
import { goto } from '$app/navigation';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import {
AlbumFilter,
AlbumGroupBy,
@ -213,7 +213,7 @@ export const confirmAlbumDelete = async (album: AlbumResponseDto) => {
const description = $t('album_delete_confirmation_description');
const prompt = `${confirmation} ${description}`;
return dialogController.show({ prompt });
return modalManager.showDialog({ prompt });
};
interface AlbumSortOption {

View File

@ -588,9 +588,7 @@
{/if}
{/if}
<main
class="relative h-dvh overflow-hidden bg-immich-bg px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg"
>
<main class="relative h-dvh overflow-hidden px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)]">
<AssetGrid
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
{album}
@ -678,7 +676,7 @@
<button
type="button"
onclick={() => (viewMode = AlbumPageViewMode.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"
class="mt-5 bg-subtle flex w-full place-items-center gap-6 rounded-md border px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
>
<span class="text-text-immich-primary dark:text-immich-dark-primary"
><Icon path={mdiPlus} size="24" />
@ -709,7 +707,7 @@
<div
transition:fly={{ duration: 150 }}
id="activity-panel"
class="z-[2] w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
class="z-[2] w-[360px] md:w-[460px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
translate="yes"
>
<ActivityViewer

View File

@ -72,7 +72,7 @@
{#if $featureFlags.loaded && $featureFlags.map}
<UserPageLayout title={data.meta.title}>
<div class="isolate h-full w-full">
<Map hash onSelect={onViewAssets} rounded />
<Map hash onSelect={onViewAssets} />
</div>
</UserPageLayout>
<Portal target="body">

View File

@ -42,7 +42,7 @@
};
</script>
<main class="grid h-dvh bg-immich-bg pt-18 dark:bg-immich-dark-bg">
<main class="grid h-dvh pt-18">
{#if assetInteraction.selectionActive}
<AssetSelectControlBar
assets={assetInteraction.selectedAssets}

View File

@ -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-immich-bg dark:bg-immich-dark-bg"
class="absolute start-0 top-0 z-[9999] h-full w-full bg-light"
aria-modal="true"
aria-labelledby="manage-visibility-title"
use:focusTrap

View File

@ -487,7 +487,7 @@
</header>
<main
class="relative h-dvh overflow-hidden bg-immich-bg tall:ms-4 md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg"
class="relative h-dvh overflow-hidden tall:ms-4 md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)]"
use:scrollMemoryClearer={{
routeStartsWith: AppRoute.PEOPLE,
beforeClear: () => {

View File

@ -291,10 +291,7 @@
{:else}
<div class="fixed z-[100] top-0 start-0 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div
class="-z-[1] bg-immich-bg dark:bg-immich-dark-bg"
style="position:absolute;top:0;left:0;right:0;bottom:0;"
></div>
<div class="-z-[1] bg-light" style="position:absolute;top:0;left:0;right:0;bottom:0;"></div>
<div class="w-full flex-1 ps-4">
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
</div>

View File

@ -4,17 +4,17 @@
import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
import PasswordField from '$lib/components/shared-components/password-field.svelte';
import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { getMySharedLink, SharedLinkType } from '@immich/sdk';
import type { PageData } from './$types';
import { setSharedLink } from '$lib/utils';
import { t } from 'svelte-i18n';
import { navigate } from '$lib/utils/navigation';
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { user } from '$lib/stores/user.store';
import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { getMySharedLink, SharedLinkType } from '@immich/sdk';
import { tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
@ -70,7 +70,7 @@
</ControlAppBar>
</header>
<main
class="relative h-dvh overflow-hidden bg-immich-bg px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40"
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"
>
<div class="flex flex-col items-center justify-center mt-20">
<div class="text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">{$t('password_required')}</div>

View File

@ -3,13 +3,13 @@
import { page } from '$app/state';
import GroupTab from '$lib/components/elements/group-tab.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import SharedLinkCard from '$lib/components/sharedlinks-page/shared-link-card.svelte';
import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getAllSharedLinks, removeSharedLink, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
@ -35,7 +35,7 @@
});
const handleDeleteLink = async (id: string) => {
const isConfirmed = await dialogController.show({
const isConfirmed = await modalManager.showDialog({
title: $t('delete_shared_link'),
prompt: $t('confirm_delete_shared_link'),
confirmText: $t('delete'),

View File

@ -4,7 +4,6 @@
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import {
notificationController,
@ -16,15 +15,16 @@
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, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
import { Button, HStack, Text } from '@immich/ui';
import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { onDestroy } from 'svelte';
interface Props {
data: PageData;
@ -116,11 +116,10 @@
return;
}
const isConfirm = await dialogController.show({
const isConfirm = await modalManager.showDialog({
title: $t('delete_tag'),
prompt: $t('delete_tag_confirmation_prompt', { values: { tagName: tag.value } }),
confirmText: $t('delete'),
cancelText: $t('cancel'),
});
if (!isConfirm) {

View File

@ -7,13 +7,13 @@
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
@ -43,10 +43,7 @@
const assetInteraction = new AssetInteraction();
const handleEmptyTrash = async () => {
const isConfirmed = await dialogController.show({
prompt: $t('empty_trash_confirmation'),
});
const isConfirmed = await modalManager.showDialog({ prompt: $t('empty_trash_confirmation') });
if (!isConfirmed) {
return;
}
@ -64,10 +61,7 @@
};
const handleRestoreTrash = async () => {
const isConfirmed = await dialogController.show({
prompt: $t('assets_restore_confirmation'),
});
const isConfirmed = await modalManager.showDialog({ prompt: $t('assets_restore_confirmation') });
if (!isConfirmed) {
return;
}

View File

@ -1,7 +1,6 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import DuplicatesModal from '$lib/components/shared-components/duplicates-modal.svelte';
import {
NotificationType,
@ -55,7 +54,7 @@
let hasDuplicates = $derived(duplicates.length > 0);
const withConfirmation = async (callback: () => Promise<void>, prompt?: string, confirmText?: string) => {
if (prompt && confirmText) {
const isConfirmed = await dialogController.show({ prompt, confirmText });
const isConfirmed = await modalManager.showDialog({ prompt, confirmText });
if (!isConfirmed) {
return;
}

View File

@ -5,7 +5,6 @@
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte';
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
import DialogWrapper from '$lib/components/shared-components/dialog/dialog-wrapper.svelte';
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
@ -122,7 +121,6 @@
<DownloadPanel />
<UploadPanel />
<NotificationList />
<DialogWrapper />
{#if $user?.isAdmin}
<VersionAnnouncementBox />

View File

@ -3,5 +3,5 @@ import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (() => {
redirect(302, AppRoute.ADMIN_USER_MANAGEMENT);
redirect(302, AppRoute.ADMIN_USERS);
}) satisfies PageLoad;

View File

@ -7,13 +7,13 @@
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import { ByteUnit, getBytesWithUnit } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
@ -210,7 +210,7 @@
return;
}
const isConfirmed = await dialogController.show({
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_delete_library', { values: { library: library.name } }),
});
@ -221,10 +221,9 @@
await refreshStats(index);
const assetCount = totalCount[index];
if (assetCount > 0) {
const isConfirmed = await dialogController.show({
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_delete_library_assets', { values: { count: assetCount } }),
});
if (!isConfirmed) {
return;
}
@ -298,9 +297,7 @@
<tbody class="block overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each libraries as library, index (library.id)}
<tr
class={`grid grid-cols-6 h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
index % 2 == 0 ? 'bg-subtle' : 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
class="grid grid-cols-6 h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="text-ellipsis px-4 text-sm">{library.name}</td>
<td class="text-ellipsis px-4 text-sm">

View File

@ -1,18 +1,5 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { searchUsersAdmin } from '@immich/sdk';
import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate({ admin: true });
await requestServerInfo();
const allUsers = await searchUsersAdmin({ withDeleted: true });
const $t = await getFormatter();
return {
allUsers,
meta: {
title: $t('admin.user_management'),
},
};
}) satisfies PageLoad;
export const load = (() => redirect(307, AppRoute.ADMIN_USERS)) satisfies PageLoad;

View File

@ -6,7 +6,7 @@
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import PasswordResetSuccess from '$lib/forms/password-reset-success.svelte';
import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
@ -18,7 +18,7 @@
import { websocketEvents } from '$lib/stores/websocket';
import { getByteUnitString } from '$lib/utils/byte-units';
import { UserStatus, searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, IconButton } from '@immich/ui';
import { Button, IconButton, Link } from '@immich/ui';
import { mdiDeleteRestore, mdiInfinity, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
@ -64,20 +64,9 @@
};
const handleEdit = async (dto: UserAdminResponseDto) => {
const result = await modalManager.show(UserEditModal, { user: dto, canResetPassword: dto.id !== $user.id });
switch (result?.action) {
case 'resetPassword': {
await modalManager.show(PasswordResetSuccess, { newPassword: result.data });
break;
}
case 'update': {
await refresh();
break;
}
case 'resetPinCode': {
notificationController.show({ type: NotificationType.Info, message: $t('pin_code_reset_successfully') });
break;
}
const result = await modalManager.show(UserEditModal, { user: dto });
if (result) {
await refresh();
}
};
@ -114,16 +103,14 @@
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#if allUsers}
{#each allUsers as immichUser, index (immichUser.id)}
{#each allUsers as immichUser (immichUser.id)}
<tr
class="flex h-[80px] overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {immichUser.deletedAt
? 'bg-red-300 dark:bg-red-900'
: index % 2 == 0
? 'bg-subtle'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'}"
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
>
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm"
>{immichUser.email}</td
><Link href="{AppRoute.ADMIN_USERS}/{immichUser.id}">{immichUser.email}</Link></td
>
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{immichUser.name}</td>
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">

View File

@ -0,0 +1,18 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate({ admin: true });
await requestServerInfo();
const allUsers = await searchUsersAdmin({ withDeleted: true });
const $t = await getFormatter();
return {
allUsers,
meta: {
title: $t('admin.user_management'),
},
};
}) satisfies PageLoad;

View File

@ -0,0 +1,343 @@
<script lang="ts">
import { goto } from '$app/navigation';
import StatsCard from '$lib/components/admin-page/server-stats/stats-card.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
import UserEditModal from '$lib/modals/UserEditModal.svelte';
import { locale } from '$lib/stores/preferences.store';
import { user as authUser } from '$lib/stores/user.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { updateUserAdmin } from '@immich/sdk';
import {
Button,
Card,
CardBody,
CardHeader,
CardTitle,
Code,
Container,
Field,
getByteUnitString,
Heading,
HStack,
Icon,
Stack,
Switch,
Text,
} from '@immich/ui';
import {
mdiAccountOutline,
mdiCameraIris,
mdiChartPie,
mdiChartPieOutline,
mdiCheckCircle,
mdiFeatureSearchOutline,
mdiLockSmart,
mdiOnepassword,
mdiPencilOutline,
mdiPlayCircle,
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let user = $derived(data.user);
const userPreferences = $derived(data.userPreferences);
const userStatistics = $derived(data.userStatistics);
const TiB = 1024 ** 4;
const usage = $derived(user.quotaUsageInBytes ?? 0);
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
const usedBytes = $derived(user.quotaUsageInBytes ?? 0);
const availableBytes = $derived(user.quotaSizeInBytes ?? 1);
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
let canResetPassword = $derived($authUser.id !== user.id);
let newPassword = $state<string>('');
const handleEdit = async () => {
const result = await modalManager.show(UserEditModal, { user: { ...user } });
if (result) {
user = result;
}
};
const handleDelete = async () => {
const result = await modalManager.show(UserDeleteConfirmModal, { user });
if (result) {
await goto(AppRoute.ADMIN_USERS);
}
};
const getUsageClass = () => {
if (usedPercentage >= 95) {
return 'bg-red-500';
}
if (usedPercentage > 80) {
return 'bg-yellow-500';
}
return 'bg-immich-primary dark:bg-immich-dark-primary';
};
const handleResetPassword = async () => {
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_user_password_reset', { values: { user: user.name } }),
});
if (!isConfirmed) {
return;
}
try {
newPassword = generatePassword();
await updateUserAdmin({
id: user.id,
userAdminUpdateDto: {
password: newPassword,
shouldChangePassword: true,
},
});
await modalManager.show(PasswordResetSuccessModal, { newPassword });
} catch (error) {
handleError(error, $t('errors.unable_to_reset_password'));
}
};
const handleResetUserPinCode = async () => {
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }),
});
if (!isConfirmed) {
return;
}
try {
await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
notificationController.show({ type: NotificationType.Info, message: $t('pin_code_reset_successfully') });
} catch (error) {
handleError(error, $t('errors.unable_to_reset_pin_code'));
}
};
// TODO move password reset server-side
function generatePassword(length: number = 16) {
let generatedPassword = '';
const characterSet = '0123456789' + 'abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + ',.-{}+!#$%/()=?';
for (let i = 0; i < length; i++) {
let randomNumber = crypto.getRandomValues(new Uint32Array(1))[0];
randomNumber = randomNumber / 2 ** 32;
randomNumber = Math.floor(randomNumber * characterSet.length);
generatedPassword += characterSet[randomNumber];
}
return generatedPassword;
}
</script>
<UserPageLayout title={data.meta.title} admin>
{#snippet buttons()}
<HStack gap={0}>
{#if canResetPassword}
<Button
color="secondary"
size="small"
variant="ghost"
leadingIcon={mdiOnepassword}
onclick={handleResetPassword}
>
<Text class="hidden md:block">{$t('reset_password')}</Text>
</Button>
{/if}
<Button
color="secondary"
size="small"
variant="ghost"
leadingIcon={mdiLockSmart}
onclick={handleResetUserPinCode}
>
<Text class="hidden md:block">{$t('reset_pin_code')}</Text>
</Button>
<Button
color="secondary"
size="small"
variant="ghost"
leadingIcon={mdiPencilOutline}
onclick={() => handleEdit()}
>
<Text class="hidden md:block">{$t('edit_user')}</Text>
</Button>
<Button
color="danger"
size="small"
variant="ghost"
leadingIcon={mdiTrashCanOutline}
onclick={() => handleDelete()}
>
<Text class="hidden md:block">{$t('delete_user')}</Text>
</Button>
</HStack>
{/snippet}
<div>
<Container size="large" center>
<div class="grid gap-4 grod-cols-1 lg:grid-cols-2 w-full">
<div class="col-span-full flex gap-4 items-center my-4">
<UserAvatar {user} size="md" />
<Heading tag="h1" size="large">{user.name}</Heading>
</div>
<div class="col-span-full">
<div class="flex flex-col lg:flex-row gap-4 w-full">
<StatsCard icon={mdiCameraIris} title={$t('photos').toUpperCase()} value={userStatistics.images} />
<StatsCard icon={mdiPlayCircle} title={$t('videos').toUpperCase()} value={userStatistics.videos} />
<StatsCard
icon={mdiChartPie}
title={$t('storage').toUpperCase()}
value={statsUsage}
unit={statsUsageUnit}
/>
</div>
</div>
<div>
<Card color="secondary">
<CardHeader>
<div class="flex items-center gap-2">
<Icon icon={mdiAccountOutline} size="1.5rem" />
<CardTitle>{$t('profile')}</CardTitle>
</div>
</CardHeader>
<CardBody>
<Stack gap={2}>
<div>
<Heading tag="h3" size="tiny">{$t('name')}</Heading>
<Text>{user.name}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('email')}</Heading>
<Text>{user.email}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
<Text>{user.createdAt}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
<Text>{user.updatedAt}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
<Code>{user.id}</Code>
</div>
</Stack>
</CardBody>
</Card>
</div>
<Card color="secondary">
<CardHeader>
<div class="flex items-center gap-2">
<Icon icon={mdiFeatureSearchOutline} size="1.5rem" />
<CardTitle>{$t('features')}</CardTitle>
</div>
</CardHeader>
<CardBody>
<div>
<Stack gap={2}>
<Field readOnly label={$t('email_notifications')}>
<Switch checked={userPreferences.emailNotifications.enabled} color="primary" />
</Field>
<Field readOnly label={$t('folders')}>
<Switch checked={userPreferences.folders.enabled} color="primary" />
</Field>
<Field readOnly label={$t('memories')}>
<Switch checked={userPreferences.memories.enabled} color="primary" />
</Field>
<Field readOnly label={$t('people')}>
<Switch checked={userPreferences.people.enabled} color="primary" />
</Field>
<Field readOnly label={$t('rating')}>
<Switch checked={userPreferences.ratings.enabled} color="primary" />
</Field>
<Field readOnly label={$t('shared_links')}>
<Switch checked={userPreferences.sharedLinks.enabled} color="primary" />
</Field>
<Field readOnly label={$t('show_supporter_badge')}>
<Switch checked={userPreferences.purchase.showSupportBadge} color="primary" />
</Field>
<Field readOnly label={$t('tags')}>
<Switch checked={userPreferences.tags.enabled} color="primary" />
</Field>
</Stack>
</div>
</CardBody>
</Card>
<Card color="secondary">
<CardHeader>
<div class="flex items-center gap-2">
<Icon icon={mdiChartPieOutline} size="1.5rem" />
<CardTitle>{$t('storage_quota')}</CardTitle>
</div>
</CardHeader>
<CardBody>
<div>
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
<Text>
{$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
</Text>
{:else}
<Text class="flex items-center gap-1">
<Icon icon={mdiCheckCircle} size="1.25rem" class="text-success" />
{$t('unlimited')}
</Text>
{/if}
</div>
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
<div
class="storage-status p-4 mt-4 bg-gray-100 dark:bg-immich-dark-primary/10 rounded-lg text-sm w-full"
title={$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
>
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
</div>
</div>
{/if}
</CardBody>
</Card>
</div>
</Container>
</div>
</UserPageLayout>

View File

@ -0,0 +1,31 @@
import { AppRoute } from '$lib/constants';
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getUserPreferencesAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
await authenticate({ admin: true });
await requestServerInfo();
const [user] = await searchUsersAdmin({ id: params.id }).catch(() => []);
if (!user) {
redirect(302, AppRoute.ADMIN_USERS);
}
const [userPreferences, userStatistics] = await Promise.all([
getUserPreferencesAdmin({ id: user.id }),
getUserStatisticsAdmin({ id: user.id }),
]);
const $t = await getFormatter();
return {
user,
userPreferences,
userStatistics,
meta: {
title: $t('admin.user_details'),
},
};
}) satisfies PageLoad;