diff --git a/AGENTS.md b/AGENTS.md index fa55649..e22c595 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -335,6 +335,113 @@ Good: ? 'warning' : 'ready')) ``` + +## Shared-system discovery and reuse + +Before creating a new component, hook, service, helper, utility, concern, +representation, normaliser, validator, parser, fetcher, store, context, event +bus, API client, query key, permission helper, dialogue, toast, form field, or +version recorder, search the existing repository first. + +Do not search by name alone. Search by responsibility, behaviour, and usage +intent as well. Typical search themes include: + +- dialogue, modal, confirm, alert, choice +- validation error, field error, unprocessable entity +- permission, role, member, admin, editable +- URL normalise, sanitise, canonicalise +- API call, query key, prefetch, cache invalidation +- version, snapshot, history, restore +- thumbnail, metadata, HTTP fetch, URL safety +- form, field, input, textarea, warning, status badge +- file storage, Active Storage, ZIP, export +- transaction, locking, race, idempotency + +Before deciding that something new is needed, confirm at least: + +1. the existing definition +2. its public API +3. representative call sites +4. other implementations with similar responsibility +5. the nearest directory-level `AGENTS.md` + +Do not reject an existing implementation by name alone. Read the code and its +usage first. + +When new behaviour is needed, make the decision in this order: + +1. use the existing common API as-is +2. use the existing extension points of that API +3. extend the existing common API minimally +4. keep the implementation local when the meaning is feature-specific +5. add a new common system only when multiple real users and a stable contract + are already clear + +Do not create a parallel foundation merely because the existing one feels +slightly awkward, because a new one seems faster, or because the current task +looks special. + +Do not create a second common platform with names such as `CommonFoo`, +`SharedFoo`, `BaseFoo`, `FooManager`, `FooService`, `FooProvider`, +`FooWrapper`, `FooUtils`, or `useFoo` when an existing system already owns the +same responsibility. Judge by responsibility, not by spelling. + +Wrapper aliases, barrel exports, and re-export shims do not count as reuse. +Using a different import path that merely forwards to the existing system does +not satisfy a reuse requirement. + +Keep this boundary explicit: + +- business meaning stays in feature code +- visual and mechanical shell stays in common code + +Common code may hold generic primitives, interaction shells, API transport, +auth and permission helpers, query-key and prefetch conventions, validation +error conversion, shared normalisation, version-recording mechanisms, storage +helpers, HTTP safety, and other contracts that carry the same meaning across +multiple features. + +Feature code should keep feature-specific states, labels, input fields, +workflow steps, payload shapes, validation rules, and business decisions. + +Do not commonise business logic merely because the visuals look similar or two +code fragments resemble each other. + +Create a new common system only when all of the following are true: + +- no existing implementation already owns the responsibility +- there are multiple real consumers, or a clearly defined platform contract +- the differences between consumers do not require option bloat +- feature-specific vocabulary does not leak into common types, props, tones, or + state names +- the location matches the existing directory structure +- the new abstraction does not compete with an existing common system +- it is not just a one-off wrapper for a single task + +Do not add unused tones, variants, options, callbacks, states, or abstractions +for speculative future use. + +Before finishing work that touches shared systems, verify: + +- you searched for an existing implementation with the same responsibility +- you are using the canonical import path or entrypoint +- feature code is not reaching directly for a low-level primitive that already + has a higher-level common API +- you did not bypass an existing common API +- any new wrapper or shim is genuinely necessary +- feature-specific vocabulary did not leak into common code +- the feature did not reimplement a common shell +- existing unrelated consumers were not changed without need +- one task did not trigger a needless redesign of the whole foundation +- any newly introduced common system really has multiple consumers + +For backend-specific discovery, Rails structure, services, representation +selection, versioning, normalisation, and HTTP safety, follow +`backend/AGENTS.md`. + +For frontend-specific component hierarchy, low-level primitive reuse, dialogue +entrypoints, API/query helpers, validation/form infrastructure, state/storage, +and layout reuse, follow `frontend/AGENTS.md`. - In TypeScript and TSX, do not write `let` followed by later `if` assignments when the value can be expressed as a single `const` initializer. Prefer `const` because it prevents accidental later reassignment. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 2953f22..e0f304e 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -67,6 +67,192 @@ pass or the remaining failure is clearly blocked. Before changing behavior, inspect the matching route, controller, model, service, representation, and spec. +## Shared backend systems + +Before adding backend behaviour, search the existing backend first. At minimum, +check these locations: + +- `app/controllers` +- `app/controllers/concerns` +- `app/models` +- `app/models/concerns` +- `app/representations` +- `app/services` +- `app/services/*` +- `app/jobs` +- `lib` +- `lib/tasks` +- `config/initializers` + +Do not infer commonality from directory names alone. Read the actual +responsibility and representative usage sites. + +### Controller reuse + +Before adding logic to a controller, inspect: + +- `ApplicationController` authentication, authorization, BAN, and IP BAN +- existing render and validation-error helpers +- existing param parsing +- controller concerns +- the controller for the same resource +- existing services +- existing representations + +Keep controllers focused on: + +- authentication and authorization +- parameter intake +- service and model invocation +- HTTP status selection +- representation selection + +Do not reimplement these per controller when an existing path already owns +them: + +- authentication and role checks +- validation error JSON +- URL normalisation +- tag normalisation +- thumbnail handling +- version recording +- complex transactions +- external HTTP fetching +- response representation assembly + +### Authentication, authorization, and BAN + +Treat these as the canonical backend entrypoints: + +- `ApplicationController#authenticate_user` +- `current_user` +- `X-Transfer-Code` +- `reject_banned_ip_address!` +- `reject_banned_user!` +- `gte_member?` +- `admin?` + +Do not create feature-local permission services, role comparisons, or header +parsing when the existing authentication boundary already owns the behaviour. +If the current boundary is insufficient, extend it minimally instead of adding +another permission path. + +### Representations + +If an endpoint for the same resource already uses `app/representations`, do not +assemble a separate JSON shape directly inside the controller without first +checking the existing representation contract. + +Inspect at least: + +- `PostRepr` +- `TagRepr` +- `MaterialRepr` +- `TheatreRepr` +- `UserRepr` +- `WikiPageRepr` +- `DeerjikistRepr` + +When a lightweight response is genuinely different in purpose, keep it +deliberate and compatible with the surrounding contracts. Do not force every +identifier list into a large representation, but do not fork the same resource +shape casually either. + +### Domain services + +When work touches multiple models, transactions, external APIs, file handling, +history creation, or multi-step workflow, search `app/services` first. + +At minimum, search for existing services in these responsibility areas: + +- version recorder and versioning +- wiki commit +- YouTube or Google Drive API client +- material sync or ZIP export +- similarity calculation +- theatre selection or skip finalisation +- metadata, thumbnail, or file processing +- URL normaliser or sanitisation +- import or export +- preview safety or HTTP fetch + +Do not create a same-responsibility service under another namespace or another +name. If an existing service is close, extend that API minimally instead of +wrapping it in a feature-local service. + +### Versioning + +When a feature writes history, snapshots, or restore roots, search the existing +versioning path first. At minimum, inspect: + +- `VersionRecorder` +- `PostVersionRecorder` +- `TagVersionRecorder` +- `TagVersioning` +- `MaterialVersionRecorder` +- `NicoTagVersionRecorder` +- `WikiVersionRecorder` + +Do not implement history writes in controllers, callbacks, or ad hoc feature +services when the recorder layer already owns the transaction boundary and +meaning. + +### Normalisation, sanitisation, and parsing + +For URLs, tag names, times, video durations, identifiers, and paths, search the +existing normaliser, sanitisation rule, parser, and model-callback path first. + +Do not let frontend, controller, service, and model each invent different rules +for the same value. Use one canonical normalisation path and keep input +validation distinct from pre-persistence normalisation. + +### External HTTP and URL safety + +When fetching external URLs, reuse the existing preview-safety stack. Search at +least for: + +- URL safety +- redirect validation +- response size limits +- timeouts +- network failure mapping +- HTML metadata extraction +- known-site extraction +- thumbnail fetching + +Do not add direct `Net::HTTP`, `Faraday`, or equivalent feature-local HTTP code +that reimplements SSRF checks, redirect restrictions, size limits, or timeouts. +If the current fetcher is insufficient, extend its existing safety contract. + +### Storage, files, and Active Storage + +When handling files, thumbnails, ZIP output, object storage, or Active Storage +blobs, inspect existing storage helpers, exporters, thumbnail generators, and +checksum helpers first. Do not reimplement the same attach, export path, +download, resize, or checksum flow in a controller or one-off service. + +### Concerns + +Do not create controller or model concerns merely because some code is shared. +Use a concern only when multiple classes share the same lifecycle, macro, +callback, or tightly cohesive behaviour. Utility collections belong in explicit +objects or services, not in `CommonConcern`, `SharedMethods`, or `Utils`. + +### Model boundaries + +Model-specific invariants, associations, validations, and normalisation may +live in the model. Multi-model workflow, external access, complex transaction +flow, and feature orchestration belong in services. Do not hide feature +workflow in model callbacks. + +### Transactions, locking, and race handling + +If transactions, locking, idempotency, or race recovery already exist in a +service or model method, do not add a second implementation in a controller or +new service. Inspect the existing transaction boundary first, avoid wrapping +the same operation in needless nested transactions, and handle unique-constraint +races according to the target constraint's business meaning. + ## Ruby style - Prefer precise, minimal changes. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 3ddbe16..3468571 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -110,19 +110,7 @@ pass or the remaining failure is clearly blocked. ## 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. +- Dialogue work follows the shared-frontend reuse rules below. ## Imports and aliases @@ -339,6 +327,237 @@ const rows = `BehaviorSettingsSection.tsx`. - Avoid reformatting unrelated JSX. +## Shared frontend systems + +Before creating a new component, hook, helper, store, context, or other +frontend abstraction, search at least: + +- `src/components/common` +- `src/components/layout` +- `src/components/ui` +- `src/components/dialogues` +- `src/lib` +- `src/lib/dialogues` +- `src/stores` +- `src/types.ts` + +Also inspect the existing pages and components in the same feature. + +Search by responsibility, not by filename alone. Check display, interaction, +state, communication, validation, and permission behaviour before deciding that +an existing implementation is unsuitable. + +### Component placement and reuse order + +When adding UI, use this order: + +1. reuse an existing feature component +2. reuse an existing component from `components/common` +3. reuse an existing layout component from `components/layout` +4. use an existing primitive from `components/ui` through the established + common API +5. extend an existing component minimally +6. add a feature-local component in the feature area +7. add a new common component only when multiple features clearly share a + stable visual contract + +Do not place a one-screen component in a common directory merely because its +name starts with `Common`. + +### Low-level primitives + +Treat `components/ui` as low-level primitives. If a higher-level common API +already exists for dialogues, toast, form validation, navigation, or similar +behaviour, feature code must use that API instead of assembling primitives +directly. + +Examples of existing preferred entrypoints include: + +- dialogue: `@/lib/dialogues/useDialogue` +- toast: the existing toast API +- internal navigation: `PrefetchLink` +- form errors: `FieldError`, `FieldWarning`, `FormField` +- buttons: `Button` +- conditional class merge: `cn` + +Do not evade the rule with aliases or thin wrappers around the low-level +primitive. + +### Dialogues + +Feature-facing dialogue work must use `@/lib/dialogues/useDialogue`. + +Reuse the existing common dialogue API and common dialogue component. Do not +import `@/components/ui/dialog` directly in feature code to assemble bespoke +dialogue shells, and do not evade this rule with aliases such as +`Dialog as Dialogue`. + +Do not reimplement overlay, portal, close button, header, footer, focus +handling, Escape handling, outside-click handling, or confirmation flow in +feature code. + +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 +exact third-party spellings only at the external boundary where compatibility +requires them. + +### API calls + +Rails API calls must use `src/lib/api.ts`. + +Do not create feature-local Axios instances, fetch wrappers, header injectors, +camelCase converters, or generic error converters. If blob or other special +transport behaviour is already supported by the common API, use the existing +options instead of bypassing the wrapper. + +### Query keys, server state, and prefetch + +Before adding query state, inspect: + +- `src/lib/queryKeys.ts` +- existing domain helpers +- existing prefetchers +- the root query-key hierarchy +- current mutation invalidation patterns +- the app-wide `QueryClient` + +Do not write ad hoc query-key arrays in feature code. Do not duplicate fetcher, +prefetcher, or invalidation helpers for the same resource. + +### Domain helpers + +For posts, tags, wiki, materials, and other domain work, inspect the existing +helpers in `src/lib/*.ts` before adding logic to a page component. + +Do not accumulate these in page components when an existing helper layer should +own them: + +- API request construction +- response-shape conversion +- query-key construction +- canonical URL generation +- permission calculation +- storage serialisation +- domain-specific parsing + +Keep purely local one-screen display shaping local when that is the clearest +place for it. + +### Permission helpers + +Use the existing permission helpers such as `src/lib/users.ts` when deciding +editability, role checks, admin/member visibility, and similar UI behaviour. + +Do not scatter `user?.role`, numeric role comparisons, or string comparisons +through components. Frontend visibility control should be consistent even though +backend authorization remains the final gate. + +### Validation errors + +Before adding feature-local validation-error handling, inspect: + +- `useValidationErrors` +- `apiErrors` +- `FieldError` +- `FieldWarning` +- `FormField` +- `inputClass` + +Do not create a new generic hook, field-error state shape, or error-rendering +component for a pattern the shared error stack already covers. Keep only +genuinely feature-specific business errors local. + +### Forms and fields + +Before creating a new input, textarea, date/time field, tag input, label, or +error layout, inspect at least: + +- `Form` +- `FormField` +- `FieldError` +- `FieldWarning` +- `DateTimeField` +- `TagInput` +- `TextArea` +- `Label` +- `Button` + +Do not create a same-function field component merely because the spacing or +surface styling is slightly different. Prefer feature-level composition over +bloated common-field option lists. + +### Navigation and prefetch + +Use `PrefetchLink` and existing router helpers for internal navigation. Do not +introduce feature-local ``, `window.location`, or custom prefetch logic for +internal routes. Keep path-segment encoding aligned with the existing rules. + +### State management + +Before adding state, decide whether the source of truth should be: + +- component-local state +- URL search params +- TanStack Query server state +- an existing Zustand store +- an existing event bus +- an existing storage helper + +Do not create a new global store, context, or event bus for one screen when +local state or an existing mechanism is enough. Do not create a second store +for the same responsibility. + +### Storage and settings + +When touching localStorage, sessionStorage, or user settings, inspect existing +settings helpers, storage helpers, expiry handling, versioning, and sanitisers. + +Do not reimplement per-component key naming, JSON parsing and serialisation, +expiry, or schema checks when a shared helper already owns the pattern. + +### Hooks + +Before creating a custom hook, search existing `src/lib/use*.ts` and +`src/lib/use*.tsx`. + +Hooks are for shared stateful behaviour or React lifecycle integration. Do not +turn a pure function, one-off helper, or mere re-export shim into `useFoo`. + +### Stores, contexts, and event buses + +Add a new store, context, or event bus only when the current mechanisms cannot +express the requirement and there are multiple genuinely separate consumers. + +Do not hold the same information redundantly across URL state, query cache, +local component state, Zustand, and an event bus. Keep one source of truth. + +### Types + +If a domain type already exists in `src/types.ts` or a domain helper, reuse it +instead of redefining the same shape in a feature file. + +Small local props and draft types may stay local. Do not create giant +catch-all type files such as `CommonTypes.ts`. + +### Styling utilities + +Use existing styling utilities such as `cn` and `inputClass`. + +Do not add feature-local class-merge helpers, generic status-colour mappers, or +responsive wrapper helpers when a shared utility already exists. Keep common +tone names visual only; feature-specific state names stay in feature code. + +### Layout + +Before adding page shells, padding rules, viewport-height handling, sidebar +offsets, or footer offsets, inspect existing layout components such as +`MainArea`, top navigation, sidebar, page title, and section-title patterns. + +Do not create a second layout shell before checking whether the current layout +can be reused or minimally extended. + ### Delimiter decision table Use this table before accepting any edited TypeScript or TSX hunk. The table is diff --git a/frontend/src/components/dialogues/CommonDialogue.tsx b/frontend/src/components/dialogues/CommonDialogue.tsx deleted file mode 100644 index 3f82385..0000000 --- a/frontend/src/components/dialogues/CommonDialogue.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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 8e434a6..fb77ecf 100644 --- a/frontend/src/components/dialogues/DialogueProvider.tsx +++ b/frontend/src/components/dialogues/DialogueProvider.tsx @@ -1,7 +1,12 @@ 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' @@ -106,60 +111,67 @@ const DialogueProvider: FC = ({ children }) => { {children} - {active && ( - { - if (!(open)) - closeActive (active.kind !== 'confirm' && null) - }} - title={active.options.title} - description={active.options.description} - bodyClassName="hidden" - footer={ - <> - {active.kind === 'confirm' && ( - <> - + { + 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.kind === 'alert' && ( - )} - - {active.kind === 'choice' && ( - <> - - - {active.options.choices.map (choice => ( - ))} - )} - }/> - )} + onClick={() => closeActive (choice.value)}> + {choice.label} + ))} + )} + +
)} +
) } diff --git a/frontend/src/components/posts/import/PostImportRowForm.tsx b/frontend/src/components/posts/import/PostImportRowForm.tsx index 6fed17b..0c23883 100644 --- a/frontend/src/components/posts/import/PostImportRowForm.tsx +++ b/frontend/src/components/posts/import/PostImportRowForm.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import FieldError from '@/components/common/FieldError' @@ -9,10 +9,9 @@ import { Button } from '@/components/ui/button' import { useDialogue } from '@/lib/dialogues/useDialogue' import { inputClass } from '@/lib/utils' -import type { FC } from 'react' +import type { Dispatch, FC, SetStateAction } from 'react' -import type { PostImportResetSnapshot, - PostImportRow } from '@/lib/postImportSession' +import type { PostImportRow } from '@/lib/postImportSession' type Draft = { url: string @@ -25,15 +24,11 @@ type Draft = { parentPostIds: string } type Props = { - row: PostImportRow - messageRow?: PostImportRow | null - saving: boolean - onCancel: () => void - onSave: ( - payload: { draft: Draft - resetRequested: boolean - resetSnapshot: PostImportResetSnapshot }, - ) => Promise } + row: PostImportRow + messageRow?: PostImportRow | null + saving: boolean + onDraftChange: Dispatch> + onResetRequestedChange: Dispatch> } const buildDraft = (row: PostImportRow): Draft => ({ url: row.url, @@ -70,13 +65,24 @@ const sameDraft = (left: Draft, right: Draft): boolean => const PostImportRowForm: FC = ( - { row, messageRow, saving, onCancel, onSave }, + { row, + messageRow, + saving, + onDraftChange, + onResetRequestedChange }, ) => { const dialogue = useDialogue () - const [draft, setDraft] = useState (() => buildDraft (row)) const [resetRequested, setResetRequested] = useState (false) + useEffect (() => { + const nextDraft = buildDraft (row) + setDraft (nextDraft) + setResetRequested (false) + onDraftChange (nextDraft) + onResetRequestedChange (false) + }, [onDraftChange, onResetRequestedChange, row]) + const displayRow = messageRow ?? row const resetDraft = buildResetDraft (row) const resetDisabled = saving || sameDraft (draft, resetDraft) @@ -85,7 +91,11 @@ const PostImportRowForm: FC = ( key: Key, value: Draft[Key], ) => { - setDraft (current => ({ ...current, [key]: value })) + setDraft (current => { + const next = { ...current, [key]: value } + onDraftChange (next) + return next + }) } const reset = async () => { @@ -103,116 +113,103 @@ const PostImportRowForm: FC = ( setDraft (resetDraft) setResetRequested (true) - } - - const save = async () => { - if (await onSave ({ - draft, - resetRequested, - resetSnapshot: row.resetSnapshot })) - onCancel () + onDraftChange (resetDraft) + onResetRequestedChange (true) } 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)}/> - - - +
+
+ +
+ +
+ 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)}/> + + + + +
+ +
+
- -
- - - -
) } @@ -260,4 +257,6 @@ const PostImportAreaField = ( ) export default PostImportRowForm +export { buildDraft } +export type { Draft as PostImportRowDraft } export type { Draft as PostImportRowDraft } diff --git a/frontend/src/lib/dialogues/useDialogue.ts b/frontend/src/lib/dialogues/useDialogue.ts index 1de50dc..0de1567 100644 --- a/frontend/src/lib/dialogues/useDialogue.ts +++ b/frontend/src/lib/dialogues/useDialogue.ts @@ -1 +1,5 @@ -export { useDialogue } from '@/components/dialogues/DialogueProvider' +import { useDialogue as useDialogueFromProvider } from '@/components/dialogues/DialogueProvider' + +export const useDialogue = () => useDialogueFromProvider () + +export default useDialogue diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 1f0f5fc..85af48c 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -1,16 +1,16 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' -import CommonDialogue from '@/components/dialogues/CommonDialogue' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' -import PostImportRowForm from '@/components/posts/import/PostImportRowForm' +import PostImportRowForm, { buildDraft } from '@/components/posts/import/PostImportRowForm' import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPost } from '@/lib/api' +import useDialogue from '@/lib/dialogues/useDialogue' import { canEditContent } from '@/lib/users' import { loadPostImportSession, creatableImportRows, @@ -35,6 +35,7 @@ type Props = { user: User | null } const PostImportReviewPage: FC = ({ user }) => { const editable = canEditContent (user) + const dialogue = useDialogue () const navigate = useNavigate () const { sessionId } = useParams () const [searchParams, setSearchParams] = useSearchParams () @@ -44,6 +45,10 @@ const PostImportReviewPage: FC = ({ user }) => { const [missing, setMissing] = useState (false) const [savingRow, setSavingRow] = useState (null) const [dialogueMessageRow, setDialogueMessageRow] = useState (null) + const [dialogueDraft, setDialogueDraft] = useState (null) + const [dialogueResetRequested, setDialogueResetRequested] = useState (false) + const dialogueDraftRef = useRef (null) + const dialogueResetRequestedRef = useRef (false) useEffect (() => { if (sessionId == null) @@ -63,6 +68,14 @@ const PostImportReviewPage: FC = ({ user }) => { toast ({ title: '取込状態を保存できませんでした', description: message })) }, [session, sessionId]) + useEffect (() => { + dialogueDraftRef.current = dialogueDraft + }, [dialogueDraft]) + + useEffect (() => { + dialogueResetRequestedRef.current = dialogueResetRequested + }, [dialogueResetRequested]) + const rows = session?.rows ?? [] const editingSourceRow = Number (searchParams.get ('edit') ?? '') const editingRow = @@ -98,10 +111,15 @@ const PostImportReviewPage: FC = ({ user }) => { if (editingRow == null) { setDialogueMessageRow (null) + setDialogueDraft (null) + setDialogueResetRequested (false) return } if (dialogueMessageRow?.sourceRow !== editingRow.sourceRow) setDialogueMessageRow (null) + setDialogueDraft (current => + current != null ? current : buildDraft (editingRow)) + setDialogueResetRequested (false) }, [editingRow, dialogueMessageRow]) const updateSessionRows = (nextRows: PostImportRow[]) => @@ -113,12 +131,13 @@ const PostImportReviewPage: FC = ({ user }) => { draft: PostImportRowDraft resetRequested: boolean resetSnapshot: PostImportRow['resetSnapshot'] }, - ): Promise => { + ): Promise<{ saved: boolean + row: PostImportRow | null }> => { if (session == null) - return false + return { saved: false, row: null } if (editingRow == null) - return false + return { saved: false, row: null } const baseRow = resetRequested @@ -159,17 +178,17 @@ const PostImportReviewPage: FC = ({ user }) => { if (target != null && Object.keys (target.validationErrors).length > 0) { setDialogueMessageRow (target) - return false + return { saved: false, row: target } } updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows)) setDialogueMessageRow (null) setSearchParams ({ }) - return true + return { saved: true, row: null } } catch { toast ({ title: '行の再検証に失敗しました' }) - return false + return { saved: false, row: null } } finally { @@ -177,6 +196,53 @@ const PostImportReviewPage: FC = ({ user }) => { } } + const openEditingDialogue = async (row: PostImportRow) => { + let currentRow = row + + while (true) + { + const initialDraft = buildDraft (currentRow) + setDialogueDraft (current => current != null ? current : initialDraft) + setDialogueResetRequested (false) + + const action = await dialogue.choice ({ + title: '投稿を編輯', + description: , + cancelText: '取消', + choices: [{ value: 'save', label: '編輯内容を保存' }] as const }) + + if (action !== 'save') + { + setSearchParams ({ }) + return + } + + const draft = dialogueDraftRef.current ?? initialDraft + const result = await saveDraft ({ + draft, + resetRequested: dialogueResetRequestedRef.current, + resetSnapshot: currentRow.resetSnapshot }) + if (result.saved) + return + + currentRow = result.row ?? currentRow + } + } + + useEffect (() => { + if (editingRow == null) + return + + void openEditingDialogue (editingRow) + }, [editingRow]) + const submit = async () => { if (sessionId == null || session == null || processable.length === 0) return @@ -315,25 +381,6 @@ const PostImportReviewPage: FC = ({ user }) => { onBack={() => navigate ('/posts/import')} onSubmit={submit}/> - {editingRow != null && ( - { - if (!open) - setSearchParams ({ }) - }} - title="投稿を編輯" - description={`投稿 ${ editingRow.sourceRow } の内容を確認し、必要な項目を編輯してください.`} - className="flex max-h-[calc(100dvh-1rem)] max-w-3xl flex-col overflow-hidden p-0" - bodyClassName="contents"> - setSearchParams ({ })} - onSave={saveDraft}/> - )} ) }