diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 00000000..11b13e51 --- /dev/null +++ b/REPORT.md @@ -0,0 +1,24 @@ +# V2 UI migration report + +`/v2/*` は `DESIGN.md` と Design Lab を基準にした移行用フロントエンドです。既存APIで成立する検索、ソース操作、メディア管理、AI操作、設定、インポート、データ転送は実データへ接続し、未対応機能は推測データを表示せず無効化しています。 + +## Backend work required + +| 項目 | 現状の原因 | 必要な実装 | 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 | 取得可能な実データだけ表示 | +| 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 | + +## 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追加が必要 | + +バックエンド追加時は、無効状態を消すだけでなく、loading / error / offline / retryとリアルタイム更新まで同じ画面内で接続します。 diff --git a/apps/server/src/components/imports/pending-downloads-indicator.tsx b/apps/server/src/components/imports/pending-downloads-indicator.tsx index 34dde467..de84a1ef 100644 --- a/apps/server/src/components/imports/pending-downloads-indicator.tsx +++ b/apps/server/src/components/imports/pending-downloads-indicator.tsx @@ -6,8 +6,10 @@ import { import { orpc } from "~/infrastructure/api-clients/orpc-client"; import { fetchMediaSources } from "~/infrastructure/api-clients/sources-api"; -export function PendingDownloadsIndicator() { - const props: PendingDownloadsIndicatorProps = { +export function PendingDownloadsIndicator( + displayProps: { compact?: boolean; variant?: "default" | "v2" } = {}, +) { + const indicatorProps: PendingDownloadsIndicatorProps = { countPending: async () => { const result = await orpc.imports.countPending(); return result.count; @@ -44,5 +46,7 @@ export function PendingDownloadsIndicator() { }, }; - return ; + return ( + + ); } diff --git a/apps/server/src/components/media/media-grid-item.tsx b/apps/server/src/components/media/media-grid-item.tsx index 2e6ef4bb..acfcf0cb 100644 --- a/apps/server/src/components/media/media-grid-item.tsx +++ b/apps/server/src/components/media/media-grid-item.tsx @@ -1,11 +1,15 @@ import type { Media } from "@solid-imager/core/domain/media/schemas"; -import { MediaGridItem as SharedMediaGridItem } from "@solid-imager/ui/media-grid-item"; -import { Link } from "@tanstack/solid-router"; +import { + type MediaGridLinkProps, + MediaGridItem as SharedMediaGridItem, +} from "@solid-imager/ui/media-grid-item"; +import { Link, useLocation } from "@tanstack/solid-router"; import { Show } from "solid-js"; import { ThumbnailImage } from "./thumbnail-image"; type MediaGridItemProps = { linkPrefix?: string; + routeVersion?: "default" | "v2"; media: Media; onContextMenu?: (event: MouseEvent) => void; priority?: boolean; @@ -13,32 +17,77 @@ type MediaGridItemProps = { isBulkSelectMode?: boolean; isSelected?: boolean; onToggleSelect?: () => void; + onPreviewSelect?: () => void; }; export function MediaGridItem(props: MediaGridItemProps) { + const location = useLocation(); + const detailLink = (linkProps: MediaGridLinkProps) => + props.routeVersion === "v2" ? ( + { + if ( + props.onPreviewSelect && + window.matchMedia("(min-width: 1536px)").matches + ) { + event.preventDefault(); + props.onPreviewSelect(); + return; + } + if ( + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + sessionStorage.setItem("v2:media-return", location().href); + } + }} + onContextMenu={linkProps.onContextMenu} + params={{ + mediaId: props.media.id, + mediaSourceId: props.media.mediaSourceId, + }} + to="/v2/sources/$mediaSourceId/$mediaId" + > + {linkProps.children} + + ) : ( + { + if ( + props.onPreviewSelect && + window.matchMedia("(min-width: 1536px)").matches + ) { + event.preventDefault(); + props.onPreviewSelect(); + } + }} + onContextMenu={linkProps.onContextMenu} + params={{ + mediaId: props.media.id, + mediaSourceId: props.media.mediaSourceId, + }} + to="/sources/$mediaSourceId/$mediaId" + > + {linkProps.children} + + ); + return ( ( - - {linkProps.children} - - } - when={props.isBulkSelectMode} - > + + + + + + + + + } + when={props.variant === "v2"} + > +
+ + + + + More actions + + + + + +

+ CCIP status: {ccipStatus()} +

+
+
+
+
+ + setIsAiTaggingModalOpen(false)} + onSuccess={props.onUpdate} + /> + setIsOppaiOracleModalOpen(false)} + /> + setIsCharacterCropModalOpen(false)} + /> + + ); +} + +export function MediaSidebar(props: MediaSidebarProps) { + const queryClient = useQueryClient(); + const tags = createMemo(() => props.media.tags || []); + // Description editing state const [isEditingDescription, setIsEditingDescription] = createSignal(false); const [descriptionValue, setDescriptionValue] = createSignal( props.media.description || "", ); + const descriptionDirty = () => + props.variant === "v2" && + isEditingDescription() && + descriptionValue() !== (props.media.description ?? ""); + const navigationBlocker = useBlocker({ + shouldBlockFn: descriptionDirty, + enableBeforeUnload: descriptionDirty, + withResolver: true, + }); + const [allowBlockedNavigation, setAllowBlockedNavigation] = + createSignal(false); const handleSaveDescription = async () => { try { @@ -380,97 +581,55 @@ export function MediaSidebar(props: MediaSidebarProps) { }; return ( -