{author.name}
-
+
({author.accountId})
@@ -578,6 +737,9 @@ export function MediaSidebar(props: MediaSidebarProps) {
+
+ Relations
+
Generation Info
-
+
- Prompt:
+
+ Prompt:
+
-
+
{genInfo()?.prompt}
@@ -695,12 +862,12 @@ export function MediaSidebar(props: MediaSidebarProps) {
-
+
Negative Prompt:
-
+
{genInfo()?.negativePrompt}
@@ -708,7 +875,9 @@ export function MediaSidebar(props: MediaSidebarProps) {
- Workflow:
+
+ Workflow:
+
-
+
{JSON.stringify(genInfo()?.workflow, null, 2)}
@@ -726,6 +895,44 @@ export function MediaSidebar(props: MediaSidebarProps) {
+
+
{
+ const resolver = navigationBlocker();
+ if (!open && resolver.status === "blocked") {
+ if (allowBlockedNavigation()) {
+ setAllowBlockedNavigation(false);
+ return;
+ }
+ resolver.reset?.();
+ }
+ }}
+ open={navigationBlocker().status === "blocked"}
+ >
+
+
+ 説明の変更を破棄しますか?
+
+ 保存されていない説明があります。このページを離れると入力内容は失われます。
+
+
+
+ 編集を続ける
+ {
+ const resolver = navigationBlocker();
+ if (resolver.status !== "blocked") return;
+ setDescriptionValue(props.media.description ?? "");
+ setIsEditingDescription(false);
+ setAllowBlockedNavigation(true);
+ resolver.proceed?.();
+ }}
+ >
+ 破棄して移動
+
+
+
+
);
}
diff --git a/apps/server/src/components/media/media-viewer.tsx b/apps/server/src/components/media/media-viewer.tsx
index f34b508e..b105ec6b 100644
--- a/apps/server/src/components/media/media-viewer.tsx
+++ b/apps/server/src/components/media/media-viewer.tsx
@@ -74,6 +74,7 @@ class ApiMediaSource implements MediaSource {
type MediaViewerProps = {
media: MediaDetails;
sourceRootPath?: string | null;
+ variant?: "default" | "v2";
};
export function MediaViewer(props: MediaViewerProps) {
@@ -99,6 +100,7 @@ export function MediaViewer(props: MediaViewerProps) {
fileName={props.media.fileName}
height={props.media.height}
source={source()}
+ variant={props.variant}
width={props.media.width}
/>
);
diff --git a/apps/server/src/components/v2/v2-app-shell.tsx b/apps/server/src/components/v2/v2-app-shell.tsx
new file mode 100644
index 00000000..8d86e20e
--- /dev/null
+++ b/apps/server/src/components/v2/v2-app-shell.tsx
@@ -0,0 +1,469 @@
+import type {
+ MediaSourceInfo,
+ SafeMediaSource,
+} from "@solid-imager/core/domain/sources/schemas";
+import { mediaSourceInfoSchema } from "@solid-imager/core/domain/sources/schemas";
+import { Button } from "@solid-imager/ui/button";
+import {
+ CollapsibleContent,
+ CollapsibleRoot,
+ CollapsibleTrigger,
+} from "@solid-imager/ui/collapsible";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@solid-imager/ui/dialog";
+import { subscribeToEventStream } from "@solid-imager/ui/event-stream";
+import type { RawEventHandler } from "@solid-imager/ui/hooks/use-sources-events";
+import { useSourcesPage } from "@solid-imager/ui/hooks/use-sources-page";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@solid-imager/ui/popover";
+import { SourceDeleteModal } from "@solid-imager/ui/source-delete-modal";
+import { SourceFormModal } from "@solid-imager/ui/source-form-modal";
+import {
+ BriefcaseBusiness,
+ ChevronDown,
+ CircleHelp,
+ Clock3,
+ Database,
+ Ellipsis,
+ FileText,
+ Image,
+ Library,
+ Menu,
+ PanelLeftClose,
+ PanelLeftOpen,
+ Plus,
+ RefreshCw,
+ Search,
+ Settings,
+} from "@solid-imager/ui/v2/icons";
+import { createQuery, useQueryClient } from "@tanstack/solid-query";
+import { Link, useLocation } from "@tanstack/solid-router";
+import type { JSX, ParentProps } from "solid-js";
+import { createSignal, For, Show } from "solid-js";
+import { PendingDownloadsIndicator } from "~/components/imports/pending-downloads-indicator";
+import { orpc } from "~/infrastructure/api-clients/orpc-client";
+import { mediaSourcesQueryOptions } from "~/infrastructure/api-clients/queries";
+import {
+ createMediaSource,
+ deleteMediaSource,
+ syncMediaSources,
+ updateMediaSource,
+} from "~/infrastructure/api-clients/sources-api";
+
+type V2AppShellProps = ParentProps<{
+ statusIndicator?: JSX.Element;
+}>;
+
+type V2SidebarProps = {
+ expanded: boolean;
+ mediaSources: SafeMediaSource[];
+ onAddSource: () => void;
+ onCollapseToggle: () => void;
+ onDeleteSource: (source: SafeMediaSource) => void;
+ onEditSource: (source: SafeMediaSource) => void;
+ onNavigate?: () => void;
+ onSyncSource: (source: SafeMediaSource) => void;
+};
+
+const V2_NAVIGATION_ITEMS = [
+ { icon: Search, label: "Search", to: "/v2/search" },
+ { icon: BriefcaseBusiness, label: "Manager", to: "/v2/manager" },
+ { icon: Clock3, label: "Jobs", to: "/v2/jobs" },
+ { icon: Settings, label: "Settings", to: "/v2/config" },
+] as const;
+
+function registerSourceEvents(handler: RawEventHandler): () => void {
+ return subscribeToEventStream(
+ (signal) => orpc.sources.events({ id: "*" }, { signal }),
+ handler,
+ );
+}
+
+function sourceTypeLabel(source: SafeMediaSource): string {
+ return source.type === "local" ? "Local" : source.type.toUpperCase();
+}
+
+function V2NavigationItem(props: {
+ children?: JSX.Element;
+ expanded: boolean;
+ icon: typeof Library;
+ label: string;
+ onClick?: () => void;
+ to: string;
+}) {
+ const location = useLocation();
+ const active = () =>
+ location().pathname === props.to ||
+ (props.to !== "/v2/search" &&
+ location().pathname.startsWith(`${props.to}/`));
+ const Icon = props.icon;
+
+ return (
+
+
+
+ {props.label}
+ {props.children}
+
+
+ );
+}
+
+function V2SourceActions(props: {
+ onDelete: () => void;
+ onEdit: () => void;
+ onSync: () => void;
+ sourceName: string;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function V2Sidebar(props: V2SidebarProps) {
+ const location = useLocation();
+ const [sourcesOpen, setSourcesOpen] = createSignal(true);
+ const currentSourceId = () => {
+ const match = /^\/v2\/sources\/([^/]+)/.exec(location().pathname);
+ return match?.[1] ? decodeURIComponent(match[1]) : undefined;
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Solid Imager
+
+
+
+
+
+
+
+
+
+ {
+ if (!props.expanded) props.onCollapseToggle();
+ }}
+ >
+
+
+ Sources
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function V2AppShell(props: V2AppShellProps) {
+ const queryClient = useQueryClient();
+ const mediaSources = createQuery(mediaSourcesQueryOptions);
+ const [sidebarExpanded, setSidebarExpanded] = createSignal(true);
+ const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);
+ const sourceData = () => mediaSources.data ?? [];
+ const sourcePage = useSourcesPage({
+ actions: {
+ createMediaSource: (data: unknown) =>
+ createMediaSource(mediaSourceInfoSchema.parse(data)),
+ updateMediaSource: (id: string, data: unknown) =>
+ updateMediaSource(id, mediaSourceInfoSchema.parse(data)),
+ deleteMediaSource,
+ syncMediaSources,
+ },
+ queryClient,
+ invalidateQueryKey: mediaSourcesQueryOptions().queryKey,
+ registerEvents: registerSourceEvents,
+ getSourceIds: () =>
+ sourceData().flatMap((source) => (source.id ? [source.id] : [])),
+ });
+ const sidebarProps = (): V2SidebarProps => ({
+ expanded: sidebarExpanded(),
+ mediaSources: sourceData(),
+ onAddSource: sourcePage.handleAddSource,
+ onCollapseToggle: () => setSidebarExpanded((expanded) => !expanded),
+ onDeleteSource: sourcePage.handleDeleteSource,
+ onEditSource: sourcePage.handleEditSource,
+ onSyncSource: (source) => void sourcePage.handleSyncSource(source),
+ });
+
+ return (
+
+
+ メインコンテンツへ移動
+
+
+
+
+
+
+
+ Solid Imager
+
+
+
+ {props.statusIndicator}
+
+ {props.children}
+
+
+
+
+
+
sourcePage.setShowFormModal(false)}
+ onSubmit={sourcePage.handleFormSubmit}
+ variant="v2"
+ />
+ sourcePage.setShowDeleteModal(false)}
+ onConfirm={sourcePage.handleDeleteConfirm}
+ sourceToDelete={sourcePage.deletingSource()}
+ />
+
+ );
+}
diff --git a/apps/server/src/router.tsx b/apps/server/src/router.tsx
index af18fb1b..aa5e0a8a 100644
--- a/apps/server/src/router.tsx
+++ b/apps/server/src/router.tsx
@@ -59,7 +59,10 @@ export function getRouter() {
routeTree,
context: { queryClient },
scrollRestoration: true,
- defaultPreload: "intent",
+ // Loader-heavy routes prefetch several queries in parallel. Disabling
+ // intent preloading avoids a stale-match race when navigation starts
+ // before an aborted preload has finished.
+ defaultPreload: false,
defaultPreloadStaleTime: 0,
// A route with `ssr: false` renders its pending component on the server,
// but its route component immediately on the client. Keep the server
diff --git a/apps/server/src/routes/__root.tsx b/apps/server/src/routes/__root.tsx
index 0b7a86c4..3ab5579f 100644
--- a/apps/server/src/routes/__root.tsx
+++ b/apps/server/src/routes/__root.tsx
@@ -41,6 +41,8 @@ export const Route = createRootRouteWithContext
()({
function RootComponent() {
const [isHydrated, setIsHydrated] = createSignal(false);
const location = useLocation();
+ const isV2Route = () =>
+ location().pathname === "/v2" || location().pathname.startsWith("/v2/");
onMount(() => {
setIsHydrated(true);
});
@@ -51,13 +53,14 @@ function RootComponent() {
-
+
}
when={
location().pathname !== "/design-lab" &&
- !location().pathname.startsWith("/design-lab/")
+ !location().pathname.startsWith("/design-lab/") &&
+ !isV2Route()
}
>
params().mediaSourceId;
+export function SourceMediaPage(
+ props: { mediaSourceId?: Accessor; variant?: "default" | "v2" } = {},
+) {
+ const params = useParams({ strict: false });
+ const location = useLocation();
+ const navigate = useNavigate();
+ const mediaSourceId = () => props.mediaSourceId?.() ?? params().mediaSourceId;
const queryClient = useQueryClient();
const [isMounted, setIsMounted] = createSignal(false);
const mediaSources = createQuery(mediaSourcesQueryOptions);
@@ -97,10 +102,11 @@ export function SourceMediaPage() {
title="メディア一覧"
/>
}
- when={isMounted()}
+ when={props.variant === "v2" || isMounted()}
>
(
handleToggleSelect(media.id)}
/>
)}
+ onOpenMediaDetail={(media) => {
+ sessionStorage.setItem("v2:media-return", location().href);
+ void navigate({
+ params: {
+ mediaId: media.id,
+ mediaSourceId: media.mediaSourceId,
+ },
+ to: "/v2/sources/$mediaSourceId/$mediaId",
+ });
+ }}
+ renderMediaPreview={(media) => (
+
+ )}
moveCopyDialogComponent={MoveCopyMediaDialog}
uploadModalComponent={UploadMediaModal}
showOpenInNewTab
diff --git a/apps/server/src/routes/v2/$.tsx b/apps/server/src/routes/v2/$.tsx
new file mode 100644
index 00000000..c890f5aa
--- /dev/null
+++ b/apps/server/src/routes/v2/$.tsx
@@ -0,0 +1,34 @@
+import { Button } from "@solid-imager/ui/button";
+import { Search } from "@solid-imager/ui/v2/icons";
+import { createFileRoute, Link } from "@tanstack/solid-router";
+
+export const Route = createFileRoute("/v2/$")({
+ component: V2NotFoundRoute,
+});
+
+function V2NotFoundRoute() {
+ return (
+
+
+
+
+
+
+ ページが見つかりません
+
+
+ URLを確認するか、ライブラリへ戻ってください。
+
+
+
+
+ );
+}
diff --git a/apps/server/src/routes/v2/about.tsx b/apps/server/src/routes/v2/about.tsx
new file mode 100644
index 00000000..2a0c51c1
--- /dev/null
+++ b/apps/server/src/routes/v2/about.tsx
@@ -0,0 +1,82 @@
+import { Badge } from "@solid-imager/ui/badge";
+import { Button } from "@solid-imager/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@solid-imager/ui/card";
+import { createFileRoute, Link } from "@tanstack/solid-router";
+
+export const Route = createFileRoute("/v2/about")({
+ component: V2AboutRoute,
+});
+
+function V2AboutRoute() {
+ return (
+
+ );
+}
diff --git a/apps/server/src/routes/v2/config.tsx b/apps/server/src/routes/v2/config.tsx
new file mode 100644
index 00000000..7bc316e5
--- /dev/null
+++ b/apps/server/src/routes/v2/config.tsx
@@ -0,0 +1,33 @@
+import { configQueryKeys } from "@solid-imager/ui/query-options";
+import { toQueryUiState } from "@solid-imager/ui/query-state";
+import { ConfigStateScreen } from "@solid-imager/ui/screens/config-state-screen";
+import { createQuery, useQueryClient } from "@tanstack/solid-query";
+import { createFileRoute } from "@tanstack/solid-router";
+import { orpc } from "~/infrastructure/api-clients/orpc-client";
+import { configQueryOptions } from "~/infrastructure/api-clients/queries";
+
+export const Route = createFileRoute("/v2/config")({
+ component: V2ConfigPage,
+});
+
+function V2ConfigPage() {
+ const configQuery = createQuery(configQueryOptions);
+ const queryClient = useQueryClient();
+
+ return (
+ {
+ await configQuery.refetch();
+ }}
+ onSubmit={async (value) => {
+ await orpc.config.update(value);
+ await queryClient.invalidateQueries({
+ queryKey: configQueryKeys.all(),
+ });
+ }}
+ state={toQueryUiState(configQuery)}
+ variant="v2"
+ />
+ );
+}
diff --git a/apps/server/src/routes/v2/index.tsx b/apps/server/src/routes/v2/index.tsx
new file mode 100644
index 00000000..ee487e24
--- /dev/null
+++ b/apps/server/src/routes/v2/index.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute, Navigate } from "@tanstack/solid-router";
+
+export const Route = createFileRoute("/v2/")({
+ component: V2Index,
+});
+
+function V2Index() {
+ return ;
+}
diff --git a/apps/server/src/routes/v2/jobs.tsx b/apps/server/src/routes/v2/jobs.tsx
new file mode 100644
index 00000000..d6403115
--- /dev/null
+++ b/apps/server/src/routes/v2/jobs.tsx
@@ -0,0 +1,10 @@
+import { V2JobsScreen } from "@solid-imager/ui/screens/v2-jobs-screen";
+import { createFileRoute } from "@tanstack/solid-router";
+
+export const Route = createFileRoute("/v2/jobs")({
+ component: V2JobsRoute,
+});
+
+function V2JobsRoute() {
+ return ;
+}
diff --git a/apps/server/src/routes/v2/manager.tsx b/apps/server/src/routes/v2/manager.tsx
new file mode 100644
index 00000000..978a5a84
--- /dev/null
+++ b/apps/server/src/routes/v2/manager.tsx
@@ -0,0 +1,160 @@
+import { useManagerPage } from "@solid-imager/ui/hooks/use-manager-page";
+import { ManagerScreen } from "@solid-imager/ui/screens/manager-screen";
+import type { V2ManagerTransferFormat } from "@solid-imager/ui/screens/v2-manager-screen";
+import { toast } from "@solid-imager/ui/toast";
+import { useQueryClient } from "@tanstack/solid-query";
+import { createFileRoute } from "@tanstack/solid-router";
+import { useBatchJobEvents } from "~/hooks/use-batch-job-events";
+import {
+ scanBatchCcipTargets,
+ scanBatchTaggingTargets,
+ startBatchCcipExtraction,
+ startBatchTagging,
+} from "~/infrastructure/api-clients/ai-api";
+import {
+ createCharacter,
+ deleteCharacter,
+ updateCharacter,
+} from "~/infrastructure/api-clients/characters-api";
+import {
+ createIp,
+ deleteIp,
+ updateIp,
+} from "~/infrastructure/api-clients/ips-api";
+import {
+ bulkDeleteMedia,
+ findDuplicateMedia,
+} from "~/infrastructure/api-clients/media-api";
+import {
+ createProject,
+ deleteProject,
+ updateProject,
+} from "~/infrastructure/api-clients/projects-api";
+import {
+ allCharactersQueryOptions,
+ allIpsQueryOptions,
+ allProjectsQueryOptions,
+ mediaSourcesQueryOptions,
+} from "~/infrastructure/api-clients/queries";
+import {
+ fetchSourceDump,
+ importSourceLanceDB,
+ importSourceNdjson,
+ importSourceZip,
+} from "~/infrastructure/api-clients/sources-api";
+
+const managerQueryOptions = {
+ projects: allProjectsQueryOptions,
+ ips: allIpsQueryOptions,
+ characters: allCharactersQueryOptions,
+ sources: mediaSourcesQueryOptions,
+};
+
+const managerActions = {
+ createProject,
+ updateProject,
+ deleteProject,
+ createIp,
+ updateIp,
+ deleteIp,
+ createCharacter,
+ updateCharacter,
+ deleteCharacter,
+ scanBatchTaggingTargets,
+ startBatchTagging,
+ scanBatchCcipTargets,
+ startBatchCcipExtraction,
+ findDuplicateMedia,
+ bulkDeleteMedia,
+};
+
+function downloadBlob(blob: Blob, fileName: string): void {
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = fileName;
+ document.body.append(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(url);
+}
+
+function dumpFileName(
+ sourceId: string,
+ format: V2ManagerTransferFormat,
+): string {
+ switch (format) {
+ case "ndjson":
+ return `source-${sourceId}-dump.ndjson`;
+ case "tar":
+ return `source-${sourceId}-dump.tar`;
+ case "lancedb":
+ return `source-${sourceId}-dump-lancedb.tar`;
+ }
+}
+
+const transferActions = {
+ exportSource: async (input: {
+ format: V2ManagerTransferFormat;
+ includeImages: boolean;
+ sourceId: string;
+ }) => {
+ try {
+ const mode =
+ input.format === "ndjson"
+ ? "json"
+ : input.format === "tar"
+ ? "zip"
+ : "lancedb";
+ const blob = await fetchSourceDump(input.sourceId, mode, {
+ includeImages: input.format === "lancedb" && input.includeImages,
+ });
+ downloadBlob(blob, dumpFileName(input.sourceId, input.format));
+ toast.success("Source dump downloaded");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Export failed");
+ throw error;
+ }
+ },
+ importSource: async (input: {
+ file: File;
+ format: V2ManagerTransferFormat;
+ sourceId: string;
+ }) => {
+ try {
+ const result =
+ input.format === "ndjson"
+ ? await importSourceNdjson(input.sourceId, input.file)
+ : input.format === "tar"
+ ? await importSourceZip(input.sourceId, input.file)
+ : await importSourceLanceDB(input.sourceId, input.file);
+ toast.success(`Restore complete: ${result.importedCount} items imported`);
+ return { importedCount: result.importedCount };
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Restore failed");
+ throw error;
+ }
+ },
+};
+
+export const Route = createFileRoute("/v2/manager")({
+ component: V2ManagerRoute,
+});
+
+function V2ManagerRoute() {
+ const queryClient = useQueryClient();
+ const manager = useManagerPage({
+ queryClient,
+ queryOptions: managerQueryOptions,
+ actions: managerActions,
+ useBatchJobEvents,
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/server/src/routes/v2/route.tsx b/apps/server/src/routes/v2/route.tsx
new file mode 100644
index 00000000..c87bb3c1
--- /dev/null
+++ b/apps/server/src/routes/v2/route.tsx
@@ -0,0 +1,24 @@
+import { RouteTransitionIndicator } from "@solid-imager/ui/router-status";
+import { createFileRoute, Outlet } from "@tanstack/solid-router";
+import { createSignal, onMount, Show } from "solid-js";
+import { ApiActivityIndicator } from "~/components/api-activity-indicator";
+import { V2AppShell } from "~/components/v2/v2-app-shell";
+
+export const Route = createFileRoute("/v2")({
+ pendingComponent: () => null,
+ component: V2Layout,
+});
+
+function V2Layout() {
+ const [isMounted, setIsMounted] = createSignal(false);
+ onMount(() => setIsMounted(true));
+
+ return (
+
+ }>
+
+
+
+
+ );
+}
diff --git a/apps/server/src/routes/v2/search.tsx b/apps/server/src/routes/v2/search.tsx
new file mode 100644
index 00000000..bf29ca88
--- /dev/null
+++ b/apps/server/src/routes/v2/search.tsx
@@ -0,0 +1,119 @@
+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 {
+ createFileRoute,
+ useLocation,
+ useNavigate,
+} from "@tanstack/solid-router";
+import { MediaGridItem } from "~/components/media/media-grid-item";
+import { ThumbnailImage } from "~/components/media/thumbnail-image";
+import { useMediaSourceEvents } from "~/hooks/use-media-source-events";
+import { PresetClient as rawPresetClient } from "~/infrastructure/api/clients/preset-client";
+import {
+ allAuthorsQueryOptions,
+ allCharactersQueryOptions,
+ allIpsQueryOptions,
+ allProjectsQueryOptions,
+ mediaSourcesQueryOptions,
+ tagsQueryOptions,
+} from "~/infrastructure/api-clients/queries";
+import {
+ searchMedia,
+ searchSimilar,
+} from "~/infrastructure/api-clients/search-api";
+import {
+ getSearchCondition,
+ searchState,
+ setSearchState,
+} from "~/presentation/store/search-store";
+
+const SEARCH_RESULTS_REFRESH_DEBOUNCE_MS = 300;
+const PresetClient = createPresetClient(rawPresetClient);
+
+export const Route = createFileRoute("/v2/search")({
+ component: V2SearchRoute,
+});
+
+function V2SearchRoute() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const isSearchStateRestored = useCurrentSearchPersistence("all");
+ const page = useSearchPage({
+ searchMedia,
+ searchSimilar,
+ queries: {
+ tags: tagsQueryOptions,
+ sources: mediaSourcesQueryOptions,
+ projects: allProjectsQueryOptions,
+ ips: allIpsQueryOptions,
+ characters: allCharactersQueryOptions,
+ authors: allAuthorsQueryOptions,
+ },
+ selectedSource: () => searchState.selectedSource,
+ getSearchCondition,
+ sortBy: () => searchState.sortBy,
+ sortOrder: () => searchState.sortOrder,
+ limit: () => searchState.limit,
+ scrollY: () => searchState.scrollY,
+ setScrollY: (value) => setSearchState("scrollY", value),
+ setOffset: (value) => setSearchState("offset", value),
+ mode: () => searchState.mode,
+ similarityAnchorMediaId: () => searchState.similarityAnchorMediaId,
+ similarityTopK: () => searchState.similarityTopK,
+ refreshDebounceMs: SEARCH_RESULTS_REFRESH_DEBOUNCE_MS,
+ isSearchStateRestored,
+ scrollContainerSelector: "[data-media-scroll]",
+ });
+
+ useMediaSourceEvents(() => searchState.selectedSource || "*", {
+ onMediaAdded: page.refreshSearchResults,
+ onMediaDeleted: page.refreshSearchResults,
+ onMediaChanged: page.refreshSearchResults,
+ onMediaCopied: page.refreshSearchResults,
+ onMediaMoved: page.refreshSearchResults,
+ onAllJobsCompleted: page.refreshSearchResults,
+ });
+
+ return (
+ setSearchState("selectedSource", id)}
+ page={page}
+ presetClient={PresetClient}
+ renderMediaItem={(media, options) => (
+
+ )}
+ onOpenMediaDetail={(media) => {
+ sessionStorage.setItem("v2:media-return", location().href);
+ void navigate({
+ params: {
+ mediaId: media.id,
+ mediaSourceId: media.mediaSourceId,
+ },
+ to: "/v2/sources/$mediaSourceId/$mediaId",
+ });
+ }}
+ renderMediaPreview={(media) => (
+
+ )}
+ selectedSource={searchState.selectedSource}
+ sources={page.sources()}
+ variant="v2"
+ />
+ );
+}
diff --git a/apps/server/src/routes/v2/sources/$mediaSourceId/$mediaId/index.tsx b/apps/server/src/routes/v2/sources/$mediaSourceId/$mediaId/index.tsx
new file mode 100644
index 00000000..c23a3419
--- /dev/null
+++ b/apps/server/src/routes/v2/sources/$mediaSourceId/$mediaId/index.tsx
@@ -0,0 +1,159 @@
+import type { MediaDetails } from "@solid-imager/core/domain/media/schemas";
+import { Button } from "@solid-imager/ui/button";
+import { MediaDetailScreen } from "@solid-imager/ui/screens/media-detail-screen";
+import {
+ ArrowLeft,
+ ChevronLeft,
+ ChevronRight,
+} from "@solid-imager/ui/v2/icons";
+import { createQuery } from "@tanstack/solid-query";
+import { createFileRoute, useNavigate } from "@tanstack/solid-router";
+import type { Accessor } from "solid-js";
+import { MediaActions, MediaSidebar } from "~/components/media/media-sidebar";
+import { MediaViewer } from "~/components/media/media-viewer";
+import { createServerTransport } from "~/hooks/use-media-source-events";
+import {
+ mediaDetailsQueryOptions,
+ mediaSourcesQueryOptions,
+} from "~/infrastructure/api-clients/queries";
+
+interface MediaRouteParams {
+ mediaId: string;
+ mediaSourceId: string;
+}
+
+export const Route = createFileRoute("/v2/sources/$mediaSourceId/$mediaId/")({
+ ssr: false,
+ remountDeps: ({ params }: { params: MediaRouteParams }) => [
+ params.mediaSourceId,
+ params.mediaId,
+ ],
+ pendingComponent: () => null,
+ component: MediaRoute,
+});
+
+function MediaRoute() {
+ const params = Route.useParams();
+ const mediaSourceId = () => params().mediaSourceId;
+ const mediaId = () => params().mediaId;
+
+ return ;
+}
+
+function MediaDetailHeader(props: {
+ media: MediaDetails;
+ onUpdate: () => void;
+ sourceName: string;
+}) {
+ const navigate = useNavigate();
+ const returnToCollection = () => {
+ const returnPath = sessionStorage.getItem("v2:media-return");
+ if (
+ typeof returnPath === "string" &&
+ (returnPath === "/v2/search" ||
+ returnPath.startsWith("/v2/search?") ||
+ returnPath.startsWith("/v2/sources/"))
+ ) {
+ sessionStorage.removeItem("v2:media-return");
+ void navigate({ to: returnPath });
+ return;
+ }
+ void navigate({
+ to: "/v2/sources/$mediaSourceId",
+ params: { mediaSourceId: props.media.mediaSourceId },
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ {props.media.fileName}
+
+
+ {props.sourceName}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function MediaContent(props: {
+ mediaId: Accessor;
+ mediaSourceId: Accessor;
+}) {
+ const mediaSources = createQuery(mediaSourcesQueryOptions);
+ const sourceName = () =>
+ mediaSources.data?.find((source) => source.id === props.mediaSourceId())
+ ?.name ?? "Media source";
+
+ return (
+ (
+ void onUpdate()}
+ sourceName={sourceName()}
+ />
+ )}
+ renderMediaSidebar={(media, isUpdating, onUpdate) => (
+
+ )}
+ renderMediaViewer={(media) => }
+ transport={createServerTransport(props.mediaSourceId)}
+ variant="v2"
+ />
+ );
+}
diff --git a/apps/server/src/routes/v2/sources/$mediaSourceId/index.tsx b/apps/server/src/routes/v2/sources/$mediaSourceId/index.tsx
new file mode 100644
index 00000000..9dd068ac
--- /dev/null
+++ b/apps/server/src/routes/v2/sources/$mediaSourceId/index.tsx
@@ -0,0 +1,19 @@
+import { createFileRoute } from "@tanstack/solid-router";
+import { SourceMediaPage } from "~/routes/sources/$mediaSourceId/components/source-media-page";
+
+export const Route = createFileRoute("/v2/sources/$mediaSourceId/")({
+ remountDeps: ({ params }: { params: { mediaSourceId: string } }) => [
+ params.mediaSourceId,
+ ],
+ component: V2SourceMediaRoute,
+});
+
+function V2SourceMediaRoute() {
+ const params = Route.useParams();
+ return (
+ params().mediaSourceId}
+ variant="v2"
+ />
+ );
+}
diff --git a/apps/server/src/tests/e2e/v2-routes.responsive.spec.ts b/apps/server/src/tests/e2e/v2-routes.responsive.spec.ts
new file mode 100644
index 00000000..b8bf58f6
--- /dev/null
+++ b/apps/server/src/tests/e2e/v2-routes.responsive.spec.ts
@@ -0,0 +1,183 @@
+import type { Page } from "@playwright/test";
+import {
+ E2E_PRIMARY_FILE_NAME,
+ E2E_PRIMARY_MEDIA_ID,
+ E2E_SIMILAR_FILE_NAME,
+ E2E_SIMILAR_MEDIA_ID,
+ E2E_SOURCE_ID,
+ E2E_SOURCE_NAME,
+} from "./support/fixture";
+import {
+ expect,
+ expectRouteHealthy,
+ test,
+ waitForAppHydration,
+} from "./support/test";
+
+const v2SourcePath = `/v2/sources/${E2E_SOURCE_ID}`;
+const v2MediaPath = (mediaId: string) => `${v2SourcePath}/${mediaId}`;
+
+async function expectNoHorizontalOverflow(page: Page): Promise {
+ const overflow = await page.evaluate(
+ () =>
+ document.documentElement.scrollWidth -
+ document.documentElement.clientWidth,
+ );
+ expect(overflow).toBeLessThanOrEqual(1);
+}
+
+test("V2 routes survive direct navigation and reload", async ({ page }) => {
+ const routes = [
+ ["/v2/search", "すべてのメディア"],
+ [v2SourcePath, E2E_SOURCE_NAME],
+ [v2MediaPath(E2E_PRIMARY_MEDIA_ID), E2E_PRIMARY_FILE_NAME],
+ ["/v2/manager", "Manager"],
+ ["/v2/jobs", "Jobs"],
+ ["/v2/config", "Settings"],
+ ["/v2/about", "About Solid Imager"],
+ ] as const;
+
+ for (const [path, visibleText] of routes) {
+ await page.goto(path);
+ await waitForAppHydration(page);
+ await expect(
+ page.getByText(visibleText, { exact: true }).last(),
+ ).toBeVisible();
+ await page.reload();
+ await waitForAppHydration(page);
+ await expect(
+ page.getByText(visibleText, { exact: true }).last(),
+ ).toBeVisible();
+ await expectRouteHealthy(page);
+ await expectNoHorizontalOverflow(page);
+ }
+});
+
+test("V2 detail changes after returning to a collection", async ({ page }) => {
+ await page.goto(v2SourcePath);
+ await waitForAppHydration(page);
+
+ await page.locator(`[data-media-id="${E2E_PRIMARY_MEDIA_ID}"]`).click();
+ await expect(page).toHaveURL(v2MediaPath(E2E_PRIMARY_MEDIA_ID));
+ await expect(
+ page.getByRole("img", { name: E2E_PRIMARY_FILE_NAME, exact: true }),
+ ).toBeVisible();
+
+ await page.getByRole("button", { name: "一覧に戻る", exact: true }).click();
+ await expect(page).toHaveURL(v2SourcePath);
+ await page.locator(`[data-media-id="${E2E_SIMILAR_MEDIA_ID}"]`).click();
+ await expect(page).toHaveURL(v2MediaPath(E2E_SIMILAR_MEDIA_ID));
+ await expect(
+ page.getByRole("img", { name: E2E_SIMILAR_FILE_NAME, exact: true }),
+ ).toBeVisible();
+});
+
+test("V2 wide collection uses selection preview before detail navigation", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await page.goto(v2SourcePath);
+ await waitForAppHydration(page);
+
+ await page.locator(`[data-media-id="${E2E_PRIMARY_MEDIA_ID}"]`).click();
+ await expect(page).toHaveURL(v2SourcePath);
+ const inspector = page.getByRole("complementary", {
+ name: "選択中のメディア",
+ });
+ await expect(inspector).toContainText(E2E_PRIMARY_FILE_NAME);
+ await page.locator(`[data-media-id="${E2E_SIMILAR_MEDIA_ID}"]`).click();
+ await expect(page).toHaveURL(v2SourcePath);
+ await expect(inspector).toContainText(E2E_SIMILAR_FILE_NAME);
+ await expect(
+ inspector.getByRole("img", { name: E2E_SIMILAR_FILE_NAME, exact: true }),
+ ).toBeVisible();
+ await inspector.getByRole("button", { name: "詳細を開く" }).click();
+ await expect(page).toHaveURL(v2MediaPath(E2E_SIMILAR_MEDIA_ID));
+});
+
+test("V2 search filter opens without remounting media results", async ({
+ page,
+}) => {
+ await page.goto("/v2/search");
+ await waitForAppHydration(page);
+
+ const firstMedia = page.locator("[data-media-id]").first();
+ await expect(firstMedia).toBeVisible();
+ await page.evaluate(() => {
+ const state = window as Window & {
+ __v2FirstMedia?: Element;
+ __v2FilterDialog?: Element;
+ __v2SawLoadingFallback?: boolean;
+ __v2LoadingObserver?: MutationObserver;
+ };
+ state.__v2FirstMedia =
+ document.querySelector("[data-media-id]") ?? undefined;
+ state.__v2SawLoadingFallback = false;
+ state.__v2LoadingObserver = new MutationObserver(() => {
+ if (
+ document.body.textContent?.includes("画面を読み込んでいます") ||
+ document.body.textContent?.includes("検索結果を読み込んでいます")
+ ) {
+ state.__v2SawLoadingFallback = true;
+ }
+ });
+ state.__v2LoadingObserver.observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+ });
+
+ const filterButton = page.getByRole("button", {
+ name: /検索フィルター、\d+件の条件/,
+ });
+ await filterButton.click();
+ const filterDialog = page.getByRole("dialog", { name: "検索フィルター" });
+ await expect(filterDialog).toBeVisible();
+ await filterDialog.evaluate((element) => {
+ (
+ window as Window & {
+ __v2FilterDialog?: Element;
+ }
+ ).__v2FilterDialog = element;
+ });
+
+ const comboboxNames = await filterDialog
+ .getByRole("combobox")
+ .evaluateAll((elements) =>
+ elements.map((element) => {
+ const labelledBy = element.getAttribute("aria-labelledby");
+ return (
+ element.getAttribute("aria-label") ??
+ (labelledBy
+ ? document.getElementById(labelledBy)?.textContent?.trim()
+ : "")
+ );
+ }),
+ );
+ expect(comboboxNames.length).toBeGreaterThan(0);
+ expect(comboboxNames.every(Boolean)).toBe(true);
+
+ await filterDialog.getByRole("button", { name: "閉じる" }).click();
+ await filterButton.click();
+ await expect(filterDialog).toBeVisible();
+ const renderState = await page.evaluate(() => {
+ const state = window as Window & {
+ __v2FirstMedia?: Element;
+ __v2FilterDialog?: Element;
+ __v2SawLoadingFallback?: boolean;
+ __v2LoadingObserver?: MutationObserver;
+ };
+ state.__v2LoadingObserver?.disconnect();
+ return {
+ filterNodeWasPreserved:
+ state.__v2FilterDialog ===
+ document.querySelector('[role="dialog"][aria-label="検索フィルター"]'),
+ mediaNodeWasPreserved:
+ state.__v2FirstMedia === document.querySelector("[data-media-id]"),
+ sawLoadingFallback: state.__v2SawLoadingFallback,
+ };
+ });
+ expect(renderState.filterNodeWasPreserved).toBe(true);
+ expect(renderState.mediaNodeWasPreserved).toBe(true);
+ expect(renderState.sawLoadingFallback).toBe(false);
+});
diff --git a/apps/server/src/tests/unit/application/services/maintenance-service.test.ts b/apps/server/src/tests/unit/application/services/maintenance-service.test.ts
index 02ceb0b9..0fff2712 100644
--- a/apps/server/src/tests/unit/application/services/maintenance-service.test.ts
+++ b/apps/server/src/tests/unit/application/services/maintenance-service.test.ts
@@ -316,5 +316,4 @@ describe("MaintenanceService", () => {
expect(mockJobRepo.createIfUnique).not.toHaveBeenCalled();
});
});
-
});
diff --git a/packages/application/src/services/media-processing-service.ts b/packages/application/src/services/media-processing-service.ts
index 443238c4..3fbe61a8 100644
--- a/packages/application/src/services/media-processing-service.ts
+++ b/packages/application/src/services/media-processing-service.ts
@@ -280,7 +280,6 @@ export class MediaProcessingServiceImpl implements IMediaProcessingService {
);
}
}
-
}
private async registerContextMetadata(
diff --git a/packages/ui/src/combobox.tsx b/packages/ui/src/combobox.tsx
index 4be46769..d0661892 100644
--- a/packages/ui/src/combobox.tsx
+++ b/packages/ui/src/combobox.tsx
@@ -14,9 +14,28 @@ import {
import { cn } from "./utils/cn";
const Combobox = ComboboxPrimitive.Root;
+const useComboboxContext = ComboboxPrimitive.useComboboxContext;
const ComboboxItemLabel = ComboboxPrimitive.ItemLabel;
const ComboboxHiddenSelect = ComboboxPrimitive.HiddenSelect;
+type ComboboxLabelProps =
+ ComboboxPrimitive.ComboboxLabelProps & { class?: string | undefined };
+
+const ComboboxLabel = (
+ props: PolymorphicProps>,
+) => {
+ const [local, others] = splitProps(props as ComboboxLabelProps, ["class"]);
+ return (
+
+ );
+};
+
type ComboboxItemProps =
ComboboxPrimitive.ComboboxItemProps & {
class?: string | undefined;
@@ -307,7 +326,9 @@ export {
ComboboxItem,
ComboboxItemIndicator,
ComboboxItemLabel,
+ ComboboxLabel,
ComboboxSection,
ComboboxTrigger,
+ useComboboxContext,
VirtualComboboxContent,
};
diff --git a/packages/ui/src/dialog.tsx b/packages/ui/src/dialog.tsx
index 8de5ad00..b43f1bd7 100644
--- a/packages/ui/src/dialog.tsx
+++ b/packages/ui/src/dialog.tsx
@@ -54,7 +54,7 @@ type DialogContentProps<_T extends ValidComponent = "div"> = ComponentProps<
> & {
class?: string | undefined;
children?: JSX.Element;
- placement?: "center" | "right";
+ placement?: "center" | "left" | "right";
};
const DialogContent = (
@@ -74,7 +74,9 @@ const DialogContent = (
"fixed z-50 grid overflow-y-auto overscroll-contain border bg-background shadow-lg duration-200 data-[closed]:animate-out data-[expanded]:animate-in motion-reduce:animate-none",
placement() === "center"
? "top-1/2 left-1/2 max-h-[calc(100dvh-2rem-env(safe-area-inset-top)-env(safe-area-inset-bottom))] w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 origin-center gap-4 p-6 data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95 sm:max-h-[calc(100dvh-4rem)] sm:rounded-lg"
- : "inset-y-0 right-0 left-auto h-dvh max-h-none w-[min(28rem,calc(100%-1rem))] max-w-none translate-x-0 translate-y-0 origin-right gap-4 rounded-none border-y-0 border-r-0 p-6 data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:slide-out-to-right data-[expanded]:slide-in-from-right sm:max-h-none",
+ : placement() === "left"
+ ? "inset-y-0 left-0 h-dvh max-h-none w-[min(20rem,calc(100%-1rem))] max-w-none translate-x-0 translate-y-0 origin-left gap-4 rounded-none border-y-0 border-l-0 p-4 data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:slide-out-to-left data-[expanded]:slide-in-from-left sm:max-h-none"
+ : "inset-y-0 right-0 left-auto h-dvh max-h-none w-[min(28rem,calc(100%-1rem))] max-w-none translate-x-0 translate-y-0 origin-right gap-4 rounded-none border-y-0 border-r-0 p-6 data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:slide-out-to-right data-[expanded]:slide-in-from-right sm:max-h-none",
props.class,
)}
{...rest}
diff --git a/packages/ui/src/hooks/scroll-container.ts b/packages/ui/src/hooks/scroll-container.ts
new file mode 100644
index 00000000..cb54bf29
--- /dev/null
+++ b/packages/ui/src/hooks/scroll-container.ts
@@ -0,0 +1,28 @@
+export function resolveScrollContainer(
+ selector: string | undefined,
+): HTMLElement | null {
+ if (!selector || typeof document === "undefined") return null;
+ return document.querySelector(selector);
+}
+
+export function scrollToPosition(
+ selector: string | undefined,
+ top: number,
+): boolean {
+ if (selector) {
+ const container = resolveScrollContainer(selector);
+ if (!container) return false;
+ container.scrollTo({ top });
+ return true;
+ }
+ if (typeof window === "undefined") return false;
+ window.scrollTo(0, top);
+ return true;
+}
+
+export function currentScrollPosition(
+ selector: string | undefined,
+): number | null {
+ if (selector) return resolveScrollContainer(selector)?.scrollTop ?? null;
+ return typeof window === "undefined" ? null : window.scrollY;
+}
diff --git a/packages/ui/src/hooks/use-manager-page.ts b/packages/ui/src/hooks/use-manager-page.ts
index ce340133..b16af346 100644
--- a/packages/ui/src/hooks/use-manager-page.ts
+++ b/packages/ui/src/hooks/use-manager-page.ts
@@ -110,6 +110,7 @@ export type UseManagerPageOptions = {
useBatchJobEvents?: (
activeJobId: Accessor,
handlers: ManagerJobHandlers,
+ options?: { subscribeImmediately?: boolean },
) => void;
onBatchTaggingStart?: (result: StartBatchTaggingResult) => void;
};
@@ -712,7 +713,9 @@ export function useManagerPage(
handleJobFailed,
};
- useBatchJobEvents?.(activeJobId, jobHandlers);
+ // Fast children may publish parent progress before the start request returns.
+ // Buffer those events until activeJobId is known.
+ useBatchJobEvents?.(activeJobId, jobHandlers, { subscribeImmediately: true });
return {
activeTab,
diff --git a/packages/ui/src/hooks/use-search-page.ts b/packages/ui/src/hooks/use-search-page.ts
index 33452a00..7af6431f 100644
--- a/packages/ui/src/hooks/use-search-page.ts
+++ b/packages/ui/src/hooks/use-search-page.ts
@@ -20,6 +20,7 @@ import {
import { isServer } from "solid-js/web";
import { buildSearchResultsQueryOptions } from "../query-options";
import { type QueryUiState, toQueryUiState } from "../query-state";
+import { currentScrollPosition, scrollToPosition } from "./scroll-container";
const DEFAULT_GC_TIME = 1000 * 60 * 5;
const DEFAULT_REFRESH_DEBOUNCE_MS = 0;
@@ -76,6 +77,7 @@ export interface UseSearchPageOptions {
gcTime?: number;
refreshDebounceMs?: number;
isSearchStateRestored?: Accessor;
+ scrollContainerSelector?: string;
}
export interface UseSearchPageResult {
@@ -246,7 +248,6 @@ export function useSearchPage(
};
const [isRestored, setIsRestored] = createSignal(false);
-
createEffect(() => {
if (
!(searchResultQuery.isLoading || isRestored()) &&
@@ -255,7 +256,7 @@ export function useSearchPage(
) {
if (scrollY() > 0) {
requestAnimationFrame(() => {
- window.scrollTo(0, scrollY());
+ scrollToPosition(options.scrollContainerSelector, scrollY());
});
}
setIsRestored(true);
@@ -264,7 +265,8 @@ export function useSearchPage(
onCleanup(() => {
if (!isServer) {
- setScrollY(window.scrollY);
+ const position = currentScrollPosition(options.scrollContainerSelector);
+ if (position !== null) setScrollY(position);
}
const timer = refreshTimer();
if (timer) {
@@ -275,7 +277,7 @@ export function useSearchPage(
const handleSearch = () => {
setOffset(0);
setScrollY(0);
- window.scrollTo(0, 0);
+ scrollToPosition(options.scrollContainerSelector, 0);
};
const [loadMoreRef, setLoadMoreRef] = createSignal<
diff --git a/packages/ui/src/hooks/use-source-media-page.ts b/packages/ui/src/hooks/use-source-media-page.ts
index 997d8a39..e4662323 100644
--- a/packages/ui/src/hooks/use-source-media-page.ts
+++ b/packages/ui/src/hooks/use-source-media-page.ts
@@ -36,6 +36,7 @@ import { type QueryUiState, toQueryUiState } from "../query-state";
import type { PresetManagerClient } from "../search-control-panel";
import { toast } from "../toast";
import { getRestoreImportStrategies } from "./restore-import";
+import { currentScrollPosition, scrollToPosition } from "./scroll-container";
import type { MediaSourceEventTransport } from "./use-media-source-events";
import { useMediaSourceEvents } from "./use-media-source-events";
@@ -170,6 +171,7 @@ export type UseSourceMediaPageOptions = {
itemsPerPage?: number;
onThumbnailReady?: (mediaId: string) => void;
isSearchStateRestored?: Accessor;
+ scrollContainerSelector?: string;
};
export type UseSourceMediaPageResult = {
@@ -240,7 +242,6 @@ export function useSourceMediaPage(
} = options;
const id = mediaSourceId;
-
// --- Filter data queries ---
const filterData = (): SourceMediaPageFilterData => ({
tags: queries.tags(),
@@ -294,7 +295,7 @@ export function useSourceMediaPage(
// --- Search handler ---
const handleSearch = () => {
- window.scrollTo(0, 0);
+ scrollToPosition(options.scrollContainerSelector, 0);
};
// --- Scroll restoration ---
@@ -324,7 +325,7 @@ export function useSourceMediaPage(
if (targetScrollY > 0) {
setTimeout(() => {
requestAnimationFrame(() => {
- window.scrollTo(0, targetScrollY);
+ scrollToPosition(options.scrollContainerSelector, targetScrollY);
setIsScrollRestored(true);
});
}, SCROLL_RESTORE_DELAY);
@@ -340,7 +341,8 @@ export function useSourceMediaPage(
}
const sourceId = id();
if (sourceId) {
- setScrollPosition(sourceId, window.scrollY);
+ const position = currentScrollPosition(options.scrollContainerSelector);
+ if (position !== null) setScrollPosition(sourceId, position);
}
});
diff --git a/packages/ui/src/import-review-modal.tsx b/packages/ui/src/import-review-modal.tsx
index 1afbd711..49588dbe 100644
--- a/packages/ui/src/import-review-modal.tsx
+++ b/packages/ui/src/import-review-modal.tsx
@@ -8,7 +8,6 @@ import {
For,
Show,
} from "solid-js";
-import { Portal } from "solid-js/web";
import {
AlertDialog,
AlertDialogAction,
@@ -19,6 +18,15 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "./alert-dialog";
+import { Button } from "./button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "./dialog";
import {
getPendingImportPrimaryAuthor,
getPreferredImportSourceId,
@@ -43,6 +51,7 @@ export type ImportReviewModalProps = {
targetSourceId: string,
) => Promise<{ success: boolean; processedCount: number }>;
cancelPending: (jobIds: string[]) => Promise<{ success: boolean }>;
+ variant?: "default" | "v2";
};
function getPreviewUrl(url?: string): string {
@@ -73,6 +82,11 @@ export function ImportReviewModal(props: ImportReviewModalProps) {
);
const [selectedSourceId, setSelectedSourceId] = createSignal("");
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = createSignal(false);
+ const [isDiscardDialogOpen, setIsDiscardDialogOpen] = createSignal(false);
+ const [isDirty, setIsDirty] = createSignal(false);
+ const [activeAction, setActiveAction] = createSignal<
+ "import" | "delete" | null
+ >(null);
const [pendingJobs, { refetch: refetchJobs }] = createResource(
() => props.isOpen || undefined,
@@ -80,6 +94,14 @@ export function ImportReviewModal(props: ImportReviewModalProps) {
);
const [sources] = createResource(props.listSources);
+ createEffect(() => {
+ if (props.isOpen) {
+ setIsDirty(false);
+ setIsDiscardDialogOpen(false);
+ void refetchJobs();
+ }
+ });
+
createEffect(() => {
const jobs = pendingJobs();
if (jobs?.length) {
@@ -109,6 +131,20 @@ export function ImportReviewModal(props: ImportReviewModalProps) {
current.add(id);
}
setSelectedJobIds(current);
+ setIsDirty(true);
+ };
+ const requestClose = () => {
+ if (activeAction() !== null) return;
+ if (isDirty()) {
+ setIsDiscardDialogOpen(true);
+ return;
+ }
+ props.onClose();
+ };
+ const discardAndClose = () => {
+ setIsDirty(false);
+ setIsDiscardDialogOpen(false);
+ props.onClose();
};
const handleProcess = async () => {
@@ -119,150 +155,228 @@ export function ImportReviewModal(props: ImportReviewModalProps) {
}
try {
+ setActiveAction("import");
await props.processPending(jobIds, sourceId);
+ setIsDirty(false);
props.onImportCompleted();
props.onClose();
} catch (error) {
toast.error(`Failed to process imports: ${getErrorMessage(error)}`);
+ } finally {
+ setActiveAction(null);
}
};
const confirmDelete = async () => {
try {
+ setActiveAction("delete");
await props.cancelPending(Array.from(selectedJobIds()));
await refetchJobs();
setSelectedJobIds(createEmptySelection());
+ setIsDirty(false);
toast.success("Requests deleted");
} catch (error) {
toast.error(`Failed to cancel imports: ${getErrorMessage(error)}`);
} finally {
+ setActiveAction(null);
setIsDeleteDialogOpen(false);
}
};
return (
-
-
-
-
-
-
- Review Pending Imports
-
-
+ <>
+
-
+
+
+
+
+
+
+
+
+
+
+
+ Discard selected changes?
+
+ Your selected items and target source have not been imported yet.
+
+
+
+ Continue reviewing
+
+ Discard and close
+
+
+
+
Cancel
void confirmDelete()}
>
- Delete
+ {activeAction() === "delete" ? "Deleting…" : "Delete"}
-
+ >
);
}
diff --git a/packages/ui/src/layouts/app-nav.tsx b/packages/ui/src/layouts/app-nav.tsx
index 6db74c27..035f948f 100644
--- a/packages/ui/src/layouts/app-nav.tsx
+++ b/packages/ui/src/layouts/app-nav.tsx
@@ -23,6 +23,8 @@ const navigationItems = [
{ label: "Manager", to: "/manager" },
{ label: "About", to: "/about" },
{ label: "Settings", to: "/config" },
+ { label: "V2", to: "/v2" },
+ { label: "Design Lab", to: "/design-lab" },
] as const;
export function AppNav(props: AppNavProps) {
diff --git a/packages/ui/src/media-grid-item.tsx b/packages/ui/src/media-grid-item.tsx
index 47e94af4..12c400d6 100644
--- a/packages/ui/src/media-grid-item.tsx
+++ b/packages/ui/src/media-grid-item.tsx
@@ -18,10 +18,13 @@ export type MediaGridLinkProps = {
class: string;
"data-media-id": string;
href: string;
+ "aria-pressed"?: boolean;
+ onClick?: (event: MouseEvent) => void;
onContextMenu: (event: MouseEvent) => void;
};
type MediaGridItemProps = {
+ variant?: "default" | "v2";
media: Media;
linkPrefix?: string;
priority?: boolean;
@@ -51,22 +54,35 @@ export function MediaGridItem(props: MediaGridItemProps) {
return (
props.onContextMenu?.(event)}
>
-
-
-
+
+
+ {props.isSelected ? "✓" : ""}
@@ -81,7 +97,10 @@ export function MediaGridItem(props: MediaGridItemProps) {
{props.renderThumbnail({
alt: props.media.fileName,
class: cn(
- "h-full w-full object-cover transition-transform duration-300 group-hover:scale-105",
+ "h-full w-full object-cover",
+ props.variant === "v2"
+ ? "transition duration-200 group-hover:scale-[1.015] motion-reduce:transition-none"
+ : "transition-transform duration-300 group-hover:scale-105",
props.thumbnailClass,
),
height: props.media.height,
@@ -94,7 +113,7 @@ export function MediaGridItem(props: MediaGridItemProps) {
diff --git a/packages/ui/src/media-viewer.tsx b/packages/ui/src/media-viewer.tsx
index 9c620361..fe603455 100644
--- a/packages/ui/src/media-viewer.tsx
+++ b/packages/ui/src/media-viewer.tsx
@@ -18,6 +18,7 @@ export interface MediaViewerProps {
fileName: string;
width?: number;
height?: number;
+ variant?: "default" | "v2";
}
export function MediaViewer(props: MediaViewerProps) {
@@ -55,7 +56,9 @@ export function MediaViewer(props: MediaViewerProps) {
return (