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
14 changes: 13 additions & 1 deletion apps/server/src/router.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import {
ROUTE_PENDING_DELAY_MS,
ROUTE_PENDING_MIN_DURATION_MS,
RouteErrorScreen,
RoutePendingScreen,
} from "@solid-imager/ui/router-status";
import { NotFoundScreen } from "@solid-imager/ui/screens/not-found-screen";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { createRouter as createTanStackRouter } from "@tanstack/solid-router";
import { isServer } from "solid-js/web";
import { isTransientApiError } from "./infrastructure/api-clients/error-policy";
import { routeTree } from "./routeTree.gen";
import type { logger as LoggerInstance } from "./infrastructure/logger";
import { routeTree } from "./routeTree.gen";

const QUERY_RETRY_DELAY_CAP_MS = 5_000;
const QUERY_RETRY_DELAY_BASE_MS = 1_000;
Expand Down Expand Up @@ -73,6 +80,11 @@ export function getRouter() {
scrollRestoration: true,
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
defaultPendingComponent: RoutePendingScreen,
defaultErrorComponent: RouteErrorScreen,
defaultNotFoundComponent: NotFoundScreen,
defaultPendingMs: ROUTE_PENDING_DELAY_MS,
defaultPendingMinMs: ROUTE_PENDING_MIN_DURATION_MS,
Wrap: (props) => (
<QueryClientProvider client={queryClient}>
{props.children}
Expand Down
14 changes: 9 additions & 5 deletions apps/server/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { AppShell } from "@solid-imager/ui/layouts/app-shell";
import {
RoutePendingScreen,
RouteTransitionIndicator,
} from "@solid-imager/ui/router-status";
import { Toaster } from "@solid-imager/ui/toast";
import type { QueryClient } from "@tanstack/solid-query";
import {
Expand Down Expand Up @@ -45,12 +49,12 @@ function RootComponent() {
</head>
<body>
<Toaster />
<Suspense>
<AppShell nav={<Nav />}>
<ApiActivityIndicator />
<AppShell nav={<Nav />} statusIndicator={<RouteTransitionIndicator />}>
<ApiActivityIndicator />
<Suspense fallback={<RoutePendingScreen />}>
<Outlet />
</AppShell>
</Suspense>
</Suspense>
</AppShell>
<Scripts />
</body>
</html>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe("AuthorRepository Deduplication", () => {
.where(
and(
eq(authorAccounts.platform, "twitter"),
eq(authorAccounts.accountId, "@creator"),
eq(authorAccounts.accountId, "creator"),
),
);
expect(accounts).toHaveLength(1);
Expand Down
79 changes: 76 additions & 3 deletions apps/tauri/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { AppShell } from "@solid-imager/ui/layouts/app-shell";
import { BootstrapStatusScreen } from "@solid-imager/ui/router-status";
import { RouterProvider } from "@tanstack/solid-router";
import { createSignal, Match, onMount, Switch } from "solid-js";
import { render } from "solid-js/web";
import "./index.css";
import { initializeCollections } from "./collections";
Expand All @@ -12,6 +15,76 @@ if (!root) {

const router = createAppRouter();

initializeCollections().then(() => {
render(() => <RouterProvider router={router} />, root);
});
type BootstrapState =
| { status: "loading" }
| { status: "ready" }
| { status: "error"; error: Error };

function toError(error: unknown): Error {
return error instanceof Error
? error
: new Error("Unknown collection initialization error");
}

function BootstrapNav() {
return (
<header class="border-border border-b bg-background px-4 py-3">
<span class="font-semibold">Solid Imager</span>
</header>
);
}

function App() {
const [state, setState] = createSignal<BootstrapState>({
status: "loading",
});
let initializationId = 0;

const initialize = async () => {
const currentId = ++initializationId;
setState({ status: "loading" });

try {
await initializeCollections();
if (currentId === initializationId) {
setState({ status: "ready" });
}
} catch (error) {
if (currentId === initializationId) {
setState({ status: "error", error: toError(error) });
}
}
};

onMount(() => {
void initialize();
});

const bootstrapError = () => {
const currentState = state();
return currentState.status === "error" ? currentState.error : undefined;
};

return (
<Switch>
<Match when={state().status === "ready"}>
<RouterProvider router={router} />
</Match>
<Match when={state().status === "error"}>
<AppShell nav={<BootstrapNav />}>
<BootstrapStatusScreen
error={bootstrapError()}
onRetry={() => void initialize()}
/>
</AppShell>
</Match>
<Match when={state().status === "loading"}>
<AppShell nav={<BootstrapNav />}>
<BootstrapStatusScreen onRetry={() => void initialize()} />
</AppShell>
</Match>
</Switch>
);
}

render(() => <App />, root);
12 changes: 12 additions & 0 deletions apps/tauri/src/router.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
ROUTE_PENDING_DELAY_MS,
ROUTE_PENDING_MIN_DURATION_MS,
RouteErrorScreen,
RoutePendingScreen,
} from "@solid-imager/ui/router-status";
import { NotFoundScreen } from "@solid-imager/ui/screens/not-found-screen";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { createHashHistory, createRouter } from "@tanstack/solid-router";
import { routeTree } from "./routeTree.gen";
Expand All @@ -24,6 +31,11 @@ export function createAppRouter() {
scrollRestoration: true,
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
defaultPendingComponent: RoutePendingScreen,
defaultErrorComponent: RouteErrorScreen,
defaultNotFoundComponent: NotFoundScreen,
defaultPendingMs: ROUTE_PENDING_DELAY_MS,
defaultPendingMinMs: ROUTE_PENDING_MIN_DURATION_MS,
Wrap: (props) => (
<QueryClientProvider client={queryClient}>
{props.children}
Expand Down
3 changes: 2 additions & 1 deletion apps/tauri/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AppShell } from "@solid-imager/ui/layouts/app-shell";
import { RouteTransitionIndicator } from "@solid-imager/ui/router-status";
import { Toaster } from "@solid-imager/ui/toast";
import { createRootRouteWithContext, Outlet } from "@tanstack/solid-router";
import { Nav } from "~/components/nav";
Expand All @@ -12,7 +13,7 @@ function RootRouteComponent() {
return (
<>
<Toaster />
<AppShell nav={<Nav />}>
<AppShell nav={<Nav />} statusIndicator={<RouteTransitionIndicator />}>
<Outlet />
</AppShell>
</>
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/layouts/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import type { JSX, ParentProps } from "solid-js";

interface AppShellProps {
nav: JSX.Element;
statusIndicator?: JSX.Element;
}

export function AppShell(props: ParentProps<AppShellProps>) {
return (
<div class="flex min-h-screen flex-col bg-background text-foreground">
{props.statusIndicator}
{props.nav}
<main class="flex-1">{props.children}</main>
</div>
Expand Down
174 changes: 174 additions & 0 deletions packages/ui/src/router-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import type { ErrorComponentProps } from "@tanstack/solid-router";
import { Link, useRouter, useRouterState } from "@tanstack/solid-router";
import { createEffect, createSignal, onCleanup, Show, untrack } from "solid-js";

export const ROUTE_PENDING_DELAY_MS = 300;
export const ROUTE_PENDING_MIN_DURATION_MS = 500;

function useDelayedPending(isPending: () => boolean) {
const [isVisible, setIsVisible] = createSignal(false);
let visibleAt = 0;
let showTimer: ReturnType<typeof setTimeout> | undefined;
let hideTimer: ReturnType<typeof setTimeout> | undefined;

const clearTimers = () => {
clearTimeout(showTimer);
clearTimeout(hideTimer);
showTimer = undefined;
hideTimer = undefined;
};

createEffect(() => {
const pending = isPending();
const visible = untrack(isVisible);

clearTimers();

if (pending) {
if (!visible) {
showTimer = setTimeout(() => {
visibleAt = Date.now();
setIsVisible(true);
}, ROUTE_PENDING_DELAY_MS);
}
return;
}

if (visible) {
const elapsed = Date.now() - visibleAt;
const remaining = Math.max(0, ROUTE_PENDING_MIN_DURATION_MS - elapsed);
hideTimer = setTimeout(() => {
setIsVisible(false);
}, remaining);
}
});

onCleanup(clearTimers);

return isVisible;
}

export function RouteTransitionIndicator() {
const isRoutePending = useRouterState({
select: (state) => state.isLoading || state.isTransitioning,
});
const isVisible = useDelayedPending(isRoutePending);

return (
<Show when={isVisible()}>
<div
aria-label="画面を読み込んでいます"
aria-valuetext="読み込み中"
class="fixed inset-x-0 top-0 z-[70] h-1 overflow-hidden bg-primary/20"
role="progressbar"
>
<div class="h-full w-1/2 animate-pulse rounded-full bg-primary" />
</div>
</Show>
);
}

export function RoutePendingScreen() {
return (
<section
aria-live="polite"
class="mx-auto flex min-h-48 max-w-xl items-center justify-center gap-3 p-6 text-muted-foreground"
role="status"
>
<span
aria-hidden="true"
class="size-5 animate-spin rounded-full border-2 border-current border-r-transparent"
/>
<span>画面を読み込んでいます...</span>
</section>
);
}

export function RouteErrorScreen(props: ErrorComponentProps) {
const router = useRouter();

const retry = () => {
void router.invalidate().finally(() => {
props.reset();
});
};

return (
<section
aria-labelledby="route-error-title"
class="mx-auto flex min-h-64 max-w-xl flex-col items-center justify-center gap-4 p-6 text-center"
role="alert"
>
<div>
<h1 class="text-xl font-semibold" id="route-error-title">
画面を表示できませんでした
</h1>
<p class="mt-2 text-sm text-muted-foreground">
通信状態を確認して、もう一度お試しください。
</p>
</div>
<div class="flex flex-wrap justify-center gap-2">
<button
class="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
onClick={retry}
type="button"
>
再試行
</button>
<Link
class="rounded-md border border-border px-4 py-2 hover:bg-muted"
to="/"
>
ホームへ戻る
</Link>
</div>
</section>
);
}

interface BootstrapStatusScreenProps {
error?: Error;
onRetry: () => void;
}

export function BootstrapStatusScreen(props: BootstrapStatusScreenProps) {
return (
<section
aria-live={props.error ? undefined : "polite"}
class="mx-auto flex min-h-64 max-w-xl flex-col items-center justify-center gap-4 p-6 text-center"
role={props.error ? "alert" : "status"}
>
<Show
fallback={
<>
<span
aria-hidden="true"
class="size-6 animate-spin rounded-full border-2 border-current border-r-transparent text-primary"
/>
<div>
<h1 class="font-semibold">アプリを準備しています</h1>
<p class="mt-2 text-sm text-muted-foreground">
保存済みデータを読み込んでいます。
</p>
</div>
</>
}
when={props.error}
>
<div>
<h1 class="text-xl font-semibold">アプリを起動できませんでした</h1>
<p class="mt-2 text-sm text-muted-foreground">
ローカルデータの初期化に失敗しました。
</p>
</div>
<button
class="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
onClick={props.onRetry}
type="button"
>
再試行
</button>
</Show>
</section>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading