Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { RouteDataPendingScreen } from "@solid-imager/ui/router-status";
import { MediaDetailScreen } from "@solid-imager/ui/screens/media-detail-screen";
import { ClientOnly, createFileRoute } from "@tanstack/solid-router";
import {
ClientOnly,
createFileRoute,
useRouterState,
} from "@tanstack/solid-router";
import { type Accessor, createSignal, onMount, Show } from "solid-js";
import { MediaSidebar } from "~/components/media/media-sidebar";
import { MediaViewer } from "~/components/media/media-viewer";
Expand All @@ -21,6 +25,10 @@ interface MediaRouteParams {

export const Route = createFileRoute("/sources/$mediaSourceId/$mediaId/")({
ssr: true,
remountDeps: ({ params }: { params: MediaRouteParams }) => [
params.mediaSourceId,
params.mediaId,
],
loader: async ({ context, params }: RouteLoaderContext<MediaRouteParams>) => {
await Promise.all([
context.queryClient.prefetchQuery(
Expand Down Expand Up @@ -70,7 +78,13 @@ function MediaRouteFallback() {

function MediaRouteContent() {
const routeData = Route.useLoaderData();
const currentParams = Route.useParams();
const currentParams = useRouterState({
select: (state) =>
state.matches.find(
(match: { routeId: string; params: MediaRouteParams }) =>
match.routeId === Route.id,
)?.params,
});
const mediaSourceId = () =>
currentParams()?.mediaSourceId ?? routeData().mediaSourceId;
const mediaId = () => currentParams()?.mediaId ?? routeData().mediaId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createPresetClient } from "@solid-imager/ui/preset-client";
import { sourceMediaQueryKeys } from "@solid-imager/ui/query-options";
import { RouteDataPendingScreen } from "@solid-imager/ui/router-status";
import { SourceMediaPage as SourceMediaPageComponent } from "@solid-imager/ui/source-media-page";
import { useQueryClient } from "@tanstack/solid-query";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { useParams } from "@tanstack/solid-router";
import { createSignal, onMount, Show } from "solid-js";
import { BulkActionDialog } from "~/components/media/bulk-action-dialog";
Expand All @@ -25,6 +25,7 @@ import {
allCharactersQueryOptions,
allIpsQueryOptions,
allProjectsQueryOptions,
mediaSourcesQueryOptions,
tagsQueryOptions,
} from "~/infrastructure/api-clients/queries";
import { searchMedia } from "~/infrastructure/api-clients/search-api";
Expand All @@ -47,6 +48,9 @@ export function SourceMediaPage() {
const mediaSourceId = () => params().mediaSourceId;
const queryClient = useQueryClient();
const [isMounted, setIsMounted] = createSignal(false);
const mediaSources = createQuery(mediaSourcesQueryOptions);
const mediaSourceName = () =>
mediaSources.data?.find((source) => source.id === mediaSourceId())?.name;

const transport = createServerTransport(mediaSourceId);

Expand Down Expand Up @@ -98,6 +102,7 @@ export function SourceMediaPage() {
<SourceMediaPageComponent
enableVirtualization
mediaSourceId={mediaSourceId}
mediaSourceName={mediaSourceName}
transport={transport}
presetClient={PresetClient}
actions={{
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/routes/sources/$mediaSourceId/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,25 @@ import {
allCharactersQueryOptions,
allIpsQueryOptions,
allProjectsQueryOptions,
mediaSourcesQueryOptions,
tagsQueryOptions,
} from "~/infrastructure/api-clients/queries";
import type { RouteLoaderContext } from "~/infrastructure/router/route-types";
import { SourceMediaPage } from "./components/source-media-page";

export const Route = createFileRoute("/sources/$mediaSourceId/")({
ssr: true,
remountDeps: ({ params }: { params: { mediaSourceId: string } }) => [
params.mediaSourceId,
],
loader: async ({ context }: RouteLoaderContext) => {
await Promise.all([
context.queryClient.prefetchQuery(tagsQueryOptions()),
context.queryClient.prefetchQuery(allProjectsQueryOptions()),
context.queryClient.prefetchQuery(allIpsQueryOptions()),
context.queryClient.prefetchQuery(allCharactersQueryOptions()),
context.queryClient.prefetchQuery(allAuthorsQueryOptions()),
context.queryClient.prefetchQuery(mediaSourcesQueryOptions()),
]);
},
pendingComponent: SourceMediaRouteFallback,
Expand Down
54 changes: 54 additions & 0 deletions apps/server/src/tests/e2e/app-nav.responsive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,55 @@ async function expectNoHorizontalOverflow(page: Page): Promise<void> {
expect(overflow).toBeLessThanOrEqual(1);
}

async function expectTextContrast(
page: Page,
selector: string,
backgroundSelector: string,
): Promise<void> {
const contrast = await page.evaluate(
({ selector, backgroundSelector }) => {
const element = document.querySelector(selector);
const background = document.querySelector(backgroundSelector);
if (
!(element instanceof HTMLElement) ||
!(background instanceof HTMLElement)
) {
throw new Error("Navigation link or drawer was not found");
}

const parseRgb = (color: string): number[] =>
color
.match(/\d+(?:\.\d+)?/g)
?.slice(0, 3)
.map(Number) ?? [];
const relativeLuminance = ([red, green, blue]: number[]): number => {
const [linearRed, linearGreen, linearBlue] = [red, green, blue].map(
(channel) => {
const normalized = channel / 255;
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
},
);
return 0.2126 * linearRed + 0.7152 * linearGreen + 0.0722 * linearBlue;
};

const foreground = relativeLuminance(
parseRgb(window.getComputedStyle(element).color),
);
const drawerBackground = relativeLuminance(
parseRgb(window.getComputedStyle(background).backgroundColor),
);
return (
(Math.max(foreground, drawerBackground) + 0.05) /
(Math.min(foreground, drawerBackground) + 0.05)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
{ selector, backgroundSelector },
);
expect(contrast).toBeGreaterThanOrEqual(4.5);
}

test("app navigation is responsive and accessible", async ({
page,
}, testInfo) => {
Expand All @@ -36,6 +85,11 @@ test("app navigation is responsive and accessible", async ({
await expect(
dialog.getByRole("link", { name: "About", exact: true }),
).toHaveAttribute("aria-current", "page");
await expectTextContrast(
page,
'[role="dialog"] a[href="/search"]',
'[role="dialog"]',
);

await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
Expand Down
37 changes: 36 additions & 1 deletion apps/server/src/tests/e2e/loading-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Page } from "@playwright/test";
import {
E2E_PRIMARY_FILE_NAME,
E2E_SOURCE_NAME,
mediaPath,
sourcePath,
} from "./support/fixture";
Expand Down Expand Up @@ -30,6 +32,29 @@ const networkFailures = [
},
] as const;

async function getGridColumnCount(
page: Page,
selector: string,
): Promise<number> {
return await page
.locator(selector)
.evaluate(
(element) =>
window.getComputedStyle(element).gridTemplateColumns.split(" ").length,
);
}

async function getVisibleSkeletonItemCount(page: Page): Promise<number> {
return await page
.locator('[data-skeleton="media-grid"] > div > [aria-hidden="true"]')
.evaluateAll(
(elements) =>
elements.filter(
(element) => window.getComputedStyle(element).display !== "none",
).length,
);
}

test.describe("loading and recovery", () => {
test("keeps the app shell visible while the initial search response is delayed", async ({
page,
Expand Down Expand Up @@ -63,6 +88,13 @@ test.describe("loading and recovery", () => {
.locator('[data-skeleton="media-grid"] [aria-hidden="true"]')
.first(),
).toHaveCSS("animation-name", "none");
const skeletonColumnCount = await getGridColumnCount(
page,
'[data-skeleton="media-grid"] > div',
);
expect(await getVisibleSkeletonItemCount(page)).toBe(
skeletonColumnCount * 2,
);
await expect(
page.getByText("APIの応答を待っています...", { exact: true }),
).toBeVisible();
Expand All @@ -72,6 +104,9 @@ test.describe("loading and recovery", () => {
await expect(
page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }),
).toBeVisible();
expect(await getGridColumnCount(page, "[data-media-grid]")).toBe(
skeletonColumnCount,
);
await expect(screenSkeleton).toHaveCount(0);
});

Expand Down Expand Up @@ -148,7 +183,7 @@ test.describe("loading and recovery", () => {
await expect(page.getByRole("link", { name: "Home" })).toBeVisible();
await expect(
page.getByRole("heading", {
name: /Media in Source:/,
name: E2E_SOURCE_NAME,
}),
).toBeVisible();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ test("media detail follows the second search result after returning to search",
test("media detail, manager, and settings remain usable on narrow screens", async ({
page,
}, testInfo) => {
if (testInfo.project.name === "responsive-768") {
await page.setViewportSize({ width: 940, height: 1036 });
}
await page.goto(mediaPath());
await expect(
page.getByRole("heading", { name: E2E_PRIMARY_FILE_NAME, exact: true }),
Expand All @@ -117,6 +120,37 @@ test("media detail, manager, and settings remain usable on narrow screens", asyn
await expect(
page.getByRole("img", { name: E2E_PRIMARY_FILE_NAME, exact: true }),
).toBeVisible();
if (testInfo.project.name === "responsive-desktop") {
const verticalOverflow = await page.evaluate(
() => document.documentElement.scrollHeight - window.innerHeight,
);
expect(verticalOverflow).toBeLessThanOrEqual(1);
const viewer = page.locator("[data-media-viewer]");
const image = page.getByRole("img", {
name: E2E_PRIMARY_FILE_NAME,
exact: true,
});
const viewerState = await Promise.all([
viewer.evaluate((element) => getComputedStyle(element).backgroundColor),
viewer.evaluate((element) => element.clientHeight),
image.evaluate((element) => element.clientHeight),
]);
expect(viewerState[0]).toBe("rgba(0, 0, 0, 0)");
expect(viewerState[2]).toBe(viewerState[1]);
}
if (testInfo.project.name === "responsive-768") {
const viewer = page.locator("[data-media-viewer]");
const image = page.getByRole("img", {
name: E2E_PRIMARY_FILE_NAME,
exact: true,
});
const viewerBox = await viewer.boundingBox();
const imageBox = await image.boundingBox();
expect(viewerBox).not.toBeNull();
expect(imageBox).not.toBeNull();
expect(viewerBox?.width ?? 0).toBeGreaterThanOrEqual(900);
expect(imageBox?.width ?? 0).toBeGreaterThanOrEqual(900);
}
const detailsHeading = page.getByRole("heading", {
name: "Details",
exact: true,
Expand Down
5 changes: 2 additions & 3 deletions apps/server/src/tests/e2e/route-reload.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Page } from "@playwright/test";
import {
E2E_PRIMARY_FILE_NAME,
E2E_SOURCE_ID,
E2E_SOURCE_NAME,
mediaPath,
sourcePath,
Expand Down Expand Up @@ -140,7 +139,7 @@ const routeCases: readonly RouteCase[] = [
{
name: "seeded source",
path: sourcePath(),
heading: `Media in Source: ${E2E_SOURCE_ID}`,
heading: E2E_SOURCE_NAME,
ssrText: "メディア一覧を準備しています...",
hydratedEndpoints: sourceMediaFilterEndpoints,
clientEndpoints: ["/api/rpc/media/search"],
Expand Down Expand Up @@ -413,7 +412,7 @@ test("SPA intent prefetch and cache revisit do not duplicate route queries", asy
await expect(page).toHaveURL(new RegExp(`${sourcePath()}/?$`));
await expect(
page.getByRole("heading", {
name: `Media in Source: ${E2E_SOURCE_ID}`,
name: E2E_SOURCE_NAME,
exact: true,
}),
).toBeVisible();
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/tests/e2e/search-pro-dialog.responsive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { expect, test } from "./support/test";

test("pro search dialog keeps the value input focused while typing", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "responsive-desktop",
"The pro-search editor is shown in the desktop filter panel.",
);

await page.goto("/search");
await page.getByRole("button", { name: "詳細", exact: true }).click();
await page.getByRole("button", { name: "詳細条件を編集" }).click();

const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "+ 条件" }).click();

const valueInput = dialog.getByPlaceholder("値...");
await valueInput.pressSequentially("focus");
await expect(valueInput).toHaveValue("focus");
await expect(valueInput).toBeFocused();
});
39 changes: 39 additions & 0 deletions apps/server/src/tests/e2e/search.responsive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,45 @@ test("search keeps controls usable without horizontal overflow", async ({
page.getByRole("heading", { name: "検索フィルター", exact: true }),
).toBeVisible();
await expect(page.getByPlaceholder("ファイル名を入力...")).toBeVisible();

if (testInfo.project.name === "responsive-768") {
await page.setViewportSize({ width: 768, height: 480 });
const filterCard = page
.getByRole("heading", { name: "検索フィルター", exact: true })
.locator("..")
.locator("..");
const scrollState = await filterCard.evaluate((element) => {
element.scrollTop = element.scrollHeight;
const bounds = element.getBoundingClientRect();
return {
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
scrollTop: element.scrollTop,
isWithinViewport:
bounds.top >= 0 && bounds.bottom <= window.innerHeight,
};
});
expect(scrollState.scrollHeight).toBeGreaterThan(
scrollState.clientHeight,
);
expect(scrollState.scrollTop).toBeGreaterThan(0);
expect(scrollState.isWithinViewport).toBe(true);

const lastFilter = filterCard.getByPlaceholder("プロジェクトを検索...");
await expect(lastFilter).toBeVisible();
expect(
await lastFilter.evaluate((element) => {
const card = element.closest(".sticky");
if (!(card instanceof HTMLElement)) return false;
const inputBounds = element.getBoundingClientRect();
const cardBounds = card.getBoundingClientRect();
return (
inputBounds.top >= cardBounds.top &&
inputBounds.bottom <= cardBounds.bottom
);
}),
).toBe(true);
}
}

await expectNoHorizontalOverflow(page);
Expand Down
Loading