このコミットが含まれているのは:
2026-07-15 22:26:58 +09:00
コミット 07ce19e32d
8個のファイルの変更784行の追加264行の削除
+107
ファイルの表示
@@ -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.
+186
ファイルの表示
@@ -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.
+232 -13
ファイルの表示
@@ -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 `<a>`, `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
-54
ファイルの表示
@@ -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<Props> = (
{ open,
onOpenChange,
title,
description,
className,
bodyClassName,
footerClassName,
footer,
children },
) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn ('px-6 pb-6 pt-7', className)}>
<DialogHeader className="pl-8">
<DialogTitle>{title}</DialogTitle>
{description != null && (
<DialogDescription asChild>
<div>{description}</div>
</DialogDescription>)}
</DialogHeader>
<div className={bodyClassName}>{children}</div>
{footer != null && (
<DialogFooter className={footerClassName}>
{footer}
</DialogFooter>)}
</DialogContent>
</Dialog>)
export default CommonDialogue
+24 -12
ファイルの表示
@@ -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,18 +111,24 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<DialogueContext.Provider value={api}>
{children}
{active && (
<CommonDialogue
<Dialog
open={Boolean (active)}
onOpenChange={open => {
if (!(open))
closeActive (active.kind !== 'confirm' && null)
}}
title={active.options.title}
description={active.options.description}
bodyClassName="hidden"
footer={
<>
closeActive (active?.kind !== 'confirm' && null)
}}>
{active && (
<DialogContent className="px-6 pb-6 pt-7">
<DialogHeader className="pl-8">
<DialogTitle>{active.options.title}</DialogTitle>
{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<DialogFooter>
{active.kind === 'confirm' && (
<>
<Button
@@ -158,8 +169,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
{choice.label}
</Button>))}
</>)}
</>}/>
)}
</DialogFooter>
</DialogContent>)}
</Dialog>
</DialogueContext.Provider>)
}
+37 -38
ファイルの表示
@@ -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
@@ -28,12 +27,8 @@ type Props = {
row: PostImportRow
messageRow?: PostImportRow | null
saving: boolean
onCancel: () => void
onSave: (
payload: { draft: Draft
resetRequested: boolean
resetSnapshot: PostImportResetSnapshot },
) => Promise<boolean> }
onDraftChange: Dispatch<SetStateAction<Draft | null>>
onResetRequestedChange: Dispatch<SetStateAction<boolean>> }
const buildDraft = (row: PostImportRow): Draft => ({
url: row.url,
@@ -70,13 +65,24 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
const PostImportRowForm: FC<Props> = (
{ row, messageRow, saving, onCancel, onSave },
{ row,
messageRow,
saving,
onDraftChange,
onResetRequestedChange },
) => {
const dialogue = useDialogue ()
const [draft, setDraft] = useState<Draft> (() => 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<Props> = (
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,19 +113,18 @@ const PostImportRowForm: FC<Props> = (
setDraft (resetDraft)
setResetRequested (true)
}
const save = async () => {
if (await onSave ({
draft,
resetRequested,
resetSnapshot: row.resetSnapshot }))
onCancel ()
onDraftChange (resetDraft)
onResetRequestedChange (true)
}
return (
<>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-6">
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
稿 {row.sourceRow}
</p>
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
<div className="space-y-3">
<ThumbnailPreview
@@ -187,11 +196,8 @@ const PostImportRowForm: FC<Props> = (
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col-reverse gap-2 px-6 pb-6 pt-4 sm:flex-row sm:justify-end">
<div className="pt-2">
<Button
type="button"
variant="destructive"
@@ -199,19 +205,10 @@ const PostImportRowForm: FC<Props> = (
disabled={resetDisabled}>
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={saving}>
</Button>
<Button
type="button"
onClick={() => save ()}
disabled={saving}>
</Button>
</div>
</div>
</div>
</div>
</div>
</>)
}
@@ -260,4 +257,6 @@ const PostImportAreaField = (
</FormField>)
export default PostImportRowForm
export { buildDraft }
export type { Draft as PostImportRowDraft }
export type { Draft as PostImportRowDraft }
+5 -1
ファイルの表示
@@ -1 +1,5 @@
export { useDialogue } from '@/components/dialogues/DialogueProvider'
import { useDialogue as useDialogueFromProvider } from '@/components/dialogues/DialogueProvider'
export const useDialogue = () => useDialogueFromProvider ()
export default useDialogue
+75 -28
ファイルの表示
@@ -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<Props> = ({ 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<Props> = ({ user }) => {
const [missing, setMissing] = useState (false)
const [savingRow, setSavingRow] = useState<number | null> (null)
const [dialogueMessageRow, setDialogueMessageRow] = useState<PostImportRow | null> (null)
const [dialogueDraft, setDialogueDraft] = useState<PostImportRowDraft | null> (null)
const [dialogueResetRequested, setDialogueResetRequested] = useState (false)
const dialogueDraftRef = useRef<PostImportRowDraft | null> (null)
const dialogueResetRequestedRef = useRef (false)
useEffect (() => {
if (sessionId == null)
@@ -63,6 +68,14 @@ const PostImportReviewPage: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ user }) => {
draft: PostImportRowDraft
resetRequested: boolean
resetSnapshot: PostImportRow['resetSnapshot'] },
): Promise<boolean> => {
): 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<Props> = ({ 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<Props> = ({ 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: <PostImportRowForm
row={currentRow}
messageRow={dialogueMessageRow?.sourceRow === currentRow.sourceRow
? dialogueMessageRow
: null}
saving={savingRow === currentRow.sourceRow}
onDraftChange={setDialogueDraft}
onResetRequestedChange={setDialogueResetRequested}/>,
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<Props> = ({ user }) => {
onBack={() => navigate ('/posts/import')}
onSubmit={submit}/>
{editingRow != null && (
<CommonDialogue
open={true}
onOpenChange={open => {
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">
<PostImportRowForm
key={editingRow.sourceRow}
row={editingRow}
messageRow={dialogueMessageRow}
saving={savingRow != null}
onCancel={() => setSearchParams ({ })}
onSave={saveDraft}/>
</CommonDialogue>)}
</>)
}