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
51 changes: 24 additions & 27 deletions apps/server/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
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 { QueryClient } from "@tanstack/solid-query";
import { createRouter as createTanStackRouter } from "@tanstack/solid-router";
import { setupRouterSsrQueryIntegration } from "@tanstack/solid-router-ssr-query";
import { isServer } from "solid-js/web";
import { routeTree } from "#route-tree";
import type { logger as LoggerInstance } from "./infrastructure/logger";
Expand All @@ -18,33 +19,32 @@ function createAppQueryClient(): QueryClient {
}

let serverLogger: typeof LoggerInstance | null = null;
let serverInitializationPromise: Promise<void> | undefined;

if (isServer) {
import("./infrastructure/logger")
.then(({ logger }) => {
serverLogger = logger;
})
.catch(() => {});
async function initializeServerDependencies(): Promise<void> {
if (!isServer) {
return;
}

// Initialize services on server startup.
// We don't use top-level await here to avoid module format issues with CJS dependencies.
import("./infrastructure/bootstrap")
.then(({ initServices }) => {
if (!serverInitializationPromise) {
serverInitializationPromise = Promise.all([
import("./infrastructure/logger"),
import("./infrastructure/bootstrap"),
]).then(([{ logger }, { initServices }]) => {
serverLogger = logger;
initServices();
})
.catch((err) => {
if (serverLogger) {
serverLogger.error({ err }, "[Router] Failed to initialize services");
} else {
console.error("[Router] Failed to initialize services:", err);
}
});
}

await serverInitializationPromise;
}

let clientQueryClient: QueryClient | undefined;

export function getRouter() {
export async function getRouter() {
try {
await initializeServerDependencies();

if (!isServer && !clientQueryClient) {
clientQueryClient = createAppQueryClient();
}
Expand All @@ -54,7 +54,7 @@ export function getRouter() {
? createAppQueryClient()
: clientQueryClient;

return createTanStackRouter({
const router = createTanStackRouter({
routeTree,
context: { queryClient },
scrollRestoration: true,
Expand All @@ -68,16 +68,13 @@ export function getRouter() {
defaultNotFoundComponent: NotFoundScreen,
defaultPendingMs: ROUTE_PENDING_DELAY_MS,
defaultPendingMinMs: ROUTE_PENDING_MIN_DURATION_MS,
Wrap: (props) => (
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
),
});
setupRouterSsrQueryIntegration({ router, queryClient });
return router;
} catch (error) {
if (isServer && serverLogger) {
serverLogger.error({ err: error }, "[Router] Error creating router");
} else {
} else if (!isServer) {
console.error("[Router] Error creating router:", error);
}
throw error;
Expand All @@ -86,6 +83,6 @@ export function getRouter() {

declare module "@tanstack/solid-router" {
interface Register {
router: ReturnType<typeof getRouter>;
router: Awaited<ReturnType<typeof getRouter>>;
}
}
8 changes: 7 additions & 1 deletion apps/server/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Outlet,
Scripts,
} from "@tanstack/solid-router";
import { createSignal, onMount } from "solid-js";
import { HydrationScript } from "solid-js/web";
import styleCss from "~/app.css?url";
import { ApiActivityIndicator } from "~/components/api-activity-indicator";
Expand Down Expand Up @@ -37,8 +38,13 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
});

function RootComponent() {
const [isHydrated, setIsHydrated] = createSignal(false);
onMount(() => {
setIsHydrated(true);
});

return (
<html lang="ja">
<html data-hydrated={isHydrated() ? "true" : "false"} lang="ja">
<head>
<HydrationScript />
<HeadContent />
Expand Down
46 changes: 26 additions & 20 deletions apps/server/src/routes/config.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,50 @@
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 { createQuery, useQueryClient } from "@tanstack/solid-query";
import { ClientOnly, createFileRoute } from "@tanstack/solid-router";
import { createFileRoute } from "@tanstack/solid-router";
import { Show } from "solid-js";
import { isServer } from "solid-js/web";
import { orpc } from "~/infrastructure/api-clients/orpc-client";
import { configQueryOptions } from "~/infrastructure/api-clients/queries";

export const Route = createFileRoute("/config")({
ssr: false,
pendingComponent: () => null,
component: ConfigPage,
ssr: true,
loader: async ({ context }) => {
const config = await context.queryClient.fetchQuery(configQueryOptions());
return { config };
},
pendingComponent: () => (
<RouteDataPendingScreen
description="設定を準備しています..."
title="Settings"
/>
),
pendingMinMs: 0,
component: ConfigPageContent,
});

function ConfigPage() {
return (
<ClientOnly fallback={null}>
<ConfigPageContent />
</ClientOnly>
);
}

function ConfigPageContent() {
const configQuery = createQuery(() => ({
...configQueryOptions(),
enabled: !isServer,
}));
const loaderData = Route.useLoaderData();
const configQuery = createQuery(configQueryOptions);
const queryClient = useQueryClient();
const state = () => toQueryUiState(configQuery);
const config = () => configQuery.data ?? loaderData().config;

return (
<div class="container mx-auto max-w-4xl p-6">
<Show when={state().phase === "pending"}>
<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"}>
<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."
Expand All @@ -58,7 +64,7 @@ function ConfigPageContent() {
</p>
</Show>

<Show when={state().data}>
<Show when={config()}>
{(data) => (
<ConfigScreen
data={data()}
Expand Down
38 changes: 32 additions & 6 deletions apps/server/src/routes/manager.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useManagerPage } from "@solid-imager/ui/hooks/use-manager-page";
import { RouteDataPendingScreen } from "@solid-imager/ui/router-status";
import { ManagerScreen } from "@solid-imager/ui/screens/manager-screen";
import { useQueryClient } from "@tanstack/solid-query";
import { ClientOnly, createFileRoute } from "@tanstack/solid-router";
import { createFileRoute } from "@tanstack/solid-router";
import { createSignal, onMount, Show } from "solid-js";
import { useBatchJobEvents } from "~/hooks/use-batch-job-events";
import {
scanBatchCcipTargets,
Expand Down Expand Up @@ -61,16 +63,40 @@ const managerActions = {
};

export const Route = createFileRoute("/manager")({
ssr: false,
pendingComponent: () => null,
ssr: true,
loader: async ({ context }) => {
await Promise.all([
context.queryClient.prefetchQuery(allProjectsQueryOptions()),
context.queryClient.prefetchQuery(allIpsQueryOptions()),
context.queryClient.prefetchQuery(allCharactersQueryOptions()),
context.queryClient.prefetchQuery(mediaSourcesQueryOptions()),
]);
},
pendingComponent: ManagerRouteFallback,
pendingMinMs: 0,
component: ManagerPage,
});

function ManagerRouteFallback() {
return (
<RouteDataPendingScreen
description="管理データを準備しています..."
title="Entity Manager"
/>
);
}

function ManagerPage() {
const [isMounted, setIsMounted] = createSignal(false);

onMount(() => {
setIsMounted(true);
});

return (
<ClientOnly fallback={null}>
<ManagerPageContent />
</ClientOnly>
<Show fallback={<ManagerRouteFallback />} when={isMounted()}>
{(_mounted) => <ManagerPageContent />}
</Show>
);
}

Expand Down
26 changes: 18 additions & 8 deletions apps/server/src/routes/search.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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 { SearchScreen } from "@solid-imager/ui/screens/search-screen";
import { useQueryClient } from "@tanstack/solid-query";
import { createFileRoute } from "@tanstack/solid-router";
import { createSignal, onMount, Show } from "solid-js";
import { MediaGridItem } from "~/components/media/media-grid-item";
import { useCurrentSearchPersistence } from "~/hooks/use-current-search-persistence";
import { useMediaSourceEvents } from "~/hooks/use-media-source-events";
import { PresetClient as rawPresetClient } from "~/infrastructure/api/clients/preset-client";

Expand All @@ -29,18 +29,27 @@ import {
} from "~/presentation/store/search-store";

export const Route = createFileRoute("/search")({
// SearchScreen is client-only, but this route itself must render on the
// server so its static fallback hydrates against the same component tree.
ssr: true,
loader: async ({ context }) => {
await Promise.all([
context.queryClient.prefetchQuery(tagsQueryOptions()),
context.queryClient.prefetchQuery(mediaSourcesQueryOptions()),
context.queryClient.prefetchQuery(allProjectsQueryOptions()),
context.queryClient.prefetchQuery(allIpsQueryOptions()),
context.queryClient.prefetchQuery(allCharactersQueryOptions()),
context.queryClient.prefetchQuery(allAuthorsQueryOptions()),
]);
},
pendingComponent: SearchRouteFallback,
component: SearchRoute,
pendingMinMs: 0,
component: SearchRouteBoundary,
});

const SEARCH_RESULTS_REFRESH_DEBOUNCE_MS = 300;

const PresetClient = createPresetClient(rawPresetClient);

function SearchRoute() {
function SearchRouteBoundary() {
const [isMounted, setIsMounted] = createSignal(false);

onMount(() => {
Expand All @@ -49,7 +58,7 @@ function SearchRoute() {

return (
<Show fallback={<SearchRouteFallback />} when={isMounted()}>
{(_mounted) => <SearchRouteContent />}
{(_mounted) => <SearchRoute />}
</Show>
);
}
Expand All @@ -69,10 +78,10 @@ function SearchRouteFallback() {
);
}

function SearchRouteContent() {
function SearchRoute() {
const queryClient = useQueryClient();

useCurrentSearchPersistence("all", PresetClient);
const isSearchStateRestored = useCurrentSearchPersistence("all");

const page = useSearchPage({
searchMedia,
Expand All @@ -98,6 +107,7 @@ function SearchRouteContent() {
similarityAnchorMediaId: () => searchState.similarityAnchorMediaId,
similarityTopK: () => searchState.similarityTopK,
refreshDebounceMs: SEARCH_RESULTS_REFRESH_DEBOUNCE_MS,
isSearchStateRestored,
});

useMediaSourceEvents(() => searchState.selectedSource || undefined, {
Expand Down
24 changes: 21 additions & 3 deletions apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,31 @@ import { 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";
import { mediaDetailsQueryOptions } from "~/infrastructure/api-clients/queries";
import {
allCharactersQueryOptions,
allIpsQueryOptions,
allProjectsQueryOptions,
mediaDetailsQueryOptions,
projectsForMediaQueryOptions,
} from "~/infrastructure/api-clients/queries";

export const Route = createFileRoute("/sources/$mediaSourceId/$mediaId/")({
// The detail data remains client-fetched, but render a server-safe static
// fallback so direct navigation never starts with an empty page.
ssr: true,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
mediaDetailsQueryOptions(params.mediaSourceId, params.mediaId),
),
context.queryClient.prefetchQuery(
projectsForMediaQueryOptions(params.mediaSourceId, params.mediaId),
),
context.queryClient.prefetchQuery(allProjectsQueryOptions()),
context.queryClient.prefetchQuery(allIpsQueryOptions()),
context.queryClient.prefetchQuery(allCharactersQueryOptions()),
]);
},
pendingComponent: MediaRouteFallback,
pendingMinMs: 0,
component: Media,
});

Expand Down
Loading