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
2 changes: 1 addition & 1 deletion apps/server/src/components/media/media-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class ApiMediaSource implements MediaSource {

async getUrl() {
const url = `/api/sources/${this.media.mediaSourceId}/${this.media.id}`;
const response = await fetch(url);
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ export const Route = createFileRoute("/api/sources/$mediaSourceId/$mediaId")({

const contentType = getContentTypeFromExtension(media.fileName);
return new Response(file, {
headers: { "Content-Type": contentType },
headers: {
"Cache-Control": "no-store",
"Content-Type": contentType,
},
});
},
},
Expand Down
32 changes: 24 additions & 8 deletions apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { RouteDataPendingScreen } from "@solid-imager/ui/router-status";
import { MediaDetailScreen } from "@solid-imager/ui/screens/media-detail-screen";
import { ClientOnly, createFileRoute, useParams } from "@tanstack/solid-router";
import { createSignal, onMount, Show } from "solid-js";
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";
import { createServerTransport } from "~/hooks/use-media-source-events";
Expand All @@ -27,6 +31,10 @@ export const Route = createFileRoute("/sources/$mediaSourceId/$mediaId/")({
context.queryClient.prefetchQuery(allIpsQueryOptions()),
context.queryClient.prefetchQuery(allCharactersQueryOptions()),
]);
return {
mediaId: params.mediaId,
mediaSourceId: params.mediaSourceId,
};
},
pendingComponent: MediaRouteFallback,
pendingMinMs: 0,
Expand Down Expand Up @@ -59,17 +67,25 @@ function MediaRouteFallback() {
}

function MediaRouteContent() {
const params = useParams({ from: "/sources/$mediaSourceId/$mediaId/" });
const mediaSourceId = () => params().mediaSourceId;
const mediaId = () => params().mediaId;
const routeData = Route.useLoaderData();
const currentParams = useRouterState({
select: (state) =>
state.matches.find((match) => match.routeId === Route.id)?.params,
});
const mediaSourceId = () =>
currentParams()?.mediaSourceId ?? routeData().mediaSourceId;
const mediaId = () => currentParams()?.mediaId ?? routeData().mediaId;
return (
<ClientOnly fallback={<MediaRouteFallback />}>
<MediaContent mediaId={mediaId()} mediaSourceId={mediaSourceId()} />
<MediaContent mediaId={mediaId} mediaSourceId={mediaSourceId} />
</ClientOnly>
);
}

function MediaContent(props: { mediaId: string; mediaSourceId: string }) {
function MediaContent(props: {
mediaId: Accessor<string>;
mediaSourceId: Accessor<string>;
}) {
return (
<MediaDetailScreen
mediaDetailsQueryOptions={mediaDetailsQueryOptions}
Expand All @@ -83,7 +99,7 @@ function MediaContent(props: { mediaId: string; mediaSourceId: string }) {
/>
)}
renderMediaViewer={(media) => <MediaViewer media={media} />}
transport={createServerTransport(() => props.mediaSourceId)}
transport={createServerTransport(props.mediaSourceId)}
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { Page } from "@playwright/test";
import { E2E_PRIMARY_FILE_NAME, mediaPath } from "./support/fixture";
import {
E2E_PRIMARY_FILE_NAME,
E2E_PRIMARY_MEDIA_ID,
E2E_SIMILAR_FILE_NAME,
E2E_SIMILAR_MEDIA_ID,
mediaPath,
sourcePath,
} from "./support/fixture";
import { expect, test, waitForAppHydration } from "./support/test";

const mobileProjects = ["responsive-320", "responsive-375"];
Expand All @@ -13,6 +20,92 @@ async function expectNoHorizontalOverflow(page: Page): Promise<void> {
expect(overflow).toBeLessThanOrEqual(1);
}

async function sampleImagePixel(
page: Page,
accessibleName: string,
): Promise<number[]> {
const image = page.locator("img.object-contain").and(
page.getByRole("img", {
name: accessibleName,
exact: true,
}),
);
await expect(image).toBeVisible();
return image.evaluate((element) => {
if (!(element instanceof HTMLImageElement)) {
throw new Error("Expected an image element");
}
const canvas = document.createElement("canvas");
canvas.width = element.naturalWidth;
canvas.height = element.naturalHeight;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Failed to create a 2D canvas context");
}
context.drawImage(element, 0, 0);
return [...context.getImageData(128, 64, 1, 1).data];
});
}

test("media detail follows the second thumbnail after returning to the list", async ({
page,
}) => {
await page.goto(sourcePath());
await waitForAppHydration(page);

const primaryResponse = page.waitForResponse(
(response) =>
response.url().endsWith(mediaPath(E2E_PRIMARY_MEDIA_ID)) &&
response.request().resourceType() === "fetch",
);
await page.locator(`[data-media-id="${E2E_PRIMARY_MEDIA_ID}"]`).click();
await expect(page).toHaveURL(mediaPath(E2E_PRIMARY_MEDIA_ID));
expect((await primaryResponse).headers()["cache-control"]).toBe("no-store");
await expect(
page.getByRole("img", { name: E2E_PRIMARY_FILE_NAME, exact: true }),
).toBeVisible();
const primaryPixel = await sampleImagePixel(page, E2E_PRIMARY_FILE_NAME);

await page.goBack();
await expect(page).toHaveURL(sourcePath());
const similarResponse = page.waitForResponse(
(response) =>
response.url().endsWith(mediaPath(E2E_SIMILAR_MEDIA_ID)) &&
response.request().resourceType() === "fetch",
);
await page.locator(`[data-media-id="${E2E_SIMILAR_MEDIA_ID}"]`).click();

await expect(page).toHaveURL(mediaPath(E2E_SIMILAR_MEDIA_ID));
expect((await similarResponse).headers()["cache-control"]).toBe("no-store");
await expect(
page.getByRole("img", { name: E2E_SIMILAR_FILE_NAME, exact: true }),
).toBeVisible();
const similarPixel = await sampleImagePixel(page, E2E_SIMILAR_FILE_NAME);
expect(similarPixel).not.toEqual(primaryPixel);
});

test("media detail follows the second search result after returning to search", async ({
page,
}) => {
await page.goto("/search");
await waitForAppHydration(page);
await expect(
page.locator(`[data-media-id="${E2E_PRIMARY_MEDIA_ID}"]`),
).toBeVisible();

await page.locator(`[data-media-id="${E2E_PRIMARY_MEDIA_ID}"]`).click();
await expect(page).toHaveURL(mediaPath(E2E_PRIMARY_MEDIA_ID));
const primaryPixel = await sampleImagePixel(page, E2E_PRIMARY_FILE_NAME);

await page.goBack();
await expect(page).toHaveURL("/search");
await page.locator(`[data-media-id="${E2E_SIMILAR_MEDIA_ID}"]`).click();

await expect(page).toHaveURL(mediaPath(E2E_SIMILAR_MEDIA_ID));
const similarPixel = await sampleImagePixel(page, E2E_SIMILAR_FILE_NAME);
expect(similarPixel).not.toEqual(primaryPixel);
});

test("media detail, manager, and settings remain usable on narrow screens", async ({
page,
}, testInfo) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/tauri/src/components/media/media-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class ApiMediaSource implements MediaSource {

async getUrl() {
const url = buildMediaContentUrl(this.media.mediaSourceId, this.media.id);
const response = await getApiFetch()(url);
const response = await getApiFetch()(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status}`);
}
Expand Down
21 changes: 15 additions & 6 deletions apps/tauri/src/routes/sources/$mediaSourceId/$mediaId/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { projectsQueryKeys } from "@solid-imager/ui/query-options";
import { RouteDataPendingScreen } from "@solid-imager/ui/router-status";
import { MediaDetailScreen } from "@solid-imager/ui/screens/media-detail-screen";
import { useQueryClient } from "@tanstack/solid-query";
import { createFileRoute, useParams } from "@tanstack/solid-router";
import { createFileRoute, useRouterState } from "@tanstack/solid-router";
import { MediaSidebar } from "~/components/media/media-sidebar";
import { MediaViewer } from "~/components/media/media-viewer";
import { createTauriTransport } from "~/hooks/use-media-source-events";
Expand All @@ -15,6 +15,10 @@ export const Route = createFileRoute("/sources/$mediaSourceId/$mediaId/")({
void context.queryClient.prefetchQuery(
mediaDetailsQueryOptions(params.mediaSourceId, params.mediaId),
);
return {
mediaId: params.mediaId,
mediaSourceId: params.mediaSourceId,
};
},
pendingComponent: () => (
<RouteDataPendingScreen
Expand All @@ -28,18 +32,23 @@ export const Route = createFileRoute("/sources/$mediaSourceId/$mediaId/")({
});

function MediaDetailRoute() {
const params = useParams({ from: "/sources/$mediaSourceId/$mediaId/" });
const routeData = Route.useLoaderData();
const currentParams = useRouterState({
select: (state) =>
state.matches.find((match) => match.routeId === Route.id)?.params,
});
const queryClient = useQueryClient();
const mediaSourceId = () => params().mediaSourceId;
const mediaId = () => params().mediaId;
const mediaSourceId = () =>
currentParams()?.mediaSourceId ?? routeData().mediaSourceId;
const mediaId = () => currentParams()?.mediaId ?? routeData().mediaId;

const sourceRootPathResolver = useSourceRootPath(mediaSourcesQueryOptions);

return (
<MediaDetailScreen
mediaDetailsQueryOptions={mediaDetailsQueryOptions}
mediaId={mediaId()}
mediaSourceId={mediaSourceId()}
mediaId={mediaId}
mediaSourceId={mediaSourceId}
onAdditionalInvalidate={async () => {
await queryClient.invalidateQueries({
queryKey: projectsQueryKeys.forMedia(mediaId()),
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/media-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function MediaViewer(props: MediaViewerProps) {
const source = props.source;
let currentUrl: string | null = null;
let disposed = false;
setMediaUrl(null);

void (async () => {
try {
Expand Down
50 changes: 26 additions & 24 deletions packages/ui/src/screens/media-detail-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
ThumbnailGeneratedEvent,
} from "@solid-imager/core/domain/sources/events";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { type JSX, Match, Switch } from "solid-js";
import { type Accessor, type JSX, Match, Show, Switch } from "solid-js";
import { ErrorState, OfflineState, QueryStatus } from "../async-state";
import {
type MediaSourceEventTransport,
Expand All @@ -15,8 +15,8 @@ import { toQueryUiState } from "../query-state";
import { LoadingRegion, MediaDetailSkeleton } from "../skeleton";

export type MediaDetailScreenProps = {
mediaSourceId: string;
mediaId: string;
mediaSourceId: Accessor<string>;
mediaId: Accessor<string>;
// biome-ignore lint/suspicious/noExplicitAny: library type mismatch between oRPC and solid-query
mediaDetailsQueryOptions: (mediaSourceId: string, mediaId: string) => any;
sourceRootPath?: string;
Expand All @@ -38,7 +38,7 @@ export function MediaDetailScreen(props: MediaDetailScreenProps) {
const queryClient = useQueryClient();

const mediaDetails = createQuery<MediaDetails>(() =>
props.mediaDetailsQueryOptions(props.mediaSourceId, props.mediaId),
props.mediaDetailsQueryOptions(props.mediaSourceId(), props.mediaId()),
);
const state = () => toQueryUiState(mediaDetails);
const errorMessage = () => {
Expand All @@ -51,8 +51,8 @@ export function MediaDetailScreen(props: MediaDetailScreenProps) {
const handleUpdate = async () => {
await queryClient.invalidateQueries({
queryKey: props.mediaDetailsQueryOptions(
props.mediaSourceId,
props.mediaId,
props.mediaSourceId(),
props.mediaId(),
).queryKey,
});
if (props.onAdditionalInvalidate) {
Expand All @@ -64,22 +64,22 @@ export function MediaDetailScreen(props: MediaDetailScreenProps) {
transport: props.transport,
onMediaDeleted: (data: MediaDeletedEvent) => {
if (
data.mediaId === props.mediaId ||
data.mediaId === props.mediaId() ||
data.filePath === mediaDetails.data?.filePath
) {
void handleUpdate();
}
},
onMediaChanged: (data: MediaChangedEvent) => {
if (
data.mediaId === props.mediaId ||
data.mediaId === props.mediaId() ||
data.filePath === mediaDetails.data?.filePath
) {
void handleUpdate();
}
},
onThumbnailGenerated: (data: ThumbnailGeneratedEvent) => {
if (data.mediaId === props.mediaId) {
if (data.mediaId === props.mediaId()) {
void handleUpdate();
}
},
Expand All @@ -94,22 +94,24 @@ export function MediaDetailScreen(props: MediaDetailScreenProps) {
updatingLabel="メディア情報を更新中..."
/>
<Switch>
<Match when={state().data}>
{(details) => (
<div class="flex flex-col gap-4 lg:h-[calc(100dvh-5rem)] lg:flex-row">
<div class="aspect-[4/3] min-h-64 min-w-0 overflow-hidden rounded-lg lg:aspect-auto lg:min-h-0 lg:flex-1">
{props.renderMediaViewer(details(), props.sourceRootPath)}
<Match when={state().phase === "data"}>
<Show keyed when={state().data}>
{(details) => (
<div class="flex flex-col gap-4 lg:h-[calc(100dvh-5rem)] lg:flex-row">
<div class="aspect-[4/3] min-h-64 min-w-0 overflow-hidden rounded-lg lg:aspect-auto lg:min-h-0 lg:flex-1">
{props.renderMediaViewer(details, props.sourceRootPath)}
</div>
<div class="min-w-0 shrink-0 lg:w-96 lg:max-w-[40%]">
{props.renderMediaSidebar(
details,
mediaDetails.isRefetching,
handleUpdate,
props.sourceRootPath,
)}
</div>
</div>
<div class="min-w-0 shrink-0 lg:w-96 lg:max-w-[40%]">
{props.renderMediaSidebar(
details(),
mediaDetails.isRefetching,
handleUpdate,
props.sourceRootPath,
)}
</div>
</div>
)}
)}
</Show>
</Match>
<Match when={state().phase === "offline"}>
<OfflineState
Expand Down