mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 09:15:35 +03:00
Compare commits
2 Commits
feat/new-u
...
release/ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f47cb72424 | ||
|
|
a17f188e97 |
176
CHANGELOG.md
Normal file
176
CHANGELOG.md
Normal file
@@ -0,0 +1,176 @@
|
||||
|
||||
# v2.4.2
|
||||
|
||||
# v2.4.0
|
||||
|
||||
## Highlights
|
||||
|
||||
Welcome to the release `v2.4.0` of Immich. This release focuses on bug fixes, QoL improvements, and polished UI components across mobile and the web. Let's dive right in.
|
||||
|
||||
* Show the owner's name in the shared album
|
||||
* Command palette
|
||||
* Change search type directly in the search bar
|
||||
* Better action button placement in the mobile asset viewer
|
||||
* Notable fix: fix an issue where metadata extraction could fail on high concurrency
|
||||
|
||||
### Show the owner's name in the shared album.
|
||||
|
||||
On the web, in shared albums, you can now toggle an option to display the asset's owner name at the bottom right corner of the thumbnail.
|
||||
|
||||

|
||||
|
||||
### Command palette
|
||||
|
||||
The web app now has an integrated command palette, which can be opened with `ctrl + k` on Windows/Linux or `cmd + k` on macOS. The first iteration of this lets you quickly navigate between administration pages by typing the name of the page you want to go to. It also already supports some common actions when on the respective admin pages, many of which also support shortcuts. Have a look around and check them out!
|
||||
|
||||

|
||||
|
||||
### Change search type directly in the search bar
|
||||
|
||||
You can now click on the pill from the search bar to select a different search type without opening the search filter panel.
|
||||
|
||||

|
||||
|
||||
|
||||
### Better placement of action buttons in the mobile asset viewer
|
||||
|
||||
Previously, to perform a specific action on the asset, you needed first to swipe up to open the detail panel, then swipe all the way to the right and tap the action. It limits the discoverability of some actions. To help resolve that issue, all the action buttons in the detail panel are now moved to the drop-down menu when tapping on the vertical dot icon (or kebab menu), along with some buttons that used to be on the top bar, clearing up space to display more useful information when viewing the asset.
|
||||
|
||||
|
||||

