Compare commits

...

21 Commits

Author SHA1 Message Date
Jason Rasmussen
6e69052478 fix: album thumbnail refresh 2026-02-04 16:32:08 -05:00
Alex
e9f8521a50 fix: date time picker text color in dark mode (#25883) 2026-02-04 18:45:56 +00:00
Michel Heusschen
6bd60270b4 fix: correctly sync shared link download with metadata toggle (#25885) 2026-02-04 12:38:42 -05:00
Michel Heusschen
5a6fd7af06 fix: close tag modal after tagging assets (#25884) 2026-02-04 12:35:00 -05:00
Jason Rasmussen
6cdebdd3b3 fix(server): deleting stacked assets (#25874)
* fix(server): deleting stacked assets

* fix: log a warning when removing an empty directory fails
2026-02-04 17:33:37 +00:00
Jason Rasmussen
9dddccd831 fix: null validation (#25891) 2026-02-04 12:27:52 -05:00
Min Idzelis
440b3b4c6f chore: move devcontainer specific tasks to devcontainer.json (#25881)
refactor: move devcontainer specific tasks to devcontainer.json
2026-02-03 23:04:09 -05:00
Jason Rasmussen
3ea65f8d27 fix: album dto docs (#25873) 2026-02-03 21:05:18 +00:00
github-actions
38c1f0b1fd chore: version v2.5.3 2026-02-03 18:14:21 +00:00
Michel Heusschen
5212bca3d0 fix: reset zoom when navigating between assets (#25863) 2026-02-03 11:07:06 -06:00
Daniel Dietzler
2990bde0bb fix: metadata extraction race condition (#25866) 2026-02-03 11:03:27 -06:00
Michel Heusschen
af1ecaf5cc fix: prevent backspace from accidentally triggering delete modals (#25858)
* fix: prevent backspace from accidentally triggering delete modals

* ignore input fields instead of removing shortcut
2026-02-03 16:42:46 +00:00
Alex
3870ebc3c6 fix: prevent album page get rebuilt when resuming app (#25862) 2026-02-03 16:35:53 +00:00
Michel Heusschen
0a9d969b47 fix: prevent stale values in edit user form after save (#25859) 2026-02-03 17:29:01 +01:00
Daniel Dietzler
94965f6d66 chore: rework tags sidebar (#25855) 2026-02-03 16:06:26 +00:00
Alex
8872d2c7ae chore: remove swift logs (#25857) 2026-02-03 16:00:17 +00:00
Alex
23445fdcc1 fix: upload progress bar flickering (#25829)
* fix: upload progress bar flickering

* pr feedback and more logs
2026-02-03 09:28:29 -06:00
renovate[bot]
25f2273e24 chore(deps): update redis:6.2-alpine docker digest to 46884be (#25839)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 12:56:56 +01:00
Min Idzelis
95e8e474b8 fix(web): enable asset viewer navigation across memory boundaries (#25741) 2026-02-02 10:12:08 -06:00
Timon
9f52d864cf chore(ml): replace black with ruff format (#25578) 2026-02-02 09:02:06 -05:00
Mees Frensel
0273dcb0cf fix(web): user settings styling (#25775) 2026-02-02 08:47:28 -05:00
93 changed files with 1574 additions and 537 deletions

View File

@@ -26,7 +26,81 @@
"vitest.explorer",
"ms-playwright.playwright",
"ms-azuretools.vscode-docker"
]
],
"settings": {
"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,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "dedicated",
"showReuseMessage": true,
"clear": false,
"group": "Devcontainer tasks",
"close": true
},
"runOptions": {
"runOn": "folderOpen"
},
"problemMatcher": []
},
{
"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,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "dedicated",
"showReuseMessage": true,
"clear": false,
"group": "Devcontainer tasks",
"close": true
},
"runOptions": {
"runOn": "folderOpen"
},
"problemMatcher": []
},
{
"label": "Build Immich CLI",
"type": "shell",
"command": "pnpm --filter cli build:dev"
}
]
}
}
}
},
"features": {

View File

@@ -591,9 +591,9 @@ jobs:
- name: Lint with ruff
run: |
uv run ruff check --output-format=github immich_ml
- name: Check black formatting
- name: Format with ruff
run: |
uv run black --check immich_ml
uv run ruff format --check immich_ml
- name: Run mypy type checking
run: |
uv run mypy --strict immich_ml/

80
.vscode/tasks.json vendored
View File

@@ -1,80 +0,0 @@
{
"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,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "dedicated",
"showReuseMessage": true,
"clear": false,
"group": "Devcontainer tasks",
"close": true
},
"runOptions": {
"runOn": "default"
},
"problemMatcher": []
},
{
"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,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "dedicated",
"showReuseMessage": true,
"clear": false,
"group": "Devcontainer tasks",
"close": true
},
"runOptions": {
"runOn": "default"
},
"problemMatcher": []
},
{
"label": "Immich Server and Web",
"dependsOn": ["Immich Web Server (Vite)", "Immich API Server (Nest)"],
"runOptions": {
"runOn": "folderOpen"
},
"problemMatcher": []
},
{
"label": "Build Immich CLI",
"type": "shell",
"command": "pnpm --filter cli build:dev"
}
]
}

View File

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

View File

@@ -1,7 +1,7 @@
[
{
"label": "v2.5.2",
"url": "https://docs.v2.5.2.archive.immich.app"
"label": "v2.5.3",
"url": "https://docs.v2.5.3.archive.immich.app"
},
{
"label": "v2.4.1",

View File

@@ -70,7 +70,7 @@ services:
restart: unless-stopped
redis:
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338

View File

@@ -42,7 +42,7 @@ services:
- 2285:2285
redis:
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338

View File

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

View File

@@ -0,0 +1,2 @@
export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects';
export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects';

View File

@@ -0,0 +1,84 @@
import { faker } from '@faker-js/faker';
import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { toAssetResponseDto } from 'src/generators/timeline/rest-response';
import type { MockTimelineAsset } from 'src/generators/timeline/timeline-config';
import { SeededRandom, selectRandomMultiple } from 'src/generators/timeline/utils';
export type MemoryConfig = {
id?: string;
ownerId: string;
year: number;
memoryAt: string;
isSaved?: boolean;
};
export type MemoryYearConfig = {
year: number;
assetCount: number;
};
export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto {
const now = new Date().toISOString();
const memoryId = config.id ?? faker.string.uuid();
return {
id: memoryId,
assets: assets.map((asset) => toAssetResponseDto(asset)),
data: { year: config.year } as OnThisDayDto,
memoryAt: config.memoryAt,
createdAt: now,
updatedAt: now,
isSaved: config.isSaved ?? false,
ownerId: config.ownerId,
type: MemoryType.OnThisDay,
};
}
export function generateMemoriesFromTimeline(
timelineAssets: MockTimelineAsset[],
ownerId: string,
memoryConfigs: MemoryYearConfig[],
seed: number = 42,
): MemoryResponseDto[] {
const rng = new SeededRandom(seed);
const memories: MemoryResponseDto[] = [];
const usedAssetIds = new Set<string>();
for (const config of memoryConfigs) {
const yearAssets = timelineAssets.filter((asset) => {
const assetYear = DateTime.fromISO(asset.fileCreatedAt).year;
return assetYear === config.year && !usedAssetIds.has(asset.id);
});
if (yearAssets.length === 0) {
continue;
}
const countToSelect = Math.min(config.assetCount, yearAssets.length);
const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng);
for (const asset of selectedAssets) {
usedAssetIds.add(asset.id);
}
selectedAssets.sort(
(a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds,
);
const memoryAt = DateTime.now().set({ year: config.year }).toISO()!;
memories.push(
generateMemory(
{
ownerId,
year: config.year,
memoryAt,
},
selectedAssets,
),
);
}
return memories;
}

View File

@@ -0,0 +1,65 @@
import type { MemoryResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
export type MemoryChanges = {
memoryDeletions: string[];
assetRemovals: Map<string, string[]>;
};
export const setupMemoryMockApiRoutes = async (
context: BrowserContext,
memories: MemoryResponseDto[],
changes: MemoryChanges,
) => {
await context.route('**/api/memories*', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/memories' && request.method() === 'GET') {
const activeMemories = memories
.filter((memory) => !changes.memoryDeletions.includes(memory.id))
.map((memory) => {
const removedAssets = changes.assetRemovals.get(memory.id) ?? [];
return {
...memory,
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
};
})
.filter((memory) => memory.assets.length > 0);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: activeMemories,
});
}
const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/);
if (memoryMatch && request.method() === 'GET') {
const memoryId = memoryMatch[1];
const memory = memories.find((m) => m.id === memoryId);
if (!memory || changes.memoryDeletions.includes(memoryId)) {
return route.fulfill({ status: 404 });
}
const removedAssets = changes.assetRemovals.get(memoryId) ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
...memory,
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
},
});
}
if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') {
const memoryId = pathname.split('/').pop()!;
changes.memoryDeletions.push(memoryId);
return route.fulfill({ status: 204 });
}
await route.fallback();
});
};

View File

@@ -0,0 +1,289 @@
import { faker } from '@faker-js/faker';
import type { MemoryResponseDto } from '@immich/sdk';
import { test } from '@playwright/test';
import { generateMemoriesFromTimeline } from 'src/generators/memory';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
TimelineAssetConfig,
TimelineData,
} from 'src/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/mock-network/memory-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from 'src/web/specs/memory/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
let memories: MemoryResponseDto[];
const assets: TimelineAssetConfig[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
const memoryChanges: MemoryChanges = {
memoryDeletions: [],
assetRemovals: new Map(),
};
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: adminUserId,
});
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
memories = generateMemoriesFromTimeline(
assets,
adminUserId,
[
{ year: 2024, assetCount: 3 },
{ year: 2023, assetCount: 2 },
{ year: 2022, assetCount: 4 },
],
42,
);
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
memoryChanges.memoryDeletions = [];
memoryChanges.assetRemovals.clear();
});
test.describe('Asset viewer navigation from gallery', () => {
test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => {
const firstMemory = memories[0];
const middleAsset = firstMemory.assets[1];
await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id);
await memoryGalleryUtils.clickThumbnail(page, middleAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => {
const firstMemory = memories[0];
const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!;
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
});
test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => {
const lastMemory = memories.at(-1)!;
const firstAssetOfLastMemory = lastMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
await memoryAssetViewerUtils.clickNextButton(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id);
});
test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
await memoryAssetViewerUtils.clickPreviousButton(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
});
test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => {
const firstMemory = memories[0];
const veryFirstAsset = firstMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id);
await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset);
await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => {
const lastMemory = memories.at(-1)!;
const veryLastAsset = lastMemory.assets.at(-1)!;
await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id);
await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset);
await memoryAssetViewerUtils.expectNextButtonNotVisible(page);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
});
});
test.describe('Keyboard navigation', () => {
test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
await page.keyboard.press('ArrowLeft');
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
});
test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
await page.keyboard.press('ArrowRight');
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
});
});
});
test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
let memories: MemoryResponseDto[];
const assets: TimelineAssetConfig[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
const memoryChanges: MemoryChanges = {
memoryDeletions: [],
assetRemovals: new Map(),
};
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: adminUserId,
});
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
memories = generateMemoriesFromTimeline(
assets,
adminUserId,
[
{ year: 2024, assetCount: 2 },
{ year: 2023, assetCount: 1 },
{ year: 2022, assetCount: 2 },
],
123,
);
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
memoryChanges.memoryDeletions = [];
memoryChanges.assetRemovals.clear();
});
test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => {
const singleAssetMemory = memories[1];
const singleAsset = singleAssetMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id);
await memoryGalleryUtils.clickThumbnail(page, singleAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
});

