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
15 changes: 12 additions & 3 deletions apps/server/src/components/api-activity-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,22 @@ export function ApiActivityIndicator() {

return (
<Show when={isVisible()}>
<div class="fixed top-3 left-1/2 z-[60] -translate-x-1/2 rounded-md border border-border bg-background px-3 py-2 text-sm shadow-lg">
<div
class="fixed top-3 left-1/2 z-[60] -translate-x-1/2 rounded-md border border-border bg-background px-3 py-2 text-sm shadow-lg"
role="status"
>
<div class="flex items-center gap-2">
<Show when={isOnline()}>
<span class="inline-block size-2 animate-pulse rounded-full bg-warning-foreground" />
<span
aria-hidden="true"
class="inline-block size-2 animate-pulse rounded-full bg-warning-foreground motion-reduce:animate-none"
/>
</Show>
<Show when={!isOnline()}>
<span class="inline-block size-2 rounded-full bg-destructive" />
<span
aria-hidden="true"
class="inline-block size-2 rounded-full bg-destructive"
/>
</Show>
<span class="text-foreground">
{getStatusText(isOnline(), activeCount())}
Expand Down
65 changes: 17 additions & 48 deletions apps/server/src/routes/config.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { configQueryKeys } from "@solid-imager/ui/query-options";
import { toQueryUiState } from "@solid-imager/ui/query-state";
import { RouteDataPendingScreen } from "@solid-imager/ui/router-status";
import { ConfigScreen } from "@solid-imager/ui/screens/config-screen";
import { ConfigStateScreen } from "@solid-imager/ui/screens/config-state-screen";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { createFileRoute } from "@tanstack/solid-router";
import { Show } from "solid-js";
import { orpc } from "~/infrastructure/api-clients/orpc-client";
import { configQueryOptions } from "~/infrastructure/api-clients/queries";

Expand All @@ -16,7 +15,10 @@ export const Route = createFileRoute("/config")({
},
pendingComponent: () => (
<RouteDataPendingScreen
class="max-w-4xl p-6"
description="設定を準備しています..."
layout="config"
showAction
title="Settings"
/>
),
Expand All @@ -32,51 +34,18 @@ function ConfigPageContent() {
const config = () => configQuery.data ?? loaderData().config;

return (
<div class="container mx-auto max-w-4xl p-6">
<Show when={state().phase === "pending" && config() === undefined}>
<div class="py-10 text-center" role="status">
Loading settings...
</div>
</Show>

<Show
when={
(state().phase === "error" || state().phase === "offline") &&
config() === undefined
}
>
<div class="py-10 text-red-500" role="alert">
{state().phase === "offline"
? "Offline. Settings will load after reconnecting."
: "Error loading settings."}
</div>
</Show>
<Show when={state().fetchState === "background-fetching"}>
<p class="mb-2 text-muted-foreground text-sm" role="status">
Updating settings...
</p>
</Show>
<Show
when={state().fetchState === "paused" && state().data !== undefined}
>
<p class="mb-2 text-muted-foreground text-sm" role="status">
Offline. Showing cached settings.
</p>
</Show>

<Show when={config()}>
{(data) => (
<ConfigScreen
data={data()}
onSubmit={async (value) => {
await orpc.config.update(value);
await queryClient.invalidateQueries({
queryKey: configQueryKeys.all(),
});
}}
/>
)}
</Show>
</div>
<ConfigStateScreen
data={config()}
onRetry={async () => {
await configQuery.refetch();
}}
onSubmit={async (value) => {
await orpc.config.update(value);
await queryClient.invalidateQueries({
queryKey: configQueryKeys.all(),
});
}}
state={state()}
/>
);
}
3 changes: 3 additions & 0 deletions apps/server/src/routes/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ export const Route = createFileRoute("/manager")({
function ManagerRouteFallback() {
return (
<RouteDataPendingScreen
class="p-4 sm:p-8"
description="管理データを準備しています..."
layout="manager"
showAction
title="Entity Manager"
/>
);
Expand Down
17 changes: 7 additions & 10 deletions apps/server/src/routes/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Button } from "@solid-imager/ui/button";
import { useCurrentSearchPersistence } from "@solid-imager/ui/hooks/use-current-search-persistence";
import { useSearchPage } from "@solid-imager/ui/hooks/use-search-page";
import { createPresetClient } from "@solid-imager/ui/preset-client";
import { RouteDataPendingScreen } from "@solid-imager/ui/router-status";
import { SearchScreen } from "@solid-imager/ui/screens/search-screen";
import { useQueryClient } from "@tanstack/solid-query";
import { createFileRoute } from "@tanstack/solid-router";
Expand Down Expand Up @@ -65,16 +66,12 @@ function SearchRouteBoundary() {

function SearchRouteFallback() {
return (
<main class="container mx-auto p-4">
<section
aria-live="polite"
class="flex min-h-48 flex-col items-center justify-center gap-2 text-muted-foreground"
role="status"
>
<h1 class="font-bold text-2xl text-foreground">メディア検索</h1>
<p>検索画面を準備しています...</p>
</section>
</main>
<RouteDataPendingScreen
description="検索画面を準備しています..."
layout="media-grid"
showDescription
title="メディア検索"
/>
);
}

Expand Down
17 changes: 7 additions & 10 deletions apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
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";
Expand Down Expand Up @@ -48,16 +49,12 @@ function Media() {

function MediaRouteFallback() {
return (
<main class="container mx-auto p-4">
<section
aria-live="polite"
class="flex min-h-48 flex-col items-center justify-center gap-2 text-muted-foreground"
role="status"
>
<h1 class="font-bold text-2xl text-foreground">メディア詳細</h1>
<p>メディア詳細を準備しています...</p>
</section>
</main>
<RouteDataPendingScreen
description="メディア詳細を準備しています..."
layout="media-detail"
showDescription
title="メディア詳細"
/>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Button } from "@solid-imager/ui/button";
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 { useParams } from "@tanstack/solid-router";
Expand Down Expand Up @@ -89,9 +90,12 @@ export function SourceMediaPage() {
return (
<Show
fallback={
<div class="flex min-h-[60vh] items-center justify-center text-muted-foreground">
メディア一覧を読み込んでいます...
</div>
<RouteDataPendingScreen
description="メディア一覧を読み込んでいます..."
layout="media-grid"
showAction
title="メディア一覧"
/>
}
when={isMounted()}
>
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/routes/sources/$mediaSourceId/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ function SourceMediaRouteFallback() {
return (
<RouteDataPendingScreen
description="メディア一覧を準備しています..."
layout="media-grid"
showAction
title="メディア一覧"
/>
);
Expand Down
19 changes: 14 additions & 5 deletions apps/server/src/routes/sources/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export const Route = createFileRoute("/sources/")({
},
pendingComponent: () => (
<RouteDataPendingScreen
class="p-6"
description="ソース一覧を準備しています..."
layout="cards"
showAction
title="Media Sources"
/>
),
Expand Down Expand Up @@ -72,12 +75,18 @@ function SourcesRouteContent() {
page={page}
mediaSources={sourceData}
state={() =>
toQueryUiState(mediaSources, {
isEmpty: (data) => data.length === 0,
})
toQueryUiState(
{
data: sourceData(),
error: mediaSources.error,
fetchStatus: mediaSources.fetchStatus,
status: mediaSources.status,
},
{ isEmpty: (data) => data.length === 0 },
)
}
onRetry={() => {
void mediaSources.refetch();
onRetry={async () => {
await mediaSources.refetch();
}}
renderSourceCard={(source) => (
<SourceCard
Expand Down
57 changes: 50 additions & 7 deletions apps/server/src/tests/e2e/loading-recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ test.describe("loading and recovery", () => {
test("keeps the app shell visible while the initial search response is delayed", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
let releaseRequest: () => void = () => {};
const requestGate = new Promise<void>((resolve) => {
releaseRequest = resolve;
Expand All @@ -45,9 +46,23 @@ test.describe("loading and recovery", () => {

const navigation = page.goto("/search", { waitUntil: "commit" });
await expect(page.getByRole("link", { name: "Home" })).toBeVisible();
const screenSkeleton = page.locator('[data-screen-skeleton="media-grid"]');
await expect(screenSkeleton).toBeVisible();
await expect(
page.getByText("検索画面を準備しています...", { exact: true }),
screenSkeleton.locator(':scope > [aria-busy="true"]'),
).toBeVisible();
await expect(
screenSkeleton
.locator("p:not(.sr-only)")
.filter({ hasText: "検索画面を準備しています..." }),
).toBeVisible();
await expect(screenSkeleton.locator('[role="status"]')).toHaveCount(1);
await page.waitForLoadState("load");
await expect(
screenSkeleton
.locator('[data-skeleton="media-grid"] [aria-hidden="true"]')
.first(),
).toHaveCSS("animation-name", "none");
await expect(
page.getByText("APIの応答を待っています...", { exact: true }),
).toBeVisible();
Expand All @@ -57,6 +72,7 @@ test.describe("loading and recovery", () => {
await expect(
page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }),
).toBeVisible();
await expect(screenSkeleton).toHaveCount(0);
});

test("keeps existing results and form input during a background refresh", async ({
Expand All @@ -82,17 +98,26 @@ test.describe("loading and recovery", () => {

holdBackgroundRequest = true;
const searchInput = page.getByPlaceholder("ファイル名を入力...");
await primaryLink.evaluate((element) => {
element.setAttribute("data-existing-result", "true");
});
await searchInput.fill(E2E_PRIMARY_FILE_NAME);
await expect(
page.getByText("検索結果を更新中...", { exact: true }),
).toBeVisible();
await expect(searchInput).toHaveValue(E2E_PRIMARY_FILE_NAME);
await expect(searchInput).toBeFocused();
await expect(primaryLink).toBeVisible();
await expect(page.locator('[data-existing-result="true"]')).toBeVisible();
await expect(page.locator("[data-screen-skeleton]")).toHaveCount(0);

releaseRequest();
await expect(
page.getByText("検索結果を更新中...", { exact: true }),
).toHaveCount(0);
await expect(searchInput).toHaveValue(E2E_PRIMARY_FILE_NAME);
await expect(searchInput).toBeFocused();
await expect(primaryLink).toBeVisible();
});

test("keeps route content visible while SPA media-detail preload is delayed", async ({
Expand Down Expand Up @@ -134,7 +159,7 @@ test.describe("loading and recovery", () => {
).toBeVisible();
});

test("shows a recoverable SPA error and reload recovery when media detail is unavailable", async ({
test("recovers a SPA media-detail error with the keyboard retry action", async ({
page,
browserHealth,
}) => {
Expand All @@ -156,11 +181,15 @@ test.describe("loading and recovery", () => {
.click();
await expect(page).toHaveURL(new RegExp(`${mediaPath()}/?$`));
await expect(page.getByRole("link", { name: "Home" })).toBeVisible();
await expect(page.getByRole("alert")).toContainText("Error:");
await expect(page.getByRole("alert")).toContainText(
"メディア情報を読み込めませんでした",
);

await page.unroute(mediaDetailsEndpoint);
const recoveryResponse = await page.reload();
expect(recoveryResponse?.ok()).toBeTruthy();
const retryButton = page.getByRole("button", { name: "再試行" });
await retryButton.focus();
await expect(retryButton).toBeFocused();
await retryButton.press("Enter");
await expect(
page.getByRole("heading", { name: E2E_PRIMARY_FILE_NAME, exact: true }),
).toBeVisible();
Expand All @@ -183,7 +212,7 @@ test.describe("loading and recovery", () => {
).toBeVisible();

await page.unroute(searchEndpoint);
await page.reload();
await page.getByRole("button", { name: "再試行" }).click();
await expect(
page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }),
).toBeVisible();
Expand Down Expand Up @@ -212,12 +241,26 @@ test.describe("loading and recovery", () => {
).toBeVisible();

await page.unroute(searchEndpoint);
await page.reload();
await page.getByRole("button", { name: "再試行" }).click();
await expect(
page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }),
).toBeVisible();
});

test("shows the shared empty state for an unmatched search", async ({
page,
}) => {
await page.goto("/search");
await page
.getByPlaceholder("ファイル名を入力...")
.fill("__issue_579_no_matching_media__");

const emptyState = page.locator('[data-state-ui="empty"]');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText("検索結果が見つかりませんでした");
await expect(emptyState.locator('[role="alert"]')).toHaveCount(0);
});

test("shows and clears the offline API status without replacing existing content", async ({
page,
}) => {
Expand Down
Loading