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_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": "<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_delete_immediately_checkbox": "Queue user and assets for immediate deletion",
"user_details": "User Details",
"user_management": "User Management", "user_management": "User Management",
"user_password_has_been_reset": "The user's password has been reset:", "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.", "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_enable_button": "Enable Notifications",
"notification_permission_list_tile_title": "Notification Permission", "notification_permission_list_tile_title": "Notification Permission",
"notification_toggle_setting_description": "Enable email notifications", "notification_toggle_setting_description": "Enable email notifications",
"email_notifications": "Email notifications",
"notifications": "Notifications", "notifications": "Notifications",
"notifications_setting_description": "Manage notifications", "notifications_setting_description": "Manage notifications",
"oauth": "OAuth", "oauth": "OAuth",
@ -1394,6 +1396,7 @@
"previous_or_next_photo": "Previous or next photo", "previous_or_next_photo": "Previous or next photo",
"primary": "Primary", "primary": "Primary",
"privacy": "Privacy", "privacy": "Privacy",
"profile": "Profile",
"profile_drawer_app_logs": "Logs", "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_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.", "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": "Storage space",
"storage_label": "Storage label", "storage_label": "Storage label",
"storage_usage": "{used} of {available} used", "storage_usage": "{used} of {available} used",
"storage_quota": "Storage Quota",
"submit": "Submit", "submit": "Submit",
"suggestions": "Suggestions", "suggestions": "Suggestions",
"sunrise_on_the_beach": "Sunrise on the beach", "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_success": "Upload success, refresh the page to see new upload assets.",
"upload_to_immich": "Upload to Immich ({count})", "upload_to_immich": "Upload to Immich ({count})",
"uploading": "Uploading", "uploading": "Uploading",
"id": "ID",
"url": "URL", "url": "URL",
"usage": "Usage", "usage": "Usage",
"use_current_connection": "use current connection", "use_current_connection": "use current connection",
@ -1864,6 +1869,8 @@
"user": "User", "user": "User",
"user_id": "User ID", "user_id": "User ID",
"user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", "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": "Purchase",
"user_purchase_settings_description": "Manage your purchase", "user_purchase_settings_description": "Manage your purchase",
"user_role_set": "Set {user} as {role}", "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* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} |
*UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /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* | [**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* | [**restoreUserAdmin**](doc//UsersAdminApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore |
*UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users | *UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users |
*UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} | *UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} |

View File