View File

@@ -0,0 +1,123 @@
import type { AssetResponseDto } from '@immich/sdk';
import { expect, Page } from '@playwright/test';
function getAssetIdFromUrl(url: URL): string | null {
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
if (pathMatch) {
return pathMatch[1];
}
return url.searchParams.get('id');
}
export const memoryViewerUtils = {
locator(page: Page) {
return page.locator('#memory-viewer');
},
async waitForMemoryLoad(page: Page) {
await expect(this.locator(page)).toBeVisible();
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
},
async openMemoryPage(page: Page) {
await page.goto('/memory');
await this.waitForMemoryLoad(page);
},
async openMemoryPageWithAsset(page: Page, assetId: string) {
await page.goto(`/memory?id=${assetId}`);
await this.waitForMemoryLoad(page);
},
};
export const memoryGalleryUtils = {
locator(page: Page) {
return page.locator('#gallery-memory');
},
thumbnailWithAssetId(page: Page, assetId: string) {
return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`);
},
async scrollToGallery(page: Page) {
const showGalleryButton = page.getByLabel('Show gallery');
if (await showGalleryButton.isVisible()) {
await showGalleryButton.click();
}
await expect(this.locator(page)).toBeInViewport();
},
async clickThumbnail(page: Page, assetId: string) {
await this.scrollToGallery(page);
await this.thumbnailWithAssetId(page, assetId).click();
},
async getAllThumbnails(page: Page) {
await this.scrollToGallery(page);
return page.locator('#gallery-memory [data-thumbnail-focus-container]');
},
};
export const memoryAssetViewerUtils = {
locator(page: Page) {
return page.locator('#immich-asset-viewer');
},
async waitForViewerOpen(page: Page) {
await expect(this.locator(page)).toBeVisible();
},
async waitForAssetLoad(page: Page, asset: AssetResponseDto) {
const viewer = this.locator(page);
const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 });
},
nextButton(page: Page) {
return page.getByLabel('View next asset');
},
previousButton(page: Page) {
return page.getByLabel('View previous asset');
},
async expectNextButtonVisible(page: Page) {
await expect(this.nextButton(page)).toBeVisible();
},
async expectNextButtonNotVisible(page: Page) {
await expect(this.nextButton(page)).toHaveCount(0);
},
async expectPreviousButtonVisible(page: Page) {
await expect(this.previousButton(page)).toBeVisible();
},
async expectPreviousButtonNotVisible(page: Page) {
await expect(this.previousButton(page)).toHaveCount(0);
},
async clickNextButton(page: Page) {
await this.nextButton(page).click();
},
async clickPreviousButton(page: Page) {
await this.previousButton(page).click();
},
async closeViewer(page: Page) {
await page.keyboard.press('Escape');
await expect(this.locator(page)).not.toBeVisible();
},
getCurrentAssetId(page: Page): string | null {
const url = new URL(page.url());
return getAssetIdFromUrl(url);
},
async expectCurrentAssetId(page: Page, expectedAssetId: string) {
await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId);
},
};

View File

@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.5.2",
"version": "2.5.3",
"private": true,
"scripts": {
"format": "prettier --check .",

View File

@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.5.2"
version = "2.5.3"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
@@ -41,7 +41,6 @@ types = [
"types-ujson>=5.10.0.20240515",
]
lint = [
"black>=23.3.0",
"mypy>=1.3.0",
"ruff>=0.0.272",
{ include-group = "types" },
@@ -93,9 +92,5 @@ target-version = "py311"
select = ["E", "F", "I"]
per-file-ignores = { "test_main.py" = ["F403"] }
[tool.black]
line-length = 120
target-version = ['py311']
[tool.pytest.ini_options]
markers = ["providers", "ov_device_ids"]

View File

@@ -85,43 +85,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
]
[[package]]
name = "black"
version = "25.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" },
{ url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" },
{ url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" },
{ url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" },
{ url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" },
{ url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" },
{ url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" },
{ url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" },
{ url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" },
{ url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" },
{ url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" },
{ url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" },
{ url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" },
{ url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" },
{ url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" },
{ url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" },
{ url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" },
{ url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" },
]
[[package]]
name = "blinker"
version = "1.7.0"
@@ -919,7 +882,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.5.2"
version = "2.5.3"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
@@ -961,7 +924,6 @@ rknn = [
[package.dev-dependencies]
dev = [
{ name = "black" },
{ name = "httpx" },
{ name = "locust" },
{ name = "mypy" },
@@ -977,7 +939,6 @@ dev = [
{ name = "types-ujson" },
]
lint = [
{ name = "black" },
{ name = "mypy" },
{ name = "ruff" },
{ name = "types-pyyaml" },
@@ -1031,7 +992,6 @@ provides-extras = ["cpu", "cuda", "openvino", "armnn", "rknn", "rocm"]
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = ">=23.3.0" },
{ name = "httpx", specifier = ">=0.24.1" },
{ name = "locust", specifier = ">=2.15.1" },
{ name = "mypy", specifier = ">=1.3.0" },
@@ -1047,7 +1007,6 @@ dev = [
{ name = "types-ujson", specifier = ">=5.10.0.20240515" },
]
lint = [
{ name = "black", specifier = ">=23.3.0" },
{ name = "mypy", specifier = ">=1.3.0" },
{ name = "ruff", specifier = ">=0.0.272" },
{ name = "types-pyyaml", specifier = ">=6.0.12.20241230" },
@@ -2232,15 +2191,6 @@ client = [
{ name = "websocket-client" },
]
[[package]]
name = "pytokens"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" },
]
[[package]]
name = "pywin32"
version = "311"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3033,
"android.injected.version.name" => "2.5.2",
"android.injected.version.code" => 3034,
"android.injected.version.name" => "2.5.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -133,7 +133,6 @@ class LocalImageApiImpl: LocalImageApi {
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes)
]))
print("Successful response for \(requestId)")
Self.remove(requestId: requestId)
} catch {
Self.remove(requestId: requestId)

View File

@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.5.2</string>
<string>2.5.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -33,7 +33,7 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
@override
Widget build(BuildContext context) {
final albumCount = ref.watch(remoteAlbumProvider.select((state) => state.albums.length));
final showScrollbar = albumCount > 10;
final showScrollbar = albumCount > 20;
final scrollView = CustomScrollView(
controller: _scrollController,

View File

@@ -87,7 +87,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
}
void onSearch(String searchTerm, QuickFilterMode filterMode) {
final userId = ref.watch(currentUserProvider)?.id;
final userId = ref.read(currentUserProvider)?.id;
filter = filter.copyWith(query: searchTerm, userId: userId, mode: filterMode);
filterAlbums();
@@ -186,7 +186,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
@override
Widget build(BuildContext context) {
final userId = ref.watch(currentUserProvider)?.id;
final userId = ref.watch(currentUserProvider.select((user) => user?.id));
// refilter and sort when albums change
ref.listen(remoteAlbumProvider.select((state) => state.albums), (_, _) async {

View File

@@ -259,6 +259,11 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> startForegroundBackup(String userId) async {
// Cancel any existing backup before starting a new one
if (state.cancelToken != null) {
await stopForegroundBackup();
}
state = state.copyWith(error: BackupError.none);
final cancelToken = CancellationToken();
@@ -375,21 +380,21 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
return;
}
_logger.info("Resuming backup tasks...");
_logger.info("Start background backup sequence");
state = state.copyWith(error: BackupError.none);
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
if (!mounted) {
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
return;
}
_logger.info("Found ${tasks.length} tasks");
_logger.info("Found ${tasks.length} pending tasks");
if (tasks.isEmpty) {
_logger.info("Start backup with URLSession");
_logger.info("No pending tasks, starting new upload");
return _backgroundUploadService.uploadBackupCandidates(userId);
}
_logger.info("Tasks to resume: ${tasks.length}");
_logger.info("Resuming upload ${tasks.length} assets");
return _backgroundUploadService.resume();
}
}

View File

@@ -164,9 +164,12 @@ class BackgroundUploadService {
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
_logger.info("No new backup candidates found, finishing background upload");
return;
}
_logger.info("Found ${candidates.length} backup candidates for background tasks");
const batchSize = 100;
final batch = candidates.take(batchSize).toList();
List<UploadTask> tasks = [];
@@ -179,6 +182,7 @@ class BackgroundUploadService {
}
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
_logger.info("Enqueuing ${tasks.length} background upload tasks");
await enqueueTasks(tasks);
}
}

View File

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

View File

@@ -473,7 +473,7 @@ class AlbumsApi {
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = only own, undefined = all
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums';
@@ -516,7 +516,7 @@ class AlbumsApi {
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = only own, undefined = all
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? shared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, );
if (response.statusCode >= HttpStatus.badRequest) {

View File

@@ -53,7 +53,7 @@ class AssetBulkUpdateDto {
///
String? description;
/// Duplicate asset ID
/// Duplicate ID
String? duplicateId;
/// Asset IDs to update

View File

@@ -19,6 +19,7 @@ class UserAdminCreateDto {
required this.name,
this.notify,
required this.password,
this.pinCode,
this.quotaSizeInBytes,
this.shouldChangePassword,
this.storageLabel,
@@ -54,6 +55,9 @@ class UserAdminCreateDto {
/// User password
String password;
/// PIN code
String? pinCode;
/// Storage quota in bytes
///
/// Minimum value: 0
@@ -79,6 +83,7 @@ class UserAdminCreateDto {
other.name == name &&
other.notify == notify &&
other.password == password &&
other.pinCode == pinCode &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.shouldChangePassword == shouldChangePassword &&
other.storageLabel == storageLabel;
@@ -92,12 +97,13 @@ class UserAdminCreateDto {
(name.hashCode) +
(notify == null ? 0 : notify!.hashCode) +
(password.hashCode) +
(pinCode == null ? 0 : pinCode!.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode);
@override
String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, isAdmin=$isAdmin, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, isAdmin=$isAdmin, name=$name, notify=$notify, password=$password, pinCode=$pinCode, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -119,6 +125,11 @@ class UserAdminCreateDto {
// json[r'notify'] = null;
}
json[r'password'] = this.password;
if (this.pinCode != null) {
json[r'pinCode'] = this.pinCode;
} else {
// json[r'pinCode'] = null;
}
if (this.quotaSizeInBytes != null) {
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
} else {
@@ -152,6 +163,7 @@ class UserAdminCreateDto {
name: mapValueOfType<String>(json, r'name')!,
notify: mapValueOfType<bool>(json, r'notify'),
password: mapValueOfType<String>(json, r'password')!,
pinCode: mapValueOfType<String>(json, r'pinCode'),
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
storageLabel: mapValueOfType<String>(json, r'storageLabel'),

View File

@@ -1249,10 +1249,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1942,10 +1942,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
thumbhash:
dependency: "direct main"
description:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.5.2+3033
version: 2.5.3+3034
environment:
sdk: '>=3.8.0 <4.0.0'

View File

@@ -1618,7 +1618,7 @@
"name": "shared",
"required": false,
"in": "query",
"description": "Filter by shared status: true = only shared, false = only own, undefined = all",
"description": "Filter by shared status: true = only shared, false = not shared, undefined = all owned albums",
"schema": {
"type": "boolean"
}
@@ -2221,6 +2221,71 @@
"x-immich-state": "Stable"
}
},
"/albums/{id}/thumbnail": {
"get": {
"description": "Virtual route that redirects to the thumbnail of the album cover asset.",
"operationId": "getAlbumThumbnailRedirect",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Redirect to album thumbnail",
"tags": [
"Albums"
],
"x-immich-history": [
{
"version": "v2.6.0",
"state": "Added"
},
{
"version": "v2.6.0",
"state": "Beta"
}
],
"x-immich-permission": "album.read",
"x-immich-state": "Beta"
}
},
"/albums/{id}/user/{userId}": {
"delete": {
"description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.",
@@ -15057,7 +15122,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.5.2",
"version": "2.5.3",
"contact": {}
},
"tags": [
@@ -15760,7 +15825,7 @@
"type": "string"
},
"duplicateId": {
"description": "Duplicate asset ID",
"description": "Duplicate ID",
"nullable": true,
"type": "string"
},
@@ -19038,6 +19103,7 @@
"format": "uuid",
"type": "string"
},
"minItems": 1,
"type": "array"
}
},
@@ -19128,6 +19194,7 @@
"format": "uuid",
"type": "string"
},
"minItems": 1,
"type": "array"
},
"readAt": {
@@ -25069,6 +25136,12 @@
"description": "User password",
"type": "string"
},
"pinCode": {
"description": "PIN code",
"example": "123456",
"nullable": true,
"type": "string"
},
"quotaSizeInBytes": {
"description": "Storage quota in bytes",
"format": "int64",

View File

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

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 2.5.2
* 2.5.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -233,6 +233,8 @@ export type UserAdminCreateDto = {
notify?: boolean;
/** User password */
password: string;
/** PIN code */
pinCode?: string | null;
/** Storage quota in bytes */
quotaSizeInBytes?: number | null;
/** Require password change on next login */
@@ -822,7 +824,7 @@ export type AssetBulkUpdateDto = {
dateTimeRelative?: number;
/** Asset description */
description?: string;
/** Duplicate asset ID */
/** Duplicate ID */
duplicateId?: string | null;
/** Asset IDs to update */
ids: string[];

View File

@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "2.5.2",
"version": "2.5.3",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",

View File

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

View File

@@ -1,4 +1,17 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Put,
Query,
Redirect,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
@@ -73,6 +86,19 @@ export class AlbumController {
return this.service.get(auth, id, dto);
}
@Authenticated({ permission: Permission.AlbumRead, sharedLink: true })
@Get(':id/thumbnail')
@Redirect()
@Endpoint({
summary: 'Redirect to album thumbnail',
description: 'Virtual route that redirects to the thumbnail of the album cover asset.',
history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'),
})
async getAlbumThumbnailRedirect(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
const url = await this.service.getThumbnailRedirectUrl(auth, id);
return { url, status: 307 };
}
@Patch(':id')
@Authenticated({ permission: Permission.AlbumUpdate })
@Endpoint({

View File

@@ -24,6 +24,34 @@ describe(AssetController.name, () => {
await request(ctx.getHttpServer()).put(`/assets`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets`)
.send({ ids: ['123'] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['each value in ids must be a UUID']));
});
it('should require duplicateId to be a string', async () => {
const id = factory.uuid();
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets`)
.send({ ids: [id], duplicateId: true });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['duplicateId must be a string']));
});
it('should accept a null duplicateId', async () => {
const id = factory.uuid();
await request(ctx.getHttpServer())
.put(`/assets`)
.send({ ids: [id], duplicateId: null });
expect(service.updateAll).toHaveBeenCalledWith(undefined, expect.objectContaining({ duplicateId: null }));
});
});
describe('DELETE /assets', () => {

View File

@@ -0,0 +1,36 @@
import { NotificationAdminController } from 'src/controllers/notification-admin.controller';
import { NotificationAdminService } from 'src/services/notification-admin.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(NotificationAdminController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(NotificationAdminService);
beforeAll(async () => {
ctx = await controllerSetup(NotificationAdminController, [
{ provide: NotificationAdminService, useValue: service },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('POST /admin/notifications', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/admin/notifications');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should accept a null readAt', async () => {
await request(ctx.getHttpServer())
.post(`/admin/notifications`)
.send({ title: 'Test', userId: factory.uuid(), readAt: null });
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ readAt: null }));
});
});
});

View File

@@ -37,9 +37,33 @@ describe(NotificationController.name, () => {
describe('PUT /notifications', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/notifications');
await request(ctx.getHttpServer()).put('/notifications');
expect(ctx.authenticate).toHaveBeenCalled();
});
describe('ids', () => {
it('should require a list', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['ids must be an array'])));
});
it('should require uuids', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/notifications`)
.send({ ids: [true] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
});
it('should accept valid uuids', async () => {
const id = factory.uuid();
await request(ctx.getHttpServer())
.put(`/notifications`)
.send({ ids: [id] });
expect(service.updateAll).toHaveBeenCalledWith(undefined, expect.objectContaining({ ids: [id] }));
});
});
});
describe('GET /notifications/:id', () => {
@@ -60,5 +84,11 @@ describe(NotificationController.name, () => {
await request(ctx.getHttpServer()).put(`/notifications/${factory.uuid()}`).send({ readAt: factory.date() });
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should accept a null readAt', async () => {
const id = factory.uuid();
await request(ctx.getHttpServer()).put(`/notifications/${id}`).send({ readAt: null });
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ readAt: null }));
});
});
});

