Compare commits

..

1 Commits

Author SHA1 Message Date
jrasm91
f1834c3d7c chore: release v2.4.2 2025-12-19 18:22:19 +00:00
42 changed files with 666 additions and 599 deletions

173
CHANGELOG.md Normal file
View File

@@ -0,0 +1,173 @@
# 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.
![](/api/attachments.redirect?id=3e4d661e-4015-4b04-b8f3-a055e9d4db01 " =1071x758")
### 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!
![](/api/attachments.redirect?id=d6bc1387-f2cb-45c1-9287-4ee8176dfb41 " =748x652")
### 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.
![](/api/attachments.redirect?id=97a90b16-8b08-4292-bcb5-46aa75c01043 " =1033x199")
### 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.
![](/api/attachments.redirect?id=f68f8af5-2aaf-4625-821f-6c129da7fed3 " =342x741")
## 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 -->
**Full Changelog**: https://github.com/immich-app/immich/compare/v2.4.1...v2.4.2
---

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.105", "version": "2.2.106",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",

View File

@@ -1,4 +1,8 @@
[ [
{
"label": "v2.4.2",
"url": "https://docs.v2.4.2.archive.immich.app"
},
{ {
"label": "v2.4.1", "label": "v2.4.1",
"url": "https://docs.v2.4.1.archive.immich.app" "url": "https://docs.v2.4.1.archive.immich.app"

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "2.4.1", "version": "2.4.2",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "immich-ml" name = "immich-ml"
version = "2.4.1" version = "2.4.2"
description = "" description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0" requires-python = ">=3.10,<4.0"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3030, "android.injected.version.code" => 3031,
"android.injected.version.name" => "2.4.1", "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') 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')

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: 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 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 2.4.1+3030 version: 2.4.2+3031
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'

View File

@@ -14268,7 +14268,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "2.4.1", "version": "2.4.2",
"contact": {} "contact": {}
}, },
"tags": [ "tags": [

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "2.4.1", "version": "2.4.2",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 2.4.1 * 2.4.2
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "2.4.1", "version": "2.4.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "2.4.1", "version": "2.4.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -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 { export enum AssetAction {
ARCHIVE = 'archive', ARCHIVE = 'archive',
UNARCHIVE = 'unarchive', UNARCHIVE = 'unarchive',
@@ -22,9 +20,7 @@ export enum AssetAction {
export enum AppRoute { export enum AppRoute {
ADMIN_USERS = '/admin/users', ADMIN_USERS = '/admin/users',
ADMIN_USERS_NEW = '/admin/users/new', ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
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',

View File

@@ -1,7 +1,5 @@
<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';
@@ -10,6 +8,12 @@
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([]);
@@ -19,14 +23,10 @@
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 library = await handleCreateLibrary({ ownerId }); const success = await handleCreateLibrary({ ownerId });
if (library) { if (success) {
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`, { replaceState: true }); onClose();
} }
}; };
</script> </script>

View 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>

View File

@@ -1,6 +1,4 @@
<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';
@@ -20,6 +18,12 @@
} 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('');
@@ -42,10 +46,6 @@
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 user = await handleCreateUserAdmin({ const success = await handleCreateUserAdmin({
email, email,
password, password,
shouldChangePassword, shouldChangePassword,
@@ -65,8 +65,8 @@
isAdmin, isAdmin,
}); });
if (user) { if (success) {
await goto(`${AppRoute.ADMIN_USERS}/${user.id}`, { replaceState: true }); onClose();
} }
isCreatingUser = false; isCreatingUser = false;

View File

@@ -1,11 +1,10 @@
<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,
@@ -21,23 +20,22 @@
} 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';
type Props = { interface Props {
data: PageData; 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 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 = $derived(user.quotaSizeInBytes); const previousQuota = user.quotaSizeInBytes;
let quotaSize = $derived( let quotaSize = $state(
typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined, typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined,
); );
@@ -50,10 +48,6 @@
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();
@@ -66,7 +60,7 @@
}); });
if (success) { if (success) {
await onClose(); onClose();
} }
}; };
</script> </script>

View File

@@ -1,10 +1,12 @@
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 {
@@ -17,7 +19,6 @@ 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';
@@ -37,7 +38,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: () => goto(AppRoute.ADMIN_LIBRARIES_NEW), onAction: () => handleShowLibraryCreateModal(),
shortcuts: { shift: true, key: 'n' }, shortcuts: { shift: true, key: 'n' },
}; };
@@ -45,11 +46,11 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
}; };
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => { export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
const Edit: ActionItem = { const Rename: ActionItem = {
icon: mdiPencilOutline, icon: mdiPencilOutline,
type: $t('command'), type: $t('command'),
title: $t('edit'), title: $t('rename'),
onAction: () => goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}/edit`), onAction: () => modalManager.show(LibraryRenameModal, { library }),
shortcuts: { key: 'r' }, shortcuts: { key: 'r' },
}; };
@@ -84,7 +85,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
shortcuts: { shift: true, key: 'r' }, 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) => { export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
@@ -149,7 +150,7 @@ const handleScanLibrary = async (library: LibraryResponseDto) => {
}; };
export const handleViewLibrary = 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) => { export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
@@ -159,24 +160,33 @@ 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 library; return true;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_create_library')); 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(); const $t = await getFormatter();
if (!name) {
return false;
}
try { try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto }); const updatedLibrary = await updateLibrary({
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) => {
@@ -347,3 +357,7 @@ 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, {});
};

View File

@@ -1,9 +1,10 @@
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';
@@ -38,7 +39,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: () => goto(AppRoute.ADMIN_USERS_NEW), onAction: () => modalManager.show(UserCreateModal, {}),
shortcuts: { shift: true, key: 'n' }, shortcuts: { shift: true, key: 'n' },
}; };
@@ -49,7 +50,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: () => goto(`${AppRoute.ADMIN_USERS}/${user.id}/edit`), onAction: () => modalManager.show(UserEditModal, { user }),
}; };
const Delete: ActionItem = { const Delete: ActionItem = {
@@ -102,7 +103,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 response; return true;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_create_user')); handleError(error, $t('errors.unable_to_create_user'));
} }

View File

@@ -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_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('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} />

View File

@@ -1,7 +1,6 @@
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 UUID_REGEX.test(param); return /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/.test(param);
}; };

View File

@@ -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_LIBRARIES), onAction: () => goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT),
}, },
{ {
title: $t('server_stats'), title: $t('server_stats'),

View File

@@ -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;

View File

@@ -4,29 +4,27 @@
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, handleViewLibrary } from '$lib/services/library.service'; import { getLibrariesActions, handleShowLibraryCreateModal, 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 { PageData } from './$types';
type Props = { type Props = {
children?: Snippet;
data: PageData; data: PageData;
}; };
let { children, data }: Props = $props(); let { 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_LIBRARIES}/${library.id}`); await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
}; };
const onLibraryUpdate = async (library: LibraryResponseDto) => { const onLibraryUpdate = async (library: LibraryResponseDto) => {
@@ -102,12 +100,10 @@
{:else} {:else}
<EmptyPlaceholder <EmptyPlaceholder
text={$t('no_libraries_message')} text={$t('no_libraries_message')}
onClick={() => goto(AppRoute.ADMIN_LIBRARIES_NEW)} onClick={handleShowLibraryCreateModal}
class="mt-10 mx-auto" class="mt-10 mx-auto"
/> />
{/if} {/if}
{@render children?.()}
</div> </div>
</section> </section>
</AdminPageLayout> </AdminPageLayout>

View File

@@ -1,119 +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;
console.log(library.name);
}
};
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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -1,13 +1,26 @@
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 ({ url }) => { export const load = (async ({ params: { id }, 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'),
}, },

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -7,30 +7,28 @@
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk'; import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, CommandPaletteContext, Icon } from '@immich/ui'; import { Button, CommandPaletteContext, Icon } from '@immich/ui';
import { mdiInfinity } from '@mdi/js'; import { mdiInfinity } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { LayoutData } from './$types'; import type { PageData } from './$types';
type Props = { type Props = {
children?: Snippet; data: PageData;
data: LayoutData;
}; };
let { children, data }: Props = $props(); let { data }: Props = $props();
let users: UserAdminResponseDto[] = $state(data.users); let allUsers: UserAdminResponseDto[] = $state(data.allUsers);
const onUpdate = async (user: UserAdminResponseDto) => { const onUpdate = async (user: UserAdminResponseDto) => {
const index = users.findIndex(({ id }) => id === user.id); const index = allUsers.findIndex(({ id }) => id === user.id);
if (index === -1) { if (index === -1) {
users = await searchUsersAdmin({ withDeleted: true }); allUsers = await searchUsersAdmin({ withDeleted: true });
} else { } else {
users[index] = user; allUsers[index] = user;
} }
}; };
const onUserAdminDeleted = ({ id: userId }: { id: string }) => { const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
users = users.filter(({ id }) => id !== userId); allUsers = allUsers.filter(({ id }) => id !== userId);
}; };
const { Create } = $derived(getUserAdminsActions($t)); const { Create } = $derived(getUserAdminsActions($t));
@@ -62,7 +60,7 @@
</tr> </tr>
</thead> </thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray"> <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each users as user (user.id)} {#each allUsers as user (user.id)}
<tr <tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt 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' ? 'bg-red-300 dark:bg-red-900'
@@ -93,5 +91,3 @@
</section> </section>
</section> </section>
</AdminPageLayout> </AdminPageLayout>
{@render children?.()}

View File

@@ -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 { LayoutLoad } from './$types'; import type { PageLoad } 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 users = await searchUsersAdmin({ withDeleted: true }); const allUsers = await searchUsersAdmin({ withDeleted: true });
const $t = await getFormatter(); const $t = await getFormatter();
return { return {
users, allUsers,
meta: { meta: {
title: $t('admin.user_management'), title: $t('admin.user_management'),
}, },
}; };
}) satisfies LayoutLoad; }) satisfies PageLoad;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 { 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 ({ url }) => { export const load = (async ({ params, 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'),
}, },

View File

@@ -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;