From fa2e3e2085f88a6f60019c97331c24d89918b3ee Mon Sep 17 00:00:00 2001
From: hmjn
Date: Mon, 10 Aug 2026 11:41:12 +0900
Subject: [PATCH 1/4] feat(v2): add no-migration feature support
---
REPORT.md | 16 +-
apps/server/public/openapi.json | 250 ++++++++++
.../services/directory-sync-service.ts | 56 +++
.../media/legacy-media-grid-item.tsx | 1 +
.../components/media/v2-media-grid-item.tsx | 1 +
.../src/components/v2/v2-source-list.tsx | 14 +-
.../src/hooks/use-media-source-events.ts | 1 +
.../api-clients/queries/index.ts | 7 +
.../infrastructure/api/routers/ai-router.ts | 21 +
.../api/routers/characters-router.ts | 12 +-
.../api/routers/entity-media-counts.ts | 43 ++
.../infrastructure/api/routers/ips-router.ts | 10 +-
.../infrastructure/api/routers/jobs-router.ts | 120 ++++-
.../api/routers/projects-router.ts | 12 +-
.../api/routers/sources-router.ts | 56 ++-
.../components/source-media-page.tsx | 2 +
.../components/v2-source-media-page.tsx | 11 +-
apps/server/src/routes/v2/config.tsx | 1 +
apps/server/src/routes/v2/jobs.tsx | 43 +-
apps/server/src/routes/v2/media-context.ts | 107 ++++
apps/server/src/routes/v2/search.tsx | 9 +-
.../sources/$mediaSourceId/$mediaId/index.tsx | 29 +-
.../src/hooks/use-media-source-events.ts | 1 +
apps/tauri/src/routes/v2.tsx | 24 +
apps/tauri/src/routes/v2/$.tsx | 55 ++
.../core/src/domain/characters/schemas.ts | 1 +
.../core/src/domain/contract/ai.contract.ts | 2 +
.../core/src/domain/contract/jobs.contract.ts | 9 +
packages/core/src/domain/ips/schemas.ts | 1 +
packages/core/src/domain/jobs/schemas.ts | 53 ++
packages/core/src/domain/projects/schemas.ts | 1 +
packages/core/src/domain/sources/events.ts | 13 +
packages/core/src/domain/sources/schemas.ts | 5 +
packages/core/src/domain/tagging/schemas.ts | 9 +
packages/ui/src/hooks/use-job-events.ts | 23 +
.../ui/src/hooks/use-media-source-events.ts | 12 +
packages/ui/src/hooks/use-sources-events.ts | 32 ++
packages/ui/src/query-options/index.ts | 5 +
packages/ui/src/query-options/jobs-query.ts | 26 +
.../ui/src/screens/search-screen.types.ts | 1 +
.../src/screens/source-media-screen.types.ts | 4 +-
packages/ui/src/screens/v2-config-screen.tsx | 61 ++-
.../ui/src/screens/v2-config-state-screen.tsx | 8 +-
packages/ui/src/screens/v2-jobs-screen.tsx | 470 +++++++++++++++---
.../src/screens/v2-manager/entity-panel.tsx | 12 +
packages/ui/src/screens/v2-search-screen.tsx | 20 +-
.../ui/src/screens/v2-source-media-screen.tsx | 19 +-
packages/ui/src/source-media-grid.tsx | 342 ++++++++-----
packages/ui/src/source-media-page.tsx | 2 +
packages/ui/src/v2/search-toolbar.tsx | 24 +-
50 files changed, 1819 insertions(+), 238 deletions(-)
create mode 100644 apps/server/src/infrastructure/api/routers/entity-media-counts.ts
create mode 100644 apps/server/src/routes/v2/media-context.ts
create mode 100644 apps/tauri/src/routes/v2.tsx
create mode 100644 apps/tauri/src/routes/v2/$.tsx
create mode 100644 packages/core/src/domain/jobs/schemas.ts
create mode 100644 packages/ui/src/hooks/use-job-events.ts
create mode 100644 packages/ui/src/query-options/jobs-query.ts
diff --git a/REPORT.md b/REPORT.md
index 11b13e51c..d14dbaabb 100644
--- a/REPORT.md
+++ b/REPORT.md
@@ -6,19 +6,19 @@
| 項目 | 現状の原因 | 必要な実装 | V2での扱い |
| --- | --- | --- | --- |
-| Jobs一覧・履歴・詳細 | jobs contractはイベント購読のみで、一覧取得APIがない | Job DTO、list/detail query | 専用画面の骨格と未対応状態を表示 |
-| JobのRetry / Cancel | 対応するuse caseとmutationがない | retry/cancel command、状態遷移、権限制御 | 操作をdisabled |
-| Sourceの件数・同期中状態 | `SafeMediaSource`にmedia countとsync lifecycleがない | source summary DTO、集計、sync status event | sidebarに「件数未取得」 |
-| Managerの利用件数 | Project / IP / Character一覧にmedia count等がない | 集計queryまたはsummary DTO | 取得可能な実データだけ表示 |
+| Jobs一覧・履歴・詳細 | jobs contractはイベント購読のみで、一覧取得APIがない | Job DTO、list/detail query | 既存jobsテーブルからSafe DTOで一覧・詳細を表示。SSEで更新 |
+| JobのRetry / Cancel | 対応するuse caseとmutationがない | retry/cancel command、状態遷移、権限制御 | 失敗JobのRetryを実装。Cancelは自然な状態遷移・権限制御が必要なため未対応 |
+| Sourceの件数・同期中状態 | `SafeMediaSource`にmedia countとsync lifecycleがない | source summary DTO、集計、sync status event | 既存mediaの集計件数とプロセス内の同期状態を表示。同期イベントで更新 |
+| Managerの利用件数 | Project / IP / Character一覧にmedia count等がない | 集計queryまたはsummary DTO | 既存の中間テーブルからProject / IP / Characterの利用件数を表示 |
| Export / RestoreのJobs追跡 | 現行はHTTP download/uploadを直接実行する | job type、progress、artifact、失敗履歴 | 転送自体は実行し、Jobs連携は表示しない |
-| AI接続状態・latency | health check contractがない | typed health endpointとtimeout/error定義 | Settingsで「未確認」、確認操作をdisabled |
-| リロード後の前後メディア移動 | 詳細routeだけでは元のfilter/sort/cursorを復元できない | navigation context永続化、またはneighbor query | 前後ボタンをdisabled |
+| AI接続状態・latency | health check contractがない | typed health endpointとtimeout/error定義 | Settingsの確認操作から実際のhealth checkを実行し、状態とlatencyを表示 |
+| リロード後の前後メディア移動 | 詳細routeだけでは元のfilter/sort/cursorを復元できない | navigation context永続化、またはneighbor query | sessionStorageに最大500件の表示コンテキストを保存し、前後移動を提供。コンテキストなしではdisabled |
## Frontend follow-up
| 項目 | 理由 | V2での扱い |
| --- | --- | --- |
-| Collectionのlist表示 | 4:3 gridを先行し、listの情報設計とvirtual row実装が未着手 | toggleをdisabled |
-| Tauri route adapter | TauriはWebと別のhash routerを持つ | 今回はWebの`/v2/*`のみ。共有screenを使う薄いroute追加が必要 |
+| Collectionのlist表示 | 4:3 gridを先行し、listの情報設計とvirtual row実装が未着手 | Name / Type / Dimensions / Size / Modifiedを表示するlistへ切り替え可能 |
+| Tauri route adapter | TauriはWebと別のhash routerを持つ | 既存Tauri画面へつなぐ`/v2/*`薄いroute adapterを追加。JobsはWeb側のみ |
バックエンド追加時は、無効状態を消すだけでなく、loading / error / offline / retryとリアルタイム更新まで同じ画面内で接続します。
diff --git a/apps/server/public/openapi.json b/apps/server/public/openapi.json
index ec1a9a928..bb9c6cc57 100644
--- a/apps/server/public/openapi.json
+++ b/apps/server/public/openapi.json
@@ -1175,6 +1175,45 @@
"data"
]
},
+ {
+ "type": "object",
+ "properties": {
+ "event": {
+ "const": "source-sync-status"
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "mediaSourceId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "status": {
+ "enum": [
+ "idle",
+ "syncing",
+ "error"
+ ],
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "mediaSourceId",
+ "status"
+ ]
+ }
+ },
+ "required": [
+ "event",
+ "data"
+ ]
+ },
{
"type": "object",
"properties": {
@@ -5253,6 +5292,77 @@
}
}
},
+ "/ai/health": {
+ "post": {
+ "operationId": "ai.health",
+ "summary": "health",
+ "tags": [
+ "AI"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "enum": [
+ "available",
+ "unavailable"
+ ],
+ "type": "string"
+ },
+ "mode": {
+ "enum": [
+ "local",
+ "remote"
+ ],
+ "type": "string"
+ },
+ "latencyMs": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "message": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "checkedAt": {
+ "type": "string",
+ "format": "date-time",
+ "x-native-type": "date"
+ }
+ },
+ "required": [
+ "status",
+ "mode",
+ "latencyMs",
+ "message",
+ "checkedAt"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/ai/tag": {
"post": {
"operationId": "ai.tag",
@@ -6670,6 +6780,146 @@
}
}
},
+ "/jobs/list": {
+ "post": {
+ "operationId": "jobs.list",
+ "summary": "list",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "enum": [
+ "pending",
+ "in_progress",
+ "completed",
+ "failed"
+ ],
+ "type": "string"
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 200,
+ "default": 50
+ },
+ "offset": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991,
+ "default": 0
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {},
+ {
+ "not": {}
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/jobs/get": {
+ "post": {
+ "operationId": "jobs.get",
+ "summary": "get",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {},
+ {
+ "not": {}
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/jobs/retry": {
+ "post": {
+ "operationId": "jobs.retry",
+ "summary": "retry",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {},
+ {
+ "not": {}
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/jobs/events": {
"post": {
"operationId": "jobs.events",
diff --git a/apps/server/src/application/services/directory-sync-service.ts b/apps/server/src/application/services/directory-sync-service.ts
index 5a6d5535b..5ba822d29 100644
--- a/apps/server/src/application/services/directory-sync-service.ts
+++ b/apps/server/src/application/services/directory-sync-service.ts
@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
+import type { MediaSourceSyncState } from "@solid-imager/core/domain/sources/schemas";
import { services } from "~/application/registry";
import { ccipVectorService } from "~/application/services/ccip-vector-service";
import { MediaProcessingService } from "~/application/services/media-processing-service";
@@ -17,6 +18,42 @@ type SyncResult = {
deleted: number;
};
+type SourceSyncStatus = {
+ message?: string;
+ status: MediaSourceSyncState;
+ updatedAt: Date;
+};
+
+const syncStateGlobal = globalThis as typeof globalThis & {
+ __SOLID_IMAGER_SOURCE_SYNC_STATES__?: Map;
+};
+const sourceSyncStates =
+ syncStateGlobal.__SOLID_IMAGER_SOURCE_SYNC_STATES__ ??
+ new Map();
+syncStateGlobal.__SOLID_IMAGER_SOURCE_SYNC_STATES__ = sourceSyncStates;
+
+function publishSyncStatus(
+ mediaSourceId: string,
+ status: MediaSourceSyncState,
+ message?: string,
+): void {
+ const updatedAt = new Date();
+ const state = { status, message, updatedAt } satisfies SourceSyncStatus;
+ sourceSyncStates.set(mediaSourceId, state);
+ RealtimeEventBus.publishSource(mediaSourceId, "source-sync-status", {
+ mediaSourceId,
+ status,
+ message,
+ timestamp: updatedAt.toISOString(),
+ });
+}
+
+export function getSourceSyncState(
+ mediaSourceId: string,
+): MediaSourceSyncState {
+ return sourceSyncStates.get(mediaSourceId)?.status ?? "idle";
+}
+
async function scanFiles(basePath: string): Promise {
const files: string[] = [];
const pendingDirectories = [""];
@@ -128,6 +165,7 @@ export const DirectorySyncService = {
added: 0,
deleted: 0,
};
+ publishSyncStatus(mediaSourceId, "syncing");
try {
const source = await sourceRepo.findById(mediaSourceId);
if (source?.type !== "local") {
@@ -135,6 +173,13 @@ export const DirectorySyncService = {
{ mediaSourceId },
"Skipping sync for non-local or missing source",
);
+ publishSyncStatus(
+ mediaSourceId,
+ "idle",
+ source
+ ? "Source type does not support directory sync"
+ : "Source not found",
+ );
return result;
}
@@ -147,6 +192,11 @@ export const DirectorySyncService = {
{ mediaSourceId, basePath },
"Base path does not exist or is not accessible during sync",
);
+ publishSyncStatus(
+ mediaSourceId,
+ "error",
+ "Source path is not accessible",
+ );
return result;
}
@@ -207,12 +257,18 @@ export const DirectorySyncService = {
{ mediaSourceId, syncResult: result },
"Directory sync completed successfully",
);
+ publishSyncStatus(mediaSourceId, "idle");
return result;
} catch (error) {
logger.error(
{ err: error, mediaSourceId },
"Error during directory sync",
);
+ publishSyncStatus(
+ mediaSourceId,
+ "error",
+ error instanceof Error ? error.message : "Directory sync failed",
+ );
return result;
}
},
diff --git a/apps/server/src/components/media/legacy-media-grid-item.tsx b/apps/server/src/components/media/legacy-media-grid-item.tsx
index 16f295555..7d0c357a9 100644
--- a/apps/server/src/components/media/legacy-media-grid-item.tsx
+++ b/apps/server/src/components/media/legacy-media-grid-item.tsx
@@ -18,6 +18,7 @@ export type ServerMediaGridItemProps = {
isBulkSelectMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
+ onPrepareMediaDetail?: () => void;
onPreviewSelect?: () => void;
};
diff --git a/apps/server/src/components/media/v2-media-grid-item.tsx b/apps/server/src/components/media/v2-media-grid-item.tsx
index 93ba45241..478211bb7 100644
--- a/apps/server/src/components/media/v2-media-grid-item.tsx
+++ b/apps/server/src/components/media/v2-media-grid-item.tsx
@@ -37,6 +37,7 @@ export function V2MediaGridItem(props: V2ServerMediaGridItemProps) {
!event.shiftKey &&
!event.altKey
) {
+ props.onPrepareMediaDetail?.();
sessionStorage.setItem("v2:media-return", location().href);
}
}}
diff --git a/apps/server/src/components/v2/v2-source-list.tsx b/apps/server/src/components/v2/v2-source-list.tsx
index 7619d978c..a8294c1e6 100644
--- a/apps/server/src/components/v2/v2-source-list.tsx
+++ b/apps/server/src/components/v2/v2-source-list.tsx
@@ -35,10 +35,17 @@ function sourceTypeLabel(source: SafeMediaSource): string {
return source.type === "local" ? "Local" : source.type.toUpperCase();
}
+function sourceStatusLabel(source: SafeMediaSource): string {
+ if (source.syncStatus === "syncing") return "同期中";
+ if (source.syncStatus === "error") return "同期エラー";
+ return sourceTypeLabel(source);
+}
+
function V2SourceActions(props: {
onDelete: () => void;
onEdit: () => void;
onSync: () => void;
+ isSyncing: boolean;
sourceName: string;
}) {
return (
@@ -52,12 +59,13 @@ function V2SourceActions(props: {
+ }
+ when={props.job}
+ >
+ {(job) => (
+ <>
+
+ {jobTypeLabel(job().type)}
+
+
+
+
- Status
+ -
+ {statusLabel(job().status)}
+
+
+
+
- Source
+ -
+ {job().mediaSourceId?.slice(0, 8) ?? "—"}
+
+
+
+
- Created
+ -
+ {formatDate(job().createdAt)}
+
+
+
+
- Updated
+ -
+ {formatDate(job().updatedAt)}
+
+
+
+
- Target
+ -
+ {job().targetMediaId?.slice(0, 8) ?? "—"}
+
+
+
+
- Job ID
+ -
+ {job().id}
+
+
+
-
-
-
- Status
- - —
-
-
-
- Source
- - —
-
-
-
- Started
- - —
-
-
-
- Job ID
- - —
-
-
+
+ {(progress) => (
+
+ )}
+
-
-
-
-
再実行とキャンセル用APIも未実装のため、操作は無効です。
-
-
+
+ {(error) => (
+
+ )}
+
-
-
-
-
+
+
+ 再実行できるのは失敗したジョブのみです。
+
+ }
+ when={job().status === "failed"}
+ >
+
+
+
+ >
+ )}
+
);
}
-export function V2JobsScreen() {
- const [activeFilter, setActiveFilter] = createSignal("all");
+export function V2JobsScreen(props: V2JobsScreenProps) {
+ const [activeFilter, setActiveFilter] = createSignal("all");
+ const [selectedJobId, setSelectedJobId] = createSignal(null);
+
+ const selectedJob = createMemo(() =>
+ props.jobs().find((job) => job.id === selectedJobId()),
+ );
+ const filteredJobs = createMemo(() => {
+ const jobs = props.jobs();
+ switch (activeFilter()) {
+ case "active":
+ return jobs.filter(
+ (job) => job.status === "pending" || job.status === "in_progress",
+ );
+ case "completed":
+ return jobs.filter((job) => job.status === "completed");
+ case "failed":
+ return jobs.filter((job) => job.status === "failed");
+ default:
+ return jobs;
+ }
+ });
+ const refresh = async () => {
+ await props.onRefresh();
+ };
return (
-
+ {(data) => (
+
+ {data().total.toLocaleString()} jobs
+
+ )}
+
+
-
}
@@ -153,7 +401,10 @@ export function V2JobsScreen() {
);
diff --git a/packages/ui/src/screens/v2-manager/entity-panel.tsx b/packages/ui/src/screens/v2-manager/entity-panel.tsx
index 59eb587cb..c58b43482 100644
--- a/packages/ui/src/screens/v2-manager/entity-panel.tsx
+++ b/packages/ui/src/screens/v2-manager/entity-panel.tsx
@@ -88,6 +88,12 @@ function EntityInspector(props: {
+
+
Media
+
+ {props.item.mediaCount?.toLocaleString() ?? "—"}
+
+
@@ -157,7 +172,7 @@ export function V2SourceMediaScreen(props: SourceMediaScreenProps) {
{(media) => (
diff --git a/packages/ui/src/source-media-grid.tsx b/packages/ui/src/source-media-grid.tsx
index 3f18de7bc..8fb80cdc5 100644
--- a/packages/ui/src/source-media-grid.tsx
+++ b/packages/ui/src/source-media-grid.tsx
@@ -48,6 +48,8 @@ const INITIAL_PRIORITY_ROWS = 2;
const INITIAL_HIGH_PRIORITY_MEDIA = 2;
const INITIAL_SKELETON_ROWS = 3;
+export type SourceMediaViewMode = "grid" | "list";
+
type ScrollDirection = "backward" | "forward" | null;
function extractDirectionalRows(
@@ -102,6 +104,7 @@ type SourceMediaGridProps = {
isBulkSelectMode?: boolean;
isSelected?: boolean;
onPreviewSelect?: () => void;
+ onPrepareMediaDetail?: () => void;
isPreviewSelected?: boolean;
},
) => JSX.Element;
@@ -119,6 +122,12 @@ type SourceMediaGridProps = {
totalCount?: number;
/** Screen-specific copy for the initial error state. */
errorTitle?: string;
+ /** Optional collection presentation mode. */
+ viewMode?: Accessor;
+ /** Opens the media detail route from list rows. */
+ onOpenMediaDetail?: (media: Media) => void;
+ /** Saves the current collection context before a direct card navigation. */
+ onPrepareMediaDetail?: (media: Media) => void;
};
export function SourceMediaGrid(props: SourceMediaGridProps) {
@@ -128,6 +137,7 @@ export function SourceMediaGrid(props: SourceMediaGridProps) {
const enableVirtualization = () => props.enableVirtualization ?? false;
const disableContextMenu = () => props.disableContextMenu ?? false;
const totalCount = () => props.totalCount ?? props.mediaResults().length;
+ const viewMode = () => props.viewMode?.() ?? "grid";
const errorMessage = () => {
const error = props.state().error;
return error instanceof Error ? error.message : "API接続に失敗しました";
@@ -472,6 +482,8 @@ export function SourceMediaGrid(props: SourceMediaGridProps) {
}
: undefined,
onContextMenu: onContextMenuHandler(media.id),
+ onPrepareMediaDetail: () =>
+ props.onPrepareMediaDetail?.(media),
priority:
props.scrollMode === "element"
? index() < initialPriorityMediaCount()
@@ -525,6 +537,8 @@ export function SourceMediaGrid(props: SourceMediaGridProps) {
)
: undefined,
onContextMenu: onContextMenuHandler(media.id),
+ onPrepareMediaDetail: () =>
+ props.onPrepareMediaDetail?.(media),
// Window virtualization keeps its established native lazy-loading
// behavior. Element mode supplies a separate direction-aware policy.
priority:
@@ -553,6 +567,101 @@ export function SourceMediaGrid(props: SourceMediaGridProps) {
);
+ const formatFileSize = (bytes: number | null): string => {
+ if (bytes === null) return "—";
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ };
+ const listContent = (
+
+ );
return (
@@ -595,121 +704,128 @@ export function SourceMediaGrid(props: SourceMediaGridProps) {
- {/* Grid with optional context menu */}
-
-
-
- {gridContent}
-
-
-
- No media selected
-
- }
- when={contextMenuMediaId()}
- >
- {
- const id = contextMenuMediaId();
- if (id) props.onToggleSelect?.(id);
- }}
- >
- {(() => {
- const id = contextMenuMediaId();
- return id &&
- props.isBulkSelectMode?.() &&
- props.isSelected?.(id)
- ? "選択解除"
- : "選択";
- })()}
-
-
-
-
- 0
- }
- >
- {
- props.onBulkAction?.();
- }}
- >
- 一括操作を実行 ({props.selectedCount?.()}件選択中)
-
- {
- props.onClearSelection?.();
- }}
+ {/* Grid/list with optional context menu for the grid presentation */}
+
+
+
+ {gridContent}
+
+
+
+ No media selected
+
+ }
+ when={contextMenuMediaId()}
>
- 選択をクリア
-
-
-
-
-
- {
- const id = contextMenuMediaId();
- const sourceId = props.mediaSourceId();
- if (id && sourceId) {
- window.open(
- `${props.detailBasePath ?? "/sources"}/${sourceId}/${id}`,
- "_blank",
- );
+ {
+ const id = contextMenuMediaId();
+ if (id) props.onToggleSelect?.(id);
+ }}
+ >
+ {(() => {
+ const id = contextMenuMediaId();
+ return id &&
+ props.isBulkSelectMode?.() &&
+ props.isSelected?.(id)
+ ? "選択解除"
+ : "選択";
+ })()}
+
+
+
+
+ 0
}
- }}
- >
- 新しいタブで開く
-
-
-
- {
- const id = contextMenuMediaId();
- if (id) props.onDelete?.(id);
- }}
- >
- 削除
-
-
-
-
- {
- const id = contextMenuMediaId();
- if (id) props.onCopyMove?.(id, "copy");
- }}
- >
- 他のソースへコピー
-
- {
- const id = contextMenuMediaId();
- if (id) props.onCopyMove?.(id, "move");
- }}
- >
- 他のソースへ移動
-
-
-
-
- {
- const id = contextMenuMediaId();
- if (id) props.onSyncSingleMedia?.(id);
- }}
- >
- メタデータを同期 (再処理)
-
-
-
-
+ >
+ {
+ props.onBulkAction?.();
+ }}
+ >
+ 一括操作を実行 ({props.selectedCount?.()}件選択中)
+
+ {
+ props.onClearSelection?.();
+ }}
+ >
+ 選択をクリア
+
+
+
+
+
+ {
+ const id = contextMenuMediaId();
+ const sourceId = props.mediaSourceId();
+ if (id && sourceId) {
+ window.open(
+ `${props.detailBasePath ?? "/sources"}/${sourceId}/${id}`,
+ "_blank",
+ );
+ }
+ }}
+ >
+ 新しいタブで開く
+
+
+
+ {
+ const id = contextMenuMediaId();
+ if (id) props.onDelete?.(id);
+ }}
+ >
+ 削除
+
+
+
+
+ {
+ const id = contextMenuMediaId();
+ if (id) props.onCopyMove?.(id, "copy");
+ }}
+ >
+ 他のソースへコピー
+
+ {
+ const id = contextMenuMediaId();
+ if (id) props.onCopyMove?.(id, "move");
+ }}
+ >
+ 他のソースへ移動
+
+
+
+
+ {
+ const id = contextMenuMediaId();
+ if (id) props.onSyncSingleMedia?.(id);
+ }}
+ >
+ メタデータを同期 (再処理)
+
+
+
+
+
+ }
+ when={viewMode() === "list"}
+ >
+ {listContent}
{/* Empty state */}
diff --git a/packages/ui/src/source-media-page.tsx b/packages/ui/src/source-media-page.tsx
index 03a268313..ad403f021 100644
--- a/packages/ui/src/source-media-page.tsx
+++ b/packages/ui/src/source-media-page.tsx
@@ -54,6 +54,7 @@ export type SourceMediaPageProps = {
renderItem: SourceMediaScreenProps["renderItem"];
renderMediaPreview?: SourceMediaScreenProps["renderMediaPreview"];
onOpenMediaDetail?: SourceMediaScreenProps["onOpenMediaDetail"];
+ onPrepareMediaDetail?: SourceMediaScreenProps["onPrepareMediaDetail"];
showOpenInNewTab?: boolean;
onToggleSelect?: (mediaId: string) => void;
isBulkSelectMode?: () => boolean;
@@ -155,6 +156,7 @@ export function SourceMediaPage(props: SourceMediaPageProps): JSX.Element {
renderActions={renderActions}
renderItem={props.renderItem}
onOpenMediaDetail={props.onOpenMediaDetail}
+ onPrepareMediaDetail={props.onPrepareMediaDetail}
renderMediaPreview={props.renderMediaPreview}
moveCopyDialogComponent={props.moveCopyDialogComponent}
uploadModalComponent={props.uploadModalComponent}
diff --git a/packages/ui/src/v2/search-toolbar.tsx b/packages/ui/src/v2/search-toolbar.tsx
index e2db26770..b93650e1e 100644
--- a/packages/ui/src/v2/search-toolbar.tsx
+++ b/packages/ui/src/v2/search-toolbar.tsx
@@ -19,6 +19,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "../popover";
import type { PresetManagerClient } from "../search-control-panel";
import { SearchControlPanel } from "../search-control-panel";
import { SortControls } from "../sort-controls";
+import type { SourceMediaViewMode } from "../source-media-grid";
import {
clearPresetFilters,
searchState,
@@ -56,6 +57,8 @@ export type V2SearchToolbarProps = {
selectedSource?: string;
sourceName: string;
sources?: SafeMediaSource[];
+ viewMode?: SourceMediaViewMode;
+ onViewModeChange?: (mode: SourceMediaViewMode) => void;
};
function parseValues(value: string): string[] {
@@ -482,18 +485,27 @@ export function V2SearchToolbar(props: V2SearchToolbarProps) {