View File

@@ -58,6 +58,11 @@ describe(PersonController.name, () => {
await request(ctx.getHttpServer()).post('/people').send({ birthDate: '' });
expect(service.create).toHaveBeenCalledWith(undefined, { birthDate: null });
});
it('should map an empty color to null', async () => {
await request(ctx.getHttpServer()).post('/people').send({ color: '' });
expect(service.create).toHaveBeenCalledWith(undefined, { color: null });
});
});
describe('DELETE /people', () => {

View File

@@ -0,0 +1,34 @@
import { SharedLinkController } from 'src/controllers/shared-link.controller';
import { SharedLinkType } from 'src/enum';
import { SharedLinkService } from 'src/services/shared-link.service';
import request from 'supertest';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(SharedLinkController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(SharedLinkService);
beforeAll(async () => {
ctx = await controllerSetup(SharedLinkController, [{ provide: SharedLinkService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('POST /shared-links', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/shared-links');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should allow an null expiresAt', async () => {
await request(ctx.getHttpServer())
.post('/shared-links')
.send({ expiresAt: null, type: SharedLinkType.Individual });
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
});
});
});

View File

@@ -0,0 +1,73 @@
import { TagController } from 'src/controllers/tag.controller';
import { TagService } from 'src/services/tag.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(TagController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(TagService);
beforeAll(async () => {
ctx = await controllerSetup(TagController, [{ provide: TagService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /tags', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/tags');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /tags', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/tags');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should a null parentId', async () => {
await request(ctx.getHttpServer()).post(`/tags`).send({ name: 'tag', parentId: null });
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ parentId: null }));
});
});
describe('PUT /tags', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put('/tags');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('GET /tags/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/tags/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')]));
});
});
describe('PUT /tags/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should allow setting a null color via an empty string', async () => {
const id = factory.uuid();
await request(ctx.getHttpServer()).put(`/tags/${id}`).send({ color: '' });
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ color: null }));
});
});
});

