広場投稿追加画面の刷新 (#399) #413
@@ -335,6 +335,113 @@ Good:
|
|||||||
? 'warning'
|
? 'warning'
|
||||||
: 'ready'))
|
: '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
|
- 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
|
when the value can be expressed as a single `const` initializer. Prefer
|
||||||
`const` because it prevents accidental later reassignment.
|
`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,
|
Before changing behavior, inspect the matching route, controller, model,
|
||||||
service, representation, and spec.
|
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
|
## Ruby style
|
||||||
|
|
||||||
- Prefer precise, minimal changes.
|
- Prefer precise, minimal changes.
|
||||||
|
|||||||
+232
-13
@@ -110,19 +110,7 @@ pass or the remaining failure is clearly blocked.
|
|||||||
|
|
||||||
## Dialogues
|
## Dialogues
|
||||||
|
|
||||||
- Feature code must use `@/lib/dialogues/useDialogue` as the entrypoint for
|
- Dialogue work follows the shared-frontend reuse rules below.
|
||||||
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
|
## Imports and aliases
|
||||||
|
|
||||||
@@ -339,6 +327,237 @@ const rows =
|
|||||||
`BehaviorSettingsSection.tsx`.
|
`BehaviorSettingsSection.tsx`.
|
||||||
- Avoid reformatting unrelated JSX.
|
- 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
|
### Delimiter decision table
|
||||||
|
|
||||||
Use this table before accepting any edited TypeScript or TSX hunk. The table is
|
Use this table before accepting any edited TypeScript or TSX hunk. The table is
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import CommonDialogue from '@/components/dialogues/CommonDialogue'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle } from '@/components/ui/dialog'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -106,18 +111,24 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
<DialogueContext.Provider value={api}>
|
<DialogueContext.Provider value={api}>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{active && (
|
<Dialog
|
||||||
<CommonDialogue
|
|
||||||
open={Boolean (active)}
|
open={Boolean (active)}
|
||||||
onOpenChange={open => {
|
onOpenChange={open => {
|
||||||
if (!(open))
|
if (!(open))
|
||||||
closeActive (active.kind !== 'confirm' && null)
|
closeActive (active?.kind !== 'confirm' && null)
|
||||||
}}
|
}}>
|
||||||
title={active.options.title}
|
{active && (
|
||||||
description={active.options.description}
|
<DialogContent className="px-6 pb-6 pt-7">
|
||||||
bodyClassName="hidden"
|
<DialogHeader className="pl-8">
|
||||||
footer={
|
<DialogTitle>{active.options.title}</DialogTitle>
|
||||||
<>
|
|
||||||
|
{active.options.description && (
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div>{active.options.description}</div>
|
||||||
|
</DialogDescription>)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
{active.kind === 'confirm' && (
|
{active.kind === 'confirm' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -158,8 +169,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
{choice.label}
|
{choice.label}
|
||||||
</Button>))}
|
</Button>))}
|
||||||
</>)}
|
</>)}
|
||||||
</>}/>
|
</DialogFooter>
|
||||||
)}
|
</DialogContent>)}
|
||||||
|
</Dialog>
|
||||||
</DialogueContext.Provider>)
|
</DialogueContext.Provider>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
import FieldError from '@/components/common/FieldError'
|
import FieldError from '@/components/common/FieldError'
|
||||||
@@ -9,10 +9,9 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||||
|
|
||||||
import type { PostImportResetSnapshot,
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
PostImportRow } from '@/lib/postImportSession'
|
|
||||||
|
|
||||||
type Draft = {
|
type Draft = {
|
||||||
url: string
|
url: string
|
||||||
@@ -28,12 +27,8 @@ type Props = {
|
|||||||
row: PostImportRow
|
row: PostImportRow
|
||||||
messageRow?: PostImportRow | null
|
messageRow?: PostImportRow | null
|
||||||
saving: boolean
|
saving: boolean
|
||||||
onCancel: () => void
|
onDraftChange: Dispatch<SetStateAction<Draft | null>>
|
||||||
onSave: (
|
onResetRequestedChange: Dispatch<SetStateAction<boolean>> }
|
||||||
payload: { draft: Draft
|
|
||||||
resetRequested: boolean
|
|
||||||
resetSnapshot: PostImportResetSnapshot },
|
|
||||||
) => Promise<boolean> }
|
|
||||||
|
|
||||||
const buildDraft = (row: PostImportRow): Draft => ({
|
const buildDraft = (row: PostImportRow): Draft => ({
|
||||||
url: row.url,
|
url: row.url,
|
||||||
@@ -70,13 +65,24 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
|
|||||||
|
|
||||||
|
|
||||||
const PostImportRowForm: FC<Props> = (
|
const PostImportRowForm: FC<Props> = (
|
||||||
{ row, messageRow, saving, onCancel, onSave },
|
{ row,
|
||||||
|
messageRow,
|
||||||
|
saving,
|
||||||
|
onDraftChange,
|
||||||
|
onResetRequestedChange },
|
||||||
) => {
|
) => {
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
|
|
||||||
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
|
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
|
||||||
const [resetRequested, setResetRequested] = useState (false)
|
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 displayRow = messageRow ?? row
|
||||||
const resetDraft = buildResetDraft (row)
|
const resetDraft = buildResetDraft (row)
|
||||||
const resetDisabled = saving || sameDraft (draft, resetDraft)
|
const resetDisabled = saving || sameDraft (draft, resetDraft)
|
||||||
@@ -85,7 +91,11 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
key: Key,
|
key: Key,
|
||||||
value: Draft[Key],
|
value: Draft[Key],
|
||||||
) => {
|
) => {
|
||||||
setDraft (current => ({ ...current, [key]: value }))
|
setDraft (current => {
|
||||||
|
const next = { ...current, [key]: value }
|
||||||
|
onDraftChange (next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const reset = async () => {
|
const reset = async () => {
|
||||||
@@ -103,19 +113,18 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
|
|
||||||
setDraft (resetDraft)
|
setDraft (resetDraft)
|
||||||
setResetRequested (true)
|
setResetRequested (true)
|
||||||
}
|
onDraftChange (resetDraft)
|
||||||
|
onResetRequestedChange (true)
|
||||||
const save = async () => {
|
|
||||||
if (await onSave ({
|
|
||||||
draft,
|
|
||||||
resetRequested,
|
|
||||||
resetSnapshot: row.resetSnapshot }))
|
|
||||||
onCancel ()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-6">
|
<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="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<ThumbnailPreview
|
<ThumbnailPreview
|
||||||
@@ -187,11 +196,8 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
<FieldWarning messages={displayRow.baseWarnings}/>
|
<FieldWarning messages={displayRow.baseWarnings}/>
|
||||||
<FieldError messages={displayRow.validationErrors.base}/>
|
<FieldError messages={displayRow.validationErrors.base}/>
|
||||||
<FieldError messages={displayRow.importErrors?.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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -199,19 +205,10 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
disabled={resetDisabled}>
|
disabled={resetDisabled}>
|
||||||
変更をリセット
|
変更をリセット
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</div>
|
||||||
type="button"
|
</div>
|
||||||
variant="outline"
|
</div>
|
||||||
onClick={onCancel}
|
</div>
|
||||||
disabled={saving}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => save ()}
|
|
||||||
disabled={saving}>
|
|
||||||
編輯内容を保存
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
@@ -260,4 +257,6 @@ const PostImportAreaField = (
|
|||||||
</FormField>)
|
</FormField>)
|
||||||
|
|
||||||
export default PostImportRowForm
|
export default PostImportRowForm
|
||||||
|
export { buildDraft }
|
||||||
|
export type { Draft as PostImportRowDraft }
|
||||||
export type { Draft as PostImportRowDraft }
|
export type { Draft as PostImportRowDraft }
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
export { useDialogue } from '@/components/dialogues/DialogueProvider'
|
import { useDialogue as useDialogueFromProvider } from '@/components/dialogues/DialogueProvider'
|
||||||
|
|
||||||
|
export const useDialogue = () => useDialogueFromProvider ()
|
||||||
|
|
||||||
|
export default useDialogue
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import CommonDialogue from '@/components/dialogues/CommonDialogue'
|
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
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 PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiPost } from '@/lib/api'
|
import { apiPost } from '@/lib/api'
|
||||||
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
import { loadPostImportSession,
|
import { loadPostImportSession,
|
||||||
creatableImportRows,
|
creatableImportRows,
|
||||||
@@ -35,6 +35,7 @@ type Props = { user: User | null }
|
|||||||
|
|
||||||
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
|
const dialogue = useDialogue ()
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
const { sessionId } = useParams ()
|
const { sessionId } = useParams ()
|
||||||
const [searchParams, setSearchParams] = useSearchParams ()
|
const [searchParams, setSearchParams] = useSearchParams ()
|
||||||
@@ -44,6 +45,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const [missing, setMissing] = useState (false)
|
const [missing, setMissing] = useState (false)
|
||||||
const [savingRow, setSavingRow] = useState<number | null> (null)
|
const [savingRow, setSavingRow] = useState<number | null> (null)
|
||||||
const [dialogueMessageRow, setDialogueMessageRow] = useState<PostImportRow | 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 (() => {
|
useEffect (() => {
|
||||||
if (sessionId == null)
|
if (sessionId == null)
|
||||||
@@ -63,6 +68,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
}, [session, sessionId])
|
}, [session, sessionId])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
dialogueDraftRef.current = dialogueDraft
|
||||||
|
}, [dialogueDraft])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
dialogueResetRequestedRef.current = dialogueResetRequested
|
||||||
|
}, [dialogueResetRequested])
|
||||||
|
|
||||||
const rows = session?.rows ?? []
|
const rows = session?.rows ?? []
|
||||||
const editingSourceRow = Number (searchParams.get ('edit') ?? '')
|
const editingSourceRow = Number (searchParams.get ('edit') ?? '')
|
||||||
const editingRow =
|
const editingRow =
|
||||||
@@ -98,10 +111,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (editingRow == null)
|
if (editingRow == null)
|
||||||
{
|
{
|
||||||
setDialogueMessageRow (null)
|
setDialogueMessageRow (null)
|
||||||
|
setDialogueDraft (null)
|
||||||
|
setDialogueResetRequested (false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (dialogueMessageRow?.sourceRow !== editingRow.sourceRow)
|
if (dialogueMessageRow?.sourceRow !== editingRow.sourceRow)
|
||||||
setDialogueMessageRow (null)
|
setDialogueMessageRow (null)
|
||||||
|
setDialogueDraft (current =>
|
||||||
|
current != null ? current : buildDraft (editingRow))
|
||||||
|
setDialogueResetRequested (false)
|
||||||
}, [editingRow, dialogueMessageRow])
|
}, [editingRow, dialogueMessageRow])
|
||||||
|
|
||||||
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
||||||
@@ -113,12 +131,13 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
draft: PostImportRowDraft
|
draft: PostImportRowDraft
|
||||||
resetRequested: boolean
|
resetRequested: boolean
|
||||||
resetSnapshot: PostImportRow['resetSnapshot'] },
|
resetSnapshot: PostImportRow['resetSnapshot'] },
|
||||||
): Promise<boolean> => {
|
): Promise<{ saved: boolean
|
||||||
|
row: PostImportRow | null }> => {
|
||||||
if (session == null)
|
if (session == null)
|
||||||
return false
|
return { saved: false, row: null }
|
||||||
|
|
||||||
if (editingRow == null)
|
if (editingRow == null)
|
||||||
return false
|
return { saved: false, row: null }
|
||||||
|
|
||||||
const baseRow =
|
const baseRow =
|
||||||
resetRequested
|
resetRequested
|
||||||
@@ -159,17 +178,17 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (target != null && Object.keys (target.validationErrors).length > 0)
|
if (target != null && Object.keys (target.validationErrors).length > 0)
|
||||||
{
|
{
|
||||||
setDialogueMessageRow (target)
|
setDialogueMessageRow (target)
|
||||||
return false
|
return { saved: false, row: target }
|
||||||
}
|
}
|
||||||
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows))
|
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows))
|
||||||
setDialogueMessageRow (null)
|
setDialogueMessageRow (null)
|
||||||
setSearchParams ({ })
|
setSearchParams ({ })
|
||||||
return true
|
return { saved: true, row: null }
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
toast ({ title: '行の再検証に失敗しました' })
|
toast ({ title: '行の再検証に失敗しました' })
|
||||||
return false
|
return { saved: false, row: null }
|
||||||
}
|
}
|
||||||
finally
|
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 () => {
|
const submit = async () => {
|
||||||
if (sessionId == null || session == null || processable.length === 0)
|
if (sessionId == null || session == null || processable.length === 0)
|
||||||
return
|
return
|
||||||
@@ -315,25 +381,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
onBack={() => navigate ('/posts/import')}
|
onBack={() => navigate ('/posts/import')}
|
||||||
onSubmit={submit}/>
|
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>)}
|
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする