Compare commits

...

14 Commits

Author SHA1 Message Date
Thomas Way d416e225e6 feat: sort smart search
Smart search currently returns a list of assets by their score. It would
be nice if we could instead filter assets, and then list them by date.
This is the default behaviour of other platforms.
2026-02-24 10:28:56 +00:00
Mert 1d25267f22 fix(mobile): buffer width/height referenced after recycling (#26415)
recycle after getters
2026-02-21 09:41:44 -06:00
Michel Heusschen a4d95b7aba fix(web): prevent side panel overlap during transition (#26398) 2026-02-21 09:14:53 -06:00
Min Idzelis 25d0bdc9f5 chore: replace remaining usages of npm with pnpm (#26411) 2026-02-21 08:44:33 -05:00
Michel Heusschen 905b9bd560 fix(web): album description auto height (#26420) 2026-02-21 08:43:23 -05:00
Michel Heusschen 672743f543 fix(web): escape handling on album page (#26419) 2026-02-21 08:42:31 -05:00
Michel Heusschen 27c45b5ddb fix(web): restore close action for asset viewer (#26418) 2026-02-21 10:31:30 +00:00
Peter Ombodi 82c6302549 feat(mobile): timeline - add persistentBottomBar flag (#25634)
* feat(mobile): timeline - add selectable all-assets control

* feature(mobile): introduce bottomWidgetBuilder in Timeline
remove redundant code

* fix(mobile): remove redundant code

* refactor(mobile): refactor new code in Timeline

* fix(mobile): fix format

* refactor(mobile): replace unsupported Dart syntax for analyzer compatibility

* refactor(mobile): remove Timeline.bottomSheet and migrate to bottomWidgetBuilder

* refactor(mobile): restore Timeline.bottomSheet and remove bottomWidgetBuilder
add withPersistentBottomBar param to Timeline class

* refactor(mobile): refactor var name

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-02-20 23:51:26 +05:30
Min Idzelis aae64b5e2f test: thumbnail selector (#26383)
* test: face ordering issue/flakiness

* test: thumbnail selector
2026-02-20 15:04:17 +00:00
Benjamin Nguyen 18bf96b4b2 fix(mobile): handle userPreferencesProvider error state during sync (#26332)
fix drift_search_page render bug
2026-02-20 08:57:28 -06:00
Timon 84f2956941 fix(cli): delete sidecar files after upload if requested (#26353)
* fix(cli): delete sidecar files after upload if requested

Introduced a new function, findSidecar, to locate XMP sidecar files based on specified naming conventions. Updated the deleteFiles function to delete associated sidecar files when the main asset file is deleted. Added unit tests for findSidecar to ensure correct functionality.

* lint and format

* fix test

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-20 14:54:08 +00:00
Min Idzelis 6044b41648 fix: align devcontainers with standard development containers (#26321) 2026-02-20 09:37:07 -05:00
Min Idzelis b4e16efdf4 test: face ordering issue/flakiness (#26382) 2026-02-20 09:23:40 -05:00
Min Idzelis 19da655390 fix: exiftool-vendored.exe (#26393) 2026-02-20 09:16:42 -05:00
45 changed files with 942 additions and 245 deletions
+3 -24
View File
@@ -2,6 +2,7 @@
"name": "Immich - Backend, Frontend and ML",
"service": "immich-server",
"runServices": [
"immich-init",
"immich-server",
"redis",
"database",
@@ -31,29 +32,8 @@
"tasks": {
"version": "2.0.0",
"tasks": [
{
"label": "Fix Permissions, Install Dependencies",
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0",
"isBackground": true,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "dedicated",
"showReuseMessage": true,
"clear": false,
"group": "Devcontainer tasks",
"close": true
},
"runOptions": {
"runOn": "default"
},
"problemMatcher": []
},
{
"label": "Immich API Server (Nest)",
"dependsOn": ["Fix Permissions, Install Dependencies"],
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0",
"isBackground": true,
@@ -74,7 +54,6 @@
},
{
"label": "Immich Web Server (Vite)",
"dependsOn": ["Fix Permissions, Install Dependencies"],
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0",
"isBackground": true,
@@ -130,8 +109,8 @@
}
},
"overrideCommand": true,
"workspaceFolder": "/workspaces/immich",
"remoteUser": "node",
"workspaceFolder": "/usr/src/app",
"remoteUser": "root",
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
// The location where your uploaded files are stored
@@ -1,23 +1,17 @@
services:
immich-app-base:
image: busybox
immich-server:
extends:
service: immich-app-base
profiles: !reset []
image: immich-server-dev:latest
build:
target: dev-container-mobile
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override # bind mount host to /workspaces/immich
- ..:/workspaces/immich
volumes:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- /etc/localtime:/etc/localtime:ro
immich-web:
env_file: !reset []
+2 -1
View File
@@ -2,6 +2,7 @@
"name": "Immich - Mobile",
"service": "immich-server",
"runServices": [
"immich-init",
"immich-server",
"redis",
"database",
@@ -35,7 +36,7 @@
},
"forwardPorts": [],
"overrideCommand": true,
"workspaceFolder": "/workspaces/immich",
"workspaceFolder": "/usr/src/app",
"remoteUser": "node",
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
+1 -50
View File
@@ -2,11 +2,6 @@
export IMMICH_PORT="${DEV_SERVER_PORT:-2283}"
export DEV_PORT="${DEV_PORT:-3000}"
# search for immich directory inside workspace.
# /workspaces/immich is the bind mount, but other directories can be mounted if runing
# Devcontainer: Clone [repository|pull request] in container volumne
WORKSPACES_DIR="/workspaces"
IMMICH_DIR="$WORKSPACES_DIR/immich"
IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log"
log() {
@@ -30,52 +25,8 @@ run_cmd() {
return "${PIPESTATUS[0]}"
}
# Find directories excluding /workspaces/immich
mapfile -t other_dirs < <(find "$WORKSPACES_DIR" -mindepth 1 -maxdepth 1 -type d ! -path "$IMMICH_DIR" ! -name ".*")
if [ ${#other_dirs[@]} -gt 1 ]; then
log "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR."
exit 1
elif [ ${#other_dirs[@]} -eq 1 ]; then
export IMMICH_WORKSPACE="${other_dirs[0]}"
else
export IMMICH_WORKSPACE="$IMMICH_DIR"
fi
export IMMICH_WORKSPACE="/usr/src/app"
log "Found immich workspace in $IMMICH_WORKSPACE"
log ""
fix_permissions() {
log "Fixing permissions for ${IMMICH_WORKSPACE}"
# Change ownership for directories that exist
for dir in "${IMMICH_WORKSPACE}/.vscode" \
"${IMMICH_WORKSPACE}/server/upload" \
"${IMMICH_WORKSPACE}/.pnpm-store" \
"${IMMICH_WORKSPACE}/.github/node_modules" \
"${IMMICH_WORKSPACE}/cli/node_modules" \
"${IMMICH_WORKSPACE}/e2e/node_modules" \
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
"${IMMICH_WORKSPACE}/server/node_modules" \
"${IMMICH_WORKSPACE}/server/dist" \
"${IMMICH_WORKSPACE}/web/node_modules" \
"${IMMICH_WORKSPACE}/web/dist"; do
if [ -d "$dir" ]; then
run_cmd sudo chown node -R "$dir"
fi
done
log ""
}
install_dependencies() {
log "Installing dependencies"
(
cd "${IMMICH_WORKSPACE}" || exit 1
export CI=1 FROZEN=1 OFFLINE=1
run_cmd make setup-web-dev setup-server-dev
)
log ""
}
@@ -1,26 +1,21 @@
services:
immich-app-base:
image: busybox
immich-server:
extends:
service: immich-app-base
profiles: !reset []
image: immich-server-dev:latest
build:
target: dev-container-server
env_file: !reset []
hostname: immich-dev
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override
- ..:/workspaces/immich
volumes:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- pnpm_store_server:/buildcache/pnpm-store
- ../plugins:/build/corePlugin
immich-web:
env_file: !reset []
-17
View File
@@ -1,17 +0,0 @@
#!/bin/bash
# shellcheck source=common.sh
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Setting up Immich dev container..."
fix_permissions
log "Setup complete, please wait while backend and frontend services automatically start"
log
log "If necessary, the services may be manually started using"
log
log "$ /immich-devcontainer/container-start-backend.sh"
log "$ /immich-devcontainer/container-start-frontend.sh"
log
log "From different terminal windows, as these scripts automatically restart the server"
log "on error, and will continuously run in a loop"
+1 -1
View File
@@ -511,7 +511,7 @@ jobs:
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: npx playwright install chromium --only-shell
run: pnpm exec playwright install chromium --only-shell
if: ${{ !cancelled() }}
- name: Docker build
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
+11 -5
View File
@@ -4,12 +4,18 @@ module.exports = {
if (!pkg.name) {
return pkg;
}
// make exiftool-vendored.pl a regular dependency since Docker prod
// images build with --no-optional to reduce image size
if (pkg.name === "exiftool-vendored") {
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
// make exiftool-vendored.pl a regular dependency
pkg.dependencies["exiftool-vendored.pl"] =
pkg.optionalDependencies["exiftool-vendored.pl"];
delete pkg.optionalDependencies["exiftool-vendored.pl"];
const binaryPackage =
process.platform === "win32"
? "exiftool-vendored.exe"
: "exiftool-vendored.pl";
if (pkg.optionalDependencies[binaryPackage]) {
pkg.dependencies[binaryPackage] =
pkg.optionalDependencies[binaryPackage];
delete pkg.optionalDependencies[binaryPackage];
}
}
return pkg;
+1 -1
View File
@@ -52,7 +52,7 @@ attach-server:
docker exec -it docker_immich-server_1 sh
renovate:
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
LOG_LEVEL=debug pnpm exec renovate --platform=local --repository-cache=reset
# Directories that need to be created for volumes or build output
VOLUME_DIRS = \
+2 -2
View File
@@ -45,8 +45,8 @@
"build": "vite build",
"build:dev": "vite build --sourcemap true",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"prepack": "npm run build",
"lint:fix": "pnpm run lint --fix",
"prepack": "pnpm run build",
"test": "vitest",
"test:cov": "vitest --coverage",
"format": "prettier --check .",
+91 -1
View File
@@ -7,7 +7,15 @@ import { describe, expect, it, MockedFunction, vi } from 'vitest';
import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk';
import createFetchMock from 'vitest-fetch-mock';
import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset';
import {
checkForDuplicates,
deleteFiles,
findSidecar,
getAlbumName,
startWatch,
uploadFiles,
UploadOptionsDto,
} from 'src/commands/asset';
vi.mock('@immich/sdk');
@@ -309,3 +317,85 @@ describe('startWatch', () => {
await fs.promises.rm(testFolder, { recursive: true, force: true });
});
});
describe('findSidecar', () => {
let testDir: string;
let testFilePath: string;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-sidecar-'));
testFilePath = path.join(testDir, 'test.jpg');
fs.writeFileSync(testFilePath, 'test');
});
afterEach(() => {
fs.rmSync(testDir, { recursive: true, force: true });
});
it('should find sidecar file with photo.xmp naming convention', () => {
const sidecarPath = path.join(testDir, 'test.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
const result = findSidecar(testFilePath);
expect(result).toBe(sidecarPath);
});
it('should find sidecar file with photo.ext.xmp naming convention', () => {
const sidecarPath = path.join(testDir, 'test.jpg.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
const result = findSidecar(testFilePath);
expect(result).toBe(sidecarPath);
});
it('should prefer photo.ext.xmp over photo.xmp when both exist', () => {
const sidecarPath1 = path.join(testDir, 'test.xmp');
const sidecarPath2 = path.join(testDir, 'test.jpg.xmp');
fs.writeFileSync(sidecarPath1, 'xmp data 1');
fs.writeFileSync(sidecarPath2, 'xmp data 2');
const result = findSidecar(testFilePath);
// Should return the first one found (photo.xmp) based on the order in the code
expect(result).toBe(sidecarPath1);
});
it('should return undefined when no sidecar file exists', () => {
const result = findSidecar(testFilePath);
expect(result).toBeUndefined();
});
});
describe('deleteFiles', () => {
let testDir: string;
let testFilePath: string;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-delete-'));
testFilePath = path.join(testDir, 'test.jpg');
fs.writeFileSync(testFilePath, 'test');
});
afterEach(() => {
fs.rmSync(testDir, { recursive: true, force: true });
});
it('should delete asset and sidecar file when main file is deleted', async () => {
const sidecarPath = path.join(testDir, 'test.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: true, concurrency: 1 });
expect(fs.existsSync(testFilePath)).toBe(false);
expect(fs.existsSync(sidecarPath)).toBe(false);
});
it('should not delete sidecar file when delete option is false', async () => {
const sidecarPath = path.join(testDir, 'test.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: false, concurrency: 1 });
expect(fs.existsSync(testFilePath)).toBe(true);
expect(fs.existsSync(sidecarPath)).toBe(true);
});
});
+32 -22
View File
@@ -17,7 +17,7 @@ import { Matcher, watch as watchFs } from 'chokidar';
import { MultiBar, Presets, SingleBar } from 'cli-progress';
import { chunk } from 'lodash-es';
import micromatch from 'micromatch';
import { Stats, createReadStream } from 'node:fs';
import { Stats, createReadStream, existsSync } from 'node:fs';
import { stat, unlink } from 'node:fs/promises';
import path, { basename } from 'node:path';
import { Queue } from 'src/queue';
@@ -403,23 +403,6 @@ export const uploadFiles = async (
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
const { baseUrl, headers } = defaults;
const assetPath = path.parse(input);
const noExtension = path.join(assetPath.dir, assetPath.name);
const sidecarsFiles = await Promise.all(
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
[`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => {
try {
const stats = await stat(sidecarPath);
return new UploadFile(sidecarPath, stats.size);
} catch {
return false;
}
}),
);
const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false);
const formData = new FormData();
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
formData.append('deviceId', 'CLI');
@@ -429,8 +412,15 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
formData.append('isFavorite', 'false');
formData.append('assetData', new UploadFile(input, stats.size));
if (sidecarData) {
formData.append('sidecarData', sidecarData);
const sidecarPath = findSidecar(input);
if (sidecarPath) {
try {
const stats = await stat(sidecarPath);
const sidecarData = new UploadFile(sidecarPath, stats.size);
formData.append('sidecarData', sidecarData);
} catch {
// noop
}
}
const response = await fetch(`${baseUrl}/assets`, {
@@ -446,7 +436,19 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
return response.json();
};
const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
export const findSidecar = (filepath: string): string | undefined => {
const assetPath = path.parse(filepath);
const noExtension = path.join(assetPath.dir, assetPath.name);
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
for (const sidecarPath of [`${noExtension}.xmp`, `${filepath}.xmp`]) {
if (existsSync(sidecarPath)) {
return sidecarPath;
}
}
};
export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
let fileCount = 0;
if (options.delete) {
fileCount += uploaded.length;
@@ -474,7 +476,15 @@ const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: Uplo
const chunkDelete = async (files: Asset[]) => {
for (const assetBatch of chunk(files, options.concurrency)) {
await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath)));
await Promise.all(
assetBatch.map(async (input: Asset) => {
await unlink(input.filepath);
const sidecarPath = findSidecar(input.filepath);
if (sidecarPath) {
await unlink(sidecarPath);
}
}),
);
deletionProgress.update(assetBatch.length);
}
};
+1 -1
View File
@@ -8,7 +8,7 @@
"format:fix": "prettier --write .",
"start": "docusaurus start --port 3005",
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
"build": "npm run copy:openapi && docusaurus build",
"build": "pnpm run copy:openapi && docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
+7 -7
View File
@@ -8,16 +8,16 @@
"test": "vitest --run",
"test:watch": "vitest",
"test:maintenance": "vitest --run --config vitest.maintenance.config.ts",
"test:web": "npx playwright test --project=web",
"test:web:maintenance": "npx playwright test --project=maintenance",
"test:web:ui": "npx playwright test --project=ui",
"start:web": "npx playwright test --ui --project=web",
"start:web:maintenance": "npx playwright test --ui --project=maintenance",
"start:web:ui": "npx playwright test --ui --project=ui",
"test:web": "pnpm exec playwright test --project=web",
"test:web:maintenance": "pnpm exec playwright test --project=maintenance",
"test:web:ui": "pnpm exec playwright test --project=ui",
"start:web": "pnpm exec playwright test --ui --project=web",
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"lint:fix": "pnpm run lint --fix",
"check": "tsc --noEmit"
},
"keywords": [],
+2 -1
View File
@@ -253,7 +253,8 @@ describe('/asset', () => {
expect(status).toBe(200);
expect(body.id).toEqual(facesAsset.id);
expect(body.people).toMatchObject(expectedFaces);
const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name));
expect(sortedPeople).toMatchObject(expectedFaces);
});
});
+2 -5
View File
@@ -65,7 +65,7 @@ export const thumbnailUtils = {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
return page.locator('[data-thumbnail-focus-container][data-selected]');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
@@ -103,11 +103,8 @@ export const thumbnailUtils = {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
},
async expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected
await expect(
page.locator(
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
),
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {
+4
View File
@@ -689,6 +689,7 @@
"backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward",
"best_match": "Best match",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
"biometric_no_options": "No biometric options available",
@@ -1945,6 +1946,8 @@
"search_filter_media_type_title": "Select media type",
"search_filter_ocr": "Search by OCR",
"search_filter_people_title": "Select people",
"search_filter_sort_order": "Sort Order",
"search_filter_sort_order_title": "Select sort order",
"search_filter_star_rating": "Star Rating",
"search_filter_tags_title": "Select tags",
"search_for": "Search for",
@@ -2160,6 +2163,7 @@
"sort_modified": "Date modified",
"sort_newest": "Newest photo",
"sort_oldest": "Oldest photo",
"sort_order": "Sort order",
"sort_people_by_similarity": "Sort people by similarity",
"sort_recent": "Most recent photo",
"sort_title": "Title",
@@ -48,7 +48,6 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
try {
val buffer = NativeBuffer.wrap(pointer, size)
copyPixelsToBuffer(buffer)
recycle()
return mapOf(
"pointer" to pointer,
"width" to width.toLong(),
@@ -57,8 +56,9 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
)
} catch (e: Exception) {
NativeBuffer.free(pointer)
recycle()
throw e
} finally {
recycle()
}
}
@@ -37,6 +37,7 @@ class SearchApiRepository extends ApiRepository {
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
order: filter.order,
page: page,
size: 100,
),
@@ -62,6 +63,7 @@ class SearchApiRepository extends ApiRepository {
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
order: filter.order ?? AssetOrder.desc,
page: page,
size: 1000,
),
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:openapi/api.dart' show AssetOrder;
class SearchLocationFilter {
String? country;
@@ -221,6 +222,7 @@ class SearchFilter {
SearchDateFilter date;
SearchRatingFilter rating;
SearchDisplayFilters display;
AssetOrder? order;
// Enum
AssetType mediaType;
@@ -233,6 +235,7 @@ class SearchFilter {
this.language,
this.assetId,
this.tagIds,
this.order,
required this.people,
required this.location,
required this.camera,
@@ -279,6 +282,7 @@ class SearchFilter {
SearchDisplayFilters? display,
SearchRatingFilter? rating,
AssetType? mediaType,
AssetOrder? Function()? order,
}) {
return SearchFilter(
context: context ?? this.context,
@@ -295,12 +299,13 @@ class SearchFilter {
rating: rating ?? this.rating,
mediaType: mediaType ?? this.mediaType,
tagIds: tagIds ?? this.tagIds,
order: order != null ? order() : this.order,
);
}
@override
String toString() {
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId, order: $order)';
}
@override
@@ -320,7 +325,8 @@ class SearchFilter {
other.date == date &&
other.display == display &&
other.rating == rating &&
other.mediaType == mediaType;
other.mediaType == mediaType &&
other.order == order;
}
@override
@@ -338,6 +344,7 @@ class SearchFilter {
date.hashCode ^
display.hashCode ^
rating.hashCode ^
mediaType.hashCode;
mediaType.hashCode ^
order.hashCode;
}
}
+56 -3
View File
@@ -11,6 +11,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
@@ -23,6 +24,8 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
import 'package:immich_mobile/widgets/search/search_filter/sort_order_picker.dart';
import 'package:openapi/api.dart' show AssetOrder;
@RoutePage()
class SearchPage extends HookConsumerWidget {
@@ -56,6 +59,7 @@ class SearchPage extends HookConsumerWidget {
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final sortOrderCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
@@ -78,6 +82,8 @@ class SearchPage extends HookConsumerWidget {
}
isSearching.value = true;
ref.read(searchGroupByProvider.notifier).state =
filter.value.order != null ? GroupAssetsBy.day : GroupAssetsBy.none;
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
@@ -387,6 +393,37 @@ class SearchPage extends HookConsumerWidget {
);
}
// SORT ORDER
showSortOrderPicker() {
handleOnSelect(AssetOrder? value) {
filter.value = filter.value.copyWith(order: () => value);
if (value == null) {
sortOrderCurrentFilterWidget.value = null;
} else if (value == AssetOrder.desc) {
sortOrderCurrentFilterWidget.value = Text('newest_first'.tr(), style: context.textTheme.labelLarge);
} else {
sortOrderCurrentFilterWidget.value = Text('oldest_first'.tr(), style: context.textTheme.labelLarge);
}
}
handleClear() {
filter.value = filter.value.copyWith(order: () => null);
sortOrderCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_sort_order_title'.tr(),
onSearch: search,
onClear: handleClear,
child: SortOrderPicker(onSelect: handleOnSelect, order: filter.value.order),
),
);
}
handleTextSubmitted(String value) {
switch (textSearchType.value) {
case TextSearchType.context:
@@ -594,6 +631,12 @@ class SearchPage extends HookConsumerWidget {
label: 'search_filter_display_options'.tr(),
currentFilter: displayOptionCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.sort_outlined,
onTap: showSortOrderPicker,
label: 'search_filter_sort_order'.tr(),
currentFilter: sortOrderCurrentFilterWidget.value,
),
],
),
),
@@ -601,7 +644,11 @@ class SearchPage extends HookConsumerWidget {
if (isSearching.value)
const Expanded(child: Center(child: CircularProgressIndicator()))
else
SearchResultGrid(onScrollEnd: loadMoreSearchResult, isSearching: isSearching.value),
SearchResultGrid(
onScrollEnd: loadMoreSearchResult,
isSearching: isSearching.value,
dragScrollLabelEnabled: filter.value.order != null,
),
],
),
);
@@ -611,8 +658,14 @@ class SearchPage extends HookConsumerWidget {
class SearchResultGrid extends StatelessWidget {
final VoidCallback onScrollEnd;
final bool isSearching;
final bool dragScrollLabelEnabled;
const SearchResultGrid({super.key, required this.onScrollEnd, this.isSearching = false});
const SearchResultGrid({
super.key,
required this.onScrollEnd,
this.isSearching = false,
this.dragScrollLabelEnabled = false,
});
@override
Widget build(BuildContext context) {
@@ -640,7 +693,7 @@ class SearchResultGrid extends StatelessWidget {
editEnabled: true,
favoriteEnabled: true,
stackEnabled: false,
dragScrollLabelEnabled: false,
dragScrollLabelEnabled: dragScrollLabelEnabled,
emptyIndicator: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: !isSearching ? const SearchEmptyContent() : const SizedBox.shrink(),
@@ -698,7 +698,7 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_location'.t(context: context),
currentFilter: locationCurrentFilterWidget.value,
),
if (userPreferences.value?.tagsEnabled ?? false)
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
SearchFilterChip(
icon: Icons.sell_outlined,
onTap: showTagPicker,
@@ -724,14 +724,13 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
if (userPreferences.value?.ratingsEnabled ?? false) ...[
if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
SearchFilterChip(
icon: Icons.star_outline_rounded,
onTap: showStarRatingPicker,
label: 'search_filter_star_rating'.t(context: context),
currentFilter: ratingCurrentFilterWidget.value,
),
],
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
@@ -74,6 +74,7 @@ class Timeline extends StatefulWidget {
this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false,
this.persistentBottomBar = false,
});
final Widget? topSliverWidget;
@@ -87,6 +88,7 @@ class Timeline extends StatefulWidget {
final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly;
final bool persistentBottomBar;
@override
State<Timeline> createState() => _TimelineState();
@@ -143,6 +145,7 @@ class _TimelineState extends State<Timeline> {
appBar: widget.appBar,
bottomSheet: widget.bottomSheet,
withScrubber: widget.withScrubber,
persistentBottomBar: widget.persistentBottomBar,
snapToMonth: widget.snapToMonth,
initialScrollOffset: widget.initialScrollOffset,
),
@@ -173,6 +176,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
this.appBar,
this.bottomSheet,
this.withScrubber = true,
this.persistentBottomBar = false,
this.snapToMonth = true,
this.initialScrollOffset,
});
@@ -182,6 +186,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final Widget? appBar;
final Widget? bottomSheet;
final bool withScrubber;
final bool persistentBottomBar;
final bool snapToMonth;
final double? initialScrollOffset;
@@ -404,6 +409,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
final isBottomWidgetVisible =
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
return PopScope(
canPop: !isMultiSelectEnabled,
@@ -519,7 +527,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: Stack(
children: [
timeline,
if (!isSelectionMode && isMultiSelectEnabled) ...[
if (isMultiSelectStatusVisible)
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
@@ -528,8 +536,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: Center(child: _MultiSelectStatusButton()),
),
),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
if (isBottomWidgetVisible) widget.bottomSheet!,
],
),
),
@@ -8,6 +8,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'paginated_search.provider.g.dart';
final searchGroupByProvider = StateProvider<GroupAssetsBy>((ref) => GroupAssetsBy.none);
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
);
@@ -41,6 +43,7 @@ class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
@riverpod
Future<RenderList> paginatedSearchRenderList(Ref ref) {
final result = ref.watch(paginatedSearchProvider);
final groupBy = ref.watch(searchGroupByProvider);
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.getTimelineFromAssets(result.assets, GroupAssetsBy.none);
return timelineService.getTimelineFromAssets(result.assets, groupBy);
}
@@ -7,7 +7,7 @@ part of 'paginated_search.provider.dart';
// **************************************************************************
String _$paginatedSearchRenderListHash() =>
r'22d715ff7864e5a946be38322ce7813616f899c2';
r'bb1ea9153b2a186778420426f1fb1add6d6a9140';
/// See also [paginatedSearchRenderList].
@ProviderFor(paginatedSearchRenderList)
@@ -0,0 +1,42 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:openapi/api.dart' show AssetOrder;
enum _SortOption { bestMatch, newest, oldest }
class SortOrderPicker extends HookWidget {
const SortOrderPicker({super.key, required this.onSelect, this.order});
final Function(AssetOrder?) onSelect;
final AssetOrder? order;
@override
Widget build(BuildContext context) {
final selected = useState<_SortOption>(switch (order) {
AssetOrder.desc => _SortOption.newest,
AssetOrder.asc => _SortOption.oldest,
_ => _SortOption.bestMatch,
});
return RadioGroup<_SortOption>(
onChanged: (value) {
if (value == null) return;
selected.value = value;
onSelect(switch (value) {
_SortOption.bestMatch => null,
_SortOption.newest => AssetOrder.desc,
_SortOption.oldest => AssetOrder.asc,
});
},
groupValue: selected.value,
child: Column(
children: [
RadioListTile(title: const Text('best_match').tr(), value: _SortOption.bestMatch),
RadioListTile(title: const Text('newest_first').tr(), value: _SortOption.newest),
RadioListTile(title: const Text('oldest_first').tr(), value: _SortOption.oldest),
],
),
);
}
}
+19 -1
View File
@@ -30,6 +30,7 @@ class SmartSearchDto {
this.make,
this.model,
this.ocr,
this.order,
this.page,
this.personIds = const [],
this.query,
@@ -167,6 +168,15 @@ class SmartSearchDto {
///
String? ocr;
/// Sort order by date. If not provided, results are sorted by relevance.
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AssetOrder? order;
/// Page number
///
/// Minimum value: 1
@@ -338,6 +348,7 @@ class SmartSearchDto {
other.make == make &&
other.model == model &&
other.ocr == ocr &&
other.order == order &&
other.page == page &&
_deepEquality.equals(other.personIds, personIds) &&
other.query == query &&
@@ -377,6 +388,7 @@ class SmartSearchDto {
(make == null ? 0 : make!.hashCode) +
(model == null ? 0 : model!.hashCode) +
(ocr == null ? 0 : ocr!.hashCode) +
(order == null ? 0 : order!.hashCode) +
(page == null ? 0 : page!.hashCode) +
(personIds.hashCode) +
(query == null ? 0 : query!.hashCode) +
@@ -397,7 +409,7 @@ class SmartSearchDto {
(withExif == null ? 0 : withExif!.hashCode);
@override
String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, ocr=$ocr, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]';
String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, ocr=$ocr, order=$order, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -482,6 +494,11 @@ class SmartSearchDto {
} else {
// json[r'ocr'] = null;
}
if (this.order != null) {
json[r'order'] = this.order;
} else {
// json[r'order'] = null;
}
if (this.page != null) {
json[r'page'] = this.page;
} else {
@@ -599,6 +616,7 @@ class SmartSearchDto {
make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'),
ocr: mapValueOfType<String>(json, r'ocr'),
order: AssetOrder.fromJson(json[r'order']),
page: num.parse('${json[r'page']}'),
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
+8
View File
@@ -22063,6 +22063,14 @@
"description": "Filter by OCR text content",
"type": "string"
},
"order": {
"allOf": [
{
"$ref": "#/components/schemas/AssetOrder"
}
],
"description": "Sort order by date. If not provided, results are sorted by relevance."
},
"page": {
"description": "Page number",
"minimum": 1,
@@ -1893,6 +1893,8 @@ export type SmartSearchDto = {
model?: string | null;
/** Filter by OCR text content */
ocr?: string;
/** Sort order by date. If not provided, results are sorted by relevance. */
order?: AssetOrder;
/** Page number */
page?: number;
/** Filter by person IDs */
+1 -1
View File
@@ -11,7 +11,7 @@ overrides:
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0=
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
importers:
+4 -6
View File
@@ -27,16 +27,14 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
FROM dev AS dev-container-server
RUN apt-get update --allow-releaseinfo-change && \
apt-get install sudo inetutils-ping openjdk-21-jre-headless \
apt-get install inetutils-ping openjdk-21-jre-headless \
vim nano curl \
-y --no-install-recommends --fix-missing
RUN usermod -aG sudo node && \
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
mkdir -p /workspaces/immich
RUN mkdir -p /workspaces && \
ln -s /usr/src/app /workspaces/immich
RUN chown node:node -R /workspaces
COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
COPY --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
WORKDIR /workspaces/immich
+5 -5
View File
@@ -9,15 +9,15 @@
"build": "nest build",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"start": "npm run start:dev",
"start": "pnpm run start:dev",
"nest": "nest",
"start:dev": "nest start --watch --",
"start:debug": "nest start --debug 0.0.0.0:9230 --watch --",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"lint:fix": "pnpm run lint --fix",
"check": "tsc --noEmit",
"check:code": "npm run format && npm run lint && npm run check",
"check:all": "npm run check:code && npm run test:cov",
"check:code": "pnpm run format && pnpm run lint && pnpm run check",
"check:all": "pnpm run check:code && pnpm run test:cov",
"test": "vitest --config test/vitest.config.mjs",
"test:cov": "vitest --config test/vitest.config.mjs --coverage",
"test:medium": "vitest --config test/vitest.config.medium.mjs",
@@ -28,7 +28,7 @@
"migrations:run": "node ./dist/bin/migrations.js run",
"migrations:revert": "node ./dist/bin/migrations.js revert",
"schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'",
"schema:reset": "npm run schema:drop && npm run migrations:run",
"schema:reset": "pnpm run schema:drop && pnpm run migrations:run",
"sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js",
"email:dev": "email dev -p 3050 --dir src/emails"
+8
View File
@@ -237,6 +237,14 @@ export class SmartSearchDto extends BaseSearchWithResultsDto {
@Type(() => Number)
@Optional()
page?: number;
@ValidateEnum({
enum: AssetOrder,
name: 'AssetOrder',
optional: true,
description: 'Sort order by date. If not provided, results are sorted by relevance.',
})
order?: AssetOrder;
}
export class SearchPlacesDto {
+7 -1
View File
@@ -129,6 +129,7 @@ export type SmartSearchOptions = SearchDateOptions &
SearchEmbeddingOptions &
SearchExifOptions &
SearchOneToOneRelationOptions &
SearchOrderOptions &
SearchStatusOptions &
SearchUserIdOptions &
SearchPeopleOptions &
@@ -300,7 +301,12 @@ export class SearchRepository {
const items = await searchAssetBuilder(trx, options)
.selectAll('asset')
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
.$if(!options.orderDirection, (qb) => qb.orderBy(sql`smart_search.embedding <=> ${options.embedding}`))
.$if(!!options.orderDirection, (qb) =>
qb
.where(sql`(smart_search.embedding <=> ${options.embedding}) <= 0.9`)
.orderBy('asset.fileCreatedAt', options.orderDirection as OrderByDirection),
)
.limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size)
.execute();
+1 -1
View File
@@ -139,7 +139,7 @@ export class SearchService extends BaseService {
const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmart(
{ page, size },
{ ...dto, userIds: await userIds, embedding },
{ ...dto, userIds: await userIds, embedding, orderDirection: dto.order },
);
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
@@ -94,7 +94,7 @@
const sharedLink = getSharedLink();
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Cast, ...Object.values(Actions)])} />
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Close, Cast, ...Object.values(Actions)])} />
<div
class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200"
@@ -432,6 +432,12 @@
);
const { Tag } = $derived(getAssetActions($t, asset));
const showDetailPanel = $derived(
asset.hasMetadata &&
$slideshowState === SlideshowState.None &&
assetViewerManager.isShowDetailPanel &&
!assetViewerManager.isShowEditor,
);
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
@@ -571,25 +577,22 @@
</div>
{/if}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor}
{#if showDetailPanel || assetViewerManager.isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 w-90 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes"
>
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
</div>
{/if}
{#if assetViewerManager.isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="editor-panel"
class="row-start-1 row-span-4 w-100 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
translate="yes"
>
<EditorPanel {asset} onClose={closeEditor} />
{#if showDetailPanel}
<div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
</div>
{:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full">
<EditorPanel {asset} onClose={closeEditor} />
</div>
{/if}
</div>
{/if}
@@ -223,6 +223,7 @@
bind:this={element}
data-asset={asset.id}
data-thumbnail-focus-container
data-selected={selected ? true : undefined}
tabindex={0}
role="link"
>
@@ -0,0 +1,459 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, getNextAsset, getPreviousAsset, navigateToAsset } from '$lib/utils/asset-utils';
import { moveFocus } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error';
import type { CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
import { formatGroupTitle, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { modalManager, Text } from '@immich/ui';
import { DateTime } from 'luxon';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n';
type DateGroup = {
date: DateTime;
title: string;
assets: AssetResponseDto[];
geometry: CommonJustifiedLayout;
offsetTop: number;
};
type Props = {
assets: AssetResponseDto[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
viewport: Viewport;
onIntersected?: (() => void) | undefined;
onReload?: (() => void) | undefined;
slidingWindowOffset?: number;
};
let {
assets = $bindable(),
assetInteraction,
disableAssetSelect = false,
showArchiveIcon = false,
viewport,
onIntersected = undefined,
onReload = undefined,
slidingWindowOffset = 0,
}: Props = $props();
const HEADER_HEIGHT = 48;
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
function groupAssetsByDate(items: AssetResponseDto[]): DateGroup[] {
const groupEntries: { key: string; assets: AssetResponseDto[] }[] = [];
for (const asset of items) {
const date = DateTime.fromISO(asset.localDateTime, { zone: 'UTC' });
const key = date.toISODate() ?? 'unknown';
const last = groupEntries.at(-1);
if (last && last.key === key) {
last.assets.push(asset);
} else {
groupEntries.push({ key, assets: [asset] });
}
}
const groups: DateGroup[] = [];
let offsetTop = 0;
const rowWidth = Math.floor(viewport.width);
const rowHeight = rowWidth < 850 ? 100 : 235;
for (const { key, assets: groupAssets } of groupEntries) {
const date = DateTime.fromISO(key, { zone: 'local' });
const geometry = getJustifiedLayoutFromAssets(groupAssets, {
spacing: 2,
heightTolerance: 0.5,
rowHeight,
rowWidth,
});
groups.push({
date: date as DateTime<true>,
title: formatGroupTitle(date),
assets: groupAssets,
geometry,
offsetTop,
});
offsetTop += HEADER_HEIGHT + geometry.containerHeight;
}
return groups;
}
const dateGroups = $derived(groupAssetsByDate(assets));
const totalHeight = $derived(
dateGroups.length > 0
? dateGroups.at(-1)!.offsetTop + HEADER_HEIGHT + dateGroups.at(-1)!.geometry.containerHeight
: 0,
);
let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let scrollTop = $state(0);
let slidingWindow = $derived.by(() => {
const top = (scrollTop || 0) - slidingWindowOffset;
const bottom = top + viewport.height + slidingWindowOffset;
return { top, bottom };
});
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
let lastIntersectedHeight = 0;
$effect(() => {
if (totalHeight - slidingWindow.bottom <= viewport.height && lastIntersectedHeight !== totalHeight) {
debouncedOnIntersected();
lastIntersectedHeight = totalHeight;
}
});
function isGroupVisible(group: DateGroup): boolean {
const groupTop = group.offsetTop;
const groupBottom = groupTop + HEADER_HEIGHT + group.geometry.containerHeight;
return groupTop < slidingWindow.bottom && groupBottom > slidingWindow.top;
}
function isAssetVisible(group: DateGroup, assetIndex: number): boolean {
const assetTop = group.offsetTop + HEADER_HEIGHT + group.geometry.getTop(assetIndex);
const assetBottom = assetTop + group.geometry.getHeight(assetIndex);
return assetTop < slidingWindow.bottom && assetBottom > slidingWindow.top;
}
const selectAllAssets = () => {
assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a)));
};
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
const handleSelectAssets = (asset: TimelineAsset) => {
if (!asset) {
return;
}
const deselect = assetInteraction.hasSelectedAsset(asset.id);
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.selectAsset(candidate);
}
assetInteraction.selectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
let start = assets.findIndex((a) => a.id === startAsset.id);
let end = assets.findIndex((a) => a.id === endAsset.id);
if (start > end) {
[start, end] = [end, start];
}
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1).map((a) => toTimelineAsset(a)));
};
const onSelectStart = (event: Event) => {
if (assetInteraction.selectionActive && shiftKeyIsDown) {
event.preventDefault();
}
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const trashOrDelete = async (force: boolean = false) => {
const forceOrNoTrash = force || !featureFlagsManager.value.trash;
const selectedAssets = assetInteraction.selectedAssets;
if ($showDeleteModal && forceOrNoTrash) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: selectedAssets.length });
if (!confirmed) {
return;
}
}
await deleteAssets(
forceOrNoTrash,
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
selectedAssets,
onReload,
);
assetInteraction.clearMultiselect();
};
const toggleArchive = async () => {
const ids = await archiveAssets(
assetInteraction.selectedAssets,
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
);
if (ids) {
assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets();
}
};
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
return [];
}
const sc: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
];
if (assetInteraction.selectionActive) {
sc.push(
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return sc;
})(),
);
const handleRandom = async (): Promise<{ id: string } | undefined> => {
if (assets.length === 0) {
return;
}
try {
const randomIndex = Math.floor(Math.random() * assets.length);
const asset = assets[randomIndex];
await navigateToAsset(asset);
return asset;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_next_asset'));
return;
}
};
const updateCurrentAsset = (asset: AssetResponseDto) => {
const index = assets.findIndex((oldAsset) => oldAsset.id === asset.id);
assets[index] = asset;
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets.splice(
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
1,
);
if (assets.length === 0) {
return await goto(Route.photos());
}
if (nextAsset) {
await navigateToAsset(nextAsset);
}
break;
}
}
};
const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset);
}
};
$effect(() => {
if (!lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (!shiftKeyIsDown) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (shiftKeyIsDown && lastAssetMouseEvent) {
selectAssetCandidates(lastAssetMouseEvent);
}
});
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
});
</script>
<svelte:document
onkeydown={onKeyDown}
onkeyup={onKeyUp}
onselectstart={onSelectStart}
use:shortcuts={shortcutList}
onscroll={() => updateSlidingWindow()}
/>
{#if assets.length > 0}
<div style:position="relative" style:height={totalHeight + 'px'} style:width={viewport.width + 'px'}>
{#each dateGroups as group (group.date.toISODate())}
{#if isGroupVisible(group)}
<!-- Date header -->
<div
class="absolute flex items-center px-2"
style:top={group.offsetTop + 'px'}
style:height={HEADER_HEIGHT + 'px'}
style:width="100%"
>
<Text fontWeight="medium" class="text-sm md:text-base">{group.title}</Text>
</div>
<!-- Thumbnails -->
<div
class="absolute"
style:top={group.offsetTop + HEADER_HEIGHT + 'px'}
style:height={group.geometry.containerHeight + 'px'}
style:width={group.geometry.containerWidth + 'px'}
>
{#each group.assets as asset, i (asset.id)}
{#if isAssetVisible(group, i)}
{@const currentAsset = toTimelineAsset(asset)}
<div
class="absolute"
style:overflow="clip"
style:top={group.geometry.getTop(i) + 'px'}
style:left={group.geometry.getLeft(i) + 'px'}
style:width={group.geometry.getWidth(i) + 'px'}
style:height={group.geometry.getHeight(i) + 'px'}
>
<Thumbnail
readonly={disableAssetSelect}
onClick={() => {
if (assetInteraction.selectionActive) {
handleSelectAssets(currentAsset);
return;
}
void navigateToAsset(asset);
}}
onSelect={() => handleSelectAssets(currentAsset)}
onMouseEvent={() => assetMouseEventHandler(currentAsset)}
{showArchiveIcon}
asset={currentAsset}
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
thumbnailWidth={group.geometry.getWidth(i)}
thumbnailHeight={group.geometry.getHeight(i)}
/>
</div>
{/if}
{/each}
</div>
{/if}
{/each}
</div>
{/if}
<!-- Overlay Asset Viewer -->
{#if $isViewerOpen}
<Portal target="body">
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
cursor={assetCursor}
onAction={handleAction}
onRandom={handleRandom}
onAssetChange={updateCurrentAsset}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
{/await}
</Portal>
{/if}
@@ -0,0 +1,41 @@
<script lang="ts">
import RadioButton from '$lib/elements/RadioButton.svelte';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
sortOrder: 'best-match' | 'newest' | 'oldest';
}
let { sortOrder = $bindable() }: Props = $props();
</script>
<div id="sort-order-selection">
<fieldset>
<Text class="mb-2" fontWeight="medium">{$t('sort_order')}</Text>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
<RadioButton
name="sort-order"
id="sort-best-match"
bind:group={sortOrder}
label={$t('best_match')}
value="best-match"
/>
<RadioButton
name="sort-order"
id="sort-newest"
bind:group={sortOrder}
label={$t('newest_first')}
value="newest"
/>
<RadioButton
name="sort-order"
id="sort-oldest"
bind:group={sortOrder}
label={$t('oldest_first')}
value="oldest"
/>
</div>
</fieldset>
</div>
+1 -2
View File
@@ -3,7 +3,6 @@
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import {
getAlbumActions,
handleRemoveUserFromAlbum,
@@ -56,7 +55,7 @@
sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id);
};
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album, AlbumPageViewMode.OPTIONS));
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
let sharedLinks: SharedLinkResponseDto[] = $state([]);
+17 -1
View File
@@ -16,6 +16,7 @@
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number;
sortOrder: 'best-match' | 'newest' | 'oldest';
};
</script>
@@ -28,13 +29,14 @@
import SearchLocationSection from '$lib/components/shared-components/search-bar/search-location-section.svelte';
import SearchMediaSection from '$lib/components/shared-components/search-bar/search-media-section.svelte';
import SearchPeopleSection from '$lib/components/shared-components/search-bar/search-people-section.svelte';
import SearchSortSection from '$lib/components/shared-components/search-bar/search-sort-section.svelte';
import SearchRatingsSection from '$lib/components/shared-components/search-bar/search-ratings-section.svelte';
import SearchTagsSection from '$lib/components/shared-components/search-bar/search-tags-section.svelte';
import SearchTextSection from '$lib/components/shared-components/search-bar/search-text-section.svelte';
import { preferences } from '$lib/stores/user.store';
import { parseUtcDate } from '$lib/utils/date-time';
import { generateId } from '$lib/utils/generate-id';
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { AssetOrder, AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiTune } from '@mdi/js';
import type { DateTime } from 'luxon';
@@ -111,6 +113,12 @@
? MediaType.Video
: MediaType.All,
rating: searchQuery.rating,
sortOrder:
'order' in searchQuery && searchQuery.order
? searchQuery.order === AssetOrder.Asc
? 'oldest'
: 'newest'
: 'best-match',
});
const resetForm = () => {
@@ -130,6 +138,7 @@
},
mediaType: MediaType.All,
rating: undefined,
sortOrder: 'best-match',
};
};
@@ -143,6 +152,9 @@
const query = filter.query || undefined;
const order =
filter.sortOrder === 'newest' ? AssetOrder.Desc : filter.sortOrder === 'oldest' ? AssetOrder.Asc : undefined;
let payload: SmartSearchDto | MetadataSearchDto = {
query: filter.queryType === 'smart' ? query : undefined,
ocr: filter.queryType === 'ocr' ? query : undefined,
@@ -163,6 +175,7 @@
tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
type,
rating: filter.rating,
order,
};
onClose(payload);
@@ -218,6 +231,9 @@
<!-- DISPLAY OPTIONS -->
<SearchDisplaySection bind:filters={filter.display} />
<!-- SORT ORDER -->
<SearchSortSection bind:sortOrder={filter.sortOrder} />
</div>
</div>
</form>
+3 -13
View File
@@ -1,6 +1,5 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -32,7 +31,7 @@ import {
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiArrowLeft, mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
@@ -46,7 +45,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => {
return { Create };
};
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, viewMode: AlbumPageViewMode) => {
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
const isOwned = get(user).id === album.ownerId;
const Share: ActionItem = {
@@ -73,16 +72,7 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, v
onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }),
};
const Close: ActionItem = {
title: $t('go_back'),
type: $t('command'),
icon: mdiArrowLeft,
onAction: () => goto(Route.albums()),
$if: () => viewMode === AlbumPageViewMode.VIEW,
shortcuts: { key: 'Escape' },
};
return { Share, AddUsers, CreateSharedLink, Close };
return { Share, AddUsers, CreateSharedLink };
};
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {
@@ -127,10 +127,6 @@
await handleCloseSelectAssets();
return;
}
if (viewMode === AlbumPageViewMode.OPTIONS) {
viewMode = AlbumPageViewMode.VIEW;
return;
}
if ($showAssetViewer) {
return;
}
@@ -138,7 +134,7 @@
cancelMultiselect(assetInteraction);
return;
}
return;
await goto(Route.albums());
};
const refreshAlbum = async () => {
@@ -305,14 +301,24 @@
return;
}
album.albumUsers = album.albumUsers.map((albumUser) =>
const albumUsers = album.albumUsers.map((albumUser) =>
albumUser.user.id === userId ? { ...albumUser, role } : albumUser,
);
album = { ...album, albumUsers };
};
const { Cast } = $derived(getGlobalActions($t));
const { Share, Close } = $derived(getAlbumActions($t, album, viewMode));
const { Share } = $derived(getAlbumActions($t, album));
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets));
const Close = $derived({
title: $t('go_back'),
type: $t('command'),
icon: mdiArrowLeft,
onAction: handleEscape,
$if: () => !$showAssetViewer,
shortcuts: { key: 'Escape' },
});
</script>
<OnEvents
@@ -352,7 +358,7 @@
id={album.id}
albumName={album.albumName}
{isOwned}
onUpdate={(albumName) => (album.albumName = albumName)}
onUpdate={(albumName) => (album = { ...album, albumName })}
/>
{#if album.assetCount > 0}
@@ -401,8 +407,11 @@
<ActionButton action={Share} />
</div>
{/if}
<!-- ALBUM DESCRIPTION -->
<AlbumDescription id={album.id} bind:description={album.description} {isOwned} />
<AlbumDescription
id={album.id}
{isOwned}
bind:description={() => album.description, (description) => (album = { ...album, description })}
/>
</section>
{/if}
@@ -5,6 +5,7 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import DateGroupedGalleryViewer from '$lib/components/shared-components/gallery-viewer/date-grouped-gallery-viewer.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
@@ -68,6 +69,7 @@
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
let isSortedByDate = $derived(!!terms.order);
const isAllUserOwned = $derived(
$user && assetInteraction.selectedAssets.every((asset) => asset.ownerId === $user.id),
@@ -196,6 +198,7 @@
description: $t('description'),
queryAssetId: $t('query_asset_id'),
ocr: $t('ocr'),
order: $t('sort_order'),
};
return keyMap[key] || key;
}
@@ -296,15 +299,27 @@
>
<section id="search-content">
{#if searchResultAssets.length > 0}
<GalleryViewer
assets={searchResultAssets}
{assetInteraction}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}
onReload={onSearchQueryUpdate}
slidingWindowOffset={searchResultsElement.offsetTop}
/>
{#if isSortedByDate}
<DateGroupedGalleryViewer
assets={searchResultAssets}
{assetInteraction}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}
onReload={onSearchQueryUpdate}
slidingWindowOffset={searchResultsElement.offsetTop}
/>
{:else}
<GalleryViewer
assets={searchResultAssets}
{assetInteraction}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}
onReload={onSearchQueryUpdate}
slidingWindowOffset={searchResultsElement.offsetTop}
/>
{/if}
{:else if !isLoading}
<div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white">
<div class="flex flex-col content-center items-center text-center">