View File

@@ -31,12 +31,55 @@ describe(UserAdminController.name, () => {
});
});
describe('PUT /admin/users/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /admin/users', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/admin/users');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should allow a null pinCode', async () => {
await request(ctx.getHttpServer()).post(`/admin/users`).send({
name: 'Test user',
email: 'test@immich.cloud',
password: 'password',
pinCode: null,
});
expect(service.create).toHaveBeenCalledWith(expect.objectContaining({ pinCode: null }));
});
it('should allow a null avatarColor', async () => {
await request(ctx.getHttpServer()).post(`/admin/users`).send({
name: 'Test user',
email: 'test@immich.cloud',
password: 'password',
avatarColor: null,
});
expect(service.create).toHaveBeenCalledWith(expect.objectContaining({ avatarColor: null }));
});
it(`should `, async () => {
const dto: UserAdminCreateDto = {
email: 'user@immich.app',
password: 'test',
name: 'Test User',
quotaSizeInBytes: 1.2,
};
const { status, body } = await request(ctx.getHttpServer())
.post(`/admin/users`)
.set('Authorization', `Bearer token`)
.send(dto);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
});
it(`should not allow decimal quota`, async () => {
const dto: UserAdminCreateDto = {
email: 'user@immich.app',
@@ -75,5 +118,17 @@ describe(UserAdminController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
});
it('should allow a null pinCode', async () => {
const id = factory.uuid();
await request(ctx.getHttpServer()).put(`/admin/users/${id}`).send({ pinCode: null });
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ pinCode: null }));
});
it('should allow a null avatarColor', async () => {
const id = factory.uuid();
await request(ctx.getHttpServer()).put(`/admin/users/${id}`).send({ avatarColor: null });
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ avatarColor: null }));
});
});
});

View File

