mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 11:49:46 +03:00
feat: add responsive layout to broken asset (#26384)
This commit is contained in:
167
e2e/src/ui/mock-network/broken-asset-network.ts
Normal file
167
e2e/src/ui/mock-network/broken-asset-network.ts
Normal file
@@ -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<string>;
|
||||||
|
assetMap: Map<string, AssetResponseDto>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string>,
|
||||||
|
): 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\/(?<assetId>[^/]+)\/thumbnail\?size=(?<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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
84
e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts
Normal file
84
e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts
Normal file
@@ -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')).not.toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
116
e2e/src/ui/specs/asset-viewer/utils.ts
Normal file
116
e2e/src/ui/specs/asset-viewer/utils.ts
Normal file
@@ -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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,15 +15,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
data-broken-asset
|
||||||
class={[
|
class={[
|
||||||
'flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4',
|
'@container flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4',
|
||||||
className,
|
className,
|
||||||
]}
|
]}
|
||||||
style:width
|
style:width
|
||||||
style:height
|
style:height
|
||||||
>
|
>
|
||||||
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
|
<div class="hidden @min-[75px]:block">
|
||||||
|
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
|
||||||
|
</div>
|
||||||
{#if !hideMessage}
|
{#if !hideMessage}
|
||||||
<span class="text-center">{$t('error_loading_image')}</span>
|
<span class="text-center text-xs @min-[100px]:text-sm @min-[150px]:text-base">{$t('error_loading_image')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user