このコミットが含まれているのは:
+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
|
||||
|
||||
@@ -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 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<Props> = ({ children }) => {
|
||||
<DialogueContext.Provider value={api}>
|
||||
{children}
|
||||
|
||||
{active && (
|
||||
<CommonDialogue
|
||||
open={Boolean (active)}
|
||||
onOpenChange={open => {
|
||||
if (!(open))
|
||||
closeActive (active.kind !== 'confirm' && null)
|
||||
}}
|
||||
title={active.options.title}
|
||||
description={active.options.description}
|
||||
bodyClassName="hidden"
|
||||
footer={
|
||||
<>
|
||||
{active.kind === 'confirm' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => closeActive (false)}>
|
||||
{active.options.cancelText ?? '取消'}
|
||||
</Button>
|
||||
<Dialog
|
||||
open={Boolean (active)}
|
||||
onOpenChange={open => {
|
||||
if (!(open))
|
||||
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
|
||||
variant="outline"
|
||||
onClick={() => closeActive (false)}>
|
||||
{active.options.cancelText ?? '取消'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={(active.options.variant === 'danger')
|
||||
? 'destructive'
|
||||
: 'default'}
|
||||
onClick={() => closeActive (true)}>
|
||||
{active.options.confirmText ?? '確定'}
|
||||
</Button>
|
||||
</>)}
|
||||
|
||||
{active.kind === 'alert' && (
|
||||
<Button onClick={() => closeActive ()}>
|
||||
{active.options.okText ?? '確定'}
|
||||
</Button>)}
|
||||
|
||||
{active.kind === 'choice' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => closeActive (null)}>
|
||||
{active.options.cancelText ?? '取消'}
|
||||
</Button>
|
||||
|
||||
{active.options.choices.map (choice => (
|
||||
<Button
|
||||
variant={(active.options.variant === 'danger')
|
||||
key={choice.value}
|
||||
variant={(choice.variant === 'danger')
|
||||
? 'destructive'
|
||||
: 'default'}
|
||||
onClick={() => closeActive (true)}>
|
||||
{active.options.confirmText ?? '確定'}
|
||||
</Button>
|
||||
</>)}
|
||||
|
||||
{active.kind === 'alert' && (
|
||||
<Button onClick={() => closeActive ()}>
|
||||
{active.options.okText ?? '確定'}
|
||||
</Button>)}
|
||||
|
||||
{active.kind === 'choice' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => closeActive (null)}>
|
||||
{active.options.cancelText ?? '取消'}
|
||||
</Button>
|
||||
|
||||
{active.options.choices.map (choice => (
|
||||
<Button
|
||||
key={choice.value}
|
||||
variant={(choice.variant === 'danger')
|
||||
? 'destructive'
|
||||
: 'default'}
|
||||
onClick={() => closeActive (choice.value)}>
|
||||
{choice.label}
|
||||
</Button>))}
|
||||
</>)}
|
||||
</>}/>
|
||||
)}
|
||||
onClick={() => closeActive (choice.value)}>
|
||||
{choice.label}
|
||||
</Button>))}
|
||||
</>)}
|
||||
</DialogFooter>
|
||||
</DialogContent>)}
|
||||
</Dialog>
|
||||
</DialogueContext.Provider>)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean> }
|
||||
row: PostImportRow
|
||||
messageRow?: PostImportRow | null
|
||||
saving: 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,116 +113,103 @@ 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="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<ThumbnailPreview
|
||||
url={draft.thumbnailBase}
|
||||
className="h-28 w-28"/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
投稿 {row.sourceRow} の内容を確認し、必要な項目を編輯してください.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<PostImportTextField
|
||||
label="URL"
|
||||
value={draft.url}
|
||||
warnings={displayRow.fieldWarnings.url}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.url,
|
||||
displayRow.importErrors?.url)}
|
||||
onChange={value => update ('url', value)}/>
|
||||
<PostImportTextField
|
||||
label="タイトル"
|
||||
value={draft.title}
|
||||
warnings={displayRow.fieldWarnings.title}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.title,
|
||||
displayRow.importErrors?.title)}
|
||||
onChange={value => update ('title', value)}/>
|
||||
<PostImportTextField
|
||||
label="サムネール基底 URL"
|
||||
value={draft.thumbnailBase}
|
||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.thumbnailBase,
|
||||
displayRow.importErrors?.thumbnailBase)}
|
||||
onChange={value => update ('thumbnailBase', value)}/>
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom={draft.originalCreatedFrom || null}
|
||||
setOriginalCreatedFrom={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)}/>
|
||||
<PostImportTextField
|
||||
label="動画時間"
|
||||
value={draft.duration}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.duration,
|
||||
displayRow.validationErrors.videoMs,
|
||||
displayRow.importErrors?.duration,
|
||||
displayRow.importErrors?.videoMs)}
|
||||
onChange={value => update ('duration', value)}/>
|
||||
<PostImportAreaField
|
||||
label="タグ"
|
||||
value={draft.tags}
|
||||
warnings={displayRow.fieldWarnings.tags}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.tags,
|
||||
displayRow.importErrors?.tags)}
|
||||
onChange={value => update ('tags', value)}/>
|
||||
<PostImportTextField
|
||||
label="親投稿"
|
||||
value={draft.parentPostIds}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.parentPostIds,
|
||||
displayRow.importErrors?.parentPostIds)}
|
||||
onChange={value => update ('parentPostIds', value)}/>
|
||||
<FieldWarning messages={displayRow.baseWarnings}/>
|
||||
<FieldError messages={displayRow.validationErrors.base}/>
|
||||
<FieldError messages={displayRow.importErrors?.base}/>
|
||||
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<ThumbnailPreview
|
||||
url={draft.thumbnailBase}
|
||||
className="h-28 w-28"/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<PostImportTextField
|
||||
label="URL"
|
||||
value={draft.url}
|
||||
warnings={displayRow.fieldWarnings.url}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.url,
|
||||
displayRow.importErrors?.url)}
|
||||
onChange={value => update ('url', value)}/>
|
||||
<PostImportTextField
|
||||
label="タイトル"
|
||||
value={draft.title}
|
||||
warnings={displayRow.fieldWarnings.title}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.title,
|
||||
displayRow.importErrors?.title)}
|
||||
onChange={value => update ('title', value)}/>
|
||||
<PostImportTextField
|
||||
label="サムネール基底 URL"
|
||||
value={draft.thumbnailBase}
|
||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.thumbnailBase,
|
||||
displayRow.importErrors?.thumbnailBase)}
|
||||
onChange={value => update ('thumbnailBase', value)}/>
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom={draft.originalCreatedFrom || null}
|
||||
setOriginalCreatedFrom={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)}/>
|
||||
<PostImportTextField
|
||||
label="動画時間"
|
||||
value={draft.duration}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.duration,
|
||||
displayRow.validationErrors.videoMs,
|
||||
displayRow.importErrors?.duration,
|
||||
displayRow.importErrors?.videoMs)}
|
||||
onChange={value => update ('duration', value)}/>
|
||||
<PostImportAreaField
|
||||
label="タグ"
|
||||
value={draft.tags}
|
||||
warnings={displayRow.fieldWarnings.tags}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.tags,
|
||||
displayRow.importErrors?.tags)}
|
||||
onChange={value => update ('tags', value)}/>
|
||||
<PostImportTextField
|
||||
label="親投稿"
|
||||
value={draft.parentPostIds}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.parentPostIds,
|
||||
displayRow.importErrors?.parentPostIds)}
|
||||
onChange={value => update ('parentPostIds', value)}/>
|
||||
<FieldWarning messages={displayRow.baseWarnings}/>
|
||||
<FieldError messages={displayRow.validationErrors.base}/>
|
||||
<FieldError messages={displayRow.importErrors?.base}/>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => reset ()}
|
||||
disabled={resetDisabled}>
|
||||
変更をリセット
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => reset ()}
|
||||
disabled={resetDisabled}>
|
||||
変更をリセット
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => save ()}
|
||||
disabled={saving}>
|
||||
編輯内容を保存
|
||||
</Button>
|
||||
</div>
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -260,4 +257,6 @@ const PostImportAreaField = (
|
||||
</FormField>)
|
||||
|
||||
export default PostImportRowForm
|
||||
export { buildDraft }
|
||||
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 { 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>)}
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
新しい課題から参照
ユーザをブロックする