mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 01:11:46 +03:00
Compare commits
10 Commits
fix/mainte
...
release/ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1834c3d7c | ||
|
|
5b80323326 | ||
|
|
1425b3da6b | ||
|
|
3d2196b0f2 | ||
|
|
50d7956c07 | ||
|
|
22d3fd3b92 | ||
|
|
a469e86b32 | ||
|
|
138c9232df | ||
|
|
2e1f8625ec | ||
|
|
f7cbb7417c |
173
CHANGELOG.md
Normal file
173
CHANGELOG.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
### 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 -->
|
||||
|
||||
|
||||
|
||||
**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.104",
|
||||
"version": "2.2.106",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
8
docs/static/archived-versions.json
vendored
8
docs/static/archived-versions.json
vendored
@@ -1,4 +1,12 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"label": "v2.4.0",
|
||||
"url": "https://docs.v2.4.0.archive.immich.app"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.4.0"
|
||||
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" => 3029,
|
||||
"android.injected.version.name" => "2.4.0",
|
||||
"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.0
|
||||
- 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.0+3029
|
||||
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.0",
|
||||
"version": "2.4.2",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.4.0",
|
||||
"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.0
|
||||
* 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.0",
|
||||
"version": "2.4.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -7,14 +7,22 @@ export const ImmichFooter = () => (
|
||||
<Column align="center" className="w-6/12 sm:w-full">
|
||||
<div>
|
||||
<Link href="https://play.google.com/store/apps/details?id=app.alextran.immich" className="object-contain">
|
||||
<Img className="max-w-full" src={`https://immich.app/img/google-play-badge.png`} />
|
||||
<Img
|
||||
alt="Get it on Google Play"
|
||||
className="max-w-full"
|
||||
src={`https://immich.app/img/google-play-badge.png`}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</Column>
|
||||
<Column align="center" className="w-6/12 sm:w-full">
|
||||
<div className="h-full p-6">
|
||||
<Link href="https://apps.apple.com/sg/app/immich/id1613945652">
|
||||
<Img src={`https://immich.app/img/ios-app-store-badge.png`} alt="Immich" className="max-w-full" />
|
||||
<Img
|
||||
alt="Download on the App Store"
|
||||
className="max-w-full"
|
||||
src={`https://immich.app/img/ios-app-store-badge.png`}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</Column>
|
||||
|
||||
@@ -144,14 +144,28 @@ export class AssetService extends BaseService {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
|
||||
|
||||
const assetDto = _.omitBy({ isFavorite, visibility, duplicateId }, _.isUndefined);
|
||||
const exifDto = _.omitBy({ latitude, longitude, rating, description, dateTimeOriginal }, _.isUndefined);
|
||||
const exifDto = _.omitBy(
|
||||
{
|
||||
latitude,
|
||||
longitude,
|
||||
rating,
|
||||
description,
|
||||
dateTimeOriginal,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
|
||||
|
||||
if (Object.keys(exifDto).length > 0) {
|
||||
await this.assetRepository.updateAllExif(ids, exifDto);
|
||||
}
|
||||
|
||||
if ((dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined) {
|
||||
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone);
|
||||
if (
|
||||
(dateTimeRelative !== undefined && dateTimeRelative !== 0) ||
|
||||
timeZone !== undefined ||
|
||||
extractedTimeZone?.type === 'fixed'
|
||||
) {
|
||||
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone ?? extractedTimeZone?.name);
|
||||
}
|
||||
|
||||
if (Object.keys(assetDto).length > 0) {
|
||||
@@ -436,7 +450,19 @@ export class AssetService extends BaseService {
|
||||
rating?: number;
|
||||
}) {
|
||||
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
|
||||
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
|
||||
const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
|
||||
const writes = _.omitBy(
|
||||
{
|
||||
description,
|
||||
dateTimeOriginal,
|
||||
timeZone: extractedTimeZone?.type === 'fixed' ? extractedTimeZone.name : undefined,
|
||||
latitude,
|
||||
longitude,
|
||||
rating,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
|
||||
if (Object.keys(writes).length > 0) {
|
||||
await this.assetRepository.upsertExif(
|
||||
updateLockedColumns({
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
const { ctx } = newMediumService(BaseService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
return { ctx, sut: ctx.get(AssetRepository) };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(AssetRepository.name, () => {
|
||||
describe('upsertExif', () => {
|
||||
it('should append to locked columns', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({
|
||||
assetId: asset.id,
|
||||
dateTimeOriginal: '2023-11-19T18:11:00',
|
||||
lockedProperties: ['dateTimeOriginal'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
|
||||
|
||||
await sut.upsertExif(
|
||||
{ assetId: asset.id, lockedProperties: ['description'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
|
||||
});
|
||||
|
||||
it('should deduplicate locked columns', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({
|
||||
assetId: asset.id,
|
||||
dateTimeOriginal: '2023-11-19T18:11:00',
|
||||
lockedProperties: ['dateTimeOriginal', 'description'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] });
|
||||
|
||||
await sut.upsertExif(
|
||||
{ assetId: asset.id, lockedProperties: ['description'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -270,13 +270,13 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update dateTimeOriginal', async () => {
|
||||
it('should automatically lock lockable columns', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||
await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
@@ -285,7 +285,14 @@ describe(AssetService.name, () => {
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: null });
|
||||
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||
|
||||
await sut.update(auth, asset.id, {
|
||||
latitude: 42,
|
||||
longitude: 42,
|
||||
rating: 3,
|
||||
description: 'foo',
|
||||
dateTimeOriginal: '2023-11-19T18:11:00+01:00',
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
@@ -293,16 +300,83 @@ describe(AssetService.name, () => {
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
|
||||
).resolves.toEqual({
|
||||
lockedProperties: ['timeZone', 'rating', 'description', 'latitude', 'longitude', 'dateTimeOriginal'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should update dateTimeOriginal', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||
|
||||
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00' }),
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dateTimeOriginal with time zone', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||
|
||||
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAll', () => {
|
||||
it('should automatically lock lockable columns', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: null });
|
||||
|
||||
await sut.updateAll(auth, {
|
||||
ids: [asset.id],
|
||||
latitude: 42,
|
||||
description: 'foo',
|
||||
longitude: 42,
|
||||
rating: 3,
|
||||
dateTimeOriginal: '2023-11-19T18:11:00+01:00',
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({
|
||||
lockedProperties: ['timeZone', 'rating', 'description', 'latitude', 'longitude', 'dateTimeOriginal'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should relatively update assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
@@ -313,13 +387,6 @@ describe(AssetService.name, () => {
|
||||
|
||||
await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -11 });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] });
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({
|
||||
@@ -328,5 +395,39 @@ describe(AssetService.name, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dateTimeOriginal', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||
|
||||
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dateTimeOriginal with time zone', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||
|
||||
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.2",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
33
web/src/lib/components/AdminCard.svelte
Normal file
33
web/src/lib/components/AdminCard.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import { Card, CardBody, CardHeader, CardTitle, Icon, type ActionItem, type IconLike } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
icon: IconLike;
|
||||
title: string;
|
||||
headerAction?: ActionItem;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
const { icon, title, headerAction, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex w-full justify-between items-center px-4 py-2">
|
||||
<div class="flex gap-2 text-primary">
|
||||
<Icon {icon} size="1.5rem" />
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</div>
|
||||
{#if headerAction}
|
||||
<HeaderActionButton action={headerAction} />
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -308,18 +308,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
|
||||
<IconButton
|
||||
aria-label={$t('show_search_options')}
|
||||
shape="round"
|
||||
icon={mdiTune}
|
||||
onclick={onFilterClick}
|
||||
size="medium"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if searchStore.isSearchEnabled}
|
||||
<div
|
||||
id={searchTypeId}
|
||||
@@ -327,7 +315,7 @@
|
||||
class:max-md:hidden={value}
|
||||
class:end-28={value.length > 0}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="relative" use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}>
|
||||
<Button
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onclick={toggleSearchTypeDropdown}
|
||||
@@ -340,11 +328,11 @@
|
||||
{#if showSearchTypeDropdown}
|
||||
<div
|
||||
class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999"
|
||||
use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}
|
||||
>
|
||||
{#each searchTypes as searchType (searchType.value)}
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
|
||||
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
|
||||
onclick={() => selectSearchType(searchType.value)}
|
||||
@@ -358,6 +346,18 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
|
||||
<IconButton
|
||||
aria-label={$t('show_search_options')}
|
||||
shape="round"
|
||||
icon={mdiTune}
|
||||
onclick={onFilterClick}
|
||||
size="medium"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showClearIcon}
|
||||
<div class="absolute inset-y-0 end-0 flex items-center pe-2">
|
||||
<IconButton
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<Text size="small" class="mb-4">{$t('api_key_description')}</Text>
|
||||
<Textarea bind:value={secret} readonly />
|
||||
<Textarea bind:value={secret} readonly class="font-mono" />
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
|
||||
@@ -71,6 +71,34 @@ describe('DateSelectionModal component', () => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not fall back to UTC when datetime-local value has no seconds', async () => {
|
||||
render(AssetSelectionChangeDateModal, {
|
||||
props: { initialDate, initialTimeZone, assets: [], onClose },
|
||||
});
|
||||
|
||||
await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00' } });
|
||||
await fireEvent.blur(getDateInput());
|
||||
|
||||
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
|
||||
|
||||
await fireEvent.focus(getTimeZoneInput());
|
||||
expect(screen.queryByText('no_results')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not fall back to UTC when datetime-local value has no milliseconds', async () => {
|
||||
render(AssetSelectionChangeDateModal, {
|
||||
props: { initialDate, initialTimeZone, assets: [], onClose },
|
||||
});
|
||||
|
||||
await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00:00' } });
|
||||
await fireEvent.blur(getDateInput());
|
||||
|
||||
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
|
||||
|
||||
await fireEvent.focus(getTimeZoneInput());
|
||||
expect(screen.queryByText('no_results')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('when date is in daylight saving time', () => {
|
||||
const dstDate = DateTime.fromISO('2024-07-01');
|
||||
|
||||
|
||||
44
web/src/lib/modals/LibraryCreateModal.svelte
Normal file
44
web/src/lib/modals/LibraryCreateModal.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { handleCreateLibrary } from '$lib/services/library.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { searchUsersAdmin } from '@immich/sdk';
|
||||
import { FormModal, Text } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
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([]);
|
||||
|
||||
onMount(async () => {
|
||||
const users = await searchUsersAdmin({});
|
||||
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const success = await handleCreateLibrary({ ownerId });
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
title={$t('create_library')}
|
||||
icon={mdiFolderSync}
|
||||
{onClose}
|
||||
size="small"
|
||||
{onSubmit}
|
||||
submitText={$t('create')}
|
||||
>
|
||||
<SettingSelect label={$t('owner')} bind:value={ownerId} options={userOptions} name="user" />
|
||||
<Text color="warning" size="small">{$t('admin.note_cannot_be_changed_later')}</Text>
|
||||
</FormModal>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { searchUsersAdmin } from '@immich/sdk';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onClose: (ownerId?: string) => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let ownerId: string = $state($user.id);
|
||||
|
||||
let userOptions: { value: string; text: string }[] = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
const users = await searchUsersAdmin({});
|
||||
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
|
||||
});
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onClose(ownerId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('select_library_owner')} icon={mdiFolderSync} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="select-library-owner-form">
|
||||
<p class="p-5 text-sm">{$t('admin.note_cannot_be_changed_later')}</p>
|
||||
|
||||
<SettingSelect bind:value={ownerId} options={userOptions} name="user" />
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button shape="round" type="submit" fullWidth form="select-library-owner-form">{$t('create')}</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -75,8 +75,15 @@ function zoneOptionForDate(zone: string, date: string) {
|
||||
// Ignore milliseconds:
|
||||
// - milliseconds are not relevant for TZ calculations
|
||||
// - browsers strip insignificant .000 making string comparison with milliseconds more fragile.
|
||||
//
|
||||
// Also, some browsers emit `datetime-local` values without seconds when seconds are 00,
|
||||
// e.g. `2024-01-01T00:00` instead of `2024-01-01T00:00:00.000`.
|
||||
// In that case we must compare with minute precision (otherwise every zone looks "invalid").
|
||||
const dateInTimezone = DateTime.fromISO(date, { zone });
|
||||
const exists = date.replace(/\.\d+/, '') === dateInTimezone.toFormat("yyyy-MM-dd'T'HH:mm:ss");
|
||||
const withoutMillis = date.replace(/\.\d+/, '');
|
||||
const hasSeconds = /T\d{2}:\d{2}:\d{2}$/.test(withoutMillis);
|
||||
const compareFormat = hasSeconds ? "yyyy-MM-dd'T'HH:mm:ss" : "yyyy-MM-dd'T'HH:mm";
|
||||
const exists = withoutMillis === dateInTimezone.toFormat(compareFormat);
|
||||
const valid = dateInTimezone.isValid && exists;
|
||||
return {
|
||||
value: zone,
|
||||
|
||||
@@ -1,12 +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 LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
runQueueCommandLegacy,
|
||||
scanLibrary,
|
||||
updateLibrary,
|
||||
type CreateLibraryDto,
|
||||
type LibraryResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
@@ -37,7 +38,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
title: $t('create_library'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onAction: () => handleCreateLibrary(),
|
||||
onAction: () => handleShowLibraryCreateModal(),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
};
|
||||
|
||||
@@ -152,20 +153,17 @@ export const handleViewLibrary = async (library: LibraryResponseDto) => {
|
||||
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
||||
};
|
||||
|
||||
export const handleCreateLibrary = async () => {
|
||||
export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const ownerId = await modalManager.show(LibraryUserPickerModal, {});
|
||||
if (!ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } });
|
||||
eventManager.emit('LibraryCreate', createdLibrary);
|
||||
toastManager.success($t('admin.library_created', { values: { library: createdLibrary.name } }));
|
||||
const library = await createLibrary({ createLibraryDto: dto });
|
||||
eventManager.emit('LibraryCreate', library);
|
||||
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create_library'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -359,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, {});
|
||||
};
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { getLibrariesActions, handleCreateLibrary, 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, getUserAdmin, type LibraryResponseDto } from '@immich/sdk';
|
||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, CommandPaletteContext } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
data: PageData;
|
||||
}
|
||||
};
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
@@ -23,15 +23,11 @@
|
||||
let statistics = $state(data.statistics);
|
||||
let owners = $state(data.owners);
|
||||
|
||||
const handleLibraryAdd = async (library: LibraryResponseDto) => {
|
||||
statistics[library.id] = await getLibraryStatistics({ id: library.id });
|
||||
owners[library.id] = await getUserAdmin({ id: library.ownerId });
|
||||
libraries.push(library);
|
||||
|
||||
const onLibraryCreate = async (library: LibraryResponseDto) => {
|
||||
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
||||
};
|
||||
|
||||
const handleLibraryUpdate = async (library: LibraryResponseDto) => {
|
||||
const onLibraryUpdate = async (library: LibraryResponseDto) => {
|
||||
const index = libraries.findIndex(({ id }) => id === library.id);
|
||||
|
||||
if (index === -1) {
|
||||
@@ -42,7 +38,7 @@
|
||||
statistics[library.id] = await getLibraryStatistics({ id: library.id });
|
||||
};
|
||||
|
||||
const handleDeleteLibrary = ({ id }: { id: string }) => {
|
||||
const onLibraryDelete = ({ id }: { id: string }) => {
|
||||
libraries = libraries.filter((library) => library.id !== id);
|
||||
delete statistics[id];
|
||||
delete owners[id];
|
||||
@@ -51,11 +47,7 @@
|
||||
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
|
||||
</script>
|
||||
|
||||
<OnEvents
|
||||
onLibraryCreate={handleLibraryAdd}
|
||||
onLibraryUpdate={handleLibraryUpdate}
|
||||
onLibraryDelete={handleDeleteLibrary}
|
||||
/>
|
||||
<OnEvents {onLibraryCreate} {onLibraryUpdate} {onLibraryDelete} />
|
||||
|
||||
<CommandPaletteContext commands={[Create, ScanAll]} />
|
||||
|
||||
@@ -106,7 +98,11 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<EmptyPlaceholder text={$t('no_libraries_message')} onClick={handleCreateLibrary} class="mt-10 mx-auto" />
|
||||
<EmptyPlaceholder
|
||||
text={$t('no_libraries_message')}
|
||||
onClick={handleShowLibraryCreateModal}
|
||||
class="mt-10 mx-auto"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
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';
|
||||
@@ -15,18 +15,7 @@
|
||||
getLibraryFolderActions,
|
||||
} from '$lib/services/library.service';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Code,
|
||||
CommandPaletteContext,
|
||||
Container,
|
||||
Heading,
|
||||
Icon,
|
||||
modalManager,
|
||||
} from '@immich/ui';
|
||||
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';
|
||||
@@ -64,18 +53,8 @@
|
||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
|
||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
|
||||
</div>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex w-full justify-between items-center px-4 py-2">
|
||||
<div class="flex gap-2 text-primary">
|
||||
<Icon icon={mdiFolderOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('folders')}</CardTitle>
|
||||
</div>
|
||||
<HeaderActionButton action={AddFolder} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
|
||||
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
|
||||
{#if library.importPaths.length === 0}
|
||||
<EmptyPlaceholder
|
||||
src={emptyFoldersUrl}
|
||||
@@ -101,21 +80,9 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex w-full justify-between items-center px-4 py-2">
|
||||
<div class="flex gap-2 text-primary">
|
||||
<Icon icon={mdiFilterMinusOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('exclusion_pattern')}</CardTitle>
|
||||
</div>
|
||||
<HeaderActionButton action={AddExclusionPattern} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
</AdminCard>
|
||||
|
||||
<AdminCard icon={mdiFilterMinusOutline} title={$t('exclusion_pattern')} headerAction={AddExclusionPattern}>
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
|
||||
@@ -132,9 +99,7 @@
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</AdminCard>
|
||||
</div>
|
||||
</Container>
|
||||
</AdminPageLayout>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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';
|
||||
@@ -15,10 +16,6 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Code,
|
||||
CommandPaletteContext,
|
||||
Container,
|
||||
@@ -131,16 +128,8 @@
|
||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
||||
<Icon icon={mdiAccountOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('profile')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
|
||||
<AdminCard icon={mdiAccountOutline} title={$t('profile')}>
|
||||
<Stack gap={2}>
|
||||
<div>
|
||||
<Heading tag="h3" size="tiny">{$t('name')}</Heading>
|
||||
@@ -163,19 +152,9 @@
|
||||
<Code>{user.id}</Code>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
||||
<Icon icon={mdiFeatureSearchOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('features')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-4">
|
||||
</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} />
|
||||
@@ -187,18 +166,9 @@
|
||||
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
|
||||
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
||||
<Icon icon={mdiChartPieOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('storage_quota')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-4">
|
||||
</AdminCard>
|
||||
|
||||
<AdminCard icon={mdiChartPieOutline} title={$t('storage_quota')}>
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
<Text>
|
||||
{$t('storage_usage', {
|
||||
@@ -214,7 +184,6 @@
|
||||
{$t('unlimited')}
|
||||
</Text>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
<div
|
||||
@@ -232,17 +201,9 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
||||
<Icon icon={mdiDevices} size="1.5rem" />
|
||||
<CardTitle>{$t('authorized_devices')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
</AdminCard>
|
||||
|
||||
<AdminCard icon={mdiDevices} title={$t('authorized_devices')}>
|
||||
<Stack gap={3}>
|
||||
{#each userSessions as session (session.id)}
|
||||
<DeviceCard {session} />
|
||||
@@ -250,9 +211,7 @@
|
||||
<span class="text-dark">{$t('no_devices')}</span>
|
||||
{/each}
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</AdminCard>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user