diff --git a/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts b/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts
new file mode 100644
index 0000000000..1f5bdfdf2e
--- /dev/null
+++ b/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts
@@ -0,0 +1,273 @@
+import { faker } from '@faker-js/faker';
+import { expect, test } from '@playwright/test';
+import {
+ Changes,
+ createDefaultTimelineConfig,
+ generateTimelineData,
+ SeededRandom,
+ selectRandom,
+ TimelineAssetConfig,
+ TimelineData,
+} 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 { assetViewerUtils } from 'src/ui/specs/timeline/utils';
+import { utils } from 'src/utils';
+
+test.describe.configure({ mode: 'parallel' });
+test.describe('asset-viewer', () => {
+ const rng = new SeededRandom(529);
+ let adminUserId: string;
+ let timelineRestData: TimelineData;
+ const assets: TimelineAssetConfig[] = [];
+ const yearMonths: string[] = [];
+ const testContext = new TimelineTestContext();
+ const changes: Changes = {
+ albumAdditions: [],
+ assetDeletions: [],
+ assetArchivals: [],
+ assetFavorites: [],
+ };
+
+ test.beforeAll(async () => {
+ utils.initSdk();
+ adminUserId = faker.string.uuid();
+ testContext.adminId = adminUserId;
+ timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
+ for (const timeBucket of timelineRestData.buckets.values()) {
+ assets.push(...timeBucket);
+ }
+ for (const yearMonth of timelineRestData.buckets.keys()) {
+ const [year, month] = yearMonth.split('-');
+ yearMonths.push(`${year}-${Number(month)}`);
+ }
+ });
+
+ test.beforeEach(async ({ context }) => {
+ await setupBaseMockApiRoutes(context, adminUserId);
+ await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
+ });
+
+ test.afterEach(() => {
+ testContext.slowBucket = false;
+ changes.albumAdditions = [];
+ changes.assetDeletions = [];
+ changes.assetArchivals = [];
+ changes.assetFavorites = [];
+ });
+
+ test.describe('/photos/:id', () => {
+ test('Navigate to next asset via button', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ const index = assets.indexOf(asset);
+ await page.goto(`/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+ await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
+
+ await page.getByLabel('View next asset').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
+ await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
+ });
+
+ test('Navigate to previous asset via button', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ const index = assets.indexOf(asset);
+ await page.goto(`/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+ await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
+
+ await page.getByLabel('View previous asset').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
+ await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
+ });
+
+ test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ const index = assets.indexOf(asset);
+ await page.goto(`/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+ await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
+
+ await page.getByTestId('next-asset').waitFor();
+ await page.keyboard.press('ArrowRight');
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
+ await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
+ });
+
+ test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ const index = assets.indexOf(asset);
+ await page.goto(`/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+ await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
+
+ await page.getByTestId('previous-asset').waitFor();
+ await page.keyboard.press('ArrowLeft');
+ await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
+ await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
+ });
+
+ test('Navigate forward 5 times via button', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ const index = assets.indexOf(asset);
+ await page.goto(`/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+
+ for (let i = 1; i <= 5; i++) {
+ await page.getByLabel('View next asset').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
+ await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
+ }
+ });
+
+ test('Navigate backward 5 times via button', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ const index = assets.indexOf(asset);
+ await page.goto(`/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+
+ for (let i = 1; i <= 5; i++) {
+ await page.getByLabel('View previous asset').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
+ await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
+ }
+ });
+
+ test('Navigate forward then backward via keyboard', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ const index = assets.indexOf(asset);
+ await page.goto(`/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+
+ // Navigate forward 3 times
+ for (let i = 1; i <= 3; i++) {
+ await page.getByTestId('next-asset').waitFor();
+ await page.keyboard.press('ArrowRight');
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
+ }
+
+ // Navigate backward 3 times to return to original
+ for (let i = 2; i >= 0; i--) {
+ await page.getByTestId('previous-asset').waitFor();
+ await page.keyboard.press('ArrowLeft');
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
+ }
+
+ // Verify we're back at the original asset
+ await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
+ });
+
+ test('Verify no next button on last asset', async ({ page }) => {
+ const lastAsset = assets.at(-1)!;
+ await page.goto(`/photos/${lastAsset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, lastAsset);
+
+ // Verify next button doesn't exist
+ await expect(page.getByLabel('View next asset')).toHaveCount(0);
+ });
+
+ test('Verify no previous button on first asset', async ({ page }) => {
+ const firstAsset = assets[0];
+ await page.goto(`/photos/${firstAsset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, firstAsset);
+
+ // Verify previous button doesn't exist
+ await expect(page.getByLabel('View previous asset')).toHaveCount(0);
+ });
+
+ test('Delete photo advances to next', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ await page.goto(`/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+ await page.getByLabel('Delete').click();
+ const index = assets.indexOf(asset);
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
+ });
+ test('Delete photo advances to next (2x)', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ await page.goto(`/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+ await page.getByLabel('Delete').click();
+ const index = assets.indexOf(asset);
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
+ await page.getByLabel('Delete').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
+ });
+ test('Delete last photo advances to prev', async ({ page }) => {
+ const asset = assets.at(-1)!;
+ await page.goto(`/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+ await page.getByLabel('Delete').click();
+ const index = assets.indexOf(asset);
+ await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
+ });
+ test('Delete last photo advances to prev (2x)', async ({ page }) => {
+ const asset = assets.at(-1)!;
+ await page.goto(`/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+ await page.getByLabel('Delete').click();
+ const index = assets.indexOf(asset);
+ await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
+ await page.getByLabel('Delete').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
+ });
+ });
+ test.describe('/trash/photos/:id', () => {
+ test('Delete trashed photo advances to next', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ const index = assets.indexOf(asset);
+ const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
+ changes.assetDeletions.push(...deletedAssets);
+ await page.goto(`/trash/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+ await page.getByLabel('Delete').click();
+ // confirm dialog
+ await page.getByRole('button').getByText('Delete').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
+ });
+ test('Delete trashed photo advances to next 2x', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ const index = assets.indexOf(asset);
+ const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
+ changes.assetDeletions.push(...deletedAssets);
+ await page.goto(`/trash/photos/${asset.id}`);
+ await assetViewerUtils.waitForViewerLoad(page, asset);
+ await page.getByLabel('Delete').click();
+ // confirm dialog
+ await page.getByRole('button').getByText('Delete').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
+ await page.getByLabel('Delete').click();
+ // confirm dialog
+ await page.getByRole('button').getByText('Delete').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
+ });
+ test('Delete trashed photo advances to prev', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ const index = assets.indexOf(asset);
+ const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
+ changes.assetDeletions.push(...deletedAssets);
+ await page.goto(`/trash/photos/${assets[index + 9].id}`);
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
+ await page.getByLabel('Delete').click();
+ // confirm dialog
+ await page.getByRole('button').getByText('Delete').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
+ });
+ test('Delete trashed photo advances to prev 2x', async ({ page }) => {
+ const asset = selectRandom(assets, rng);
+ const index = assets.indexOf(asset);
+ const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
+ changes.assetDeletions.push(...deletedAssets);
+ await page.goto(`/trash/photos/${assets[index + 9].id}`);
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
+ await page.getByLabel('Delete').click();
+ // confirm dialog
+ await page.getByRole('button').getByText('Delete').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
+ await page.getByLabel('Delete').click();
+ // confirm dialog
+ await page.getByRole('button').getByText('Delete').click();
+ await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
+ });
+ });
+});
diff --git a/web/src/app.css b/web/src/app.css
index 57cb503f82..687a105d2c 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -219,6 +219,14 @@
animation: var(--vt-duration-default) 0s panelSlideInRight forwards;
}
+ ::view-transition-group(detail-panel) {
+ z-index: 1;
+ }
+ ::view-transition-old(detail-panel),
+ ::view-transition-new(detail-panel) {
+ animation: none;
+ }
+
::view-transition-group(exclude-previousbutton),
::view-transition-group(exclude-nextbutton),
::view-transition-group(exclude) {
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
index 42c371f593..31815dcccc 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -1,3 +1,9 @@
+
+