|
||||
|
||||
## What's Changed
|
||||
|
||||
### 🫥 Deprecated Changes
|
||||
|
||||
* feat: queues by @jrasm91 in <https://github.com/immich-app/immich/pull/24142>
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
* feat: improve performance: don't sort timeline buckets from server by @midzelis in <https://github.com/immich-app/immich/pull/24032>
|
||||
* feat: command palette by @danieldietzler in <https://github.com/immich-app/immich/pull/23693>
|
||||
* feat(web): Shared album owner labels by @xCJPECKOVERx and @idubnori in <https://github.com/immich-app/immich/pull/21171>
|
||||
* feat(mobile): persist album sorting & layout in settings by @YarosMallorca in <https://github.com/immich-app/immich/pull/22133>
|
||||
* feat: queue detail page by @jrasm91 in <https://github.com/immich-app/immich/pull/24352>
|
||||
* chore(mobile): add kebabu menu in asset viewer by @idubnori in <https://github.com/immich-app/immich/pull/24387>
|
||||
* feat(mobile): create new album from add to modal by @YarosMallorca in <https://github.com/immich-app/immich/pull/24431>
|
||||
* feat(mobile): move buttons in the bottom sheet to the kebabu menu by @idubnori in <https://github.com/immich-app/immich/pull/24175>
|
||||
|
||||
### 🌟 Enhancements
|
||||
|
||||
* feat(web): allow navigating the map with arrow keys by @lukashass in <https://github.com/immich-app/immich/pull/24080>
|
||||
* feat: separate camera and lens info in detail panel by @fabianbees in <https://github.com/immich-app/immich/pull/23670>
|
||||
* feat(web): shared link card tweaks by @jrasm91 in <https://github.com/immich-app/immich/pull/24192>
|
||||
* feat(server): exclude syncthing folders from external libraries by @SaphuA in <https://github.com/immich-app/immich/pull/24240>
|
||||
* feat(web): search type selection dropdown by @YarosMallorca in <https://github.com/immich-app/immich/pull/24091>
|
||||
* feat: header context menu by @jrasm91 in <https://github.com/immich-app/immich/pull/24374>
|
||||
* feat(mobile): move top bar buttons into kebabu menu in AssetViewer by @idubnori in <https://github.com/immich-app/immich/pull/24461>
|
||||
* feat(web): asset selection bar in tags view by @YarosMallorca in <https://github.com/immich-app/immich/pull/24522>
|
||||
* feat(web): slideshow feature on shared albums by @YarosMallorca in <https://github.com/immich-app/immich/pull/24598>
|
||||
* feat: replace heart icons to thumbs-up across activity by @idubnori in <https://github.com/immich-app/immich/pull/24590>
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
* fix: effect loop by @jrasm91 in <https://github.com/immich-app/immich/pull/24014>
|
||||
* fix: do not clear hash on updated_at change by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24039>
|
||||
* fix: disable animation "add to" action menu by @bwees in <https://github.com/immich-app/immich/pull/24040>
|
||||
* fix: Use correct app store link by @Mraedis in <https://github.com/immich-app/immich/pull/24062>
|
||||
* fix: show archived assets in favorite page by @bwees in <https://github.com/immich-app/immich/pull/24052>
|
||||
* fix(mobile): first video memory on page doesn't play by @YarosMallorca in <https://github.com/immich-app/immich/pull/23906>
|
||||
* feat(web): show detected faces in spherical photos by @meesfrensel in <https://github.com/immich-app/immich/pull/23974>
|
||||
* fix: add users to album by @danieldietzler in <https://github.com/immich-app/immich/pull/24133>
|
||||
* fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params by @lutostag in <https://github.com/immich-app/immich/pull/23333>
|
||||
* fix: use proper updatedAt value in local assets by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24137>
|
||||
* fix: albums page reactivity loops by @danieldietzler in <https://github.com/immich-app/immich/pull/24046>
|
||||
* fix: getAspectRatio fallback to db width and height by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24131>
|
||||
* fix(web): fix support & feedback modal wrapping by @Snowknight26 in <https://github.com/immich-app/immich/pull/24018>
|
||||
* fix: don't get OCR data in shared link by @alextran1502 in <https://github.com/immich-app/immich/pull/24152>
|
||||
* fix: duration extraction by @jrasm91 in <https://github.com/immich-app/immich/pull/24178>
|
||||
* fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures by @LukaPrebil in <https://github.com/immich-app/immich/pull/24045>
|
||||
* fix: update timeline-manager after archive actions by @midzelis in <https://github.com/immich-app/immich/pull/24010>
|
||||
* fix: theme switcher by @jrasm91 in <https://github.com/immich-app/immich/pull/24209>
|
||||
* fix: label 'for' attributes in user-api-key-grid by @kimsey0 in <https://github.com/immich-app/immich/pull/24232>
|
||||
* fix(mobile): enable backup text overflows by @YarosMallorca in <https://github.com/immich-app/immich/pull/24227>
|
||||
* fix(web): integrate zoom toggle button into panorama photo viewer by @meesfrensel in <https://github.com/immich-app/immich/pull/24189>
|
||||
* fix(web): use full tag path when creating nested subtags by @NiklasvonM in <https://github.com/immich-app/immich/pull/24249>
|
||||
* fix: only generate memory based on users assets by @alextran1502 in <https://github.com/immich-app/immich/pull/24151>
|
||||
* fix(mobile): docs link by @mmomjian in <https://github.com/immich-app/immich/pull/24277>
|
||||
* fix(server): use bigrams for cjk by @mertalev in <https://github.com/immich-app/immich/pull/24285>
|
||||
* fix(ml): do not upscale preview by @mertalev in <https://github.com/immich-app/immich/pull/24322>
|
||||
* fix(web): open onboarding documentation link in new tab by @carbonemys in <https://github.com/immich-app/immich/pull/24289>
|
||||
* fix(mobile): use correct timezone displayed in the info sheet by @kao-byte in <https://github.com/immich-app/immich/pull/24310>
|
||||
* fix(web): folder view sort oder by @etnoy in <https://github.com/immich-app/immich/pull/24337>
|
||||
* fix(server): do not delete offline assets by @mertalev in <https://github.com/immich-app/immich/pull/24355>
|
||||
* fix: exposure info and better readability by @alextran1502 in <https://github.com/immich-app/immich/pull/24344>
|
||||
* fix: Adjust the zoom level by @jforseth210 in <https://github.com/immich-app/immich/pull/24353>
|
||||
* fix: local full sync on Android on resume by @alextran1502 in <https://github.com/immich-app/immich/pull/24348>
|
||||
* fix(web): Add minimum content size to logo for consistent visual on small screens by @kiloomar in <https://github.com/immich-app/immich/pull/24372>
|
||||
* fix: use adjustment time in iOS for hash reset by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24047>
|
||||
* fix(server): update exiftool-vendored to v34 for more robust metadata extraction by @skatsubo in <https://github.com/immich-app/immich/pull/24424>
|
||||
* fix(mobile): cannot create album while name field is focused by @YarosMallorca in <https://github.com/immich-app/immich/pull/24449>
|
||||
* fix(web): \[album table view\] long album title overflows table row by @simonkub in <https://github.com/immich-app/immich/pull/24450>
|
||||
* fix(mobile): fix overflow text in backup card by @YarosMallorca in <https://github.com/immich-app/immich/pull/24448>
|
||||
* fix(mobile): timeline bottom padding on selection by @YarosMallorca in <https://github.com/immich-app/immich/pull/24480>
|
||||
* feat(mobile): Localized backup upload details page by @ArnyminerZ in <https://github.com/immich-app/immich/pull/21136>
|
||||
* fix(mobile): iOS local permission dialog extra whitespace by @kurtmckee in <https://github.com/immich-app/immich/pull/24491>
|
||||
* fix(mobile): versionStatus.message text overflow by @idubnori in <https://github.com/immich-app/immich/pull/24504>
|
||||
* fix(server): prevent metadata extraction failures on large video files by @hubert-taieb in <https://github.com/immich-app/immich/pull/24094>
|
||||
* fix(web): show inferred timezone in date editor by @skatsubo in <https://github.com/immich-app/immich/pull/24513>
|
||||
* fix(mobile): local videos with '#' don't play on android by @YarosMallorca in <https://github.com/immich-app/immich/pull/24373>
|
||||
* fix: refresh appear in list after asset is added to a current or new album by @alextran1502 in <https://github.com/immich-app/immich/pull/24523>
|
||||
* fix(mobile): birthday off by one day on remote by @YarosMallorca in <https://github.com/immich-app/immich/pull/24527>
|
||||
* fix(web): download panel being hidden by admin sidebar by @diogotcorreia in <https://github.com/immich-app/immich/pull/24583>
|
||||
* fix(web): recent search doesn't use search type by @YarosMallorca in <https://github.com/immich-app/immich/pull/24578>
|
||||
* fix(server): only extract image's duration if format supports animation by @meesfrensel in <https://github.com/immich-app/immich/pull/24587>
|
||||
* fix(mobile): local delete missing from sheet on some routes by @YarosMallorca in <https://github.com/immich-app/immich/pull/24505>
|
||||
* fix(mobile): better UI for metadata panel by @kao-byte in <https://github.com/immich-app/immich/pull/24428>
|
||||
* fix: shared link expiration and small styling by @alextran1502 in <https://github.com/immich-app/immich/pull/24566>
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
* docs: DB_STORAGE_TYPE is only used by the database container by @dionysius in <https://github.com/immich-app/immich/pull/24215>
|
||||
* fix(docs): build `cli` for e2e tests by @roschaefer in <https://github.com/immich-app/immich/pull/24184>
|
||||
* docs(faq): add more info on archiving by @etnoy in <https://github.com/immich-app/immich/pull/24326>
|
||||
* fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT by @dionysius in <https://github.com/immich-app/immich/pull/24335>
|
||||
* fix: prevent OOM on nginx reverse proxy servers by @NicholasFlamy in <https://github.com/immich-app/immich/pull/24351>
|
||||
* fix(docs): obsolete docs about rootless docker by @roschaefer in <https://github.com/immich-app/immich/pull/24376>
|
||||
* fix(docs): websockets in nginx example by @fourthwall in <https://github.com/immich-app/immich/pull/24411>
|
||||
* fix(docs): slow upload speed with example nginx reverse proxy config by @goalie2002 in <https://github.com/immich-app/immich/pull/24490>
|
||||
* fix(docs): typo in maintenance mode command by @bartvanvelden in <https://github.com/immich-app/immich/pull/24518>
|
||||
|
||||
### 🌐 Translations
|
||||
|
||||
* chore: add new language requests by @danieldietzler in <https://github.com/immich-app/immich/pull/23991>
|
||||
|
||||
## New Contributors
|
||||
|
||||
* @ujjwal123123 made their first contribution in <https://github.com/immich-app/immich/pull/24101>
|
||||
* @lutostag made their first contribution in <https://github.com/immich-app/immich/pull/23333>
|
||||
* @LukaPrebil made their first contribution in <https://github.com/immich-app/immich/pull/24045>
|
||||
* @kimsey0 made their first contribution in <https://github.com/immich-app/immich/pull/24232>
|
||||
* @SaphuA made their first contribution in <https://github.com/immich-app/immich/pull/24240>
|
||||
* @dionysius made their first contribution in <https://github.com/immich-app/immich/pull/24215>
|
||||
* @NiklasvonM made their first contribution in <https://github.com/immich-app/immich/pull/24249>
|
||||
* @kao-byte made their first contribution in <https://github.com/immich-app/immich/pull/24098>
|
||||
* @carbonemys made their first contribution in <https://github.com/immich-app/immich/pull/24289>
|
||||
* @kiloomar made their first contribution in <https://github.com/immich-app/immich/pull/24372>
|
||||
* @fourthwall made their first contribution in <https://github.com/immich-app/immich/pull/24411>
|
||||
* @simonkub made their first contribution in <https://github.com/immich-app/immich/pull/24450>
|
||||
* @ArnyminerZ made their first contribution in <https://github.com/immich-app/immich/pull/21136>
|
||||
* @kurtmckee made their first contribution in <https://github.com/immich-app/immich/pull/24491>
|
||||
* @hubert-taieb made their first contribution in <https://github.com/immich-app/immich/pull/24094>
|
||||
* @bartvanvelden made their first contribution in <https://github.com/immich-app/immich/pull/24518>
|
||||
|
||||
**Full Changelog**: <https://github.com/immich-app/immich/compare/v2.3.1...v2.4.0>
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### 🐛 Bug fixes
|
||||
* fix(maintenance): prevent enable/disable maintenance CLI hanging on occasion by @insertish in https://github.com/immich-app/immich/pull/24713
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/immich-app/immich/compare/v2.4.1...v2.4.2
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.105",
|
||||
"version": "2.2.106",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.4.2",
|
||||
"url": "https://docs.v2.4.2.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.4.1",
|
||||
"url": "https://docs.v2.4.1.archive.immich.app"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.4.1"
|
||||
version = "2.4.2"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.10,<4.0"
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3030,
|
||||
"android.injected.version.name" => "2.4.1",
|
||||
"android.injected.version.code" => 3031,
|
||||
"android.injected.version.name" => "2.4.2",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.4.1
|
||||
- API version: 2.4.2
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.4.1+3030
|
||||
version: 2.4.2+3031
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -14268,7 +14268,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.2",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.2",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.4.1
|
||||
* 2.4.2
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -37,7 +37,13 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
|
||||
|
||||
afterInit(websocketServer: 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]) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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 { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||
|
||||
@Injectable()
|
||||
export class AppRepository {
|
||||
@@ -17,4 +22,26 @@ export class AppRepository {
|
||||
setCloseFn(fn: () => Promise<void>) {
|
||||
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,6 +89,7 @@ describe(CliService.name, () => {
|
||||
alreadyDisabled: true,
|
||||
});
|
||||
|
||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -99,6 +100,7 @@ describe(CliService.name, () => {
|
||||
alreadyDisabled: false,
|
||||
});
|
||||
|
||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: false,
|
||||
});
|
||||
@@ -114,6 +116,7 @@ describe(CliService.name, () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -126,6 +129,7 @@ describe(CliService.name, () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: true,
|
||||
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 { SystemMetadataKey } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance';
|
||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
|
||||
import { getExternalDomain } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
@@ -55,8 +55,7 @@ export class CliService extends BaseService {
|
||||
|
||||
const state = { isMaintenanceMode: false as const };
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
|
||||
|
||||
sendOneShotAppRestart(state);
|
||||
await this.appRepository.sendOneShotAppRestart(state);
|
||||
|
||||
return {
|
||||
alreadyDisabled: false,
|
||||
@@ -89,7 +88,7 @@ export class CliService extends BaseService {
|
||||
secret,
|
||||
});
|
||||
|
||||
sendOneShotAppRestart({
|
||||
await this.appRepository.sendOneShotAppRestart({
|
||||
isMaintenanceMode: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { MaintenanceModeState } from 'src/types';
|
||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
|
||||
@@ -31,7 +32,10 @@ export class MaintenanceService extends BaseService {
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AppRestart', server: true })
|
||||
onRestart(): void {
|
||||
onRestart(event: ArgOf<'AppRestart'>, ack?: (ok: 'ok') => void): void {
|
||||
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
|
||||
|
||||
ack?.('ok');
|
||||
this.appRepository.exitApp();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +1,6 @@
|
||||
import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import Redis from 'ioredis';
|
||||
import { SignJWT } from 'jose';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Server as SocketIO } from 'socket.io';
|
||||
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(
|
||||
baseUrl: string,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.2",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/;
|
||||
|
||||
export enum AssetAction {
|
||||
ARCHIVE = 'archive',
|
||||
UNARCHIVE = 'unarchive',
|
||||
@@ -22,9 +20,7 @@ export enum AssetAction {
|
||||
|
||||
export enum AppRoute {
|
||||
ADMIN_USERS = '/admin/users',
|
||||
ADMIN_USERS_NEW = '/admin/users/new',
|
||||
ADMIN_LIBRARIES = '/admin/library-management',
|
||||
ADMIN_LIBRARIES_NEW = '/admin/library-management/new',
|
||||
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
|
||||
ADMIN_SETTINGS = '/admin/system-settings',
|
||||
ADMIN_STATS = '/admin/server-status',
|
||||
ADMIN_QUEUES = '/admin/queues',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { handleCreateLibrary } from '$lib/services/library.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { searchUsersAdmin } from '@immich/sdk';
|
||||
@@ -10,6 +8,12 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let ownerId: string = $state($user.id);
|
||||
|
||||
let userOptions: { value: string; text: string }[] = $state([]);
|
||||
@@ -19,14 +23,10 @@
|
||||
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
|
||||
});
|
||||
|
||||
const onClose = async () => {
|
||||
await goto(AppRoute.ADMIN_LIBRARIES);
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const library = await handleCreateLibrary({ ownerId });
|
||||
if (library) {
|
||||
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`, { replaceState: true });
|
||||
const success = await handleCreateLibrary({ ownerId });
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
41
web/src/lib/modals/LibraryRenameModal.svelte
Normal file
41
web/src/lib/modals/LibraryRenameModal.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<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,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { handleCreateUserAdmin } from '$lib/services/user-admin.service';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
@@ -20,6 +18,12 @@
|
||||
} from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let success = $state(false);
|
||||
|
||||
let email = $state('');
|
||||
@@ -42,10 +46,6 @@
|
||||
const passwordMismatchMessage = $derived(passwordMismatch ? $t('password_does_not_match') : '');
|
||||
const valid = $derived(!passwordMismatch && !isCreatingUser);
|
||||
|
||||
const onClose = async () => {
|
||||
await goto(AppRoute.ADMIN_USERS);
|
||||
};
|
||||
|
||||
const onSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
isCreatingUser = true;
|
||||
|
||||
const user = await handleCreateUserAdmin({
|
||||
const success = await handleCreateUserAdmin({
|
||||
email,
|
||||
password,
|
||||
shouldChangePassword,
|
||||
@@ -65,8 +65,8 @@
|
||||
isAdmin,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
await goto(`${AppRoute.ADMIN_USERS}/${user.id}`, { replaceState: true });
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
isCreatingUser = false;
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { handleUpdateUserAdmin } from '$lib/services/user-admin.service';
|
||||
import { user as authUser } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
|
||||
import type { UserAdminResponseDto } from '@immich/sdk';
|
||||
import { type UserAdminResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
@@ -21,23 +20,22 @@
|
||||
} from '@immich/ui';
|
||||
import { mdiAccountEditOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
interface Props {
|
||||
user: UserAdminResponseDto;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
let { user, onClose }: Props = $props();
|
||||
|
||||
const user = $derived(data.user as UserAdminResponseDto);
|
||||
let isAdmin = $derived(user.isAdmin);
|
||||
let name = $derived(user.name);
|
||||
let email = $derived(user.email);
|
||||
let storageLabel = $derived(user.storageLabel || '');
|
||||
|
||||
const previousQuota = $derived(user.quotaSizeInBytes);
|
||||
const previousQuota = user.quotaSizeInBytes;
|
||||
|
||||
let quotaSize = $derived(
|
||||
let quotaSize = $state(
|
||||
typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined,
|
||||
);
|
||||
|
||||
@@ -50,10 +48,6 @@
|
||||
quotaSizeBytes > userInteraction.serverInfo.diskSizeRaw,
|
||||
);
|
||||
|
||||
const onClose = async () => {
|
||||
await goto(`${AppRoute.ADMIN_USERS}/${user.id}`);
|
||||
};
|
||||
|
||||
const onSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -66,7 +60,7 @@
|
||||
});
|
||||
|
||||
if (success) {
|
||||
await onClose();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1,10 +1,12 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import LibraryCreateModal from '$lib/modals/LibraryCreateModal.svelte';
|
||||
import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte';
|
||||
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
|
||||
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
|
||||
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
|
||||
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
@@ -17,7 +19,6 @@ import {
|
||||
updateLibrary,
|
||||
type CreateLibraryDto,
|
||||
type LibraryResponseDto,
|
||||
type UpdateLibraryDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||
@@ -37,7 +38,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
title: $t('create_library'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onAction: () => goto(AppRoute.ADMIN_LIBRARIES_NEW),
|
||||
onAction: () => handleShowLibraryCreateModal(),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
};
|
||||
|
||||
@@ -45,11 +46,11 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
};
|
||||
|
||||
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
|
||||
const Edit: ActionItem = {
|
||||
const Rename: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
title: $t('edit'),
|
||||
onAction: () => goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}/edit`),
|
||||
title: $t('rename'),
|
||||
onAction: () => modalManager.show(LibraryRenameModal, { library }),
|
||||
shortcuts: { key: 'r' },
|
||||
};
|
||||
|
||||
@@ -84,7 +85,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
};
|
||||
|
||||
return { Edit, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||
};
|
||||
|
||||
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
|
||||
@@ -149,7 +150,7 @@ const handleScanLibrary = async (library: LibraryResponseDto) => {
|
||||
};
|
||||
|
||||
export const handleViewLibrary = async (library: LibraryResponseDto) => {
|
||||
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
|
||||
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
||||
};
|
||||
|
||||
export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
|
||||
@@ -159,24 +160,33 @@ export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
|
||||
const library = await createLibrary({ createLibraryDto: dto });
|
||||
eventManager.emit('LibraryCreate', library);
|
||||
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
|
||||
return library;
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create_library'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const handleUpdateLibrary = async (library: LibraryResponseDto, dto: UpdateLibraryDto) => {
|
||||
export const handleRenameLibrary = async (library: { id: string }, name?: string) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto });
|
||||
const updatedLibrary = await updateLibrary({
|
||||
id: library.id,
|
||||
updateLibraryDto: { name },
|
||||
});
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDeleteLibrary = async (library: LibraryResponseDto) => {
|
||||
@@ -347,3 +357,7 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleShowLibraryCreateModal = async () => {
|
||||
await modalManager.show(LibraryCreateModal, {});
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
|
||||
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
|
||||
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
|
||||
import UserEditModal from '$lib/modals/UserEditModal.svelte';
|
||||
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
|
||||
import { user as authUser } from '$lib/stores/user.store';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
@@ -38,7 +39,7 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
|
||||
title: $t('create_user'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onAction: () => goto(AppRoute.ADMIN_USERS_NEW),
|
||||
onAction: () => modalManager.show(UserCreateModal, {}),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
};
|
||||
|
||||
@@ -49,7 +50,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
||||
const Update: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
title: $t('edit'),
|
||||
onAction: () => goto(`${AppRoute.ADMIN_USERS}/${user.id}/edit`),
|
||||
onAction: () => modalManager.show(UserEditModal, { user }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
@@ -102,7 +103,7 @@ export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
|
||||
const response = await createUserAdmin({ userAdminCreateDto: dto });
|
||||
eventManager.emit('UserAdminCreate', response);
|
||||
toastManager.success();
|
||||
return response;
|
||||
return true;
|
||||
} catch (error) {
|
||||
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="flex flex-col pt-8 pe-4 gap-1">
|
||||
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARIES} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
|
||||
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
|
||||
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { UUID_REGEX } from '$lib/constants';
|
||||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
/* Returns true if the given param matches UUID format */
|
||||
export const match: ParamMatcher = (param: string) => {
|
||||
return UUID_REGEX.test(param);
|
||||
return /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/.test(param);
|
||||
};
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
title: $t('external_libraries'),
|
||||
description: $t('admin.external_libraries_page_description'),
|
||||
icon: mdiBookshelf,
|
||||
onAction: () => goto(AppRoute.ADMIN_LIBRARIES),
|
||||
onAction: () => goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT),
|
||||
},
|
||||
{
|
||||
title: $t('server_stats'),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
@@ -4,29 +4,27 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { getLibrariesActions, handleViewLibrary } from '$lib/services/library.service';
|
||||
import { getLibrariesActions, handleShowLibraryCreateModal, handleViewLibrary } from '$lib/services/library.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, CommandPaletteContext } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { LayoutData } from './$types';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
children?: Snippet;
|
||||
data: LayoutData;
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
let { data }: Props = $props();
|
||||
|
||||
let libraries = $state(data.libraries);
|
||||
let statistics = $state(data.statistics);
|
||||
let owners = $state(data.owners);
|
||||
|
||||
const onLibraryCreate = async (library: LibraryResponseDto) => {
|
||||
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
|
||||
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
||||
};
|
||||
|
||||
const onLibraryUpdate = async (library: LibraryResponseDto) => {
|
||||
@@ -102,12 +100,10 @@
|
||||
{:else}
|
||||
<EmptyPlaceholder
|
||||
text={$t('no_libraries_message')}
|
||||
onClick={() => goto(AppRoute.ADMIN_LIBRARIES_NEW)}
|
||||
onClick={handleShowLibraryCreateModal}
|
||||
class="mt-10 mx-auto"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</section>
|
||||
</AdminPageLayout>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk';
|
||||
import type { LayoutLoad } from './$types';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
@@ -26,4 +26,4 @@ export const load = (async ({ url }) => {
|
||||
title: $t('external_libraries'),
|
||||
},
|
||||
};
|
||||
}) satisfies LayoutLoad;
|
||||
}) satisfies PageLoad;
|
||||
@@ -1,118 +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 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>
|
||||
@@ -1,30 +0,0 @@
|
||||
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;
|
||||
@@ -0,0 +1,105 @@
|
||||
<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,13 +1,26 @@
|
||||
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 { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
export const load = (async ({ params: { id }, url }) => {
|
||||
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();
|
||||
|
||||
return {
|
||||
library,
|
||||
statistics,
|
||||
meta: {
|
||||
title: $t('admin.library_details'),
|
||||
},
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<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>
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
@@ -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, 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,14 +0,0 @@
|
||||
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;
|
||||
93
web/src/routes/admin/users/+page.svelte
Normal file
93
web/src/routes/admin/users/+page.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, 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>
|
||||
@@ -1,18 +1,18 @@
|
||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { searchUsersAdmin } from '@immich/sdk';
|
||||
import type { LayoutLoad } from './$types';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
await requestServerInfo();
|
||||
const users = await searchUsersAdmin({ withDeleted: true });
|
||||
const allUsers = await searchUsersAdmin({ withDeleted: true });
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
users,
|
||||
allUsers,
|
||||
meta: {
|
||||
title: $t('admin.user_management'),
|
||||
},
|
||||
};
|
||||
}) satisfies LayoutLoad;
|
||||
}) satisfies PageLoad;
|
||||
@@ -1,222 +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 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>
|
||||
@@ -1,38 +0,0 @@
|
||||
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;
|
||||
@@ -0,0 +1,218 @@
|
||||
<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,12 +1,31 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { AppRoute } 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 { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
export const load = (async ({ params, url }) => {
|
||||
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();
|
||||
|
||||
return {
|
||||
user,
|
||||
userPreferences,
|
||||
userStatistics,
|
||||
userSessions,
|
||||
meta: {
|
||||
title: $t('admin.user_details'),
|
||||
},
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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