@@ -54,6 +54,14 @@ describe(UserController.name, () => {
expect(body).toEqual(errorDto.badRequest());
});
}
it('should allow an empty avatarColor', async () => {
await request(ctx.getHttpServer())
.put(`/users/me`)
.set('Authorization', `Bearer token`)
.send({ avatarColor: null });
expect(service.updateMe).toHaveBeenCalledWith(undefined, expect.objectContaining({ avatarColor: null }));
});
});
describe('GET /users/:id', () => {

View File

@@ -102,7 +102,7 @@ export class UpdateAlbumDto {
export class GetAlbumsDto {
@ValidateBoolean({
optional: true,
description: 'Filter by shared status: true = only shared, false = only own, undefined = all',
description: 'Filter by shared status: true = only shared, false = not shared, undefined = all owned albums',
})
shared?: boolean;

View File

@@ -73,8 +73,7 @@ export class AssetBulkUpdateDto extends UpdateAssetBase {
@ValidateUUID({ each: true, description: 'Asset IDs to update' })
ids!: string[];
@ApiProperty({ description: 'Duplicate asset ID' })
@Optional()
@ValidateString({ optional: true, nullable: true, description: 'Duplicate ID' })
duplicateId?: string | null;
@ApiProperty({ description: 'Relative time offset in seconds' })

View File

@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { ArrayMinSize, IsString } from 'class-validator';
import { NotificationLevel, NotificationType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
export class TestEmailResponseDto {
@ApiProperty({ description: 'Email message ID' })
@@ -75,20 +75,17 @@ export class NotificationCreateDto {
@ValidateEnum({ enum: NotificationType, name: 'NotificationType', optional: true, description: 'Notification type' })
type?: NotificationType;
@ApiProperty({ description: 'Notification title' })
@IsString()
@ValidateString({ description: 'Notification title' })
title!: string;
@ApiPropertyOptional({ description: 'Notification description' })
@IsString()
@Optional({ nullable: true })
@ValidateString({ optional: true, nullable: true, description: 'Notification description' })
description?: string | null;
@ApiPropertyOptional({ description: 'Additional notification data' })
@Optional({ nullable: true })
data?: any;
@ValidateDate({ optional: true, description: 'Date when notification was read' })
@ValidateDate({ optional: true, nullable: true, description: 'Date when notification was read' })
readAt?: Date | null;
@ValidateUUID({ description: 'User ID to send notification to' })
@@ -96,20 +93,22 @@ export class NotificationCreateDto {
}
export class NotificationUpdateDto {
@ValidateDate({ optional: true, description: 'Date when notification was read' })
@ValidateDate({ optional: true, nullable: true, description: 'Date when notification was read' })
readAt?: Date | null;
}
export class NotificationUpdateAllDto {
@ValidateUUID({ each: true, optional: true, description: 'Notification IDs to update' })
@ValidateUUID({ each: true, description: 'Notification IDs to update' })
@ArrayMinSize(1)
ids!: string[];
@ValidateDate({ optional: true, description: 'Date when notifications were read' })
@ValidateDate({ optional: true, nullable: true, description: 'Date when notifications were read' })
readAt?: Date | null;
}
export class NotificationDeleteAllDto {
@ValidateUUID({ each: true, description: 'Notification IDs to delete' })
@ArrayMinSize(1)
ids!: string[];
}

View File

@@ -44,7 +44,7 @@ export class SharedLinkCreateDto {
@IsString()
slug?: string | null;
@ValidateDate({ optional: true, description: 'Expiration date' })
@ValidateDate({ optional: true, nullable: true, description: 'Expiration date' })
expiresAt?: Date | null = null;
@ValidateBoolean({ optional: true, description: 'Allow uploads' })

View File

@@ -9,7 +9,7 @@ export class TagCreateDto {
@IsNotEmpty()
name!: string;
@ValidateUUID({ optional: true, description: 'Parent tag ID' })
@ValidateUUID({ nullable: true, optional: true, description: 'Parent tag ID' })
parentId?: string | null;
@ApiPropertyOptional({ description: 'Tag color (hex)' })
@@ -20,7 +20,7 @@ export class TagCreateDto {
export class TagUpdateDto {
@ApiPropertyOptional({ description: 'Tag color (hex)' })
@Optional({ emptyToNull: true })
@Optional({ nullable: true, emptyToNull: true })
@ValidateHexColor()
color?: string | null;
}

View File

@@ -26,7 +26,13 @@ export class UserUpdateMeDto {
@IsNotEmpty()
name?: string;
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true, description: 'Avatar color' })
@ValidateEnum({
enum: UserAvatarColor,
name: 'UserAvatarColor',
optional: true,
nullable: true,
description: 'Avatar color',
})
avatarColor?: UserAvatarColor | null;
}
@@ -96,9 +102,19 @@ export class UserAdminCreateDto {
@IsString()
name!: string;
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true, description: 'Avatar color' })
@ValidateEnum({
enum: UserAvatarColor,
name: 'UserAvatarColor',
optional: true,
nullable: true,
description: 'Avatar color',
})
avatarColor?: UserAvatarColor | null;
@ApiPropertyOptional({ description: 'PIN code' })
@PinCode({ optional: true, nullable: true, emptyToNull: true })
pinCode?: string | null;
@ApiPropertyOptional({ description: 'Storage label' })
@Optional({ nullable: true })
@IsString()
@@ -135,7 +151,7 @@ export class UserAdminUpdateDto {
password?: string;
@ApiPropertyOptional({ description: 'PIN code' })
@PinCode({ optional: true, emptyToNull: true })
@PinCode({ optional: true, nullable: true, emptyToNull: true })
pinCode?: string | null;
@ApiPropertyOptional({ description: 'User name' })
@@ -144,7 +160,13 @@ export class UserAdminUpdateDto {
@IsNotEmpty()
name?: string;
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true, description: 'Avatar color' })
@ValidateEnum({
enum: UserAvatarColor,
name: 'UserAvatarColor',
optional: true,
nullable: true,
description: 'Avatar color',
})
avatarColor?: UserAvatarColor | null;
@ApiPropertyOptional({ description: 'Storage label' })

View File

@@ -430,30 +430,6 @@ select
"asset"."originalPath",
"asset"."isOffline",
to_json("asset_exif") as "exifInfo",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_face".*,
"person" as "person"
from
"asset_face"
left join lateral (
select
"person".*
from
"person"
where
"asset_face"."personId" = "person"."id"
) as "person" on true
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
) as agg
) as "faces",
(
select
coalesce(json_agg(agg), '[]')
@@ -470,27 +446,37 @@ select
"asset_file"."assetId" = "asset"."id"
) as agg
) as "files",
to_json("stacked_assets") as "stack"
to_json("stack_result") as "stack"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
left join "stack" on "stack"."id" = "asset"."stackId"
left join lateral (
select
"stack"."id",
"stack"."primaryAssetId",
array_agg("stacked") as "assets"
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"stack_asset"."id"
from
"asset" as "stack_asset"
where
"stack_asset"."stackId" = "stack"."id"
and "stack_asset"."id" != "stack"."primaryAssetId"
and "stack_asset"."visibility" = $1
and "stack_asset"."status" != $2
) as agg
) as "assets"
from
"asset" as "stacked"
"stack"
where
"stacked"."deletedAt" is not null
and "stacked"."visibility" = $1
and "stacked"."stackId" = "stack"."id"
group by
"stack"."id"
) as "stacked_assets" on "stack"."id" is not null
"stack"."id" = "asset"."stackId"
) as "stack_result" on true
where
"asset"."id" = $2
"asset"."id" = $3
-- AssetJobRepository.streamForVideoConversion
select

View File

@@ -87,6 +87,16 @@ export class AlbumRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getForThumbnailRedirect(id: string) {
return this.db
.selectFrom('asset')
.innerJoin('album', 'album.albumThumbnailAssetId', 'asset.id')
.where('album.id', '=', id)
.select(['asset.id', 'asset.thumbhash'])
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getByAssetId(ownerId: string, assetId: string) {
return this.db

View File

@@ -1,10 +1,10 @@
import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { Kysely, sql } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { Asset, columns } from 'src/database';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetType, AssetVisibility } from 'src/enum';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import {
anyUuid,
@@ -15,7 +15,6 @@ import {
withExif,
withExifInner,
withFaces,
withFacesAndPeople,
withFilePath,
withFiles,
} from 'src/utils/database';
@@ -269,23 +268,29 @@ export class AssetJobRepository {
'asset.isOffline',
])
.$call(withExif)
.select(withFacesAndPeople)
.select(withFiles)
.leftJoin('stack', 'stack.id', 'asset.stackId')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('asset as stacked')
.select(['stack.id', 'stack.primaryAssetId'])
.select((eb) => eb.fn<Asset[]>('array_agg', [eb.table('stacked')]).as('assets'))
.where('stacked.deletedAt', 'is not', null)
.where('stacked.visibility', '=', AssetVisibility.Timeline)
.whereRef('stacked.stackId', '=', 'stack.id')
.groupBy('stack.id')
.as('stacked_assets'),
(join) => join.on('stack.id', 'is not', null),
.selectFrom('stack')
.whereRef('stack.id', '=', 'asset.stackId')
.select((eb) => [
'stack.id',
'stack.primaryAssetId',
jsonArrayFrom(
eb
.selectFrom('asset as stack_asset')
.select(['stack_asset.id'])
.whereRef('stack_asset.stackId', '=', 'stack.id')
.whereRef('stack_asset.id', '!=', 'stack.primaryAssetId')
.where('stack_asset.visibility', '=', sql.val(AssetVisibility.Timeline))
.where('stack_asset.status', '!=', sql.val(AssetStatus.Deleted)),
).as('assets'),
])
.as('stack_result'),
(join) => join.onTrue(),
)
.select((eb) => toJson(eb, 'stacked_assets').as('stack'))
.select((eb) => toJson(eb, 'stack_result').as('stack'))
.where('asset.id', '=', id)
.executeTakeFirst();
}

View File

@@ -152,7 +152,7 @@ export class StorageRepository {
}
async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) {
await fs.rm(folder, options);
await fs.rm(folder, { ...options, maxRetries: 5, retryDelay: 100 });
}
async removeEmptyDirs(directory: string, self: boolean = false) {
@@ -168,7 +168,13 @@ export class StorageRepository {
if (self) {
const updated = await fs.readdir(directory);
if (updated.length === 0) {
await fs.rmdir(directory);
try {
await fs.rmdir(directory);
} catch (error: Error | any) {
if (error.code !== 'ENOTEMPTY') {
this.logger.warn(`Attempted to remove directory, but failed: ${error}`);
}
}
}
}
}

View File

@@ -21,6 +21,7 @@ import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { getPreferences } from 'src/utils/preferences';
@Injectable()
@@ -93,6 +94,23 @@ export class AlbumService extends BaseService {
};
}
async getThumbnailRedirectUrl(auth: AuthDto, id: string) {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
const asset = await this.albumRepository.getForThumbnailRedirect(id);
if (!asset) {
throw new BadRequestException('Album has no thumbnail');
}
const params = new URLSearchParams();
params.append('edited', 'true');
if (asset.thumbhash) {
params.append('c', hexOrBufferToBase64(asset.thumbhash));
}
return `/api/assets/${asset.id}/thumbnail?${params.toString()}`;
}
async create(auth: AuthDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
const albumUsers = dto.albumUsers || [];

View File

@@ -8,7 +8,6 @@ import { AssetStats } from 'src/repositories/asset.repository';
import { AssetService } from 'src/services/asset.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { faceStub } from 'test/fixtures/face.stub';
import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@@ -565,12 +564,11 @@ describe(AssetService.name, () => {
});
describe('handleAssetDeletion', () => {
it('should remove faces', async () => {
const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] };
it('should clean up files', async () => {
const asset = assetStub.image;
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetWithFace);
await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true });
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
expect(mocks.job.queue.mock.calls).toEqual([
[
@@ -581,38 +579,29 @@ describe(AssetService.name, () => {
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
assetWithFace.originalPath,
asset.originalPath,
],
},
},
],
]);
expect(mocks.asset.remove).toHaveBeenCalledWith(assetWithFace);
});
it('should update stack primary asset if deleted asset was primary asset in a stack', async () => {
mocks.stack.update.mockResolvedValue(factory.stack() as any);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.primaryImage);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
expect(mocks.stack.update).toHaveBeenCalledWith('stack-1', {
id: 'stack-1',
primaryAssetId: 'stack-child-asset-1',
});
expect(mocks.asset.remove).toHaveBeenCalledWith(asset);
});
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
mocks.stack.delete.mockResolvedValue();
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
...assetStub.primaryImage,
stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) },
stack: {
id: 'stack-id',
primaryAssetId: assetStub.primaryImage.id,
assets: [{ id: 'one-asset' }],
},
});
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-1');
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id');
});
it('should delete a live photo', async () => {

View File

@@ -327,10 +327,11 @@ export class AssetService extends BaseService {
return JobStatus.Failed;
}
// Replace the parent of the stack children with a new asset
// replace the parent of the stack children with a new asset
if (asset.stack?.primaryAssetId === id) {
const stackAssetIds = asset.stack?.assets.map((a) => a.id) ?? [];
if (stackAssetIds.length > 2) {
// this only includes timeline visible assets and excludes the primary asset
const stackAssetIds = asset.stack.assets.map((a) => a.id);
if (stackAssetIds.length >= 2) {
const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!;
await this.stackRepository.update(asset.stack.id, {
id: asset.stack.id,

View File

@@ -307,7 +307,6 @@ export class MetadataService extends BaseService {
const assetHeight = isSidewards ? validate(width) : validate(height);
const promises: Promise<unknown>[] = [
this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }),
this.assetRepository.update({
id: asset.id,
duration: this.getDuration(exifTags),
@@ -322,6 +321,7 @@ export class MetadataService extends BaseService {
}),
];
await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' });
await this.applyTagList(asset);
if (this.isMotionPhoto(asset, exifTags)) {

View File

@@ -232,19 +232,20 @@ export const ValidateHexColor = () => {
return applyDecorators(...decorators);
};
type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' };
type DateOptions = OptionalOptions & { optional?: boolean; format?: 'date' | 'date-time' };
export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => {
const { optional, nullable, format, ...apiPropertyOptions } = {
optional: false,
nullable: false,
format: 'date-time',
...options,
};
const {
optional,
nullable = false,
emptyToNull = false,
format = 'date-time',
...apiPropertyOptions
} = options || {};
const decorators = [
return applyDecorators(
ApiProperty({ format, ...apiPropertyOptions }),
IsDate(),
optional ? Optional({ nullable: true }) : IsNotEmpty(),
optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(),
Transform(({ key, value }) => {
if (value === null || value === undefined) {
return value;
@@ -256,19 +257,17 @@ export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => {
return new Date(value as string);
}),
];
if (optional) {
decorators.push(Optional({ nullable }));
}
return applyDecorators(...decorators);
);
};
type StringOptions = { optional?: boolean; nullable?: boolean; trim?: boolean };
type StringOptions = OptionalOptions & { optional?: boolean; trim?: boolean };
export const ValidateString = (options?: StringOptions & ApiPropertyOptions) => {
const { optional, nullable, trim, ...apiPropertyOptions } = options || {};
const decorators = [ApiProperty(apiPropertyOptions), IsString(), optional ? Optional({ nullable }) : IsNotEmpty()];
const { optional, nullable, emptyToNull, trim, ...apiPropertyOptions } = options || {};
const decorators = [
ApiProperty(apiPropertyOptions),
IsString(),
optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(),
];
if (trim) {
decorators.push(Transform(({ value }: { value: string }) => value?.trim()));
@@ -277,9 +276,9 @@ export const ValidateString = (options?: StringOptions & ApiPropertyOptions) =>
return applyDecorators(...decorators);
};
type BooleanOptions = { optional?: boolean; nullable?: boolean };
type BooleanOptions = OptionalOptions & { optional?: boolean };
export const ValidateBoolean = (options?: BooleanOptions & PropertyOptions) => {
const { optional, nullable, ...apiPropertyOptions } = options || {};
const { optional, nullable, emptyToNull, ...apiPropertyOptions } = options || {};
const decorators = [
Property(apiPropertyOptions),
IsBoolean(),
@@ -291,7 +290,7 @@ export const ValidateBoolean = (options?: BooleanOptions & PropertyOptions) => {
}
return value;
}),
optional ? Optional({ nullable }) : IsNotEmpty(),
optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(),
];
return applyDecorators(...decorators);

View File

@@ -1,5 +1,5 @@
import { Kysely } from 'kysely';
import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum';
import { AssetFileType, AssetMetadataKey, AssetStatus, JobName, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
@@ -246,6 +246,66 @@ describe(AssetService.name, () => {
});
});
it('should delete a stacked primary asset (2 assets)', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const { stack, result } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id]);
const stackRepo = ctx.get(StackRepository);
expect(result).toMatchObject({ primaryAssetId: asset1.id });
await sut.handleAssetDeletion({ id: asset1.id, deleteOnDisk: true });
// stack is deleted as well
await expect(stackRepo.getById(stack.id)).resolves.toBe(undefined);
});
it('should delete a stacked primary asset (3 assets)', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset3 } = await ctx.newAsset({ ownerId: user.id });
const { stack, result } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id, asset3.id]);
expect(result).toMatchObject({ primaryAssetId: asset1.id });
await sut.handleAssetDeletion({ id: asset1.id, deleteOnDisk: true });
// new primary asset is picked
await expect(ctx.get(StackRepository).getById(stack.id)).resolves.toMatchObject({ primaryAssetId: asset2.id });
});
it('should delete a stacked primary asset (3 trashed assets)', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset3 } = await ctx.newAsset({ ownerId: user.id });
const { stack, result } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id, asset3.id]);
await ctx.get(AssetRepository).updateAll([asset1.id, asset2.id, asset3.id], {
deletedAt: new Date(),
status: AssetStatus.Deleted,
});
expect(result).toMatchObject({ primaryAssetId: asset1.id });
await sut.handleAssetDeletion({ id: asset1.id, deleteOnDisk: true });
// stack is deleted as well
await expect(ctx.get(StackRepository).getById(stack.id)).resolves.toBe(undefined);
});
it('should not delete offline assets', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();

View File

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

View File

@@ -0,0 +1,34 @@
import { render } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import SharedLinkFormFields from './SharedLinkFormFields.svelte';
describe('SharedLinkFormFields component', () => {
const isChecked = (element: Element) =>
element instanceof HTMLInputElement ? element.checked : element.getAttribute('aria-checked') === 'true';
it('turns downloads off when metadata is disabled', async () => {
const { container } = render(SharedLinkFormFields, {
props: {
slug: '',
password: '',
description: '',
allowDownload: true,
allowUpload: false,
showMetadata: true,
expiresAt: null,
},
});
const user = userEvent.setup();
const switches = Array.from(container.querySelectorAll('[role="switch"], input[type="checkbox"]'));
expect(switches).toHaveLength(3);
const [showMetadataSwitch, allowDownloadSwitch] = switches;
expect(isChecked(allowDownloadSwitch)).toBe(true);
await user.click(showMetadataSwitch);
expect(isChecked(showMetadataSwitch)).toBe(false);
expect(isChecked(allowDownloadSwitch)).toBe(false);
});
});

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import { Field, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
slug: string;
password: string;
description: string;
allowDownload: boolean;
allowUpload: boolean;
showMetadata: boolean;
expiresAt: string | null;
createdAt?: string;
};
let {
slug = $bindable(),
password = $bindable(),
description = $bindable(),
allowDownload = $bindable(),
allowUpload = $bindable(),
showMetadata = $bindable(),
expiresAt = $bindable(),
createdAt,
}: Props = $props();
$effect(() => {
if (!showMetadata && allowDownload) {
allowDownload = false;
}
});
</script>
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration {createdAt} bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
</Field>
</div>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { type AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
@@ -14,9 +13,7 @@
let { album, preload = false, class: className = '' }: Props = $props();
let alt = $derived(album.albumName || $t('unnamed_album'));
let thumbnailUrl = $derived(
album.albumThumbnailAssetId ? getAssetMediaUrl({ id: album.albumThumbnailAssetId }) : null,
);
let thumbnailUrl = $derived(album.albumThumbnailAssetId ? `/api/albums/${album.id}/thumbnail` : null);
</script>
{#if thumbnailUrl}

View File

@@ -14,6 +14,7 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
@@ -36,6 +37,7 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { CommandPaletteDefaultProvider } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -426,8 +428,11 @@
!assetViewerManager.isShowEditor &&
ocrManager.hasOcrData,
);
const { Tag } = $derived(getAssetActions($t, asset));
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<svelte:document bind:fullscreenElement />

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service';
import { removeTag } from '$lib/utils/asset-utils';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Icon, modalManager, Text } from '@immich/ui';
import { mdiClose, mdiPlus } from '@mdi/js';
import { Badge, IconButton, Link, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -18,22 +19,23 @@
let tags = $derived(asset.tags || []);
const handleAddTag = async () => {
const success = await modalManager.show(AssetTagModal, { assetIds: [asset.id] });
if (success) {
asset = await getAssetInfo({ id: asset.id });
}
};
const handleRemove = async (tagId: string) => {
const ids = await removeTag({ tagIds: [tagId], assetIds: [asset.id], showNotification: false });
if (ids) {
asset = await getAssetInfo({ id: asset.id });
}
};
const onAssetsTag = async (ids: string[]) => {
if (ids.includes(asset.id)) {
asset = await getAssetInfo({ id: asset.id });
}
};
const { Tag } = $derived(getAssetActions($t, asset));
</script>
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleAddTag }} />
<OnEvents {onAssetsTag} />
{#if isOwner && !authManager.isSharedLink}
<section class="px-4 mt-4">
@@ -42,36 +44,24 @@
</div>
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
{#each tags as tag (tag.id)}
<div class="flex group transition-all">
<a
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
<Badge size="small" class="items-center px-0" shape="round">
<Link
href={Route.tags({ path: tag.value })}
class="text-light no-underline rounded-full hover:bg-primary-400 px-2"
>
<p class="text-sm">
{tag.value}
</p>
</a>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title="Remove tag"
{tag.value}
</Link>
<IconButton
aria-label={$t('remove_tag')}
icon={mdiClose}
onclick={() => handleRemove(tag.id)}
>
<Icon icon={mdiClose} />
</button>
</div>
size="tiny"
class="hover:bg-primary-400"
shape="round"
/>
</Badge>
{/each}
<button
type="button"
class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1"
title={$t('add_tag')}
onclick={handleAddTag}
>
<span class="text-sm px-1 flex place-items-center place-content-center gap-1"
><Icon icon={mdiPlus} />{$t('add')}</span
>
</button>
<HeaderActionButton action={Tag} />
</section>
</section>
{/if}

View File

@@ -55,13 +55,10 @@
let loader = $state<HTMLImageElement>();
assetViewerManager.zoomState = {
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
};
$effect.pre(() => {
void asset.id;
untrack(() => assetViewerManager.resetZoomState());
});
onDestroy(() => {
$boundingBoxesArray = [];

View File

@@ -68,7 +68,11 @@
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
);
let currentTimelineAssets = $derived(current?.memory.assets || []);
let currentTimelineAssets = $derived([
...(current?.previousMemory?.assets ?? []),
...(current?.memory.assets ?? []),
...(current?.nextMemory?.assets ?? []),
]);
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);

View File

@@ -20,11 +20,8 @@
const handleTagAssets = async () => {
const assets = [...getOwnedAssets()];
const success = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
if (success) {
clearSelect();
}
await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
clearSelect();
};
</script>

View File

@@ -5,7 +5,6 @@
import { changePinCode } from '@immich/sdk';
import { Button, Heading, modalManager, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let currentPinCode = $state('');
let newPinCode = $state('');
@@ -38,27 +37,23 @@
};
</script>
<section class="my-4">
<div in:fade={{ duration: 200 }}>
<form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
<div class="flex flex-col gap-6 place-items-center place-content-center">
<Heading>{$t('change_pin_code')}</Heading>
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
</button>
</div>
<div class="flex justify-end gap-2 mt-4">
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
{$t('clear')}
</Button>
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
{$t('save')}
</Button>
</div>
</form>
<form autocomplete="off" onsubmit={handleSubmit}>
<div class="flex flex-col gap-6 place-items-center place-content-center">
<Heading>{$t('change_pin_code')}</Heading>
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
</button>
</div>
</section>
<div class="flex justify-end gap-2 mt-4">
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
{$t('clear')}
</Button>
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
{$t('save')}
</Button>
</div>
</form>

View File

@@ -20,7 +20,7 @@
<OnEvents {onUserPinCodeReset} />
<section>
<section class="my-4 sm:ms-8">
{#if hasPinCode}
<div in:fade={{ duration: 200 }}>
<PinCodeChangeForm />

View File

@@ -59,7 +59,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="ms-8 mt-4 flex flex-col gap-6">
<div class="sm:ms-8 flex flex-col gap-6">
<Field label={$t('theme_selection')} description={$t('theme_selection_description')}>
<Switch checked={themeManager.theme.system} onCheckedChange={(checked) => themeManager.setSystem(checked)} />
</Field>

View File

@@ -23,7 +23,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<div class="sm:ms-8 flex flex-col gap-4">
<Field label={$t('password')} required>
<PasswordInput bind:value={password} autocomplete="current-password" />
</Field>

View File

@@ -3,6 +3,7 @@
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
import { Button, modalManager, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import DeviceCard from './device-card.svelte';
interface Props {
@@ -50,33 +51,39 @@
</script>
<section class="my-4">
{#if currentSession}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('current_device')}
</Text>
<DeviceCard session={currentSession} />
</div>
{/if}
{#if otherSessions.length > 0}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('other_devices')}
</Text>
{#each otherSessions as session, index (session.id)}
<DeviceCard {session} onDelete={() => handleDelete(session)} />
{#if index !== otherSessions.length - 1}
<hr class="my-3" />
{/if}
{/each}
</div>
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex flex-col gap-4">
{#if currentSession}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('current_device')}
</Text>
<DeviceCard session={currentSession} />
</div>
{/if}
{#if otherSessions.length > 0}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('other_devices')}
</Text>
{#each otherSessions as session, index (session.id)}
<DeviceCard {session} onDelete={() => handleDelete(session)} />
{#if index !== otherSessions.length - 1}
<hr class="my-3" />
{/if}
{/each}
</div>
<div class="my-3">
<hr />
</div>
<div class="my-3">
<hr />
</div>
<div class="flex justify-end">
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}>{$t('log_out_all_devices')}</Button>
<div class="flex justify-end">
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}
>{$t('log_out_all_devices')}</Button
>
</div>
{/if}
</div>
{/if}
</div>
</section>

View File

@@ -39,7 +39,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<div class="sm:ms-8 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('archive_size')}

View File

@@ -67,9 +67,9 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col">
<div class="sm:ms-4 md:ms-8 flex flex-col">
<SettingAccordion key="albums" title={$t('albums')} subtitle={$t('albums_feature_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('albums_default_sort_order')} description={$t('albums_default_sort_order_description')}>
<Select
options={[
@@ -83,7 +83,7 @@
</SettingAccordion>
<SettingAccordion key="folders" title={$t('folders')} subtitle={$t('folders_feature_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={foldersEnabled} />
</Field>
@@ -97,7 +97,7 @@
</SettingAccordion>
<SettingAccordion key="memories" title={$t('time_based_memories')} subtitle={$t('photos_from_previous_years')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={memoriesEnabled} />
</Field>
@@ -109,7 +109,7 @@
</SettingAccordion>
<SettingAccordion key="people" title={$t('people')} subtitle={$t('people_feature_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={peopleEnabled} />
</Field>
@@ -123,7 +123,7 @@
</SettingAccordion>
<SettingAccordion key="rating" title={$t('rating')} subtitle={$t('rating_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={ratingsEnabled} />
</Field>
@@ -131,7 +131,7 @@
</SettingAccordion>
<SettingAccordion key="shared-links" title={$t('shared_links')} subtitle={$t('shared_links_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={sharedLinksEnabled} />
</Field>
@@ -145,7 +145,7 @@
</SettingAccordion>
<SettingAccordion key="tags" title={$t('tags')} subtitle={$t('tag_feature_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={tagsEnabled} />
</Field>
@@ -159,7 +159,7 @@
</SettingAccordion>
<SettingAccordion key="cast" title={$t('cast')} subtitle={$t('cast_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('gcast_enabled')} description={$t('gcast_enabled_description')}>
<Switch bind:checked={gCastEnabled} />
</Field>

View File

@@ -42,7 +42,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-6">
<div class="sm:ms-8 flex flex-col gap-6">
<Field label={$t('enable')} description={$t('notification_toggle_setting_description')}>
<Switch bind:checked={emailNotificationsEnabled} />
</Field>

View File

@@ -45,7 +45,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="flex justify-end">
<div class="sm:ms-8 flex justify-end">
{#if loading}
<div class="flex place-content-center place-items-center">
<LoadingSpinner />

View File

@@ -33,7 +33,7 @@
<OnEvents {onApiKeyCreate} {onApiKeyUpdate} {onApiKeyDelete} />
<section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="mb-2 flex justify-end">
<Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}>
{Create.title}

View File

@@ -33,7 +33,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={preventDefault(bubble('submit'))}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<div class="sm:ms-8 flex flex-col gap-4">
<Field label={$t('user_id')} disabled>
<Input bind:value={editedUser.id} />
</Field>

View File

@@ -105,7 +105,7 @@
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8" in:fade={{ duration: 500 }}>
{#if $isPurchased}
<!-- BADGE TOGGLE -->
<div class="mb-4">

View File

@@ -65,7 +65,7 @@
</TableRow>
{/snippet}
<section class="my-6 w-full">
<section class="my-4 w-full">
<Heading size="tiny">{$t('photos_and_videos')}</Heading>
<Table striped spacing="small" class="mt-4" size="small">
<TableHeader>

View File

@@ -5,6 +5,14 @@ import type { ZoomImageWheelState } from '@zoom-image/core';
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
const createDefaultZoomState = (): ZoomImageWheelState => ({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
export type Events = {
Zoom: [];
ZoomChange: [ZoomImageWheelState];
@@ -12,13 +20,7 @@ export type Events = {
};
export class AssetViewerManager extends BaseEventManager<Events> {
#zoomState = $state<ZoomImageWheelState>({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
#zoomState = $state(createDefaultZoomState());
imgRef = $state<HTMLImageElement | undefined>();
isShowActivityPanel = $state(false);
@@ -67,6 +69,10 @@ export class AssetViewerManager extends BaseEventManager<Events> {
this.#zoomState = state;
}
resetZoomState() {
this.zoomState = createDefaultZoomState();
}
toggleActivityPanel() {
this.closeDetailPanel();
this.isShowActivityPanel = !this.isShowActivityPanel;

View File

@@ -37,6 +37,7 @@ export type Events = {
AssetsArchive: [string[]];
AssetsDelete: [string[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
AlbumAddAssets: [];
AlbumUpdate: [AlbumResponseDto];

View File

@@ -59,12 +59,7 @@
size="small"
>
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput
class="immich-form-input text-gray-700 w-full mb-2"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
/>
<DateInput class="immich-form-input w-full mb-2" id="datetime" type="datetime-local" bind:value={selectedDate} />
{#if timezoneInput}
<div class="w-full">
<Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} />

View File

@@ -77,11 +77,7 @@
</Field>
{#if showRelative}
<Label for="relativedatetime" class="block mb-1">{$t('offset')}</Label>
<DurationInput
class="immich-form-input w-full text-gray-700 mb-2"
id="relativedatetime"
bind:value={selectedDuration}
/>
<DurationInput class="immich-form-input w-full mb-2" id="relativedatetime" bind:value={selectedDuration} />
{:else}
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput class="immich-form-input w-full mb-2" id="datetime" type="datetime-local" bind:value={selectedDate} />

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { eventManager } from '$lib/managers/event-manager.svelte';
import { tagAssets } from '$lib/utils/asset-utils';
import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk';
import { FormModal, Icon } from '@immich/ui';
@@ -9,7 +10,7 @@
import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte';
interface Props {
onClose: (success?: true) => void;
onClose: () => void;
assetIds: string[];
}
@@ -30,8 +31,9 @@
return;
}
await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
onClose(true);
const updatedIds = await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
eventManager.emit('AssetsTag', updatedIds);
onClose();
};
const handleSelect = async (option?: ComboBoxOption) => {
@@ -80,7 +82,7 @@
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import SharedLinkFormFields from '$lib/components/SharedLinkFormFields.svelte';
import { handleCreateSharedLink } from '$lib/services/shared-link.service';
import { SharedLinkType } from '@immich/sdk';
import { Field, FormModal, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { FormModal } from '@immich/ui';
import { mdiLink } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -24,12 +24,6 @@
let type = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
$effect(() => {
if (!showMetadata) {
allowDownload = false;
}
});
const onSubmit = async () => {
const success = await handleCreateSharedLink({
type,
@@ -65,36 +59,13 @@
<div>{$t('create_link_to_share_description')}</div>
{/if}
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
</Field>
</div>
<SharedLinkFormFields
bind:slug
bind:password
bind:description
bind:allowDownload
bind:allowUpload
bind:showMetadata
bind:expiresAt
/>
</FormModal>

View File

@@ -2,6 +2,7 @@ import { ProjectionType } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import { getAssetJobName, getSharedLink, sleep } from '$lib/utils';
@@ -41,6 +42,7 @@ import {
mdiMotionPauseOutline,
mdiMotionPlayOutline,
mdiShareVariantOutline,
mdiTagPlusOutline,
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
@@ -49,6 +51,7 @@ import { get } from 'svelte/store';
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
const sharedLink = getSharedLink();
const currentAuthUser = get(authUser);
const userPreferences = get(preferences);
const isOwner = !!(currentAuthUser && currentAuthUser.id === asset.ownerId);
const Share: ActionItem = {
@@ -155,7 +158,16 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
type: $t('assets'),
$if: () => asset.hasMetadata,
onAction: () => assetViewerManager.toggleDetailPanel(),
shortcuts: [{ key: 'i' }],
shortcuts: { key: 'i' },
};
const Tag: ActionItem = {
title: $t('add_tag'),
icon: mdiTagPlusOutline,
type: $t('assets'),
$if: () => userPreferences.tags.enabled,
onAction: () => modalManager.show(AssetTagModal, { assetIds: [asset.id] }),
shortcuts: { key: 't' },
};
const Edit: ActionItem = {
@@ -212,6 +224,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
ZoomIn,
ZoomOut,
Copy,
Tag,
Edit,
RefreshFacesJob,
RefreshMetadataJob,

View File

@@ -67,6 +67,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
color: 'danger',
onAction: () => handleDeleteLibrary(library),
shortcuts: { key: 'Backspace' },
shortcutOptions: { ignoreInputFields: true },
};
const AddFolder: ActionItem = {

View File

@@ -67,6 +67,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
$if: () => get(authUser).id !== user.id && !user.deletedAt,
onAction: () => modalManager.show(UserDeleteConfirmModal, { user }),
shortcuts: { key: 'Backspace' },
shortcutOptions: { ignoreInputFields: true },
};
const getDeleteDate = (deletedAt: string): Date =>

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import SharedLinkFormFields from '$lib/components/SharedLinkFormFields.svelte';
import { Route } from '$lib/route';
import { handleUpdateSharedLink } from '$lib/services/shared-link.service';
import { SharedLinkType } from '@immich/sdk';
import { Field, FormModal, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { FormModal } from '@immich/ui';
import { mdiLink } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -61,36 +61,14 @@
</div>
{/if}
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration createdAt={sharedLink.createdAt} bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
</Field>
</div>
<SharedLinkFormFields
bind:slug
bind:password
bind:description
bind:allowDownload
bind:allowUpload
bind:showMetadata
bind:expiresAt
createdAt={sharedLink.createdAt}
/>
</FormModal>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, invalidateAll } 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';
@@ -48,10 +48,7 @@
const { children, data }: Props = $props();
let user = $state(data.user);
const userPreferences = $state(data.userPreferences);
const userStatistics = $state(data.userStatistics);
const userSessions = $state(data.userSessions);
const { user, userPreferences, userStatistics, userSessions } = $derived(data);
const TiB = 1024 ** 4;
const usage = $derived(user.quotaUsageInBytes ?? 0);
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
@@ -79,9 +76,10 @@
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
const onUpdate = (update: UserAdminResponseDto) => {
const onUpdate = async (update: UserAdminResponseDto) => {
if (update.id === user.id) {
user = update;
data.user = update;
await invalidateAll();
}
};

View File

@@ -16,12 +16,10 @@
let { data }: Props = $props();
const user = $state(data.user);
let isAdmin = $state(user.isAdmin);
let name = $state(user.name);
let email = $state(user.email);
let storageLabel = $state(user.storageLabel || '');
const previousQuota = $state(user.quotaSizeInBytes);
const user = $derived(data.user);
let { isAdmin, name, email } = $derived(user);
let storageLabel = $derived(user.storageLabel || '');
const previousQuota = $derived(user.quotaSizeInBytes);
let quotaSize = $derived(
typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined,