From 93f9a5190bfc0b62d3e64a568b0f2cf9a7f8fc68 Mon Sep 17 00:00:00 2001 From: hmjn Date: Thu, 30 Jul 2026 23:58:30 +0900 Subject: [PATCH 01/14] feat(ui): add shared v2 interaction components --- .../imports/pending-downloads-indicator.tsx | 10 +- packages/ui/src/dialog.tsx | 6 +- packages/ui/src/import-review-modal.tsx | 337 ++++++---- .../ui/src/pending-downloads-indicator.tsx | 41 +- packages/ui/src/skeleton.tsx | 38 +- packages/ui/src/source-form-modal.tsx | 588 +++++++++-------- packages/ui/src/styles/theme.css | 51 ++ packages/ui/src/toast.tsx | 40 +- packages/ui/src/upload-media-modal.tsx | 590 ++++++++++-------- packages/ui/src/v2/collection-inspector.tsx | 109 ++++ packages/ui/src/v2/icons.tsx | 159 +++++ packages/ui/src/v2/management-layout.tsx | 68 ++ packages/ui/src/v2/search-toolbar.tsx | 440 +++++++++++++ 13 files changed, 1788 insertions(+), 689 deletions(-) create mode 100644 packages/ui/src/v2/collection-inspector.tsx create mode 100644 packages/ui/src/v2/icons.tsx create mode 100644 packages/ui/src/v2/management-layout.tsx create mode 100644 packages/ui/src/v2/search-toolbar.tsx diff --git a/apps/server/src/components/imports/pending-downloads-indicator.tsx b/apps/server/src/components/imports/pending-downloads-indicator.tsx index 149d03c0c..4e5b708e5 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 = { listPending: async () => { const jobs = await orpc.imports.listPending(); return jobs; @@ -38,5 +40,7 @@ export function PendingDownloadsIndicator() { }, }; - return ; + return ( + + ); } diff --git a/packages/ui/src/dialog.tsx b/packages/ui/src/dialog.tsx index 8de5ad00d..b43f1bd76 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/import-review-modal.tsx b/packages/ui/src/import-review-modal.tsx index bb7570113..7b10d6149 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.listPending, @@ -81,6 +95,8 @@ export function ImportReviewModal(props: ImportReviewModalProps) { createEffect(() => { if (props.isOpen) { + setIsDirty(false); + setIsDiscardDialogOpen(false); void refetchJobs(); } }); @@ -114,6 +130,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 () => { @@ -124,150 +154,224 @@ 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 -

- + <> + { + if (!open) { + requestClose(); + } + }} + open={props.isOpen} + > + + + Import inbox + + Review downloaded media, choose a source, then import the selected + items. + + + +
+
+ +
+ {selectedJobIds().size} of {pendingJobs()?.length ?? 0} selected +
-
-
-
- Target Source: - -
-
- Selected: {selectedJobIds().size} /{" "} - {pendingJobs()?.length || 0} + +
+ +
+ Loading import inbox…
-
-
- - {(job) => ( - - )} - -
-
-
- -
- -
+
+ {job.item.description || + job.item.targetUrl || + "Untitled import"} +
+
+ {getPendingImportPrimaryAuthor(job.item)} +
+ + )} + +
+ + +
+

+ Failed to load import inbox. +

+ +
+
+ - Import{" "} - {selectedJobIds().size ? `(${selectedJobIds().size})` : ""} - +
+

Import inbox is empty

+

+ New bulk-upload requests will appear here. +

