From cdfbd49b2a3c2ae8a2765adc901b87daebd7ebf2 Mon Sep 17 00:00:00 2001 From: midzelis Date: Thu, 15 Jan 2026 20:34:21 +0000 Subject: [PATCH] feat: add responsive layout to broken asset --- .../ui/mock-network/broken-asset-network.ts | 167 ++++++++++++++++++ .../asset-viewer/broken-asset.e2e-spec.ts | 84 +++++++++ e2e/src/ui/specs/asset-viewer/utils.ts | 116 ++++++++++++ .../lib/components/assets/broken-asset.svelte | 19 +- 4 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 e2e/src/ui/mock-network/broken-asset-network.ts create mode 100644 e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts create mode 100644 e2e/src/ui/specs/asset-viewer/utils.ts diff --git a/e2e/src/ui/mock-network/broken-asset-network.ts b/e2e/src/ui/mock-network/broken-asset-network.ts new file mode 100644 index 0000000000..1494b40531 --- /dev/null +++ b/e2e/src/ui/mock-network/broken-asset-network.ts @@ -0,0 +1,167 @@ +import { faker } from '@faker-js/faker'; +import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk'; +import { BrowserContext } from '@playwright/test'; +import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline'; + +export type MockStack = { + id: string; + primaryAssetId: string; + assets: AssetResponseDto[]; + brokenAssetIds: Set; + assetMap: Map; +}; + +export const createMockStackAsset = (ownerId: string): AssetResponseDto => { + const assetId = faker.string.uuid(); + const now = new Date().toISOString(); + return { + id: assetId, + deviceAssetId: `device-${assetId}`, + ownerId, + owner: { + id: ownerId, + email: 'admin@immich.cloud', + name: 'Admin', + profileImagePath: '', + profileChangedAt: now, + avatarColor: 'blue' as never, + }, + libraryId: `library-${ownerId}`, + deviceId: `device-${ownerId}`, + type: AssetTypeEnum.Image, + originalPath: `/original/${assetId}.jpg`, + originalFileName: `${assetId}.jpg`, + originalMimeType: 'image/jpeg', + thumbhash: null, + fileCreatedAt: now, + fileModifiedAt: now, + localDateTime: now, + updatedAt: now, + createdAt: now, + isFavorite: false, + isArchived: false, + isTrashed: false, + visibility: AssetVisibility.Timeline, + duration: '0:00:00.00000', + exifInfo: { + make: null, + model: null, + exifImageWidth: 3000, + exifImageHeight: 4000, + fileSizeInByte: null, + orientation: null, + dateTimeOriginal: now, + modifyDate: null, + timeZone: null, + lensModel: null, + fNumber: null, + focalLength: null, + iso: null, + exposureTime: null, + latitude: null, + longitude: null, + city: null, + country: null, + state: null, + description: null, + }, + livePhotoVideoId: null, + tags: [], + people: [], + unassignedFaces: [], + stack: null, + isOffline: false, + hasMetadata: true, + duplicateId: null, + resized: true, + checksum: faker.string.alphanumeric({ length: 28 }), + width: 3000, + height: 4000, + isEdited: false, + }; +}; + +export const createMockStack = ( + primaryAssetDto: AssetResponseDto, + additionalAssets: AssetResponseDto[], + brokenAssetIds?: Set, +): MockStack => { + const stackId = faker.string.uuid(); + const allAssets = [primaryAssetDto, ...additionalAssets]; + const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id)); + const assetMap = new Map(allAssets.map((a) => [a.id, a])); + + primaryAssetDto.stack = { + id: stackId, + assetCount: allAssets.length, + primaryAssetId: primaryAssetDto.id, + }; + + return { + id: stackId, + primaryAssetId: primaryAssetDto.id, + assets: allAssets, + brokenAssetIds: resolvedBrokenIds, + assetMap, + }; +}; + +export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => { + await context.route('**/api/stacks/*', async (route, request) => { + if (request.method() !== 'GET') { + return route.fallback(); + } + const stackResponse: StackResponseDto = { + id: mockStack.id, + primaryAssetId: mockStack.primaryAssetId, + assets: mockStack.assets, + }; + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: stackResponse, + }); + }); + + await context.route('**/api/assets/*', async (route, request) => { + if (request.method() !== 'GET') { + return route.fallback(); + } + const url = new URL(request.url()); + const segments = url.pathname.split('/'); + const assetId = segments.at(-1); + if (assetId && mockStack.assetMap.has(assetId)) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: mockStack.assetMap.get(assetId), + }); + } + return route.fallback(); + }); + + await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => { + if (!route.request().serviceWorker()) { + return route.continue(); + } + const pattern = /\/api\/assets\/(?[^/]+)\/thumbnail\?size=(?preview|thumbnail)/; + const match = request.url().match(pattern); + if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) { + return route.fallback(); + } + if (mockStack.brokenAssetIds.has(match.groups.assetId)) { + return route.fulfill({ status: 404 }); + } + const asset = mockStack.assetMap.get(match.groups.assetId)!; + const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000); + const body = + match.groups.size === 'preview' + ? await randomPreview(match.groups.assetId, ratio) + : await randomThumbnail(match.groups.assetId, ratio); + return route.fulfill({ + status: 200, + headers: { 'content-type': 'image/jpeg' }, + body, + }); + }); +}; diff --git a/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts new file mode 100644 index 0000000000..fdfa1045f3 --- /dev/null +++ b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from '@playwright/test'; +import { toAssetResponseDto } from 'src/ui/generators/timeline'; +import { + createMockStack, + createMockStackAsset, + MockStack, + setupBrokenAssetMockApiRoutes, +} from 'src/ui/mock-network/broken-asset-network'; +import { assetViewerUtils } from '../timeline/utils'; +import { setupAssetViewerFixture } from './utils'; + +test.describe.configure({ mode: 'parallel' }); +test.describe('broken-asset responsiveness', () => { + const fixture = setupAssetViewerFixture(889); + let mockStack: MockStack; + + test.beforeAll(async () => { + const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset); + + const brokenAssets = [ + createMockStackAsset(fixture.adminUserId), + createMockStackAsset(fixture.adminUserId), + createMockStackAsset(fixture.adminUserId), + ]; + + mockStack = createMockStack(primaryAssetDto, brokenAssets); + }); + + test.beforeEach(async ({ context }) => { + await setupBrokenAssetMockApiRoutes(context, mockStack); + }); + + test('broken asset in stack strip hides icon at small size', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const stackSlideshow = page.locator('#stack-slideshow'); + await expect(stackSlideshow).toBeVisible(); + + const brokenAssets = stackSlideshow.locator('[data-broken-asset]'); + await expect(brokenAssets.first()).toBeVisible(); + await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size); + + for (const brokenAsset of await brokenAssets.all()) { + await expect(brokenAsset.locator('svg')).toHaveCount(0); + } + }); + + test('broken asset in stack strip uses text-xs class', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const stackSlideshow = page.locator('#stack-slideshow'); + await expect(stackSlideshow).toBeVisible(); + + const brokenAssets = stackSlideshow.locator('[data-broken-asset]'); + await expect(brokenAssets.first()).toBeVisible(); + + for (const brokenAsset of await brokenAssets.all()) { + const messageSpan = brokenAsset.locator('span'); + await expect(messageSpan).toHaveClass(/text-xs/); + } + }); + + test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => { + await context.route( + (url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`), + async (route) => { + return route.fulfill({ status: 404 }); + }, + ); + + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await page.waitForSelector('#immich-asset-viewer'); + + const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]'); + await expect(viewerBrokenAsset).toBeVisible(); + + await expect(viewerBrokenAsset.locator('svg')).toBeVisible(); + + const messageSpan = viewerBrokenAsset.locator('span'); + await expect(messageSpan).toHaveClass(/text-base/); + }); +}); diff --git a/e2e/src/ui/specs/asset-viewer/utils.ts b/e2e/src/ui/specs/asset-viewer/utils.ts new file mode 100644 index 0000000000..adaace8a34 --- /dev/null +++ b/e2e/src/ui/specs/asset-viewer/utils.ts @@ -0,0 +1,116 @@ +import { faker } from '@faker-js/faker'; +import type { AssetResponseDto } from '@immich/sdk'; +import { BrowserContext, Page, test } from '@playwright/test'; +import { + Changes, + createDefaultTimelineConfig, + generateTimelineData, + SeededRandom, + selectRandom, + TimelineAssetConfig, + TimelineData, + toAssetResponseDto, +} from 'src/ui/generators/timeline'; +import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network'; +import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network'; +import { utils } from 'src/utils'; + +export type AssetViewerTestFixture = { + adminUserId: string; + timelineRestData: TimelineData; + assets: TimelineAssetConfig[]; + testContext: TimelineTestContext; + changes: Changes; + primaryAsset: TimelineAssetConfig; + primaryAssetDto: AssetResponseDto; +}; + +export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture { + const rng = new SeededRandom(seed); + const testContext = new TimelineTestContext(); + + const fixture: AssetViewerTestFixture = { + adminUserId: undefined!, + timelineRestData: undefined!, + assets: [], + testContext, + changes: { + albumAdditions: [], + assetDeletions: [], + assetArchivals: [], + assetFavorites: [], + }, + primaryAsset: undefined!, + primaryAssetDto: undefined!, + }; + + test.beforeAll(async () => { + test.fail( + process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1', + 'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1', + ); + utils.initSdk(); + fixture.adminUserId = faker.string.uuid(); + testContext.adminId = fixture.adminUserId; + fixture.timelineRestData = generateTimelineData({ + ...createDefaultTimelineConfig(), + ownerId: fixture.adminUserId, + }); + for (const timeBucket of fixture.timelineRestData.buckets.values()) { + fixture.assets.push(...timeBucket); + } + + fixture.primaryAsset = selectRandom( + fixture.assets.filter((a) => a.isImage), + rng, + ); + fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset); + }); + + test.beforeEach(async ({ context }) => { + await setupBaseMockApiRoutes(context, fixture.adminUserId); + await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext); + }); + + test.afterEach(() => { + fixture.testContext.slowBucket = false; + fixture.changes.albumAdditions = []; + fixture.changes.assetDeletions = []; + fixture.changes.assetArchivals = []; + fixture.changes.assetFavorites = []; + }); + + return fixture; +} + +export async function ensureDetailPanelVisible(page: Page) { + await page.waitForSelector('#immich-asset-viewer'); + + const isVisible = await page.locator('#detail-panel').isVisible(); + if (!isVisible) { + await page.keyboard.press('i'); + await page.waitForSelector('#detail-panel'); + } +} + +export async function enableTagsPreference(context: BrowserContext) { + await context.route('**/users/me/preferences', async (route) => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + albums: { defaultAssetOrder: 'desc' }, + folders: { enabled: false, sidebarWeb: false }, + memories: { enabled: true, duration: 5 }, + people: { enabled: true, sidebarWeb: false }, + sharedLinks: { enabled: true, sidebarWeb: false }, + ratings: { enabled: false }, + tags: { enabled: true, sidebarWeb: false }, + emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true }, + download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false }, + purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' }, + cast: { gCastEnabled: false }, + }, + }); + }); +} diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte index a15a787e64..77104df9f5 100644 --- a/web/src/lib/components/assets/broken-asset.svelte +++ b/web/src/lib/components/assets/broken-asset.svelte @@ -2,24 +2,35 @@ import { Icon } from '@immich/ui'; import { mdiImageBrokenVariant } from '@mdi/js'; import { t } from 'svelte-i18n'; + import type { ClassValue } from 'svelte/elements'; interface Props { - class?: string; + class?: ClassValue; hideMessage?: boolean; width?: string | undefined; height?: string | undefined; } let { class: className = '', hideMessage = false, width = undefined, height = undefined }: Props = $props(); + + let clientWidth = $state(0); + let textClass = $derived(clientWidth < 100 ? 'text-xs' : clientWidth < 150 ? 'text-sm' : 'text-base');
- + {#if clientWidth >= 75} + + {/if} {#if !hideMessage} - {$t('error_loading_image')} + {$t('error_loading_image')} {/if}