mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 01:11:16 +03:00
Compare commits
1 Commits
main
...
feat/new-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b37c64171 |
@@ -37,13 +37,7 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
|
|||||||
|
|
||||||
afterInit(websocketServer: Server) {
|
afterInit(websocketServer: Server) {
|
||||||
this.logger.log('Initialized websocket server');
|
this.logger.log('Initialized websocket server');
|
||||||
|
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
|
||||||
websocketServer.on('AppRestart', (event: ArgsOf<'AppRestart'>, ack?: (ok: 'ok') => void) => {
|
|
||||||
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
|
|
||||||
|
|
||||||
ack?.('ok');
|
|
||||||
this.appRepository.exitApp();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
|
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { createAdapter } from '@socket.io/redis-adapter';
|
|
||||||
import Redis from 'ioredis';
|
|
||||||
import { Server as SocketIO } from 'socket.io';
|
|
||||||
import { ExitCode } from 'src/enum';
|
import { ExitCode } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
|
||||||
import { AppRestartEvent } from 'src/repositories/event.repository';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppRepository {
|
export class AppRepository {
|
||||||
@@ -22,26 +17,4 @@ export class AppRepository {
|
|||||||
setCloseFn(fn: () => Promise<void>) {
|
setCloseFn(fn: () => Promise<void>) {
|
||||||
this.closeFn = fn;
|
this.closeFn = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
|
|
||||||
const server = new SocketIO();
|
|
||||||
const { redis } = new ConfigRepository().getEnv();
|
|
||||||
const pubClient = new Redis({ ...redis, lazyConnect: true });
|
|
||||||
const subClient = pubClient.duplicate();
|
|
||||||
|
|
||||||
await Promise.all([pubClient.connect(), subClient.connect()]);
|
|
||||||
|
|
||||||
server.adapter(createAdapter(pubClient, subClient));
|
|
||||||
|
|
||||||
// => corresponds to notification.service.ts#onAppRestart
|
|
||||||
server.emit('AppRestartV1', state, async () => {
|
|
||||||
const responses = await server.serverSideEmitWithAck('AppRestart', state);
|
|
||||||
if (responses.some((response) => response !== 'ok')) {
|
|
||||||
throw new Error("One or more node(s) returned a non-'ok' response to our restart request!");
|
|
||||||
}
|
|
||||||
|
|
||||||
pubClient.disconnect();
|
|
||||||
subClient.disconnect();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ describe(CliService.name, () => {
|
|||||||
alreadyDisabled: true,
|
alreadyDisabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
|
|
||||||
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
||||||
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
@@ -100,7 +99,6 @@ describe(CliService.name, () => {
|
|||||||
alreadyDisabled: false,
|
alreadyDisabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
|
|
||||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||||
isMaintenanceMode: false,
|
isMaintenanceMode: false,
|
||||||
});
|
});
|
||||||
@@ -116,7 +114,6 @@ describe(CliService.name, () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
|
|
||||||
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
||||||
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
@@ -129,7 +126,6 @@ describe(CliService.name, () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
|
|
||||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||||
isMaintenanceMode: true,
|
isMaintenanceMode: true,
|
||||||
secret: expect.stringMatching(/^\w{128}$/),
|
secret: expect.stringMatching(/^\w{128}$/),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
|||||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { SystemMetadataKey } from 'src/enum';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
|
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance';
|
||||||
import { getExternalDomain } from 'src/utils/misc';
|
import { getExternalDomain } from 'src/utils/misc';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -55,7 +55,8 @@ export class CliService extends BaseService {
|
|||||||
|
|
||||||
const state = { isMaintenanceMode: false as const };
|
const state = { isMaintenanceMode: false as const };
|
||||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
|
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
|
||||||
await this.appRepository.sendOneShotAppRestart(state);
|
|
||||||
|
sendOneShotAppRestart(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alreadyDisabled: false,
|
alreadyDisabled: false,
|
||||||
@@ -88,7 +89,7 @@ export class CliService extends BaseService {
|
|||||||
secret,
|
secret,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.appRepository.sendOneShotAppRestart({
|
sendOneShotAppRestart({
|
||||||
isMaintenanceMode: true,
|
isMaintenanceMode: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { OnEvent } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { SystemMetadataKey } from 'src/enum';
|
||||||
import { ArgOf } from 'src/repositories/event.repository';
|
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { MaintenanceModeState } from 'src/types';
|
import { MaintenanceModeState } from 'src/types';
|
||||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
|
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
|
||||||
@@ -32,10 +31,7 @@ export class MaintenanceService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'AppRestart', server: true })
|
@OnEvent({ name: 'AppRestart', server: true })
|
||||||
onRestart(event: ArgOf<'AppRestart'>, ack?: (ok: 'ok') => void): void {
|
onRestart(): void {
|
||||||
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
|
|
||||||
|
|
||||||
ack?.('ok');
|
|
||||||
this.appRepository.exitApp();
|
this.appRepository.exitApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,55 @@
|
|||||||
|
import { createAdapter } from '@socket.io/redis-adapter';
|
||||||
|
import Redis from 'ioredis';
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { Server as SocketIO } from 'socket.io';
|
||||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||||
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
|
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||||
|
|
||||||
|
export function sendOneShotAppRestart(state: AppRestartEvent): void {
|
||||||
|
const server = new SocketIO();
|
||||||
|
const { redis } = new ConfigRepository().getEnv();
|
||||||
|
const pubClient = new Redis(redis);
|
||||||
|
const subClient = pubClient.duplicate();
|
||||||
|
server.adapter(createAdapter(pubClient, subClient));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep trying until we manage to stop Immich
|
||||||
|
*
|
||||||
|
* Sometimes there appear to be communication
|
||||||
|
* issues between to the other servers.
|
||||||
|
*
|
||||||
|
* This issue only occurs with this method.
|
||||||
|
*/
|
||||||
|
async function tryTerminate() {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const responses = await server.serverSideEmitWithAck('AppRestart', state);
|
||||||
|
if (responses.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
console.error('Encountered an error while telling Immich to stop.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 1e3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// => corresponds to notification.service.ts#onAppRestart
|
||||||
|
server.emit('AppRestartV1', state, () => {
|
||||||
|
void tryTerminate().finally(() => {
|
||||||
|
pubClient.disconnect();
|
||||||
|
subClient.disconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createMaintenanceLoginUrl(
|
export async function createMaintenanceLoginUrl(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/;
|
||||||
|
|
||||||
export enum AssetAction {
|
export enum AssetAction {
|
||||||
ARCHIVE = 'archive',
|
ARCHIVE = 'archive',
|
||||||
UNARCHIVE = 'unarchive',
|
UNARCHIVE = 'unarchive',
|
||||||
@@ -20,7 +22,9 @@ export enum AssetAction {
|
|||||||
|
|
||||||
export enum AppRoute {
|
export enum AppRoute {
|
||||||
ADMIN_USERS = '/admin/users',
|
ADMIN_USERS = '/admin/users',
|
||||||
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
|
ADMIN_USERS_NEW = '/admin/users/new',
|
||||||
|
ADMIN_LIBRARIES = '/admin/library-management',
|
||||||
|
ADMIN_LIBRARIES_NEW = '/admin/library-management/new',
|
||||||
ADMIN_SETTINGS = '/admin/system-settings',
|
ADMIN_SETTINGS = '/admin/system-settings',
|
||||||
ADMIN_STATS = '/admin/server-status',
|
ADMIN_STATS = '/admin/server-status',
|
||||||
ADMIN_QUEUES = '/admin/queues',
|
ADMIN_QUEUES = '/admin/queues',
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { handleRenameLibrary } from '$lib/services/library.service';
|
|
||||||
import type { LibraryResponseDto } from '@immich/sdk';
|
|
||||||
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
|
||||||
import { mdiRenameOutline } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
library: LibraryResponseDto;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { library, onClose }: Props = $props();
|
|
||||||
|
|
||||||
let newName = $state(library.name);
|
|
||||||
|
|
||||||
const onsubmit = async () => {
|
|
||||||
const success = await handleRenameLibrary(library, newName);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal icon={mdiRenameOutline} title={$t('rename')} {onClose} size="small">
|
|
||||||
<ModalBody>
|
|
||||||
<form {onsubmit} autocomplete="off" id="rename-library-form">
|
|
||||||
<Field label={$t('name')}>
|
|
||||||
<Input bind:value={newName} />
|
|
||||||
</Field>
|
|
||||||
</form>
|
|
||||||
</ModalBody>
|
|
||||||
|
|
||||||
<ModalFooter>
|
|
||||||
<HStack fullWidth>
|
|
||||||
<Button shape="round" fullWidth color="secondary" onclick={() => onClose()}>{$t('cancel')}</Button>
|
|
||||||
<Button shape="round" fullWidth type="submit" form="rename-library-form">{$t('save')}</Button>
|
|
||||||
</HStack>
|
|
||||||
</ModalFooter>
|
|
||||||
</Modal>
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import LibraryCreateModal from '$lib/modals/LibraryCreateModal.svelte';
|
|
||||||
import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte';
|
import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte';
|
||||||
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
|
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
|
||||||
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
|
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
|
||||||
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
|
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
|
||||||
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +17,7 @@ import {
|
|||||||
updateLibrary,
|
updateLibrary,
|
||||||
type CreateLibraryDto,
|
type CreateLibraryDto,
|
||||||
type LibraryResponseDto,
|
type LibraryResponseDto,
|
||||||
|
type UpdateLibraryDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||||
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||||
@@ -38,7 +37,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
|||||||
title: $t('create_library'),
|
title: $t('create_library'),
|
||||||
type: $t('command'),
|
type: $t('command'),
|
||||||
icon: mdiPlusBoxOutline,
|
icon: mdiPlusBoxOutline,
|
||||||
onAction: () => handleShowLibraryCreateModal(),
|
onAction: () => goto(AppRoute.ADMIN_LIBRARIES_NEW),
|
||||||
shortcuts: { shift: true, key: 'n' },
|
shortcuts: { shift: true, key: 'n' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,11 +45,11 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
|
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
|
||||||
const Rename: ActionItem = {
|
const Edit: ActionItem = {
|
||||||
icon: mdiPencilOutline,
|
icon: mdiPencilOutline,
|
||||||
type: $t('command'),
|
type: $t('command'),
|
||||||
title: $t('rename'),
|
title: $t('edit'),
|
||||||
onAction: () => modalManager.show(LibraryRenameModal, { library }),
|
onAction: () => goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}/edit`),
|
||||||
shortcuts: { key: 'r' },
|
shortcuts: { key: 'r' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
|||||||
shortcuts: { shift: true, key: 'r' },
|
shortcuts: { shift: true, key: 'r' },
|
||||||
};
|
};
|
||||||
|
|
||||||
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
|
return { Edit, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
|
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
|
||||||
@@ -150,7 +149,7 @@ const handleScanLibrary = async (library: LibraryResponseDto) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleViewLibrary = async (library: LibraryResponseDto) => {
|
export const handleViewLibrary = async (library: LibraryResponseDto) => {
|
||||||
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
|
export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
|
||||||
@@ -160,33 +159,24 @@ export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
|
|||||||
const library = await createLibrary({ createLibraryDto: dto });
|
const library = await createLibrary({ createLibraryDto: dto });
|
||||||
eventManager.emit('LibraryCreate', library);
|
eventManager.emit('LibraryCreate', library);
|
||||||
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
|
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
|
||||||
return true;
|
return library;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_create_library'));
|
handleError(error, $t('errors.unable_to_create_library'));
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleRenameLibrary = async (library: { id: string }, name?: string) => {
|
export const handleUpdateLibrary = async (library: LibraryResponseDto, dto: UpdateLibraryDto) => {
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedLibrary = await updateLibrary({
|
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto });
|
||||||
id: library.id,
|
|
||||||
updateLibraryDto: { name },
|
|
||||||
});
|
|
||||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||||
toastManager.success($t('admin.library_updated'));
|
toastManager.success($t('admin.library_updated'));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_update_library'));
|
handleError(error, $t('errors.unable_to_update_library'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteLibrary = async (library: LibraryResponseDto) => {
|
const handleDeleteLibrary = async (library: LibraryResponseDto) => {
|
||||||
@@ -357,7 +347,3 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
|
|||||||
handleError(error, $t('errors.unable_to_update_library'));
|
handleError(error, $t('errors.unable_to_update_library'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleShowLibraryCreateModal = async () => {
|
|
||||||
await modalManager.show(LibraryCreateModal, {});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||||
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
|
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
|
||||||
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
|
|
||||||
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
|
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
|
||||||
import UserEditModal from '$lib/modals/UserEditModal.svelte';
|
|
||||||
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
|
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
|
||||||
import { user as authUser } from '$lib/stores/user.store';
|
import { user as authUser } from '$lib/stores/user.store';
|
||||||
import type { HeaderButtonActionItem } from '$lib/types';
|
import type { HeaderButtonActionItem } from '$lib/types';
|
||||||
@@ -39,7 +38,7 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
|
|||||||
title: $t('create_user'),
|
title: $t('create_user'),
|
||||||
type: $t('command'),
|
type: $t('command'),
|
||||||
icon: mdiPlusBoxOutline,
|
icon: mdiPlusBoxOutline,
|
||||||
onAction: () => modalManager.show(UserCreateModal, {}),
|
onAction: () => goto(AppRoute.ADMIN_USERS_NEW),
|
||||||
shortcuts: { shift: true, key: 'n' },
|
shortcuts: { shift: true, key: 'n' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,7 +49,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
|||||||
const Update: ActionItem = {
|
const Update: ActionItem = {
|
||||||
icon: mdiPencilOutline,
|
icon: mdiPencilOutline,
|
||||||
title: $t('edit'),
|
title: $t('edit'),
|
||||||
onAction: () => modalManager.show(UserEditModal, { user }),
|
onAction: () => goto(`${AppRoute.ADMIN_USERS}/${user.id}/edit`),
|
||||||
};
|
};
|
||||||
|
|
||||||
const Delete: ActionItem = {
|
const Delete: ActionItem = {
|
||||||
@@ -103,7 +102,7 @@ export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
|
|||||||
const response = await createUserAdmin({ userAdminCreateDto: dto });
|
const response = await createUserAdmin({ userAdminCreateDto: dto });
|
||||||
eventManager.emit('UserAdminCreate', response);
|
eventManager.emit('UserAdminCreate', response);
|
||||||
toastManager.success();
|
toastManager.success();
|
||||||
return true;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_create_user'));
|
handleError(error, $t('errors.unable_to_create_user'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="h-full flex flex-col justify-between gap-2">
|
<div class="h-full flex flex-col justify-between gap-2">
|
||||||
<div class="flex flex-col pt-8 pe-4 gap-1">
|
<div class="flex flex-col pt-8 pe-4 gap-1">
|
||||||
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
|
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
|
||||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
|
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARIES} icon={mdiBookshelf} />
|
||||||
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
|
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
|
||||||
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
|
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
|
||||||
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { UUID_REGEX } from '$lib/constants';
|
||||||
import type { ParamMatcher } from '@sveltejs/kit';
|
import type { ParamMatcher } from '@sveltejs/kit';
|
||||||
|
|
||||||
/* Returns true if the given param matches UUID format */
|
/* Returns true if the given param matches UUID format */
|
||||||
export const match: ParamMatcher = (param: string) => {
|
export const match: ParamMatcher = (param: string) => {
|
||||||
return /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/.test(param);
|
return UUID_REGEX.test(param);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -166,7 +166,7 @@
|
|||||||
title: $t('external_libraries'),
|
title: $t('external_libraries'),
|
||||||
description: $t('admin.external_libraries_page_description'),
|
description: $t('admin.external_libraries_page_description'),
|
||||||
icon: mdiBookshelf,
|
icon: mdiBookshelf,
|
||||||
onAction: () => goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT),
|
onAction: () => goto(AppRoute.ADMIN_LIBRARIES),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: $t('server_stats'),
|
title: $t('server_stats'),
|
||||||
|
|||||||
@@ -4,27 +4,29 @@
|
|||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { getLibrariesActions, handleShowLibraryCreateModal, handleViewLibrary } from '$lib/services/library.service';
|
import { getLibrariesActions, handleViewLibrary } from '$lib/services/library.service';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||||
import { Button, CommandPaletteContext } from '@immich/ui';
|
import { Button, CommandPaletteContext } from '@immich/ui';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { PageData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: PageData;
|
children?: Snippet;
|
||||||
|
data: LayoutData;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { data }: Props = $props();
|
let { children, data }: Props = $props();
|
||||||
|
|
||||||
let libraries = $state(data.libraries);
|
let libraries = $state(data.libraries);
|
||||||
let statistics = $state(data.statistics);
|
let statistics = $state(data.statistics);
|
||||||
let owners = $state(data.owners);
|
let owners = $state(data.owners);
|
||||||
|
|
||||||
const onLibraryCreate = async (library: LibraryResponseDto) => {
|
const onLibraryCreate = async (library: LibraryResponseDto) => {
|
||||||
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLibraryUpdate = async (library: LibraryResponseDto) => {
|
const onLibraryUpdate = async (library: LibraryResponseDto) => {
|
||||||
@@ -100,10 +102,12 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<EmptyPlaceholder
|
<EmptyPlaceholder
|
||||||
text={$t('no_libraries_message')}
|
text={$t('no_libraries_message')}
|
||||||
onClick={handleShowLibraryCreateModal}
|
onClick={() => goto(AppRoute.ADMIN_LIBRARIES_NEW)}
|
||||||
class="mt-10 mx-auto"
|
class="mt-10 mx-auto"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk';
|
import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk';
|
||||||
import type { PageLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
export const load = (async ({ url }) => {
|
export const load = (async ({ url }) => {
|
||||||
await authenticate(url, { admin: true });
|
await authenticate(url, { admin: true });
|
||||||
@@ -26,4 +26,4 @@ export const load = (async ({ url }) => {
|
|||||||
title: $t('external_libraries'),
|
title: $t('external_libraries'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}) satisfies PageLoad;
|
}) satisfies LayoutLoad;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
import { handleCreateLibrary } from '$lib/services/library.service';
|
import { handleCreateLibrary } from '$lib/services/library.service';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { searchUsersAdmin } from '@immich/sdk';
|
import { searchUsersAdmin } from '@immich/sdk';
|
||||||
@@ -8,12 +10,6 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { onClose }: Props = $props();
|
|
||||||
|
|
||||||
let ownerId: string = $state($user.id);
|
let ownerId: string = $state($user.id);
|
||||||
|
|
||||||
let userOptions: { value: string; text: string }[] = $state([]);
|
let userOptions: { value: string; text: string }[] = $state([]);
|
||||||
@@ -23,10 +19,14 @@
|
|||||||
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
|
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onClose = async () => {
|
||||||
|
await goto(AppRoute.ADMIN_LIBRARIES);
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const success = await handleCreateLibrary({ ownerId });
|
const library = await handleCreateLibrary({ ownerId });
|
||||||
if (success) {
|
if (library) {
|
||||||
onClose();
|
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`, { replaceState: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
14
web/src/routes/admin/library-management/(list)/new/+page.ts
Normal file
14
web/src/routes/admin/library-management/(list)/new/+page.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { authenticate } from '$lib/utils/auth';
|
||||||
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async ({ url }) => {
|
||||||
|
await authenticate(url, { admin: true });
|
||||||
|
const $t = await getFormatter();
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
title: $t('external_libraries'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) satisfies PageLoad;
|
||||||
118
web/src/routes/admin/library-management/[id]/+layout.svelte
Normal file
118
web/src/routes/admin/library-management/[id]/+layout.svelte
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
|
||||||
|
import AdminCard from '$lib/components/AdminCard.svelte';
|
||||||
|
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||||
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
|
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||||
|
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
|
import TableButton from '$lib/components/TableButton.svelte';
|
||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
|
||||||
|
import {
|
||||||
|
getLibraryActions,
|
||||||
|
getLibraryExclusionPatternActions,
|
||||||
|
getLibraryFolderActions,
|
||||||
|
} from '$lib/services/library.service';
|
||||||
|
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||||
|
import type { LibraryResponseDto } from '@immich/sdk';
|
||||||
|
import { Code, CommandPaletteContext, Container, Heading, modalManager } from '@immich/ui';
|
||||||
|
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children?: Snippet;
|
||||||
|
data: LayoutData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { children, data }: Props = $props();
|
||||||
|
|
||||||
|
const statistics = data.statistics;
|
||||||
|
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
|
||||||
|
|
||||||
|
let library = $state(data.library);
|
||||||
|
|
||||||
|
const onLibraryUpdate = (newLibrary: LibraryResponseDto) => {
|
||||||
|
if (newLibrary.id === library.id) {
|
||||||
|
library = newLibrary;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLibraryDelete = async ({ id }: { id: string }) => {
|
||||||
|
if (id === library.id) {
|
||||||
|
await goto(AppRoute.ADMIN_LIBRARIES);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { Edit, Delete, AddFolder, AddExclusionPattern, Scan } = $derived(getLibraryActions($t, library));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<OnEvents {onLibraryUpdate} {onLibraryDelete} />
|
||||||
|
|
||||||
|
<CommandPaletteContext commands={[Edit, Delete, AddFolder, AddExclusionPattern, Scan]} />
|
||||||
|
|
||||||
|
<AdminPageLayout
|
||||||
|
breadcrumbs={[{ title: $t('external_libraries'), href: AppRoute.ADMIN_LIBRARIES }, { title: library.name }]}
|
||||||
|
actions={[Scan, Edit, Delete]}
|
||||||
|
>
|
||||||
|
<Container size="large" center>
|
||||||
|
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
|
||||||
|
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
|
||||||
|
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
|
||||||
|
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics.photos} />
|
||||||
|
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
|
||||||
|
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
|
||||||
|
{#if library.importPaths.length === 0}
|
||||||
|
<EmptyPlaceholder
|
||||||
|
src={emptyFoldersUrl}
|
||||||
|
text={$t('admin.library_folder_description')}
|
||||||
|
fullWidth
|
||||||
|
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<table class="w-full">
|
||||||
|
<tbody>
|
||||||
|
{#each library.importPaths as folder (folder)}
|
||||||
|
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
|
||||||
|
<tr class="h-12">
|
||||||
|
<td>
|
||||||
|
<Code>{folder}</Code>
|
||||||
|
</td>
|
||||||
|
<td class="flex gap-2 justify-end">
|
||||||
|
<TableButton action={Edit} />
|
||||||
|
<TableButton action={Delete} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</AdminCard>
|
||||||
|
|
||||||
|
<AdminCard icon={mdiFilterMinusOutline} title={$t('exclusion_pattern')} headerAction={AddExclusionPattern}>
|
||||||
|
<table class="w-full">
|
||||||
|
<tbody>
|
||||||
|
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
|
||||||
|
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
|
||||||
|
<tr class="h-12">
|
||||||
|
<td>
|
||||||
|
<Code>{exclusionPattern}</Code>
|
||||||
|
</td>
|
||||||
|
<td class="flex gap-2 justify-end">
|
||||||
|
<TableButton action={Edit} />
|
||||||
|
<TableButton action={Delete} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</AdminCard>
|
||||||
|
</div>
|
||||||
|
{@render children?.()}
|
||||||
|
</Container>
|
||||||
|
</AdminPageLayout>
|
||||||
30
web/src/routes/admin/library-management/[id]/+layout.ts
Normal file
30
web/src/routes/admin/library-management/[id]/+layout.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { authenticate } from '$lib/utils/auth';
|
||||||
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async ({ params: { id }, url }) => {
|
||||||
|
await authenticate(url, { admin: true });
|
||||||
|
|
||||||
|
let library: LibraryResponseDto;
|
||||||
|
|
||||||
|
try {
|
||||||
|
library = await getLibrary({ id });
|
||||||
|
console.log(`Fetched latest library: ${library.name}`);
|
||||||
|
} catch {
|
||||||
|
redirect(302, AppRoute.ADMIN_LIBRARIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statistics = await getLibraryStatistics({ id });
|
||||||
|
const $t = await getFormatter();
|
||||||
|
|
||||||
|
return {
|
||||||
|
library,
|
||||||
|
statistics,
|
||||||
|
meta: {
|
||||||
|
title: $t('admin.library_details'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) satisfies LayoutLoad;
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
|
|
||||||
import AdminCard from '$lib/components/AdminCard.svelte';
|
|
||||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
|
||||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
|
||||||
import TableButton from '$lib/components/TableButton.svelte';
|
|
||||||
import { AppRoute } from '$lib/constants';
|
|
||||||
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
|
|
||||||
import {
|
|
||||||
getLibraryActions,
|
|
||||||
getLibraryExclusionPatternActions,
|
|
||||||
getLibraryFolderActions,
|
|
||||||
} from '$lib/services/library.service';
|
|
||||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
|
||||||
import { Code, CommandPaletteContext, Container, Heading, modalManager } from '@immich/ui';
|
|
||||||
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
data: PageData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data }: Props = $props();
|
|
||||||
|
|
||||||
const statistics = data.statistics;
|
|
||||||
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
|
|
||||||
|
|
||||||
let library = $derived(data.library);
|
|
||||||
|
|
||||||
const { Rename, Delete, AddFolder, AddExclusionPattern, Scan } = $derived(getLibraryActions($t, library));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<OnEvents
|
|
||||||
onLibraryUpdate={(newLibrary) => (library = newLibrary)}
|
|
||||||
onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CommandPaletteContext commands={[Rename, Delete, AddFolder, AddExclusionPattern, Scan]} />
|
|
||||||
|
|
||||||
<AdminPageLayout
|
|
||||||
breadcrumbs={[{ title: $t('external_libraries'), href: AppRoute.ADMIN_LIBRARY_MANAGEMENT }, { title: library.name }]}
|
|
||||||
actions={[Scan, Rename, Delete]}
|
|
||||||
>
|
|
||||||
<Container size="large" center>
|
|
||||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
|
|
||||||
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
|
|
||||||
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
|
|
||||||
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics.photos} />
|
|
||||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
|
|
||||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
|
|
||||||
{#if library.importPaths.length === 0}
|
|
||||||
<EmptyPlaceholder
|
|
||||||
src={emptyFoldersUrl}
|
|
||||||
text={$t('admin.library_folder_description')}
|
|
||||||
fullWidth
|
|
||||||
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<table class="w-full">
|
|
||||||
<tbody>
|
|
||||||
{#each library.importPaths as folder (folder)}
|
|
||||||
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
|
|
||||||
<tr class="h-12">
|
|
||||||
<td>
|
|
||||||
<Code>{folder}</Code>
|
|
||||||
</td>
|
|
||||||
<td class="flex gap-2 justify-end">
|
|
||||||
<TableButton action={Edit} />
|
|
||||||
<TableButton action={Delete} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</AdminCard>
|
|
||||||
|
|
||||||
<AdminCard icon={mdiFilterMinusOutline} title={$t('exclusion_pattern')} headerAction={AddExclusionPattern}>
|
|
||||||
<table class="w-full">
|
|
||||||
<tbody>
|
|
||||||
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
|
|
||||||
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
|
|
||||||
<tr class="h-12">
|
|
||||||
<td>
|
|
||||||
<Code>{exclusionPattern}</Code>
|
|
||||||
</td>
|
|
||||||
<td class="flex gap-2 justify-end">
|
|
||||||
<TableButton action={Edit} />
|
|
||||||
<TableButton action={Delete} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</AdminCard>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</AdminPageLayout>
|
|
||||||
|
|||||||
@@ -1,26 +1,13 @@
|
|||||||
import { AppRoute } from '$lib/constants';
|
|
||||||
import { authenticate } from '$lib/utils/auth';
|
import { authenticate } from '$lib/utils/auth';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load = (async ({ params: { id }, url }) => {
|
export const load = (async ({ url }) => {
|
||||||
await authenticate(url, { admin: true });
|
await authenticate(url, { admin: true });
|
||||||
let library: LibraryResponseDto;
|
|
||||||
|
|
||||||
try {
|
|
||||||
library = await getLibrary({ id });
|
|
||||||
} catch {
|
|
||||||
redirect(302, AppRoute.ADMIN_LIBRARY_MANAGEMENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statistics = await getLibraryStatistics({ id });
|
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
library,
|
|
||||||
statistics,
|
|
||||||
meta: {
|
meta: {
|
||||||
title: $t('admin.library_details'),
|
title: $t('admin.library_details'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { handleUpdateLibrary } from '$lib/services/library.service';
|
||||||
|
import { Field, FormModal, Input } from '@immich/ui';
|
||||||
|
import { mdiRenameOutline } from '@mdi/js';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import type { PageData } from '../$types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: PageData;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const library = $derived(data.library);
|
||||||
|
let name = $state(library.name);
|
||||||
|
|
||||||
|
const onClose = async () => {
|
||||||
|
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
const success = await handleUpdateLibrary(library, { name });
|
||||||
|
if (success) {
|
||||||
|
await onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormModal icon={mdiRenameOutline} title={$t('edit')} {onSubmit} {onClose} size="small">
|
||||||
|
<Field label={$t('name')}>
|
||||||
|
<Input bind:value={name} />
|
||||||
|
</Field>
|
||||||
|
</FormModal>
|
||||||
15
web/src/routes/admin/library-management/[id]/edit/+page.ts
Normal file
15
web/src/routes/admin/library-management/[id]/edit/+page.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { authenticate } from '$lib/utils/auth';
|
||||||
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async ({ url }) => {
|
||||||
|
await authenticate(url, { admin: true });
|
||||||
|
|
||||||
|
const $t = await getFormatter();
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
title: $t('admin.library_details'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) satisfies PageLoad;
|
||||||
93
web/src/routes/admin/users/(list)/+layout.svelte
Normal file
93
web/src/routes/admin/users/(list)/+layout.svelte
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||||
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
|
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||||
|
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||||
|
import { Button, CommandPaletteContext, Container, Icon } from '@immich/ui';
|
||||||
|
import { mdiInfinity } from '@mdi/js';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children?: Snippet;
|
||||||
|
data: LayoutData;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { children, data }: Props = $props();
|
||||||
|
|
||||||
|
let users: UserAdminResponseDto[] = $state(data.users);
|
||||||
|
|
||||||
|
const onUpdate = async (user: UserAdminResponseDto) => {
|
||||||
|
const index = users.findIndex(({ id }) => id === user.id);
|
||||||
|
if (index === -1) {
|
||||||
|
users = await searchUsersAdmin({ withDeleted: true });
|
||||||
|
} else {
|
||||||
|
users[index] = user;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
|
||||||
|
users = users.filter(({ id }) => id !== userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { Create } = $derived(getUserAdminsActions($t));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<OnEvents
|
||||||
|
onUserAdminCreate={onUpdate}
|
||||||
|
onUserAdminUpdate={onUpdate}
|
||||||
|
onUserAdminDelete={onUpdate}
|
||||||
|
onUserAdminRestore={onUpdate}
|
||||||
|
{onUserAdminDeleted}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CommandPaletteContext commands={[Create]} />
|
||||||
|
|
||||||
|
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
|
||||||
|
<Container center size="large">
|
||||||
|
<table class="my-5 w-full text-start">
|
||||||
|
<thead
|
||||||
|
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||||
|
>
|
||||||
|
<tr class="flex w-full place-items-center">
|
||||||
|
<th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium">{$t('email')}</th>
|
||||||
|
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
|
||||||
|
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
||||||
|
{#each users as user (user.id)}
|
||||||
|
<tr
|
||||||
|
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
|
||||||
|
? 'bg-red-300 dark:bg-red-900'
|
||||||
|
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
|
||||||
|
>
|
||||||
|
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm">
|
||||||
|
{user.email}
|
||||||
|
</td>
|
||||||
|
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
|
||||||
|
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
|
||||||
|
<div class="container mx-auto flex flex-wrap justify-center">
|
||||||
|
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||||
|
{getByteUnitString(user.quotaSizeInBytes, $locale)}
|
||||||
|
{:else}
|
||||||
|
<Icon icon={mdiInfinity} size="16" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
|
||||||
|
>
|
||||||
|
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
|
</Container>
|
||||||
|
</AdminPageLayout>
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import { searchUsersAdmin } from '@immich/sdk';
|
import { searchUsersAdmin } from '@immich/sdk';
|
||||||
import type { PageLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
export const load = (async ({ url }) => {
|
export const load = (async ({ url }) => {
|
||||||
await authenticate(url, { admin: true });
|
await authenticate(url, { admin: true });
|
||||||
await requestServerInfo();
|
await requestServerInfo();
|
||||||
const allUsers = await searchUsersAdmin({ withDeleted: true });
|
const users = await searchUsersAdmin({ withDeleted: true });
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allUsers,
|
users,
|
||||||
meta: {
|
meta: {
|
||||||
title: $t('admin.user_management'),
|
title: $t('admin.user_management'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}) satisfies PageLoad;
|
}) satisfies LayoutLoad;
|
||||||
0
web/src/routes/admin/users/(list)/+page.svelte
Normal file
0
web/src/routes/admin/users/(list)/+page.svelte
Normal file
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { handleCreateUserAdmin } from '$lib/services/user-admin.service';
|
import { handleCreateUserAdmin } from '$lib/services/user-admin.service';
|
||||||
import { userInteraction } from '$lib/stores/user.svelte';
|
import { userInteraction } from '$lib/stores/user.svelte';
|
||||||
@@ -18,12 +20,6 @@
|
|||||||
} from '@immich/ui';
|
} from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { onClose }: Props = $props();
|
|
||||||
|
|
||||||
let success = $state(false);
|
let success = $state(false);
|
||||||
|
|
||||||
let email = $state('');
|
let email = $state('');
|
||||||
@@ -46,6 +42,10 @@
|
|||||||
const passwordMismatchMessage = $derived(passwordMismatch ? $t('password_does_not_match') : '');
|
const passwordMismatchMessage = $derived(passwordMismatch ? $t('password_does_not_match') : '');
|
||||||
const valid = $derived(!passwordMismatch && !isCreatingUser);
|
const valid = $derived(!passwordMismatch && !isCreatingUser);
|
||||||
|
|
||||||
|
const onClose = async () => {
|
||||||
|
await goto(AppRoute.ADMIN_USERS);
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (event: Event) => {
|
const onSubmit = async (event: Event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
isCreatingUser = true;
|
isCreatingUser = true;
|
||||||
|
|
||||||
const success = await handleCreateUserAdmin({
|
const user = await handleCreateUserAdmin({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
shouldChangePassword,
|
shouldChangePassword,
|
||||||
@@ -65,8 +65,8 @@
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
if (user) {
|
||||||
onClose();
|
await goto(`${AppRoute.ADMIN_USERS}/${user.id}`, { replaceState: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
isCreatingUser = false;
|
isCreatingUser = false;
|
||||||
14
web/src/routes/admin/users/(list)/new/+page.ts
Normal file
14
web/src/routes/admin/users/(list)/new/+page.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { authenticate } from '$lib/utils/auth';
|
||||||
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async ({ url }) => {
|
||||||
|
await authenticate(url, { admin: true });
|
||||||
|
const $t = await getFormatter();
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
title: $t('admin.user_management'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) satisfies PageLoad;
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
|
||||||
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
|
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
|
||||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
|
||||||
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
|
||||||
import { Button, CommandPaletteContext, Icon } from '@immich/ui';
|
|
||||||
import { mdiInfinity } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
data: PageData;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { data }: Props = $props();
|
|
||||||
|
|
||||||
let allUsers: UserAdminResponseDto[] = $state(data.allUsers);
|
|
||||||
|
|
||||||
const onUpdate = async (user: UserAdminResponseDto) => {
|
|
||||||
const index = allUsers.findIndex(({ id }) => id === user.id);
|
|
||||||
if (index === -1) {
|
|
||||||
allUsers = await searchUsersAdmin({ withDeleted: true });
|
|
||||||
} else {
|
|
||||||
allUsers[index] = user;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
|
|
||||||
allUsers = allUsers.filter(({ id }) => id !== userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { Create } = $derived(getUserAdminsActions($t));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<OnEvents
|
|
||||||
onUserAdminCreate={onUpdate}
|
|
||||||
onUserAdminUpdate={onUpdate}
|
|
||||||
onUserAdminDelete={onUpdate}
|
|
||||||
onUserAdminRestore={onUpdate}
|
|
||||||
{onUserAdminDeleted}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CommandPaletteContext commands={[Create]} />
|
|
||||||
|
|
||||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
|
|
||||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
|
||||||
<section class="w-full pb-28 lg:w-212.5">
|
|
||||||
<table class="my-5 w-full text-start">
|
|
||||||
<thead
|
|
||||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
|
||||||
>
|
|
||||||
<tr class="flex w-full place-items-center">
|
|
||||||
<th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium"
|
|
||||||
>{$t('email')}</th
|
|
||||||
>
|
|
||||||
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
|
|
||||||
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
|
||||||
{#each allUsers as user (user.id)}
|
|
||||||
<tr
|
|
||||||
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
|
|
||||||
? 'bg-red-300 dark:bg-red-900'
|
|
||||||
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
|
|
||||||
>
|
|
||||||
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm">
|
|
||||||
{user.email}
|
|
||||||
</td>
|
|
||||||
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
|
|
||||||
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
|
|
||||||
<div class="container mx-auto flex flex-wrap justify-center">
|
|
||||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
|
||||||
{getByteUnitString(user.quotaSizeInBytes, $locale)}
|
|
||||||
{:else}
|
|
||||||
<Icon icon={mdiInfinity} size="16" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
|
|
||||||
>
|
|
||||||
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
</AdminPageLayout>
|
|
||||||
222
web/src/routes/admin/users/[id]/+layout.svelte
Normal file
222
web/src/routes/admin/users/[id]/+layout.svelte
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import AdminCard from '$lib/components/AdminCard.svelte';
|
||||||
|
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||||
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
|
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||||
|
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||||
|
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
|
||||||
|
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
|
||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { getUserAdminActions } from '$lib/services/user-admin.service';
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||||
|
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||||
|
import { type UserAdminResponseDto } from '@immich/sdk';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Code,
|
||||||
|
CommandPaletteContext,
|
||||||
|
Container,
|
||||||
|
getByteUnitString,
|
||||||
|
Heading,
|
||||||
|
Icon,
|
||||||
|
MenuItemType,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from '@immich/ui';
|
||||||
|
import {
|
||||||
|
mdiAccountOutline,
|
||||||
|
mdiCameraIris,
|
||||||
|
mdiChartPie,
|
||||||
|
mdiChartPieOutline,
|
||||||
|
mdiCheckCircle,
|
||||||
|
mdiDevices,
|
||||||
|
mdiFeatureSearchOutline,
|
||||||
|
mdiPlayCircle,
|
||||||
|
mdiTrashCanOutline,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children?: Snippet;
|
||||||
|
data: LayoutData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { children, data }: Props = $props();
|
||||||
|
|
||||||
|
let user = $state(data.user);
|
||||||
|
const userPreferences = $state(data.userPreferences);
|
||||||
|
const userStatistics = $state(data.userStatistics);
|
||||||
|
const userSessions = $state(data.userSessions);
|
||||||
|
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 editedLocale = $derived(findLocale($locale).code);
|
||||||
|
let createAtDate: Date = $derived(new Date(user.createdAt));
|
||||||
|
let updatedAtDate: Date = $derived(new Date(user.updatedAt));
|
||||||
|
let userCreatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(createAtDate));
|
||||||
|
let userUpdatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(updatedAtDate));
|
||||||
|
|
||||||
|
const getUsageClass = () => {
|
||||||
|
if (usedPercentage >= 95) {
|
||||||
|
return 'bg-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usedPercentage > 80) {
|
||||||
|
return 'bg-yellow-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-primary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
|
||||||
|
|
||||||
|
const onUpdate = (update: UserAdminResponseDto) => {
|
||||||
|
if (update.id === user.id) {
|
||||||
|
user = update;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUserAdminDeleted = async ({ id }: { id: string }) => {
|
||||||
|
if (id === user.id) {
|
||||||
|
await goto(AppRoute.ADMIN_USERS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<OnEvents
|
||||||
|
onUserAdminUpdate={onUpdate}
|
||||||
|
onUserAdminDelete={onUpdate}
|
||||||
|
onUserAdminRestore={onUpdate}
|
||||||
|
{onUserAdminDeleted}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CommandPaletteContext commands={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
|
||||||
|
|
||||||
|
<AdminPageLayout
|
||||||
|
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
|
||||||
|
actions={[ResetPassword, ResetPinCode, Update, Restore, MenuItemType.Divider, Delete]}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Container size="large" center>
|
||||||
|
{#if user.deletedAt}
|
||||||
|
<Alert color="danger" class="my-4" title={$t('user_has_been_deleted')} icon={mdiTrashCanOutline} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
|
||||||
|
<div class="col-span-full flex flex-col gap-4 my-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<UserAvatar {user} size="md" />
|
||||||
|
<Heading tag="h1" size="large">{user.name}</Heading>
|
||||||
|
</div>
|
||||||
|
{#if user.isAdmin}
|
||||||
|
<div>
|
||||||
|
<Badge color="primary" size="small">{$t('admin.admin_user')}</Badge>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="col-span-full">
|
||||||
|
<div class="flex flex-col lg:flex-row gap-4 w-full">
|
||||||
|
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={userStatistics.images} />
|
||||||
|
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={userStatistics.videos} />
|
||||||
|
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminCard icon={mdiAccountOutline} title={$t('profile')}>
|
||||||
|
<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>{userCreatedAtDateAndTime}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
|
||||||
|
<Text>{userUpdatedAtDateAndTime}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
|
||||||
|
<Code>{user.id}</Code>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</AdminCard>
|
||||||
|
|
||||||
|
<AdminCard icon={mdiFeatureSearchOutline} title={$t('features')}>
|
||||||
|
<Stack gap={3}>
|
||||||
|
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
|
||||||
|
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
|
||||||
|
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
|
||||||
|
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
|
||||||
|
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
|
||||||
|
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
|
||||||
|
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
|
||||||
|
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
|
||||||
|
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
|
||||||
|
</Stack>
|
||||||
|
</AdminCard>
|
||||||
|
|
||||||
|
<AdminCard icon={mdiChartPieOutline} title={$t('storage_quota')}>
|
||||||
|
{#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}
|
||||||
|
|
||||||
|
{#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}
|
||||||
|
</AdminCard>
|
||||||
|
|
||||||
|
<AdminCard icon={mdiDevices} title={$t('authorized_devices')}>
|
||||||
|
<Stack gap={3}>
|
||||||
|
{#each userSessions as session (session.id)}
|
||||||
|
<DeviceCard {session} />
|
||||||
|
{:else}
|
||||||
|
<span class="text-dark">{$t('no_devices')}</span>
|
||||||
|
{/each}
|
||||||
|
</Stack>
|
||||||
|
</AdminCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
</AdminPageLayout>
|
||||||
38
web/src/routes/admin/users/[id]/+layout.ts
Normal file
38
web/src/routes/admin/users/[id]/+layout.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { AppRoute, UUID_REGEX } from '$lib/constants';
|
||||||
|
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||||
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async ({ params, url }) => {
|
||||||
|
await authenticate(url, { admin: true });
|
||||||
|
await requestServerInfo();
|
||||||
|
|
||||||
|
if (!UUID_REGEX.test(params.id)) {
|
||||||
|
redirect(302, AppRoute.ADMIN_USERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []);
|
||||||
|
if (!user) {
|
||||||
|
redirect(302, AppRoute.ADMIN_USERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userPreferences, userStatistics, userSessions] = await Promise.all([
|
||||||
|
getUserPreferencesAdmin({ id: user.id }),
|
||||||
|
getUserStatisticsAdmin({ id: user.id }),
|
||||||
|
getUserSessionsAdmin({ id: user.id }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const $t = await getFormatter();
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
userPreferences,
|
||||||
|
userStatistics,
|
||||||
|
userSessions,
|
||||||
|
meta: {
|
||||||
|
title: $t('admin.user_details'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) satisfies LayoutLoad;
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import AdminCard from '$lib/components/AdminCard.svelte';
|
|
||||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
|
||||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
|
||||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
|
||||||
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
|
|
||||||
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
|
|
||||||
import { AppRoute } from '$lib/constants';
|
|
||||||
import { getUserAdminActions } from '$lib/services/user-admin.service';
|
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
|
||||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
|
||||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
|
||||||
import { type UserAdminResponseDto } from '@immich/sdk';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Code,
|
|
||||||
CommandPaletteContext,
|
|
||||||
Container,
|
|
||||||
getByteUnitString,
|
|
||||||
Heading,
|
|
||||||
Icon,
|
|
||||||
MenuItemType,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
} from '@immich/ui';
|
|
||||||
import {
|
|
||||||
mdiAccountOutline,
|
|
||||||
mdiCameraIris,
|
|
||||||
mdiChartPie,
|
|
||||||
mdiChartPieOutline,
|
|
||||||
mdiCheckCircle,
|
|
||||||
mdiDevices,
|
|
||||||
mdiFeatureSearchOutline,
|
|
||||||
mdiPlayCircle,
|
|
||||||
mdiTrashCanOutline,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
data: PageData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data }: Props = $props();
|
|
||||||
|
|
||||||
let user = $derived(data.user);
|
|
||||||
const userPreferences = $derived(data.userPreferences);
|
|
||||||
const userStatistics = $derived(data.userStatistics);
|
|
||||||
const userSessions = $derived(data.userSessions);
|
|
||||||
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 editedLocale = $derived(findLocale($locale).code);
|
|
||||||
let createAtDate: Date = $derived(new Date(user.createdAt));
|
|
||||||
let updatedAtDate: Date = $derived(new Date(user.updatedAt));
|
|
||||||
let userCreatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(createAtDate));
|
|
||||||
let userUpdatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(updatedAtDate));
|
|
||||||
|
|
||||||
const getUsageClass = () => {
|
|
||||||
if (usedPercentage >= 95) {
|
|
||||||
return 'bg-red-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usedPercentage > 80) {
|
|
||||||
return 'bg-yellow-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'bg-primary';
|
|
||||||
};
|
|
||||||
|
|
||||||
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
|
|
||||||
|
|
||||||
const onUpdate = (update: UserAdminResponseDto) => {
|
|
||||||
if (update.id === user.id) {
|
|
||||||
user = update;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUserAdminDeleted = async ({ id }: { id: string }) => {
|
|
||||||
if (id === user.id) {
|
|
||||||
await goto(AppRoute.ADMIN_USERS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<OnEvents
|
|
||||||
onUserAdminUpdate={onUpdate}
|
|
||||||
onUserAdminDelete={onUpdate}
|
|
||||||
onUserAdminRestore={onUpdate}
|
|
||||||
{onUserAdminDeleted}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CommandPaletteContext commands={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
|
|
||||||
|
|
||||||
<AdminPageLayout
|
|
||||||
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
|
|
||||||
actions={[ResetPassword, ResetPinCode, Update, Restore, MenuItemType.Divider, Delete]}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Container size="large" center>
|
|
||||||
{#if user.deletedAt}
|
|
||||||
<Alert color="danger" class="my-4" title={$t('user_has_been_deleted')} icon={mdiTrashCanOutline} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
|
|
||||||
<div class="col-span-full flex flex-col gap-4 my-4">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<UserAvatar {user} size="md" />
|
|
||||||
<Heading tag="h1" size="large">{user.name}</Heading>
|
|
||||||
</div>
|
|
||||||
{#if user.isAdmin}
|
|
||||||
<div>
|
|
||||||
<Badge color="primary" size="small">{$t('admin.admin_user')}</Badge>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="col-span-full">
|
|
||||||
<div class="flex flex-col lg:flex-row gap-4 w-full">
|
|
||||||
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={userStatistics.images} />
|
|
||||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={userStatistics.videos} />
|
|
||||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AdminCard icon={mdiAccountOutline} title={$t('profile')}>
|
|
||||||
<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>{userCreatedAtDateAndTime}</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
|
|
||||||
<Text>{userUpdatedAtDateAndTime}</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
|
|
||||||
<Code>{user.id}</Code>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</AdminCard>
|
|
||||||
|
|
||||||
<AdminCard icon={mdiFeatureSearchOutline} title={$t('features')}>
|
|
||||||
<Stack gap={3}>
|
|
||||||
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
|
|
||||||
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
|
|
||||||
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
|
|
||||||
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
|
|
||||||
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
|
|
||||||
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
|
|
||||||
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
|
|
||||||
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
|
|
||||||
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
|
|
||||||
</Stack>
|
|
||||||
</AdminCard>
|
|
||||||
|
|
||||||
<AdminCard icon={mdiChartPieOutline} title={$t('storage_quota')}>
|
|
||||||
{#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}
|
|
||||||
|
|
||||||
{#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}
|
|
||||||
</AdminCard>
|
|
||||||
|
|
||||||
<AdminCard icon={mdiDevices} title={$t('authorized_devices')}>
|
|
||||||
<Stack gap={3}>
|
|
||||||
{#each userSessions as session (session.id)}
|
|
||||||
<DeviceCard {session} />
|
|
||||||
{:else}
|
|
||||||
<span class="text-dark">{$t('no_devices')}</span>
|
|
||||||
{/each}
|
|
||||||
</Stack>
|
|
||||||
</AdminCard>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
</AdminPageLayout>
|
|
||||||
|
|||||||
@@ -1,31 +1,12 @@
|
|||||||
import { AppRoute } from '$lib/constants';
|
import { authenticate } from '$lib/utils/auth';
|
||||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load = (async ({ params, url }) => {
|
export const load = (async ({ url }) => {
|
||||||
await authenticate(url, { admin: true });
|
await authenticate(url, { admin: true });
|
||||||
await requestServerInfo();
|
|
||||||
const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []);
|
|
||||||
if (!user) {
|
|
||||||
redirect(302, AppRoute.ADMIN_USERS);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [userPreferences, userStatistics, userSessions] = await Promise.all([
|
|
||||||
getUserPreferencesAdmin({ id: user.id }),
|
|
||||||
getUserStatisticsAdmin({ id: user.id }),
|
|
||||||
getUserSessionsAdmin({ id: user.id }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
|
||||||
userPreferences,
|
|
||||||
userStatistics,
|
|
||||||
userSessions,
|
|
||||||
meta: {
|
meta: {
|
||||||
title: $t('admin.user_details'),
|
title: $t('admin.user_details'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { handleUpdateUserAdmin } from '$lib/services/user-admin.service';
|
import { handleUpdateUserAdmin } from '$lib/services/user-admin.service';
|
||||||
import { user as authUser } from '$lib/stores/user.store';
|
import { user as authUser } from '$lib/stores/user.store';
|
||||||
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 { type UserAdminResponseDto } from '@immich/sdk';
|
import type { UserAdminResponseDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Field,
|
Field,
|
||||||
@@ -20,22 +21,23 @@
|
|||||||
} from '@immich/ui';
|
} from '@immich/ui';
|
||||||
import { mdiAccountEditOutline } from '@mdi/js';
|
import { mdiAccountEditOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
user: UserAdminResponseDto;
|
data: PageData;
|
||||||
onClose: () => void;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
let { user, onClose }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const user = $derived(data.user as UserAdminResponseDto);
|
||||||
let isAdmin = $derived(user.isAdmin);
|
let isAdmin = $derived(user.isAdmin);
|
||||||
let name = $derived(user.name);
|
let name = $derived(user.name);
|
||||||
let email = $derived(user.email);
|
let email = $derived(user.email);
|
||||||
let storageLabel = $derived(user.storageLabel || '');
|
let storageLabel = $derived(user.storageLabel || '');
|
||||||
|
|
||||||
const previousQuota = user.quotaSizeInBytes;
|
const previousQuota = $derived(user.quotaSizeInBytes);
|
||||||
|
|
||||||
let quotaSize = $state(
|
let quotaSize = $derived(
|
||||||
typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined,
|
typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -48,6 +50,10 @@
|
|||||||
quotaSizeBytes > userInteraction.serverInfo.diskSizeRaw,
|
quotaSizeBytes > userInteraction.serverInfo.diskSizeRaw,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onClose = async () => {
|
||||||
|
await goto(`${AppRoute.ADMIN_USERS}/${user.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (event: Event) => {
|
const onSubmit = async (event: Event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -60,7 +66,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onClose();
|
await onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
15
web/src/routes/admin/users/[id]/edit/+page.ts
Normal file
15
web/src/routes/admin/users/[id]/edit/+page.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { authenticate } from '$lib/utils/auth';
|
||||||
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async ({ url }) => {
|
||||||
|
await authenticate(url, { admin: true });
|
||||||
|
|
||||||
|
const $t = await getFormatter();
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
title: $t('admin.user_details'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) satisfies PageLoad;
|
||||||
Reference in New Issue
Block a user