+
+
-
- + + + + + + +
+
+ + + + 選択内容を破棄しますか? + + 取り込み対象と保存先の変更はまだ確定していません。 + + + + 確認を続ける + + 破棄して閉じる + + + + Cancel void confirmDelete()} > - Delete + {activeAction() === "delete" ? "Deleting…" : "Delete"} - + ); } diff --git a/packages/ui/src/pending-downloads-indicator.tsx b/packages/ui/src/pending-downloads-indicator.tsx index 57ab10b92..91ce44bb6 100644 --- a/packages/ui/src/pending-downloads-indicator.tsx +++ b/packages/ui/src/pending-downloads-indicator.tsx @@ -14,10 +14,13 @@ import { type PendingImportJob, } from "./import-review-modal"; import { toast } from "./toast"; +import { cn } from "./utils/cn"; export type ImportEventHandler = (event: ImportEvent) => void | Promise; export type PendingDownloadsIndicatorProps = { + compact?: boolean; + variant?: "default" | "v2"; listPending: () => Promise; listSources: () => Promise; processPending: ( @@ -75,7 +78,12 @@ export function PendingDownloadsIndicator( ( - ({ - canSubmit: state.canSubmit, - isSubmitting: state.isSubmitting, - })} - > - {(state) => ( - + +
)} - - - - + + state.errorMap.onSubmit}> + {(error) => } + + + + + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {(state) => ( + + )} + + + + + + + + + Discard source changes? + + The values entered in this form have not been saved. + + + + Keep editing + + Discard changes + + + + + ); } diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index f91c0fac1..b9324cc76 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -64,6 +64,57 @@ --radius: 0.5rem; } + .v2-theme { + color-scheme: light; + --v2-canvas: #fafbf9; + --v2-surface: #ffffff; + --v2-surface-subtle: #fbfcfa; + --v2-surface-muted: #f1f4f2; + --v2-surface-selected: #e1f1ed; + --v2-border: #e1e5e2; + --v2-border-strong: #d9dfdb; + --v2-text: #202624; + --v2-text-secondary: #59615d; + --v2-text-muted: #68706c; + --v2-primary: #08766a; + --v2-primary-hover: #06645a; + --v2-focus: #0b8f80; + --v2-destructive: #b43a32; + --v2-destructive-hover: #982f29; + --v2-warning: #795313; + --v2-warning-surface: #fff8e8; + --v2-info: #426d86; + --v2-info-surface: #eef6fa; + --background: 90 20% 98%; + --foreground: 160 8.6% 13.7%; + --muted: 140 12% 95.1%; + --muted-foreground: 150 4.3% 36.5%; + --popover: 0 0% 100%; + --popover-foreground: 160 8.6% 13.7%; + --border: 135 7.1% 89%; + --input: 140 8.6% 86.3%; + --card: 0 0% 100%; + --card-foreground: 160 8.6% 13.7%; + --primary: 173.5 87.3% 24.7%; + --primary-foreground: 0 0% 100%; + --secondary: 140 12% 95.1%; + --secondary-foreground: 160 8.6% 13.7%; + --accent: 165 36.4% 91.4%; + --accent-foreground: 173.5 87.3% 24.7%; + --destructive: 3.7 56.5% 45.1%; + --destructive-foreground: 0 0% 100%; + --info: 200 54.5% 95.7%; + --info-foreground: 202.1 34% 39.2%; + --success: 165 36.4% 91.4%; + --success-foreground: 173.5 87.3% 24.7%; + --warning: 41.7 100% 95.5%; + --warning-foreground: 37.6 72.9% 27.5%; + --error: 4 55% 95%; + --error-foreground: 3.7 56.5% 45.1%; + --ring: 173.2 85.7% 30.2%; + --radius: 0.375rem; + } + *, ::before, ::after { diff --git a/packages/ui/src/toast.tsx b/packages/ui/src/toast.tsx index 94dc51723..e4f645cac 100644 --- a/packages/ui/src/toast.tsx +++ b/packages/ui/src/toast.tsx @@ -47,25 +47,23 @@ if (!isServer) { export const toast = { error: (msg: string, opts?: ToastOptions) => - toastImpl.error( - msg, - opts as unknown as Parameters[1], - ), + toastImpl.error(msg, { duration: 9_000, ...opts } as unknown as Parameters< + typeof SonnerToast.error + >[1]), success: (msg: string, opts?: ToastOptions) => - toastImpl.success( - msg, - opts as unknown as Parameters[1], - ), + toastImpl.success(msg, { + duration: 4_000, + ...opts, + } as unknown as Parameters[1]), info: (msg: string, opts?: ToastOptions) => - toastImpl.info( - msg, - opts as unknown as Parameters[1], - ), + toastImpl.info(msg, { duration: 7_000, ...opts } as unknown as Parameters< + typeof SonnerToast.info + >[1]), warning: (msg: string, opts?: ToastOptions) => - toastImpl.warning( - msg, - opts as unknown as Parameters[1], - ), + toastImpl.warning(msg, { + duration: 8_000, + ...opts, + } as unknown as Parameters[1]), loading: (msg: string, opts?: ToastOptions) => toastImpl.loading( msg, @@ -82,10 +80,10 @@ export const toast = { opts as unknown as Parameters[1], ), message: (msg: string, opts?: ToastOptions) => - toastImpl.message( - msg, - opts as unknown as Parameters[1], - ), + toastImpl.message(msg, { + duration: 5_000, + ...opts, + } as unknown as Parameters[1]), }; export const Toaster = () => { @@ -116,7 +114,7 @@ export const Toaster = () => { closeButtonAriaLabel: "Close notification", classes: { actionButton: - "group-[.toast]:bg-[#08766a] group-[.toast]:font-medium group-[.toast]:text-white hover:group-[.toast]:bg-[#06645a]", + "group-[.toast]:bg-primary group-[.toast]:font-medium group-[.toast]:text-primary-foreground hover:group-[.toast]:bg-primary/90", cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", closeButton: diff --git a/packages/ui/src/upload-media-modal.tsx b/packages/ui/src/upload-media-modal.tsx index a1293c0cd..fa416eb6e 100644 --- a/packages/ui/src/upload-media-modal.tsx +++ b/packages/ui/src/upload-media-modal.tsx @@ -2,6 +2,16 @@ import { getErrorMessage } from "@solid-imager/core/utils"; import { createForm } from "@tanstack/solid-form"; import { createEffect, createSignal, For, on, onCleanup, Show } from "solid-js"; import { z } from "zod"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "./alert-dialog"; import { Button } from "./button"; import { Dialog, @@ -104,6 +114,7 @@ export function UploadMediaModalContent(props: UploadMediaModalContentProps) { const [lastFetchedUrl, setLastFetchedUrl] = createSignal(null); const [previewUrl, setPreviewUrl] = createSignal(null); const [asyncError, setAsyncError] = createSignal(null); + const [showDiscardDialog, setShowDiscardDialog] = createSignal(false); let fileInputRef: HTMLInputElement | undefined; const form = createForm(() => ({ @@ -254,6 +265,7 @@ export function UploadMediaModalContent(props: UploadMediaModalContentProps) { sourceUrl: props.pastedUrl ?? "", }); setAsyncError(null); + setShowDiscardDialog(false); }, ), ); @@ -265,149 +277,145 @@ export function UploadMediaModalContent(props: UploadMediaModalContentProps) { } }); + const requestClose = () => { + if (form.state.isDirty || selectedFiles().length > 0 || isFetchingUrl()) { + setShowDiscardDialog(true); + return; + } + props.onClose(); + }; + const discardAndClose = () => { + setShowDiscardDialog(false); + props.onClose(); + }; + return ( - - - - - メディアをアップロード - - アップロードするメディアの詳細を入力してください。 - - - -
{ - event.preventDefault(); - event.stopPropagation(); - void form.handleSubmit(); - }} - > -
- - { - handleDroppedFiles(event.currentTarget.files); - event.currentTarget.value = ""; - }} - ref={(element) => { - fileInputRef = element; - }} - type="file" - /> - - 0}> -
-

