From 5f1d619139be3872f4b838bacbd3da4b8dfa0aa4 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 15 Jul 2026 22:04:47 +0900 Subject: [PATCH] #399 --- AGENTS.md | 14 + frontend/AGENTS.md | 16 ++ .../components/dialogues/CommonDialogue.tsx | 54 ++++ .../components/dialogues/DialogueProvider.tsx | 116 ++++---- .../posts/import/PostImportRowDialog.tsx | 258 ----------------- .../posts/import/PostImportRowForm.tsx | 263 ++++++++++++++++++ frontend/src/lib/dialogues/useDialogue.ts | 1 + .../src/pages/posts/PostImportReviewPage.tsx | 65 ++--- 8 files changed, 433 insertions(+), 354 deletions(-) create mode 100644 frontend/src/components/dialogues/CommonDialogue.tsx delete mode 100644 frontend/src/components/posts/import/PostImportRowDialog.tsx create mode 100644 frontend/src/components/posts/import/PostImportRowForm.tsx create mode 100644 frontend/src/lib/dialogues/useDialogue.ts diff --git a/AGENTS.md b/AGENTS.md index d1a1d91..fa55649 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -410,6 +410,20 @@ Good: - For user-facing Japanese kanji spelling, do not normalize to 《当用漢字による書きかえ》; prefer original forms such as `編輯`. - For user-facing Japanese ellipses, prefer `……` over ASCII `...`. +- Frontend dialogue work must use `@/lib/dialogues/useDialogue` as the + feature-facing entrypoint. +- Reuse the existing common dialogue API and common dialogue component + instead of building feature-local overlay, close button, header, footer, + focus handling, outside click handling, Escape handling, or confirmation + flows. +- Do not import `@/components/ui/dialog` directly in feature code to build a + one-off dialogue, and do not evade this rule with aliases such as + `Dialog as Dialogue`. +- Keep business-specific form content in feature code, and keep the visual + and behavioural dialogue shell in common code. +- Use British spelling `Dialogue` for project-defined dialogue identifiers. + Keep an exact third-party API spelling only at the external boundary where + compatibility requires it. ### Frontend TypeScript and TSX style diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 77398dc..3ddbe16 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -108,6 +108,22 @@ pass or the remaining failure is clearly blocked. third-party request outside the Rails API. - For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body. +## Dialogues + +- Feature code must use `@/lib/dialogues/useDialogue` as the entrypoint for + dialogue work. +- Reuse the existing common dialogue API and common dialogue component. +- Do not import `@/components/ui/dialog` directly in feature code to build a + bespoke dialogue, and do not evade this rule with aliases such as + `Dialog as Dialogue`. +- Keep business-specific form content in feature code, and keep the visual + and behavioural dialogue shell in common code. +- Reuse the common confirmation flow from `useDialogue` instead of building a + feature-local confirmation dialogue. +- Use British spelling `Dialogue` for project-defined dialogue identifiers. + Keep an exact third-party API spelling only at the external boundary where + compatibility requires it. + ## Imports and aliases - The `@` alias points to `frontend/src`. diff --git a/frontend/src/components/dialogues/CommonDialogue.tsx b/frontend/src/components/dialogues/CommonDialogue.tsx new file mode 100644 index 0000000..3f82385 --- /dev/null +++ b/frontend/src/components/dialogues/CommonDialogue.tsx @@ -0,0 +1,54 @@ +import { Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle } from '@/components/ui/dialog' +import { cn } from '@/lib/utils' + +import type { FC, ReactNode } from 'react' + +type Props = { + open: boolean + onOpenChange: (open: boolean) => void + title: ReactNode + description?: ReactNode + className?: string + bodyClassName?: string + footerClassName?: string + footer?: ReactNode + children: ReactNode } + + +const CommonDialogue: FC = ( + { open, + onOpenChange, + title, + description, + className, + bodyClassName, + footerClassName, + footer, + children }, +) => ( + + + + {title} + + {description != null && ( + +
{description}
+
)} +
+ +
{children}
+ + {footer != null && ( + + {footer} + )} +
+
) + +export default CommonDialogue diff --git a/frontend/src/components/dialogues/DialogueProvider.tsx b/frontend/src/components/dialogues/DialogueProvider.tsx index fb77ecf..8e434a6 100644 --- a/frontend/src/components/dialogues/DialogueProvider.tsx +++ b/frontend/src/components/dialogues/DialogueProvider.tsx @@ -1,12 +1,7 @@ import { createContext, useCallback, useContext, useMemo, useState } from 'react' +import CommonDialogue from '@/components/dialogues/CommonDialogue' import { Button } from '@/components/ui/button' -import { Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle } from '@/components/ui/dialog' import type { FC, ReactNode } from 'react' @@ -111,67 +106,60 @@ const DialogueProvider: FC = ({ children }) => { {children} - { - if (!(open)) - closeActive (active?.kind !== 'confirm' && null) - }}> - {active && ( - - - {active.options.title} - - {active.options.description && ( - -
{active.options.description}
-
)} -
- - - {active.kind === 'confirm' && ( - <> - - - - )} - - {active.kind === 'alert' && ( - )} - - {active.kind === 'choice' && ( - <> - - - {active.options.choices.map (choice => ( + {active && ( + { + if (!(open)) + closeActive (active.kind !== 'confirm' && null) + }} + title={active.options.title} + description={active.options.description} + bodyClassName="hidden" + footer={ + <> + {active.kind === 'confirm' && ( + <> + + ))} - )} - -
)} -
+ onClick={() => closeActive (true)}> + {active.options.confirmText ?? '確定'} + + )} + + {active.kind === 'alert' && ( + )} + + {active.kind === 'choice' && ( + <> + + + {active.options.choices.map (choice => ( + ))} + )} + }/> + )}
) } diff --git a/frontend/src/components/posts/import/PostImportRowDialog.tsx b/frontend/src/components/posts/import/PostImportRowDialog.tsx deleted file mode 100644 index 7f8bae2..0000000 --- a/frontend/src/components/posts/import/PostImportRowDialog.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' -import FieldError from '@/components/common/FieldError' -import FieldWarning from '@/components/common/FieldWarning' -import FormField from '@/components/common/FormField' -import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview' -import { Button } from '@/components/ui/button' -import { Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle } from '@/components/ui/dialog' -import { inputClass } from '@/lib/utils' - -import type { FC } from 'react' -import { useEffect, useState } from 'react' - -import type { PostImportResetSnapshot, - PostImportRow } from '@/lib/postImportSession' - -type Draft = { - url: string - title: string - thumbnailBase: string - originalCreatedFrom: string - originalCreatedBefore: string - duration: string - tags: string - parentPostIds: string } - -type Props = { - open: boolean - row: PostImportRow | null - messageRow?: PostImportRow | null - saving: boolean - onOpenChange: (open: boolean) => void - onSave: ( - payload: { draft: Draft - resetRequested: boolean - resetSnapshot: PostImportResetSnapshot }, - ) => Promise } - -const buildDraft = (row: PostImportRow): Draft => ({ - url: row.url, - title: String (row.attributes.title ?? ''), - thumbnailBase: String (row.attributes.thumbnailBase ?? ''), - originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''), - originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''), - duration: String (row.attributes.duration ?? ''), - tags: String (row.attributes.tags ?? ''), - parentPostIds: String (row.attributes.parentPostIds ?? '') }) - -const buildResetDraft = (row: PostImportRow): Draft => ({ - url: row.resetSnapshot.url, - title: String (row.resetSnapshot.attributes.title ?? ''), - thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''), - originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''), - originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''), - duration: String (row.resetSnapshot.attributes.duration ?? ''), - tags: String (row.resetSnapshot.attributes.tags ?? ''), - parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') }) - -const groupedMessages = ( - ...values: Array -): string[] => - values.flatMap (value => value ?? []) - - -const PostImportRowDialog: FC = ( - { open, row, messageRow, saving, onOpenChange, onSave }, -) => { - const [draft, setDraft] = useState (null) - const [resetRequested, setResetRequested] = useState (false) - - useEffect (() => { - if (open && row != null) - { - setDraft (buildDraft (row)) - setResetRequested (false) - } - }, [open, row]) - - if (row == null || draft == null) - return null - - const displayRow = messageRow ?? row - - const update = ( - key: Key, - value: Draft[Key], - ) => { - setDraft (current => - current != null ? { ...current, [key]: value } : current) - } - - const save = async () => { - if (await onSave ({ - draft, - resetRequested, - resetSnapshot: row.resetSnapshot })) - onOpenChange (false) - } - - return ( - - - - 投稿を編輯 - - 投稿 {row.sourceRow} の内容を確認し、必要な項目を編輯してください. - - - -
-
-
- -
- -
- update ('url', value)}/> - update ('title', value)}/> - update ('thumbnailBase', value)}/> - update ('originalCreatedFrom', value ?? '')} - originalCreatedBefore={draft.originalCreatedBefore || null} - setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')} - errors={groupedMessages ( - displayRow.validationErrors.originalCreatedAt, - displayRow.validationErrors.originalCreatedFrom, - displayRow.validationErrors.originalCreatedBefore, - displayRow.importErrors?.originalCreatedAt, - displayRow.importErrors?.originalCreatedFrom, - displayRow.importErrors?.originalCreatedBefore)}/> - update ('duration', value)}/> - update ('tags', value)}/> - update ('parentPostIds', value)}/> - - - -
-
-
- - - - - - -
-
) -} - -const DialogTextField = ( - { label, value, warnings, errors, onChange }: { - label: string - value: string - warnings?: string[] - errors?: string[] - onChange: (value: string) => void }, -) => ( - - {({ describedBy, invalid }) => ( - <> - onChange (ev.target.value)} - aria-describedby={describedBy} - aria-invalid={invalid} - className={inputClass (invalid)}/> - - )} - ) - -const DialogAreaField = ( - { label, value, warnings, errors, onChange }: { - label: string - value: string - warnings?: string[] - errors?: string[] - onChange: (value: string) => void }, -) => ( - - {({ describedBy, invalid }) => ( - <> -