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: { diff --git a/packages/ui/src/screens/v2-config-state-screen.tsx b/packages/ui/src/screens/v2-config-state-screen.tsx index fcfffb250..bf48a718f 100644 --- a/packages/ui/src/screens/v2-config-state-screen.tsx +++ b/packages/ui/src/screens/v2-config-state-screen.tsx @@ -1,3 +1,4 @@ +import type { AiHealthResponse } from "@solid-imager/core/domain/tagging/schemas"; import { Match, Show, Switch } from "solid-js"; import { ErrorState, OfflineState, QueryStatus } from "../async-state"; import { ConfigSkeleton, LoadingRegion } from "../skeleton"; @@ -6,7 +7,11 @@ import { V2ManagementHeader } from "../v2/management-layout"; import type { ConfigStateScreenProps } from "./config-state-screen.types"; import { V2ConfigScreen } from "./v2-config-screen"; -export function V2ConfigStateScreen(props: ConfigStateScreenProps) { +export type V2ConfigStateScreenProps = ConfigStateScreenProps & { + checkAiHealth: () => Promise; +}; + +export function V2ConfigStateScreen(props: V2ConfigStateScreenProps) { const hasData = () => props.data !== undefined; return ( @@ -35,6 +40,7 @@ export function V2ConfigStateScreen(props: ConfigStateScreenProps) { {(data) => ( ; + jobs: Accessor; + onRefresh: () => void | Promise; + onRetry: (jobId: string) => void | Promise; + state: Accessor>; +}; + +const dateFormatter = new Intl.DateTimeFormat("ja-JP", { + dateStyle: "medium", + timeStyle: "short", +}); + +function formatDate(value: Date): string { + return dateFormatter.format(value); +} + +function jobTypeLabel(type: string): string { + return type + .replaceAll("_", " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); +} + +function statusLabel(status: JobDto["status"]): string { + return { + completed: "Completed", + failed: "Failed", + in_progress: "In progress", + pending: "Pending", + }[status]; +} + +function statusClass(status: JobDto["status"]): string { + return { + completed: + "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300", + failed: + "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300", + in_progress: + "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-300", + pending: + "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300", + }[status]; +} + +function JobStatusBadge(props: { status: JobDto["status"] }) { return ( - - - - -

- {props.filterLabel} jobsはまだ表示できません -

-

- 現在のバックエンドはジョブイベントのみを配信しており、一覧・詳細・履歴を取得するAPIは未実装です。 - 実データを取得できるようになるまで、この画面では状態を推測して表示しません。 -

- - Backend support required - -
-
+ + {statusLabel(props.status)} + ); } -function JobsInspectorPlaceholder() { +function JobProgress(props: { progress: JobDto["progress"] }) { + const percent = () => { + if (!props.progress || props.progress.total === 0) return 0; + return Math.min( + 100, + Math.round((props.progress.processed / props.progress.total) * 100), + ); + }; + + return ( + —} + when={props.progress} + > + {(progress) => ( +
+
+ {percent()}% + + {progress().processed.toLocaleString()}/ + {progress().total.toLocaleString()} + +
+
+
+
+
+ )} + + ); +} + +function JobsTable(props: { + jobs: JobDto[]; + onSelect: (job: JobDto) => void; + selectedJobId: string | null; +}) { + return ( +
+
+ + + + + + + + + + + + {(job) => ( + + + + + + + )} + + +
TypeStatusProgressUpdated
+ + + + + + + {formatDate(job.updatedAt)} +
+
+
+ ); +} + +function JobsInspector(props: { + class?: string; + job: JobDto | undefined; + onRetry: (jobId: string) => void | Promise; +}) { + const [isRetrying, setIsRetrying] = createSignal(false); + + const retry = async () => { + const job = props.job; + if (job?.status !== "failed" || isRetrying()) return; + setIsRetrying(true); + try { + await props.onRetry(job.id); + } catch { + // The route reports mutation failures; keep the selected job visible. + } finally { + setIsRetrying(false); + } + }; + return ( ); } -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) => ( + + )} + +
} @@ -153,7 +401,10 @@ export function V2JobsScreen() {
- + setActiveFilter(value as JobFilter)} + value={activeFilter()} + >
{(filter) => ( - + + + +
+ + + + + + + + + + + } + when={filteredJobs().length > 0} + > + setSelectedJobId(job.id)} + selectedJobId={selectedJobId()} + /> + + {(total) => ( +

+ Showing{" "} + {filteredJobs().length.toLocaleString()} of{" "} + {total().toLocaleString()} jobs +

+ )} +
+ + {(job) => ( + + )} + +
+
+ )} @@ -191,7 +501,7 @@ 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 = ( +
+ + + + + + + + + + + + + + + + + {(media) => ( + + + + + + + + + + + )} + + +
+ メディア一覧。{totalCount().toLocaleString()}件。 +
+ 選択 + + Name + + Type + + Dimensions + + Size + + Modified +
+ props.onToggleSelect?.(media.id)} + type="checkbox" + /> + + + + {media.mediaType} + + {media.width} × {media.height} + + {formatFileSize(media.fileSize)} + + {media.modifiedAt.toLocaleDateString("ja-JP")} +
+
+ ); 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) {