選択中のファイル

-
    - - {(file) => ( -
  • - {file.name} - - {fileSizeLabel(file)} - -
  • - )} -
    -
-
-
- - - {(field) => ( -
- -
- 0} - id={field().name} - onBlur={field().handleBlur} - onInput={(event) => - field().handleChange(event.currentTarget.value) - } - value={field().state.value} - /> - { + event.preventDefault(); + setIsDragging(false); + }} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + setIsDragging(false); + handleDroppedFiles(event.dataTransfer?.files); + }} + type="button" + > +

ファイルをドラッグ&ドロップ

+

+ またはファイル選択ダイアログから追加します。 +

+ + ファイルを選択 + + + { + handleDroppedFiles(event.currentTarget.files); + event.currentTarget.value = ""; + }} + ref={(element) => { + fileInputRef = element; + }} + type="file" + /> + + 0}> +
+

選択中のファイル

+
    + + {(file) => ( +
  • + {file.name} + + {fileSizeLabel(file)} + +
  • )} - /> -
-
- )} - - - - {(field) => ( -
- - - field().handleChange(event.currentTarget.value) - } - value={field().state.value} - /> + +
- )} -
- - - {(field) => ( -
- -
+ + + + {(field) => ( +
+ +
+ 0} + id={field().name} + onBlur={field().handleBlur} + onInput={(event) => + field().handleChange(event.currentTarget.value) + } + value={field().state.value} + /> + +
+
+ )} +
+ + + {(field) => ( +
+ 0} - disabled={isFetchingUrl()} + class="sm:col-span-3" id={field().name} onBlur={field().handleBlur} onInput={(event) => @@ -415,144 +423,190 @@ export function UploadMediaModalContent(props: UploadMediaModalContentProps) { } value={field().state.value} /> - -
- Loading... +
+ )} + + + + {(field) => ( +
+ +
+ 0} + disabled={isFetchingUrl()} + id={field().name} + onBlur={field().handleBlur} + onInput={(event) => + field().handleChange(event.currentTarget.value) + } + value={field().state.value} + /> + +
+ Loading... +
+
+ +
+ +
+ Fetched preview
-
- -
- Fetched preview + )} + + + + {(field) => ( +
+ +
+ + {(option) => ( + + )} +
- -
- )} -
- - - {(field) => ( -
- -
- - {(option) => ( - +
+ )} + + + 0}> +
+

同名ファイルがあります

+
    + + {(conflict) => ( +
  • + {conflict.filename} + + → {conflict.suggestedName} + +
  • )}
    -
+
- )} -
- - 0}> -
-