@ -211,6 +211,76 @@ class UsersAdminApi {
return null; 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]. /// Performs an HTTP 'POST /admin/users/{id}/restore' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
@ -262,8 +332,10 @@ class UsersAdminApi {
/// Performs an HTTP 'GET /admin/users' operation and returns the [Response]. /// Performs an HTTP 'GET /admin/users' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
/// * [String] id:
///
/// * [bool] withDeleted: /// * [bool] withDeleted:
Future<Response> searchUsersAdminWithHttpInfo({ bool? withDeleted, }) async { Future<Response> searchUsersAdminWithHttpInfo({ String? id, bool? withDeleted, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/admin/users'; final apiPath = r'/admin/users';
@ -274,6 +346,9 @@ class UsersAdminApi {
final headerParams = <String, String>{}; final headerParams = <String, String>{};
final formParams = <String, String>{}; final formParams = <String, String>{};
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (withDeleted != null) { if (withDeleted != null) {
queryParams.addAll(_queryParams('', 'withDeleted', withDeleted)); queryParams.addAll(_queryParams('', 'withDeleted', withDeleted));
} }
@ -294,9 +369,11 @@ class UsersAdminApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] id:
///
/// * [bool] withDeleted: /// * [bool] withDeleted:
Future<List<UserAdminResponseDto>?> searchUsersAdmin({ bool? withDeleted, }) async { Future<List<UserAdminResponseDto>?> searchUsersAdmin({ String? id, bool? withDeleted, }) async {
final response = await searchUsersAdminWithHttpInfo( withDeleted: withDeleted, ); final response = await searchUsersAdminWithHttpInfo( id: id, withDeleted: withDeleted, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }

View File

@ -345,6 +345,15 @@
"get": { "get": {
"operationId": "searchUsersAdmin", "operationId": "searchUsersAdmin",
"parameters": [ "parameters": [
{
"name": "id",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{ {
"name": "withDeleted", "name": "withDeleted",
"required": false, "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": { "/albums": {
"get": { "get": {
"operationId": "getAllAlbums", "operationId": "getAllAlbums",

View File

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

View File

@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { import {
@ -57,6 +58,16 @@ export class UserAdminController {
return this.service.delete(auth, id, dto); 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') @Get(':id/preferences')
@Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true })
getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserPreferencesResponseDto> { 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 { User, UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types'; 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 { export class UserUpdateMeDto {
@Optional() @Optional()
@ -67,6 +67,9 @@ export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
export class UserAdminSearchDto { export class UserAdminSearchDto {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
withDeleted?: boolean; withDeleted?: boolean;
@ValidateUUID({ optional: true })
id?: string;
} }
export class UserAdminCreateDto { export class UserAdminCreateDto {

View File

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

View File

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

View File

@ -3,6 +3,7 @@
notificationController, notificationController,
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { getJobName } from '$lib/utils'; import { getJobName } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@ -20,10 +21,9 @@
mdiVideo, mdiVideo,
} from '@mdi/js'; } from '@mdi/js';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
import { t } from 'svelte-i18n';
import JobTile from './job-tile.svelte'; import JobTile from './job-tile.svelte';
import StorageMigrationDescription from './storage-migration-description.svelte'; import StorageMigrationDescription from './storage-migration-description.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
interface Props { interface Props {
jobs: AllJobStatusResponseDto; jobs: AllJobStatusResponseDto;
@ -45,7 +45,7 @@
const handleConfirmCommand = async (jobId: JobName, dto: JobCommandDto) => { const handleConfirmCommand = async (jobId: JobName, dto: JobCommandDto) => {
if (dto.force) { if (dto.force) {
const isConfirmed = await dialogController.show({ const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_reprocess_all_faces'), 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" 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)} {#each stats.usageByUser as user (user.userId)}
<tr <tr class="flex h-[50px] w-full place-items-center text-center even:bg-subtle/20 odd:bg-subtle/80">
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"
>
<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.userName}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm" <td class="w-1/4 text-ellipsis px-2 text-sm"
>{user.photos.toLocaleString($locale)} ({getByteUnitString(user.usagePhotos, $locale, 0)})</td >{user.photos.toLocaleString($locale)} ({getByteUnitString(user.usagePhotos, $locale, 0)})</td

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { updateAlbumInfo } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@ -40,7 +40,7 @@
onblur={handleUpdateName} 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 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-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" type="text"
bind:value={newAlbumName} bind:value={newAlbumName}
disabled={!isOwned} disabled={!isOwned}

View File

@ -1,11 +1,18 @@
<script lang="ts"> <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 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 { 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 { 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 { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte'; import DownloadAction from '../photos-page/actions/download-action.svelte';
import AssetGrid from '../photos-page/asset-grid.svelte'; import AssetGrid from '../photos-page/asset-grid.svelte';
@ -13,14 +20,7 @@
import ControlAppBar from '../shared-components/control-app-bar.svelte'; import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte'; import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
import ThemeButton from '../shared-components/theme-button.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 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 { interface Props {
sharedLink: SharedLinkResponseDto; sharedLink: SharedLinkResponseDto;
@ -101,14 +101,12 @@
{/if} {/if}
</header> </header>
<main <main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)]">
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"
>
<AssetGrid enableRouting={true} {album} {assetStore} {assetInteraction}> <AssetGrid enableRouting={true} {album} {assetStore} {assetInteraction}>
<section class="pt-8 md:pt-24 px-2 md:px-0"> <section class="pt-8 md:pt-24 px-2 md:px-0">
<!-- ALBUM TITLE --> <!-- ALBUM TITLE -->
<h1 <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} {album.albumName}
</h1> </h1>

View File

@ -31,7 +31,7 @@
</script> </script>
<tr <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}`)} onclick={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
{oncontextmenu} {oncontextmenu}
> >

View File

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

View File

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

View File

@ -529,7 +529,7 @@
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
id="detail-panel" 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" translate="yes"
> >
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} /> <DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} />
@ -540,7 +540,7 @@
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
id="editor-panel" 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" translate="yes"
> >
<EditorPanel {asset} onUpdateSelectedType={handleUpdateSelectedEditType} onClose={closeEditor} /> <EditorPanel {asset} onUpdateSelectedType={handleUpdateSelectedEditType} onClose={closeEditor} />
@ -589,7 +589,7 @@
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
id="activity-panel" 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" translate="yes"
> >
<ActivityViewer <ActivityViewer

View File

@ -16,7 +16,7 @@
{#if downloadManager.isDownloading} {#if downloadManager.isDownloading}
<div <div
transition:fly={{ x: -100, duration: 350 }} 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> <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"> <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"> <script lang="ts">
import { shortcut } from '$lib/actions/shortcut'; 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 { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { type AssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto } from '@immich/sdk';
@ -67,11 +67,9 @@
</section> </section>
{#if $showCancelConfirmDialog} {#if $showCancelConfirmDialog}
<ConfirmDialog <ConfirmModal
title={$t('editor_close_without_save_title')} title={$t('editor_close_without_save_title')}
prompt={$t('editor_close_without_save_prompt')} prompt={$t('editor_close_without_save_prompt')}
cancelText={$t('no')}
cancelColor="secondary"
confirmColor="danger" confirmColor="danger"
confirmText={$t('close')} confirmText={$t('close')}
onClose={(confirmed) => (confirmed ? onConfirm() : ($showCancelConfirmDialog = false))} onClose={(confirmed) => (confirmed ? onConfirm() : ($showCancelConfirmDialog = false))}

View File

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

View File

@ -1,20 +1,20 @@
<script lang="ts"> <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 { 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 { 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 { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { linear } from 'svelte/easing'; import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.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 { interface Props {
editedFace: AssetFaceResponseDto; editedFace: AssetFaceResponseDto;
@ -74,7 +74,7 @@
<section <section
transition:fly={{ x: 360, duration: 100, easing: linear }} 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"> <div class="flex place-items-center justify-between gap-2">
{#if !searchFaces} {#if !searchFaces}

View File

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

View File

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

View File

@ -1,34 +1,34 @@
<script lang="ts"> <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 LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants'; 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 { boundingBoxesArray } from '$lib/stores/people.store';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
import { getPersonNameWithHiddenValue } from '$lib/utils/person'; import { getPersonNameWithHiddenValue } from '$lib/utils/person';
import { import {
AssetTypeEnum,
createPerson, createPerson,
deleteFace,
getFaces, getFaces,
reassignFacesById, reassignFacesById,
AssetTypeEnum,
type AssetFaceResponseDto, type AssetFaceResponseDto,
type PersonResponseDto, type PersonResponseDto,
deleteFace,
} from '@immich/sdk'; } from '@immich/sdk';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js'; import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { linear } from 'svelte/easing'; import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import AssignFaceSidePanel from './assign-face-side-panel.svelte'; 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 { interface Props {
assetId: string; assetId: string;
@ -173,10 +173,9 @@
return; return;
} }
const isConfirmed = await dialogController.show({ const isConfirmed = await modalManager.showDialog({
prompt: $t('confirm_delete_face', { values: { name: face.person.name } }), prompt: $t('confirm_delete_face', { values: { name: face.person.name } }),
}); });
if (!isConfirmed) { if (!isConfirmed) {
return; return;
} }
@ -194,7 +193,7 @@
<section <section
transition:fly={{ x: 360, duration: 100, easing: linear }} 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 place-items-center justify-between gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View File

@ -11,6 +11,7 @@
} from '@immich/sdk'; } from '@immich/sdk';
import { mdiMerge, mdiPlus } from '@mdi/js'; import { mdiMerge, mdiPlus } from '@mdi/js';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
@ -19,7 +20,6 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import FaceThumbnail from './face-thumbnail.svelte'; import FaceThumbnail from './face-thumbnail.svelte';
import PeopleList from './people-list.svelte'; import PeopleList from './people-list.svelte';
import { t } from 'svelte-i18n';
interface Props { interface Props {
assetIds: string[]; assetIds: string[];
@ -120,7 +120,7 @@
<section <section
transition:fly={{ y: 500, duration: 100, easing: quintOut }} 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}> <ControlAppBar {onClose}>
{#snippet leading()} {#snippet leading()}
@ -161,7 +161,7 @@
{/snippet} {/snippet}
</ControlAppBar> </ControlAppBar>
{@render merge?.()} {@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"> <section id="merge-face-selector relative">
{#if selectedPerson !== null} {#if selectedPerson !== null}
<div class="mb-10 h-[200px] place-content-center place-items-center"> <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"> <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)} {#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)}
<tr <tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${ class="flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
listIndex % 2 == 0 ? 'bg-subtle' : 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
> >
<td class="w-1/8 text-ellipsis ps-8 text-sm"> <td class="w-1/8 text-ellipsis ps-8 text-sm">
{#if validatedPath.isValid} {#if validatedPath.isValid}
@ -215,9 +213,7 @@
</tr> </tr>
{/each} {/each}
<tr <tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${ class="flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
importPaths.length % 2 == 0 ? 'bg-subtle' : 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
> >
<td class="w-4/5 text-ellipsis px-4 text-sm"> <td class="w-4/5 text-ellipsis px-4 text-sm">
{#if importPaths.length === 0} {#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"> <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)} {#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)}
<tr <tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${ class="flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
listIndex % 2 == 0 ? 'bg-subtle' : 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
> >
<td class="w-3/4 text-ellipsis px-4 text-sm">{exclusionPattern}</td> <td class="w-3/4 text-ellipsis px-4 text-sm">{exclusionPattern}</td>
<td class="w-1/4 text-ellipsis flex justify-center"> <td class="w-1/4 text-ellipsis flex justify-center">
@ -147,9 +145,7 @@
</tr> </tr>
{/each} {/each}
<tr <tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${ class="flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
exclusionPatterns.length % 2 == 0 ? 'bg-subtle' : 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
> >
<td class="w-3/4 text-ellipsis px-4 text-sm"> <td class="w-3/4 text-ellipsis px-4 text-sm">
{#if exclusionPatterns.length === 0} {#if exclusionPatterns.length === 0}

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import ImmichLogo from '$lib/components/shared-components/immich-logo.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 { copyToClipboard } from '$lib/utils';
import { mdiCodeTags, mdiContentCopy, mdiMessage, mdiPartyPopper } from '@mdi/js'; import { mdiCodeTags, mdiContentCopy, mdiMessage, mdiPartyPopper } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -22,7 +22,7 @@
</script> </script>
<div class="h-dvh w-dvw"> <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"> <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"> <a class="flex place-items-center gap-2 hover:cursor-pointer" href="/photos">
<ImmichLogo class="h-[50px]" /> <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 class="fixed top-0 flex h-full w-full place-content-center place-items-center overflow-hidden bg-black/50">
<div> <div>
<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>
<div class="flex items-center justify-between gap-4 px-4 py-4"> <div class="flex items-center justify-between gap-4 px-4 py-4">

View File

@ -51,7 +51,7 @@
</header> </header>
<div <div
tabindex="-1" 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 ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))]'}
{hideNavbar ? 'pt-[var(--navbar-height)]' : ''} {hideNavbar ? 'pt-[var(--navbar-height)]' : ''}
{hideNavbar ? 'max-md:pt-[var(--navbar-height-md)]' : ''}" {hideNavbar ? 'max-md:pt-[var(--navbar-height-md)]' : ''}"

View File

@ -23,7 +23,7 @@
<div class="flex gap-4 mb-6"> <div class="flex gap-4 mb-6">
<button <button
type="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)} onclick={() => themeManager.setTheme(Theme.LIGHT)}
> >
<div <div
@ -35,7 +35,7 @@
</button> </button>
<button <button
type="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)} onclick={() => themeManager.setTheme(Theme.DARK)}
> >
<div <div

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@
<div class="overflow-clip" style:height={height + 'px'}> <div class="overflow-clip" style:height={height + 'px'}>
<div <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} {title}
</div> </div>

View File

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

View File

@ -1,17 +1,17 @@
<script lang="ts"> <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 { initInput } from '$lib/actions/focus';
import { t } from 'svelte-i18n';
import { albumViewSettings } from '$lib/stores/preferences.store';
import { import {
AlbumModalRowConverter, AlbumModalRowConverter,
AlbumModalRowType, AlbumModalRowType,
isSelectableRowType, isSelectableRowType,
} from '$lib/components/shared-components/album-selection/album-selection-utils'; } 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 albums: AlbumResponseDto[] = $state([]);
let recentAlbums: AlbumResponseDto[] = $state([]); let recentAlbums: AlbumResponseDto[] = $state([]);
@ -98,7 +98,7 @@
{/each} {/each}
{:else} {:else}
<input <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')} placeholder={$t('search')}
{onkeydown} {onkeydown}
bind:value={search} bind:value={search}

View File

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

View File

@ -1,20 +1,18 @@
<script lang="ts"> <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 { clickOutside } from '$lib/actions/click-outside';
import { listNavigation } from '$lib/actions/list-navigation'; 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 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 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 { delay } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk'; import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import SearchBar from '../elements/search-bar.svelte';
import LoadingSpinner from './loading-spinner.svelte';
interface Point { interface Point {
lng: number; lng: number;
lat: number; lat: number;
@ -112,7 +110,7 @@
}; };
</script> </script>
<ConfirmDialog <ConfirmModal
confirmColor="primary" confirmColor="primary"
title={$t('change_location')} title={$t('change_location')}
size="medium" size="medium"
@ -208,4 +206,4 @@
</div> </div>
</div> </div>
{/snippet} {/snippet}
</ConfirmDialog> </ConfirmModal>

View File

@ -32,7 +32,7 @@
trailing, trailing,
}: Props = $props(); }: Props = $props();
let appBarBorder = $state('bg-immich-bg border border-transparent'); let appBarBorder = $state('bg-light border border-transparent');
const onScroll = () => { const onScroll = () => {
if (window.scrollY > 80) { if (window.scrollY > 80) {
@ -42,7 +42,7 @@
appBarBorder = 'border border-gray-600'; appBarBorder = 'border border-gray-600';
} }
} else { } 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 CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants'; 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 { 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 { mdiCog, mdiLogout, mdiPencil, mdiWrench } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { NotificationType, notificationController } from '../notification/notification';
import UserAvatar from '../user-avatar.svelte'; import UserAvatar from '../user-avatar.svelte';
import AvatarSelector from './avatar-selector.svelte';
interface Props { interface Props {
onLogout: () => void; onLogout: () => void;
@ -21,26 +19,6 @@
} }
let { onLogout, onClose = () => {} }: Props = $props(); 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> </script>
<div <div
@ -63,7 +41,7 @@
class="border" class="border"
size="12" size="12"
padding="2" padding="2"
onclick={() => (isShowSelectAvatar = true)} onclick={() => modalManager.show(AvatarEditModal, {})}
/> />
</div> </div>
</div> </div>
@ -83,7 +61,7 @@
</Button> </Button>
{#if $user.isAdmin} {#if $user.isAdmin}
<Button <Button
href={AppRoute.ADMIN_USER_MANAGEMENT} href={AppRoute.ADMIN_USERS}
onclick={onClose} onclick={onClose}
color="dark-gray" color="dark-gray"
size="sm" size="sm"
@ -111,7 +89,3 @@
> >
</div> </div>
</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')} /> <SkipLink text={$t('skip_to_content')} />
<div <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"> <div class="flex flex-row gap-1 mx-4 items-center">
<IconButton <IconButton

View File

@ -446,7 +446,7 @@
aria-valuemax={toScrollY(1)} aria-valuemax={toScrollY(1)}
aria-valuemin={toScrollY(0)} aria-valuemin={toScrollY(0)}
data-id="immich-scrubbable-scrollbar" 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-top={PADDING_TOP + 'px'}
style:padding-bottom={PADDING_BOTTOM + 'px'} style:padding-bottom={PADDING_BOTTOM + 'px'}
style:width style:width
@ -464,7 +464,7 @@
class={[ class={[
{ 'border-b-2': isDragging }, { 'border-b-2': isDragging },
{ 'rounded-bl-md': !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" style:top="{hoverY + 2}px"
> >
@ -506,7 +506,7 @@
{#if assetStore.scrolling && scrollHoverLabel && !isHover} {#if assetStore.scrolling && scrollHoverLabel && !isHover}
<p <p
transition:fade={{ duration: 200 }} 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} {scrollHoverLabel}
</p> </p>

View File

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

View File

@ -35,7 +35,7 @@
id="sidebar" id="sidebar"
aria-label={ariaLabel} aria-label={ariaLabel}
tabindex="-1" 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:shadow-2xl={isExpanded}
class:dark:border-e-immich-dark-gray={isExpanded} class:dark:border-e-immich-dark-gray={isExpanded}
class:border-r={isExpanded} class:border-r={isExpanded}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ export enum AssetAction {
} }
export enum AppRoute { export enum AppRoute {
ADMIN_USER_MANAGEMENT = '/admin/user-management', ADMIN_USERS = '/admin/users',
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management', ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
ADMIN_SETTINGS = '/admin/system-settings', ADMIN_SETTINGS = '/admin/system-settings',
ADMIN_STATS = '/admin/server-status', 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'; import { mount, unmount, type Component, type ComponentProps } from 'svelte';
type OnCloseData<T> = T extends { onClose: (data?: infer R) => void } ? R : never; 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'>) { showDialog(options: Omit<ComponentProps<typeof ConfirmModal>, 'onClose'>) {
return this.show(ConfirmDialog, options); 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; prompt?: string;
confirmText?: string; confirmText?: string;
confirmColor?: Color; confirmColor?: Color;
cancelText?: string;
cancelColor?: Color;
hideCancelButton?: boolean;
disabled?: boolean; disabled?: boolean;
size?: 'small' | 'medium'; size?: 'small' | 'medium';
onClose: (confirmed: boolean) => void; onClose: (confirmed: boolean) => void;
@ -22,9 +19,6 @@
prompt = $t('are_you_sure_to_do_this'), prompt = $t('are_you_sure_to_do_this'),
confirmText = $t('confirm'), confirmText = $t('confirm'),
confirmColor = 'danger', confirmColor = 'danger',
cancelText = $t('cancel'),
cancelColor = 'secondary',
hideCancelButton = false,
disabled = false, disabled = false,
size = 'small', size = 'small',
onClose, onClose,
@ -44,12 +38,10 @@
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<div class="flex gap-3 w-full my-3"> <div class="flex gap-3 w-full">
{#if !hideCancelButton} <Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>
<Button shape="round" color={cancelColor} fullWidth onclick={() => onClose(false)}> {$t('cancel')}
{cancelText} </Button>
</Button>
{/if}
<Button shape="round" color={confirmColor} fullWidth onclick={handleConfirm} {disabled}> <Button shape="round" color={confirmColor} fullWidth onclick={handleConfirm} {disabled}>
{confirmText} {confirmText}
</Button> </Button>

View File

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

View File

@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import { copyToClipboard } from '$lib/utils'; import { copyToClipboard } from '$lib/utils';
import { Code, IconButton, Text } from '@immich/ui'; import { Button, Code, IconButton, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiContentCopy } from '@mdi/js'; import { mdiCheck, mdiContentCopy } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
@ -13,14 +12,14 @@
const { onClose, newPassword }: Props = $props(); const { onClose, newPassword }: Props = $props();
</script> </script>
<ConfirmDialog <Modal
title={$t('password_reset_success')} title={$t('password_reset_success')}
confirmText={$t('done')} icon={mdiCheck}
{onClose} onClose={() => onClose()}
hideCancelButton={true} size="small"
confirmColor="success" class="bg-light text-dark"
> >
{#snippet promptSnippet()} <ModalBody>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Text>{$t('admin.user_password_has_been_reset')}</Text> <Text>{$t('admin.user_password_has_been_reset')}</Text>
@ -39,5 +38,13 @@
<Text>{$t('admin.user_password_reset_description')}</Text> <Text>{$t('admin.user_password_reset_description')}</Text>
</div> </div>
{/snippet} </ModalBody>
</ConfirmDialog>
<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"> <script lang="ts">
import Checkbox from '$lib/components/elements/checkbox.svelte'; import Checkbox from '$lib/components/elements/checkbox.svelte';
import FormatMessage from '$lib/components/i18n/format-message.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 { serverConfig } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk'; import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk';
@ -39,7 +39,7 @@
}; };
</script> </script>
<ConfirmDialog <ConfirmModal
title={$t('delete_user')} title={$t('delete_user')}
confirmText={forceDelete ? $t('permanently_delete') : $t('delete')} confirmText={forceDelete ? $t('permanently_delete') : $t('delete')}
onClose={(confirmed) => (confirmed ? handleDeleteUser() : onClose())} onClose={(confirmed) => (confirmed ? handleDeleteUser() : onClose())}
@ -98,4 +98,4 @@
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
</ConfirmDialog> </ConfirmModal>

View File

@ -1,40 +1,32 @@
<script lang="ts"> <script lang="ts">
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { userInteraction } from '$lib/stores/user.svelte'; import { userInteraction } from '$lib/stores/user.svelte';
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; 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'; import { t } from 'svelte-i18n';
interface Props { interface Props {
user: UserAdminResponseDto; user: UserAdminResponseDto;
canResetPassword?: boolean; onClose: (data?: UserAdminResponseDto) => void;
onClose: (
data?:
| { action: 'update'; data: UserAdminResponseDto }
| { action: 'resetPassword'; data: string }
| { action: 'resetPinCode' },
) => 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 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( let quotaSizeWarning = $derived(
previousQutoa !== convertToBytes(Number(quotaSize), ByteUnit.GiB) && previousQuota !== convertToBytes(Number(quotaSize), ByteUnit.GiB) &&
!!quotaSize && !!quotaSize &&
userInteraction.serverInfo && userInteraction.serverInfo &&
convertToBytes(Number(quotaSize), ByteUnit.GiB) > userInteraction.serverInfo.diskSizeRaw, convertToBytes(Number(quotaSize), ByteUnit.GiB) > userInteraction.serverInfo.diskSizeRaw,
); );
const editUser = async () => { const handleEditUser = async () => {
try { try {
const { id, email, name, storageLabel } = user; const { id, email, name, storageLabel } = user;
const newUser = await updateUserAdmin({ const newUser = await updateUserAdmin({
@ -47,76 +39,15 @@
}, },
}); });
onClose({ action: 'update', data: newUser }); onClose(newUser);
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_update_user')); 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) => { const onSubmit = async (event: Event) => {
event.preventDefault(); event.preventDefault();
await editUser(); await handleEditUser();
}; };
</script> </script>
@ -172,34 +103,11 @@
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<div class="w-full"> <div class="flex gap-3 w-full">
<div class="flex gap-3 w-full"> <Button shape="round" color="secondary" fullWidth form="edit-user-form" onclick={() => onClose()}
{#if canResetPassword} >{$t('cancel')}</Button
<Button >
shape="round" <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
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> </div>
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View File

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

View File

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

View File

@ -588,9 +588,7 @@
{/if} {/if}
{/if} {/if}
<main <main class="relative h-dvh overflow-hidden px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)]">
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"
>
<AssetGrid <AssetGrid
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true} enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
{album} {album}
@ -678,7 +676,7 @@
<button <button
type="button" type="button"
onclick={() => (viewMode = AlbumPageViewMode.SELECT_ASSETS)} 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" <span class="text-text-immich-primary dark:text-immich-dark-primary"
><Icon path={mdiPlus} size="24" /> ><Icon path={mdiPlus} size="24" />
@ -709,7 +707,7 @@
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
id="activity-panel" 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" translate="yes"
> >
<ActivityViewer <ActivityViewer

View File

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

View File

@ -42,7 +42,7 @@
}; };
</script> </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} {#if assetInteraction.selectionActive}
<AssetSelectControlBar <AssetSelectControlBar
assets={assetInteraction.selectedAssets} assets={assetInteraction.selectedAssets}

View File

@ -458,7 +458,7 @@
<dialog <dialog
open open
transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }} 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-modal="true"
aria-labelledby="manage-visibility-title" aria-labelledby="manage-visibility-title"
use:focusTrap use:focusTrap

View File

@ -487,7 +487,7 @@
</header> </header>
<main <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={{ use:scrollMemoryClearer={{
routeStartsWith: AppRoute.PEOPLE, routeStartsWith: AppRoute.PEOPLE,
beforeClear: () => { beforeClear: () => {

View File

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

View File

@ -4,17 +4,17 @@
import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte'; import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.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 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 PasswordField from '$lib/components/shared-components/password-field.svelte';
import { user } from '$lib/stores/user.store'; import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
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 { assetViewingStore } from '$lib/stores/asset-viewing.store'; 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 { tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props { interface Props {
data: PageData; data: PageData;
@ -70,7 +70,7 @@
</ControlAppBar> </ControlAppBar>
</header> </header>
<main <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="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> <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 { page } from '$app/state';
import GroupTab from '$lib/components/elements/group-tab.svelte'; import GroupTab from '$lib/components/elements/group-tab.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { import {
notificationController, notificationController,
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import SharedLinkCard from '$lib/components/sharedlinks-page/shared-link-card.svelte'; import SharedLinkCard from '$lib/components/sharedlinks-page/shared-link-card.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getAllSharedLinks, removeSharedLink, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk'; import { getAllSharedLinks, removeSharedLink, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
@ -35,7 +35,7 @@
}); });
const handleDeleteLink = async (id: string) => { const handleDeleteLink = async (id: string) => {
const isConfirmed = await dialogController.show({ const isConfirmed = await modalManager.showDialog({
title: $t('delete_shared_link'), title: $t('delete_shared_link'),
prompt: $t('confirm_delete_shared_link'), prompt: $t('confirm_delete_shared_link'),
confirmText: $t('delete'), confirmText: $t('delete'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,5 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth'; import { AppRoute } from '$lib/constants';
import { getFormatter } from '$lib/utils/i18n'; import { redirect } from '@sveltejs/kit';
import { searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async () => { export const load = (() => redirect(307, AppRoute.ADMIN_USERS)) satisfies PageLoad;
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

@ -6,7 +6,7 @@
NotificationType, NotificationType,
notificationController, notificationController,
} from '$lib/components/shared-components/notification/notification'; } 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 { modalManager } from '$lib/managers/modal-manager.svelte';
import UserCreateModal from '$lib/modals/UserCreateModal.svelte'; import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte'; import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
@ -18,7 +18,7 @@
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { getByteUnitString } from '$lib/utils/byte-units'; import { getByteUnitString } from '$lib/utils/byte-units';
import { UserStatus, searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk'; 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 { mdiDeleteRestore, mdiInfinity, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -64,20 +64,9 @@
}; };
const handleEdit = async (dto: UserAdminResponseDto) => { const handleEdit = async (dto: UserAdminResponseDto) => {
const result = await modalManager.show(UserEditModal, { user: dto, canResetPassword: dto.id !== $user.id }); const result = await modalManager.show(UserEditModal, { user: dto });
switch (result?.action) { if (result) {
case 'resetPassword': { await refresh();
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;
}
} }
}; };
@ -114,16 +103,14 @@
</thead> </thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray"> <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#if allUsers} {#if allUsers}
{#each allUsers as immichUser, index (immichUser.id)} {#each allUsers as immichUser (immichUser.id)}
<tr <tr
class="flex h-[80px] overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {immichUser.deletedAt 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' ? 'bg-red-300 dark:bg-red-900'
: index % 2 == 0 : 'even:bg-subtle/20 odd:bg-subtle/80'}"
? 'bg-subtle'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'}"
> >
<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" <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 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"> <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;