同名ファイルがあります

-
    - - {(conflict) => ( -
  • - {conflict.filename} - - → {conflict.suggestedName} - -
  • - )} -
    -
-
-
- - - {(progress) => ( -
-
- {progress().filename || "アップロード中"} - - {progress().status || - (progress().total - ? `${progress().current ?? 0}/${progress().total}` - : "処理中")} - -
-
-
{ - const total = progress().total; - return total - ? `${Math.min(100, ((progress().current ?? 0) / total) * 100)}%` - : "35%"; - })(), - }} - /> + + + + {(progress) => ( +
+
+ {progress().filename || "アップロード中"} + + {progress().status || + (progress().total + ? `${progress().current ?? 0}/${progress().total}` + : "処理中")} + +
+
+
{ + const total = progress().total; + return total + ? `${Math.min(100, ((progress().current ?? 0) / total) * 100)}%` + : "35%"; + })(), + }} + /> +
-
- )} -
- - - state.errorMap.onSubmit}> - {(error) => } - -
- - - - ({ - canSubmit: state.canSubmit, - isSubmitting: state.isSubmitting, - })} - > - {(state) => ( - - )} - - - - - -
+ )} + + + + state.errorMap.onSubmit}> + {(error) => ( + + )} + +
+ + + + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {(state) => ( + + )} + + + + + + + + + + + アップロード内容を破棄しますか? + + + 選択したファイルと入力内容は保存されていません。 + + + + 編集を続ける + + 破棄して閉じる + + + + + ); } diff --git a/packages/ui/src/v2/collection-inspector.tsx b/packages/ui/src/v2/collection-inspector.tsx new file mode 100644 index 000000000..2d0aa8955 --- /dev/null +++ b/packages/ui/src/v2/collection-inspector.tsx @@ -0,0 +1,109 @@ +import type { Media } from "@solid-imager/core/domain/media/schemas"; +import type { JSX } from "solid-js"; +import { getOwner, runWithOwner, Show } from "solid-js"; +import { Button } from "../button"; + +type V2CollectionInspectorProps = { + media: Media | undefined; + sourceName?: string; + onOpenDetail?: (media: Media) => void; + renderPreview: (media: Media) => JSX.Element; +}; + +function formatFileSize(bytes: number | null) { + if (bytes === null) return null; + const units = ["B", "KB", "MB", "GB"]; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`; +} + +function formatDate(date: Date) { + return new Intl.DateTimeFormat("ja-JP", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +export function V2CollectionInspector(props: V2CollectionInspectorProps) { + const owner = getOwner(); + const renderOwned = (render: () => JSX.Element) => + owner ? runWithOwner(owner, render) : render(); + + return ( +
+ } + when={props.media} + > + {(media) => ( +
+
+ {renderOwned(() => props.renderPreview(media))} +
+
+

+ {media.fileName} +

+
+
解像度
+
+ {media.width} × {media.height} +
+ + {(size) => ( + <> +
サイズ
+
{size()}
+ + )} +
+
作成日
+
+ {formatDate(media.createdAt)} +
+ + {(sourceName) => ( + <> +
ソース
+
+ {sourceName()} +
+ + )} +
+
+
+ + + +
+ )} +
+ + ); +} diff --git a/packages/ui/src/v2/icons.tsx b/packages/ui/src/v2/icons.tsx new file mode 100644 index 000000000..d59080b0b --- /dev/null +++ b/packages/ui/src/v2/icons.tsx @@ -0,0 +1,159 @@ +import type { Component, JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +type IconProps = JSX.SvgSVGAttributes & { + size?: number | string; +}; + +function icon(draw: () => JSX.Element): Component { + return (props) => { + const [local, rest] = splitProps(props, ["size"]); + return ( + + ); + }; +} + +// Inline Lucide geometry keeps SVGs visible without leaking lucide-solid's +// CommonJS defaults into TanStack Start route modules (`template` regression). +export const ArrowLeft = icon(() => ( + <> + + + +)); +export const BriefcaseBusiness = icon(() => ( + <> + + + + + +)); +export const Binary = icon(() => ( + <> + + + + + +)); +export const Bot = icon(() => ( + <> + + + +)); +export const ChevronDown = icon(() => ); +export const ChevronLeft = icon(() => ); +export const ChevronRight = icon(() => ); +export const CircleHelp = icon(() => ( + <> + + + + +)); +export const Clock3 = icon(() => ( + <> + + + +)); +export const Database = icon(() => ( + <> + + + + +)); +export const Ellipsis = icon(() => ( + <> + + + + +)); +export const FileText = icon(() => ( + <> + + + +)); +export const Image = icon(() => ( + <> + + + + +)); +export const Library = icon(() => ( + <> + + +)); +export const Menu = icon(() => ); +export const PanelLeftClose = icon(() => ( + <> + + + +)); +export const PanelLeftOpen = icon(() => ( + <> + + + +)); +export const Plus = icon(() => ); +export const RefreshCw = icon(() => ( + <> + + + + +)); +export const Scan = icon(() => ( + <> + + + +)); +export const ScanSearch = icon(() => ( + <> + + + + +)); +export const Search = icon(() => ( + <> + + + +)); +export const Settings = icon(() => ( + <> + + + +)); +export const Sparkles = icon(() => ( + <> + + +)); diff --git a/packages/ui/src/v2/management-layout.tsx b/packages/ui/src/v2/management-layout.tsx new file mode 100644 index 000000000..6db45af74 --- /dev/null +++ b/packages/ui/src/v2/management-layout.tsx @@ -0,0 +1,68 @@ +import type { JSX } from "solid-js"; +import { Show } from "solid-js"; + +type V2ManagementHeaderProps = { + actions?: JSX.Element; + description: string; + eyebrow?: string; + title: string; +}; + +export function V2ManagementHeader(props: V2ManagementHeaderProps) { + return ( +
+
+
+

+ {props.eyebrow ?? "Workspace"} +

+

+ {props.title} +

+

+ {props.description} +

+
+ +
{props.actions}
+
+
+
+ ); +} + +export const V2_CATEGORY_TABS_CLASS = + "min-h-11 shrink-0 gap-2.5 rounded-md px-2.5 text-[var(--v2-text-secondary)] shadow-none data-[selected]:bg-[var(--v2-surface-selected)] data-[selected]:text-[var(--v2-primary)] lg:min-h-10 lg:w-full lg:justify-start"; + +export function v2CategoryButtonClass(active: boolean): string { + return `flex min-h-10 w-full items-center gap-2.5 rounded-md px-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--v2-focus)] ${ + active + ? "bg-[var(--v2-surface-selected)] text-[var(--v2-primary)]" + : "text-[var(--v2-text-secondary)] hover:bg-[var(--v2-surface-muted)]" + }`; +} + +export function V2CategoryLabel(props: { + description: string; + icon: JSX.Element; + label: string; + responsiveDescription?: "lg" | "md"; +}) { + const descriptionBreakpoint = () => + props.responsiveDescription === "md" ? "md:block" : "lg:block"; + return ( + <> + {props.icon} + + + {props.label} + + + {props.description} + + + + ); +} diff --git a/packages/ui/src/v2/search-toolbar.tsx b/packages/ui/src/v2/search-toolbar.tsx new file mode 100644 index 000000000..563557800 --- /dev/null +++ b/packages/ui/src/v2/search-toolbar.tsx @@ -0,0 +1,440 @@ +import type { SearchState } from "@solid-imager/core/domain/search/schema"; +import type { SafeMediaSource } from "@solid-imager/core/domain/sources/schemas"; +import ArrowDownUp from "lucide-solid/icons/arrow-down-up"; +import ChevronDown from "lucide-solid/icons/chevron-down"; +import Filter from "lucide-solid/icons/filter"; +import Grid3X3 from "lucide-solid/icons/grid-3-x-3"; +import List from "lucide-solid/icons/list"; +import Search from "lucide-solid/icons/search"; +import X from "lucide-solid/icons/x"; +import type { JSX } from "solid-js"; +import { + createMemo, + createSignal, + For, + onCleanup, + onMount, + Show, +} from "solid-js"; +import { Button, buttonVariants } from "../button"; +import { Input } from "../input"; +import { Label } from "../label"; +import { Popover, PopoverContent, PopoverTrigger } from "../popover"; +import type { PresetManagerClient } from "../search-control-panel"; +import { SearchControlPanel } from "../search-control-panel"; +import { + clearPresetFilters, + searchState, + setSearchState, +} from "../stores/search-store"; + +type SearchArrayKey = + | "excludeTags" + | "selectedAuthors" + | "selectedCharacters" + | "selectedIps" + | "selectedProjects" + | "selectedTags"; + +type SearchToken = { + destructive?: boolean; + key: SearchArrayKey | "searchQuery"; + prefix: string; + value: string; +}; + +export type V2SearchToolbarProps = { + actions?: JSX.Element; + context: "global" | "source"; + filterData: { + authors: + | import("@solid-imager/core/domain/authors/schemas").Author[] + | undefined; + characters: + | import("@solid-imager/core/domain/characters/schemas").Character[] + | undefined; + ips: import("@solid-imager/core/domain/ips/schemas").Ip[] | undefined; + projects: + | import("@solid-imager/core/domain/projects/schemas").Project[] + | undefined; + tags: + | import("@solid-imager/core/domain/tags/schemas").TagResponse[] + | undefined; + }; + itemCount?: number; + onSearch: () => void; + onSelectSource?: (id: string) => void; + presetClient: PresetManagerClient; + selectedSource?: string; + sourceName: string; + sources?: SafeMediaSource[]; +}; + +function parseValues(value: string): string[] { + return [ + ...new Set( + value + .split(",") + .map((part) => part.trim()) + .filter(Boolean), + ), + ]; +} + +function appendValues(key: SearchArrayKey, rawValue: string) { + const values = parseValues(rawValue); + if (values.length === 0) return; + setSearchState(key, [...new Set([...searchState[key], ...values])]); +} + +function removeToken(token: SearchToken) { + if (token.key === "searchQuery") { + setSearchState("searchQuery", ""); + return; + } + setSearchState( + token.key, + searchState[token.key].filter((value) => value !== token.value), + ); +} + +function submitComposerDraft(rawDraft: string): boolean { + const parts = rawDraft.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []; + const freeText: string[] = []; + let changed = false; + + for (const part of parts) { + const separatorIndex = part.indexOf(":"); + if (separatorIndex < 1) { + freeText.push(part.replace(/^"|"$/g, "")); + continue; + } + const prefix = part.slice(0, separatorIndex).toLowerCase(); + const value = part + .slice(separatorIndex + 1) + .replace(/^"|"$/g, "") + .trim(); + if (!value) continue; + + switch (prefix) { + case "name": + setSearchState("searchQuery", value); + changed = true; + break; + case "tag": + appendValues("selectedTags", value); + changed = true; + break; + case "-tag": + appendValues("excludeTags", value); + changed = true; + break; + case "character": + appendValues("selectedCharacters", value); + changed = true; + break; + case "ip": + appendValues("selectedIps", value); + changed = true; + break; + case "author": + appendValues("selectedAuthors", value); + changed = true; + break; + case "project": + appendValues("selectedProjects", value); + changed = true; + break; + default: + freeText.push(part); + } + } + + if (freeText.length > 0) { + setSearchState("searchQuery", freeText.join(" ")); + changed = true; + } + return changed; +} + +function tokensFromState(state: SearchState): SearchToken[] { + return [ + ...(state.searchQuery + ? [ + { + key: "searchQuery" as const, + prefix: "name", + value: state.searchQuery, + }, + ] + : []), + ...state.selectedTags.map((value) => ({ + key: "selectedTags" as const, + prefix: "tag", + value, + })), + ...state.excludeTags.map((value) => ({ + destructive: true, + key: "excludeTags" as const, + prefix: "-tag", + value, + })), + ...state.selectedCharacters.map((value) => ({ + key: "selectedCharacters" as const, + prefix: "character", + value, + })), + ...state.selectedIps.map((value) => ({ + key: "selectedIps" as const, + prefix: "ip", + value, + })), + ...state.selectedAuthors.map((value) => ({ + key: "selectedAuthors" as const, + prefix: "author", + value, + })), + ...state.selectedProjects.map((value) => ({ + key: "selectedProjects" as const, + prefix: "project", + value, + })), + ]; +} + +function sortLabel(state: SearchState): string { + const labels: Record = { + date: "作成日", + name: "名前", + rating: "評価", + size: "サイズ", + viewCount: "閲覧数", + }; + const field = labels[state.sortBy]; + return `${field}・${state.sortOrder === "desc" ? "降順" : "昇順"}`; +} + +export function V2SearchToolbar(props: V2SearchToolbarProps) { + const [draft, setDraft] = createSignal(""); + const [filterOpen, setFilterOpen] = createSignal(false); + const tokens = createMemo(() => tokensFromState(searchState)); + let composerInput: HTMLInputElement | undefined; + const submitDraft = () => { + if (!submitComposerDraft(draft())) return; + setDraft(""); + props.onSearch(); + }; + + onMount(() => { + const focusComposer = (event: KeyboardEvent) => { + const target = event.target; + const isEditing = + target instanceof HTMLElement && + (target.matches("input, textarea, select") || target.isContentEditable); + if ( + event.key !== "/" || + event.metaKey || + event.ctrlKey || + event.altKey || + isEditing + ) { + return; + } + event.preventDefault(); + composerInput?.focus(); + }; + window.addEventListener("keydown", focusComposer); + onCleanup(() => window.removeEventListener("keydown", focusComposer)); + }); + + return ( +
+
+ Library + + + {props.sourceName} + + + + {props.itemCount?.toLocaleString()} items + + +
+
+
{ + event.preventDefault(); + submitDraft(); + }} + > + +
+
+ ); +} From 906e16a13cad5cc6d2afb90aebff740d5c265475 Mon Sep 17 00:00:00 2001 From: hmjn Date: Thu, 30 Jul 2026 23:59:02 +0900 Subject: [PATCH 02/14] feat(ui): implement v2 media browsing screens --- .../src/components/media/media-grid-item.tsx | 87 +++- .../src/components/media/media-sidebar.tsx | 404 +++++++++++++----- .../src/components/media/media-viewer.tsx | 2 + .../components/source-media-page.tsx | 42 +- packages/ui/src/hooks/use-search-page.ts | 21 +- .../ui/src/hooks/use-source-media-page.ts | 21 +- packages/ui/src/media-grid-item.tsx | 37 +- packages/ui/src/media-viewer.tsx | 44 +- .../ui/src/screens/media-detail-screen.tsx | 94 +++- packages/ui/src/screens/search-screen.tsx | 12 +- .../ui/src/screens/source-media-screen.tsx | 9 + packages/ui/src/screens/v2-search-screen.tsx | 124 ++++++ .../ui/src/screens/v2-source-media-screen.tsx | 239 +++++++++++ packages/ui/src/source-media-grid.tsx | 91 +++- packages/ui/src/source-media-page.tsx | 8 + 15 files changed, 1048 insertions(+), 187 deletions(-) create mode 100644 packages/ui/src/screens/v2-search-screen.tsx create mode 100644 packages/ui/src/screens/v2-source-media-screen.tsx diff --git a/apps/server/src/components/media/media-grid-item.tsx b/apps/server/src/components/media/media-grid-item.tsx index 2e6ef4bb0..acfcf0cb9 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 handleSaveDescription = async () => { try { @@ -380,97 +579,55 @@ export function MediaSidebar(props: MediaSidebarProps) { }; return ( -