広場投稿追加画面の刷新 (#399) #413

マージ済み
みてるぞ が 103 個のコミットを feature/399 から main へマージ 2026-07-19 00:03:11 +09:00
16個のファイルの変更299行の追加345行の削除
コミット 6e5aa1e30f の変更だけを表示してゐます - すべてのコミットを表示
+38
ファイルの表示
@@ -266,6 +266,44 @@ const value =
value selection. Do not replace a clear ternary with `if` statements, and do
not introduce immediately invoked functions just to avoid or reformat a
ternary expression.
- In TypeScript and TSX, multi-stage ternary expressions must make branch
boundaries explicit. Wrap each condition group in parentheses instead of
relying on indentation alone to show which `?` matches which `:`.
- In TypeScript and TSX, wrap nested ternary expressions in parentheses. Do
not write flat vertical chains of `?` and `:` without explicit grouping.
- In TypeScript and TSX, when a ternary condition contains `&&` or `||`, wrap
the whole condition in parentheses before `?`.
- In TypeScript and TSX, if a ternary reaches three or more stages and still
reads poorly after explicit grouping, extract a helper function or use `if`
statements instead of keeping a flat multi-stage ternary.
Bad:
```ts
row.skipReason === 'existing' || row.importStatus === 'skipped'
? 'skipped'
: Object.keys (row.validationErrors ?? { }).length > 0
|| row.importStatus === 'failed'
|| row.importStatus === 'created'
? null
: hasWarnings (row) || row.status === 'warning'
? 'warning'
: 'ready'
```
Good:
```ts
(row.skipReason === 'existing' || row.importStatus === 'skipped')
? 'skipped'
: ((Object.keys (row.validationErrors ?? { }).length > 0
|| row.importStatus === 'failed'
|| row.importStatus === 'created')
? null
: ((hasWarnings (row) || row.status === 'warning')
? 'warning'
: 'ready'))
```
- 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.
+16 -3
ファイルの表示
@@ -55,7 +55,7 @@ class PostImportPreviewer
source = row.symbolize_keys
url = source[:url].to_s.strip
normal_url = normalised_url(url)
source.merge(url_text: url, normal_url:)
source.merge(url_text: url, normal_url:, url_error: validate_url_safety(normal_url))
end
def preview_row row,
@@ -78,6 +78,9 @@ class PostImportPreviewer
validation_errors = {}
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
if row[:url_error].present?
validation_errors[:url] = [row[:url_error]]
end
if normal_url.present? && url_counts[normal_url].to_i > 1
validation_errors[:url] = ['URL が重複しています.']
end
@@ -206,8 +209,7 @@ class PostImportPreviewer
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING)
end
{ data:, warnings: }
rescue Preview::UrlSafety::UnsafeUrl,
Preview::HttpFetcher::FetchFailed,
rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e
Rails.logger.info(
"post_import_metadata_fetch_failure "\
@@ -252,6 +254,7 @@ class PostImportPreviewer
def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
urls = prepared_rows.filter_map { |row|
next unless row[:normal_url].present?
next if row[:url_error].present?
next unless should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
next if url_counts[row[:normal_url]].to_i > 1
next if existing_posts.key?(row[:normal_url])
@@ -339,12 +342,22 @@ class PostImportPreviewer
def should_apply_metadata_to_row? row, fetch_metadata, existing_posts, url_counts
normal_url = row[:normal_url]
return false if normal_url.blank?
return false if row[:url_error].present?
return false if url_counts[normal_url].to_i > 1
return false if existing_posts.key?(normal_url)
should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
end
def validate_url_safety normal_url
return nil if normal_url.blank?
Preview::UrlSafety.validate(normal_url)
nil
rescue Preview::UrlSafety::UnsafeUrl => e
e.message
end
def apply_metadata! attributes, provenance, tag_sources, metadata
metadata.each do |field, value|
if field == 'tags'
+11 -2
ファイルの表示
@@ -46,6 +46,13 @@ class PostImportRunner
end
{ source_row: row['source_row'], status: 'failed', errors: e.record.errors.to_hash }
rescue ActiveRecord::RecordNotUnique
existing_post = existing_post_for_race(row)
raise unless existing_post
{ source_row: row['source_row'],
status: 'skipped',
existing_post_id: existing_post.id }
rescue Tag::NicoTagNormalisationError
{ source_row: row['source_row'],
status: 'failed',
@@ -66,8 +73,10 @@ class PostImportRunner
errors: { base: ['登録中にエラーが発生しました.'] } }
end
def existing_post_for_race row, record
return nil unless record.errors.of_kind?(:url, :taken)
def existing_post_for_race row, record = nil
if record && !(record.errors.of_kind?(:url, :taken))
return nil
end
normal_url = PostUrlNormaliser.normalise(row['url'])
return nil if normal_url.blank?
+4
ファイルの表示
@@ -129,6 +129,10 @@ pass or the remaining failure is clearly blocked.
it is JSX- or React-specific.
- Preserve compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them.
- Multi-stage ternary expressions must use explicit parentheses for each
condition group and nested ternary branch. Do not rely on indentation alone
to show `?` / `:` pairing, and extract a helper or `if` when a three-stage
ternary still reads poorly after grouping.
- Treat TypeScript and TSX formatting rules as hard constraints, not
preferences. Before finishing a TypeScript or TSX edit, inspect the edited
hunks for closing `)`, `]`, and `}` placement and fix violations instead of
+2 -10
ファイルの表示
@@ -2,11 +2,10 @@ import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
import type { FC, ReactNode } from 'react'
import type { FC } from 'react'
type Props = {
disabled?: boolean
labelAddon?: ReactNode
originalCreatedFrom: string | null
setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null
@@ -16,19 +15,12 @@ type Props = {
const PostOriginalCreatedTimeField: FC<Props> = (
{ disabled,
labelAddon,
originalCreatedFrom,
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors }: Props) => (
<FormField
label={
<span className="flex items-center gap-2">
<span></span>
{labelAddon}
</span>}
messages={errors}>
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
-22
ファイルの表示
@@ -1,22 +0,0 @@
import type { FC, ReactNode } from 'react'
type Props = {
summary: ReactNode
actions: ReactNode }
const PageActionFooter: FC<Props> = ({ summary, actions }) => (
<div
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
dark:border-neutral-700 dark:bg-neutral-950/95">
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
{summary}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{actions}
</div>
</div>
</div>)
export default PageActionFooter
+25 -68
ファイルの表示
@@ -2,9 +2,7 @@ import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeFi
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
@@ -17,7 +15,7 @@ import { inputClass } from '@/lib/utils'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import type { PostImportOrigin,
import type { PostImportResetSnapshot,
PostImportRow } from '@/lib/postImportSession'
type Draft = {
@@ -36,37 +34,11 @@ type Props = {
messageRow?: PostImportRow | null
saving: boolean
onOpenChange: (open: boolean) => void
onSave: (draft: Draft) => Promise<boolean> }
const originOf = (
row: PostImportRow,
field: string,
): PostImportOrigin =>
row.provenance[field] ?? 'automatic'
const changedOrigin = (
changed: boolean,
row: PostImportRow,
field: string,
): PostImportOrigin =>
changed ? 'manual' : originOf (row, field)
const originalCreatedOrigin = (
row: PostImportRow,
originalDraft: Draft,
draft: Draft,
): PostImportOrigin => {
const changed =
originalDraft.originalCreatedFrom !== draft.originalCreatedFrom
|| originalDraft.originalCreatedBefore !== draft.originalCreatedBefore
if (changed)
return 'manual'
const manualOrigin =
originOf (row, 'originalCreatedFrom') === 'manual'
|| originOf (row, 'originalCreatedBefore') === 'manual'
return manualOrigin ? 'manual' : 'automatic'
}
onSave: (
payload: { draft: Draft
resetRequested: boolean
resetSnapshot: PostImportResetSnapshot },
) => Promise<boolean> }
const buildDraft = (row: PostImportRow): Draft => ({
url: row.url,
@@ -108,9 +80,6 @@ const PostImportRowDialog: FC<Props> = (
return null
const displayRow = messageRow ?? row
const originalDraft = buildDraft (row)
const fieldOrigin = (field: keyof Draft): PostImportOrigin =>
changedOrigin (draft[field] !== originalDraft[field], row, field)
const update = <Key extends keyof Draft,> (
key: Key,
@@ -121,7 +90,12 @@ const PostImportRowDialog: FC<Props> = (
}
const save = async () => {
if (await onSave (draft))
if (
await onSave ({
draft,
resetRequested: false,
resetSnapshot: row.resetSnapshot })
)
onOpenChange (false)
}
@@ -142,16 +116,12 @@ const PostImportRowDialog: FC<Props> = (
<ThumbnailPreview
url={draft.thumbnailBase}
className="h-28 w-28"/>
<div className="flex flex-wrap gap-2">
<PostImportStatusBadge value={effectivePostImportStatus (displayRow)}/>
</div>
</div>
<div className="space-y-4">
<DialogTextField
label="URL"
value={draft.url}
origin={fieldOrigin ('url')}
warnings={displayRow.fieldWarnings.url}
errors={groupedMessages (displayRow.validationErrors.url,
displayRow.importErrors?.url)}
@@ -159,7 +129,6 @@ const PostImportRowDialog: FC<Props> = (
<DialogTextField
label="タイトル"
value={draft.title}
origin={fieldOrigin ('title')}
warnings={displayRow.fieldWarnings.title}
errors={groupedMessages (displayRow.validationErrors.title,
displayRow.importErrors?.title)}
@@ -167,7 +136,6 @@ const PostImportRowDialog: FC<Props> = (
<DialogTextField
label="サムネール基底 URL"
value={draft.thumbnailBase}
origin={fieldOrigin ('thumbnailBase')}
warnings={displayRow.fieldWarnings.thumbnailBase}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
@@ -175,10 +143,6 @@ const PostImportRowDialog: FC<Props> = (
)}
onChange={value => update ('thumbnailBase', value)}/>
<PostOriginalCreatedTimeField
labelAddon={
<PostImportStatusBadge
value={originalCreatedOrigin (row, originalDraft, draft)}/>
}
originalCreatedFrom={draft.originalCreatedFrom || null}
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
originalCreatedBefore={draft.originalCreatedBefore || null}
@@ -194,7 +158,6 @@ const PostImportRowDialog: FC<Props> = (
<DialogTextField
label="動画時間"
value={draft.duration}
origin={fieldOrigin ('duration')}
errors={groupedMessages (
displayRow.validationErrors.duration,
displayRow.validationErrors.videoMs,
@@ -205,7 +168,6 @@ const PostImportRowDialog: FC<Props> = (
<DialogAreaField
label="タグ"
value={draft.tags}
origin={fieldOrigin ('tags')}
warnings={displayRow.fieldWarnings.tags}
errors={groupedMessages (displayRow.validationErrors.tags,
displayRow.importErrors?.tags)}
@@ -213,7 +175,6 @@ const PostImportRowDialog: FC<Props> = (
<DialogTextField
label="親投稿"
value={draft.parentPostIds}
origin={fieldOrigin ('parentPostIds')}
errors={groupedMessages (
displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds,
@@ -230,7 +191,15 @@ const PostImportRowDialog: FC<Props> = (
<Button
type="button"
variant="outline"
onClick={() => setDraft (buildResetDraft (row))}>
onClick={async () => {
const resetDraft = buildResetDraft (row)
setDraft (resetDraft)
if (await onSave ({
draft: resetDraft,
resetRequested: true,
resetSnapshot: row.resetSnapshot }))
onOpenChange (false)
}}>
</Button>
<Button
@@ -251,15 +220,14 @@ const PostImportRowDialog: FC<Props> = (
}
const DialogTextField = (
{ label, value, origin, warnings, errors, onChange }: {
{ label, value, warnings, errors, onChange }: {
label: string
value: string
origin: PostImportOrigin
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<FormField label={<DialogLabel label={label} origin={origin}/>} messages={errors}>
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<input
@@ -273,15 +241,14 @@ const DialogTextField = (
</FormField>)
const DialogAreaField = (
{ label, value, origin, warnings, errors, onChange }: {
{ label, value, warnings, errors, onChange }: {
label: string
value: string
origin: PostImportOrigin
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<FormField label={<DialogLabel label={label} origin={origin}/>} messages={errors}>
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<textarea
@@ -295,14 +262,4 @@ const DialogAreaField = (
</>)}
</FormField>)
const DialogLabel = (
{ label, origin }: {
label: string
origin: PostImportOrigin },
) => (
<span className="flex items-center gap-2">
<span>{label}</span>
<PostImportStatusBadge value={origin}/>
</span>)
export default PostImportRowDialog
+5 -22
ファイルの表示
@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { importPanelToneForRow } from '@/components/posts/import/postImportTone'
import type { FC } from 'react'
@@ -14,14 +14,6 @@ type Props = {
row: PostImportRow
onEdit: () => void }
const summaryError = (row: PostImportRow): string | null => {
const validation = Object.values (row.validationErrors ?? { }).flat ()[0]
if (validation)
return validation
return Object.values (row.importErrors ?? { }).flat ()[0] ?? null
}
const summaryWarning = (row: PostImportRow): string | null =>
Object.values (row.fieldWarnings ?? { }).flat ()[0]
?? row.baseWarnings?.[0]
@@ -34,9 +26,8 @@ const summaryDate = (row: PostImportRow): string =>
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
const error = summaryError (row)
const warning = error == null ? summaryWarning (row) : null
const displayStatus = effectivePostImportStatus (row)
const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row)
return (
<>
@@ -71,13 +62,9 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
{error && (
<div className="text-xs text-red-700 dark:text-red-200">
{error}
</div>)}
</div>
<div className="space-y-1">
<PostImportStatusBadge value={displayStatus}/>
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="flex justify-end">
<Button
@@ -107,12 +94,8 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
{row.url}
</div>
<div className="flex flex-wrap gap-2">
<PostImportStatusBadge value={displayStatus}/>
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
{error && (
<div className="text-xs text-red-700 dark:text-red-200">
{error}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
+2 -12
ファイルの表示
@@ -11,22 +11,12 @@ type Props = {
const LABELS: Record<PostImportBadgeValue, string> = {
ready: '登録可能',
warning: '警告',
error: '要修正',
created: '登録済み',
skipped: 'スキップ',
failed: '失敗',
automatic: '自動取得',
manual: '手修正' }
skipped: 'スキップ' }
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
ready: 'ready',
warning: 'warning',
error: 'danger',
created: 'info',
skipped: 'skipped',
failed: 'danger',
automatic: 'default',
manual: 'manual' }
skipped: 'skipped' }
const PostImportStatusBadge: FC<Props> = ({ value }) => (
+15 -23
ファイルの表示
@@ -1,37 +1,29 @@
import type { PostImportRow } from '@/lib/postImportSession'
export type PostImportEffectiveStatus =
'created'
| 'error'
| 'failed'
| 'ready'
| 'skipped'
| 'warning'
export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning'
export type PostImportBadgeValue =
PostImportEffectiveStatus
| 'automatic'
| 'manual'
export type PostImportBadgeValue = PostImportDisplayStatus
const hasWarnings = (row: PostImportRow): boolean =>
Object.values (row.fieldWarnings ?? { }).some (_1 => _1.length > 0)
|| row.baseWarnings.length > 0
export const effectivePostImportStatus = (
export const displayPostImportStatus = (
row: PostImportRow,
): PostImportEffectiveStatus => {
if (Object.keys (row.validationErrors ?? { }).length > 0 || row.status === 'error')
return 'error'
): PostImportDisplayStatus | null => {
const skipped =
row.skipReason === 'existing'
|| row.importStatus === 'skipped'
const hidden =
Object.keys (row.validationErrors ?? { }).length > 0
|| row.importStatus === 'failed'
|| row.importStatus === 'created'
switch (row.importStatus)
{
case 'created':
case 'skipped':
case 'failed':
return row.importStatus
default:
if (skipped)
return 'skipped'
if (hidden)
return null
return hasWarnings (row) || row.status === 'warning'
? 'warning'
: 'ready'
}
}
+2 -2
ファイルの表示
@@ -1,4 +1,4 @@
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import type { PostImportRow } from '@/lib/postImportSession'
@@ -33,4 +33,4 @@ export const importPanelToneClass = (
}
export const importPanelToneForRow = (row: PostImportRow): string[] =>
importPanelToneClass (effectivePostImportStatus (row))
importPanelToneClass (displayPostImportStatus (row) ?? 'ready')
+18 -77
ファイルの表示
@@ -7,18 +7,14 @@ const hasSkipReason = (row: PostImportRow): boolean =>
const hasValidationErrors = (row: PostImportRow): boolean =>
Object.keys (row.validationErrors ?? { }).length > 0
const rowChanged = (
previous: PostImportRow,
next: PostImportRow,
): boolean =>
previous.url !== next.url
|| JSON.stringify (previous.attributes) !== JSON.stringify (next.attributes)
|| JSON.stringify (previous.provenance) !== JSON.stringify (next.provenance)
|| JSON.stringify (previous.tagSources ?? { }) !== JSON.stringify (next.tagSources ?? { })
const buildResetSnapshot = (row: PostImportRow) => ({
url: row.url,
attributes: row.attributes })
attributes: { ...row.attributes },
provenance: { ...row.provenance },
tagSources: {
automatic: row.tagSources?.automatic ?? '',
manual: row.tagSources?.manual ?? '' },
metadataUrl: row.metadataUrl })
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
@@ -43,23 +39,14 @@ export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
submittable: rows.filter (row =>
creatableImportRows ([row]).length > 0
&& !(hasValidationErrors (row))).length,
invalid: rows.filter (row => hasValidationErrors (row)).length,
skipPlanned: rows.filter (row =>
hasSkipReason (row)
&& !(hasValidationErrors (row))
&& row.importStatus !== 'created').length,
created: rows.filter (row => row.importStatus === 'created').length,
failed: rows.filter (row => row.importStatus === 'failed').length })
processableImportRows ([row]).length > 0
&& hasSkipReason (row)).length })
export const resultSummaryCounts = (rows: PostImportRow[]) =>
rows.reduce (
(counts, row) => {
if (hasValidationErrors (row))
{
++counts.invalid
return counts
}
if (row.importStatus === 'created')
{
++counts.created
@@ -74,7 +61,7 @@ export const resultSummaryCounts = (rows: PostImportRow[]) =>
++counts.failed
return counts
},
{ created: 0, skipped: 0, failed: 0, invalid: 0 })
{ created: 0, skipped: 0, failed: 0 })
export const mergeValidatedImportRows = (
@@ -83,7 +70,11 @@ export const mergeValidatedImportRows = (
): PostImportRow[] => {
const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
return current.map (previous => {
if (previous.importStatus === 'created')
if (
previous.importStatus === 'created'
|| previous.importStatus === 'skipped'
|| previous.importStatus === 'failed'
)
return previous
const row = validatedMap.get (previous.sourceRow)
@@ -124,59 +115,6 @@ export const mergeValidatedImportRows = (
}
export const mergePreviewImportRows = (
current: PostImportRow[],
preview: PostImportRow[],
): PostImportRow[] => {
const currentMap = new Map (current.map (row => [row.sourceRow, row]))
return preview.map (row => {
const previous = currentMap.get (row.sourceRow)
if (previous == null)
return row
if (previous.importStatus === 'created')
return previous
const mergedAttributes = { ...row.attributes }
const mergedProvenance = { ...row.provenance }
for (const [field, origin] of Object.entries (previous.provenance))
{
if (origin !== 'manual' || field === 'url')
continue
if (field in previous.attributes)
mergedAttributes[field] = previous.attributes[field]
mergedProvenance[field] = 'manual'
}
const mergedTagSources =
previous.provenance.tags === 'manual'
? {
automatic: row.tagSources?.automatic ?? '',
manual: previous.tagSources?.manual ?? String (previous.attributes.tags ?? '') }
: row.tagSources
const nextRow = {
...row,
attributes: mergedAttributes,
provenance: mergedProvenance,
tagSources: mergedTagSources,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
resetSnapshot: row.resetSnapshot,
createdPostId: previous.createdPostId,
importStatus: previous.importStatus,
importErrors: previous.importErrors }
if (rowChanged (previous, nextRow))
{
nextRow.importStatus = 'pending'
nextRow.importErrors = undefined
}
return nextRow
})
}
export const mergeImportResults = (
rows: PostImportRow[],
results: PostImportResultRow[],
@@ -193,13 +131,15 @@ export const mergeImportResults = (
return {
...row,
importStatus: 'created',
createdPostId: result.post?.id,
skipReason: undefined,
createdPostId: result.post.id,
existingPostId: undefined,
importErrors: result.errors }
case 'skipped':
return {
...row,
importStatus: 'skipped',
skipReason: 'existing',
createdPostId: undefined,
existingPostId: result.existingPostId,
importErrors: result.errors }
@@ -207,6 +147,7 @@ export const mergeImportResults = (
return {
...row,
importStatus: 'failed',
skipReason: undefined,
createdPostId: undefined,
existingPostId: undefined,
importErrors: result.errors }
+18 -1
ファイルの表示
@@ -150,10 +150,27 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
if (!(Object.values (value.attributes).every (entry =>
typeof entry === 'string' || typeof entry === 'number')))
return null
if (!(isPlainObject (value.provenance)))
return null
if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS)))
return null
if (!(Object.values (value.provenance).every (origin => isValidOrigin (origin))))
return null
if (!(isPlainObject (value.tagSources)))
return null
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string')))
return null
if (value.metadataUrl != null && typeof value.metadataUrl !== 'string')
return null
return {
url: value.url,
attributes: value.attributes as Record<string, string | number> }
attributes: value.attributes as Record<string, string | number>,
provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string>,
metadataUrl: value.metadataUrl as string | undefined }
}
+17 -5
ファイルの表示
@@ -11,7 +11,10 @@ export type PostImportAttributeValue = string | number
export type PostImportResetSnapshot = {
url: string
attributes: Record<string, PostImportAttributeValue> }
attributes: Record<string, PostImportAttributeValue>
provenance: Record<string, PostImportOrigin>
tagSources: Record<PostImportOrigin, string>
metadataUrl?: string }
export type PostImportRow = {
sourceRow: number
@@ -31,11 +34,20 @@ export type PostImportRow = {
createdPostId?: number
importStatus?: PostImportStatus }
export type PostImportResultRow = {
export type PostImportResultRow =
| {
sourceRow: number
status: PostImportResultStatus
post?: { id: number }
existingPostId?: number
status: 'created'
post: { id: number }
errors?: Record<string, string[]> }
| {
sourceRow: number
status: 'skipped'
existingPostId: number
errors?: Record<string, string[]> }
| {
sourceRow: number
status: 'failed'
errors?: Record<string, string[]> }
export type PostImportSession = {
+27 -20
ファイルの表示
@@ -8,7 +8,7 @@ import PageTitle from '@/components/common/PageTitle'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { importPanelToneClass } from '@/components/posts/import/postImportTone'
import PostImportSummaryChip from '@/components/posts/import/PostImportSummaryChip'
import { Button } from '@/components/ui/button'
@@ -35,9 +35,8 @@ import type { User } from '@/types'
type Props = { user: User | null }
const rowMessages = (row: PostImportRow): string[] => [
...Object.values (row.validationErrors ?? { }).flat (),
...Object.values (row.importErrors ?? { }).flat ()]
const rowMessages = (row: PostImportRow): string[] =>
Object.values (row.importErrors ?? { }).flat ()
const PostImportResultPage: FC<Props> = ({ user }) => {
@@ -94,15 +93,22 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const validatedRows = mergeValidatedImportRows (
pendingRows,
initialisePreviewRows (validated.rows))
setSession (current =>
current
? { ...current, rows: validatedRows }
: current)
const nextSession = {
...session,
rows: validatedRows,
repairMode: 'failed' as const }
setSession (nextSession)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null)
return
if (Object.keys (target.validationErrors ?? { }).length > 0)
{
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
return
}
const result = await apiPost<{
created: number
@@ -174,36 +180,37 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
dark:bg-neutral-900">
<div className="flex flex-wrap gap-2">
<PostImportSummaryChip label="登録成功" value={counts.created} badge="created"/>
<PostImportSummaryChip label="登録成功" value={counts.created}/>
<PostImportSummaryChip label="スキップ" value={counts.skipped} badge="skipped"/>
<PostImportSummaryChip label="失敗" value={counts.failed} badge="failed"/>
<PostImportSummaryChip label="要修正" value={counts.invalid} badge="error"/>
<PostImportSummaryChip label="失敗" value={counts.failed}/>
</div>
</div>
<div className="space-y-3">
{session.rows.map (row => {
const displayStatus = effectivePostImportStatus (row)
const canEdit =
Object.keys (row.validationErrors).length > 0
|| row.importStatus === 'failed'
const displayStatus = displayPostImportStatus (row)
const canEdit = row.importStatus === 'failed'
const canRetry =
row.importStatus === 'created'
? false
: row.importStatus === 'failed'
row.importStatus === 'failed'
&& Object.keys (row.validationErrors).length === 0
const panelStatus =
row.importStatus === 'created'
? 'created'
: (row.importStatus === 'failed'
? 'failed'
: displayStatus ?? 'ready')
return (
<div
key={row.sourceRow}
className={[
'rounded-lg border p-4',
...importPanelToneClass (displayStatus)].join (' ')}>
...importPanelToneClass (panelStatus)].join (' ')}>
<div className="flex flex-col gap-3 md:flex-row md:items-start
md:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium"> {row.sourceRow}</span>
<PostImportStatusBadge value={displayStatus}/>
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="text-sm text-neutral-700 dark:text-neutral-200">
{String (row.attributes.title ?? '') || row.url}
+78 -57
ファイルの表示
@@ -6,7 +6,6 @@ import { useNavigate,
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PageActionFooter from '@/components/posts/import/PageActionFooter'
import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog'
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
@@ -120,57 +119,35 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
setSession (current =>
current ? { ...current, rows: nextRows } : current)
const saveDraft = async (draft: Draft): Promise<boolean> => {
const saveDraft = async (
{ draft, resetRequested, resetSnapshot }: {
draft: Draft
resetRequested: boolean
resetSnapshot: PostImportRow['resetSnapshot'] },
): Promise<boolean> => {
if (session == null)
return false
if (editingRow == null)
return false
const urlChanged = draft.url !== editingRow.url
const nextProvenance = { ...editingRow.provenance }
const nextAttributes = { ...editingRow.attributes }
const nextTagSources = {
automatic: editingRow.tagSources?.automatic ?? '',
manual: editingRow.tagSources?.manual ?? '' }
const draftFields = [
['title', draft.title],
['thumbnailBase', draft.thumbnailBase],
['originalCreatedFrom', draft.originalCreatedFrom],
['originalCreatedBefore', draft.originalCreatedBefore],
['duration', draft.duration],
['parentPostIds', draft.parentPostIds]] as const
draftFields.forEach (([field, value]) => {
nextAttributes[field] = value
nextProvenance[field] =
value !== String (editingRow.attributes[field] ?? '')
? 'manual'
: (editingRow.provenance[field] ?? 'automatic')
})
nextAttributes.tags = draft.tags
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
{
nextProvenance.tags = 'manual'
nextTagSources.manual = draft.tags
}
else
{
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
}
const nextRow =
resetRequested
? {
...editingRow,
url: resetSnapshot.url,
attributes: { ...resetSnapshot.attributes },
provenance: { ...resetSnapshot.provenance },
tagSources: { ...resetSnapshot.tagSources },
metadataUrl: resetSnapshot.metadataUrl,
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
importErrors: undefined }
: buildNextEditedRow (editingRow, draft, urlChanged)
setSavingRow (editingRow.sourceRow)
const nextRows = session.rows.map (row =>
row.sourceRow === editingRow.sourceRow
? {
...row,
url: draft.url,
attributes: nextAttributes,
provenance: {
...nextProvenance,
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
tagSources: nextTagSources,
importStatus: row.importStatus === 'created' ? 'created' : 'pending',
importErrors: undefined }
? nextRow
: row)
try
{
@@ -300,7 +277,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<div className="flex flex-wrap items-center gap-2">
<PostImportSummaryChip label="全件" value={counts.total}/>
<PostImportSummaryChip label="登録対象" value={counts.submittable} badge="ready"/>
<PostImportSummaryChip label="要確認" value={counts.invalid} badge="error"/>
<PostImportSummaryChip label="スキップ予定" value={counts.skipPlanned}
badge="skipped"/>
</div>
@@ -321,7 +297,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<PostImportFooter
loading={loading}
invalidCount={counts.invalid}
processableCount={processable.length}
creatableCount={creatable.length}
skipPlannedCount={counts.skipPlanned}
@@ -343,34 +318,31 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const PostImportFooter = (
{ loading,
invalidCount,
processableCount,
creatableCount,
skipPlannedCount,
onBack,
onSubmit }: {
loading: boolean
invalidCount: number
processableCount: number
creatableCount: number
skipPlannedCount: number
onBack: () => void
onSubmit: () => void },
) => (
<PageActionFooter
summary={
<>
<div
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
dark:border-neutral-700 dark:bg-neutral-950/95">
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> {processableCount} </span>
<PostImportStatusBadge value="ready"/>
<span> {creatableCount} </span>
<PostImportStatusBadge value="skipped"/>
<span> {skipPlannedCount} </span>
<PostImportStatusBadge value="error"/>
<span> {invalidCount} </span>
</>
}
actions={
<>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={onBack}>
URL
</Button>
@@ -380,7 +352,56 @@ const PostImportFooter = (
disabled={loading || processableCount === 0}>
</Button>
</>
}/>)
</div>
</div>
</div>)
const buildNextEditedRow = (
editingRow: PostImportRow,
draft: Draft,
urlChanged: boolean,
): PostImportRow => {
const nextProvenance = { ...editingRow.provenance }
const nextAttributes = { ...editingRow.attributes }
const nextTagSources = {
automatic: editingRow.tagSources?.automatic ?? '',
manual: editingRow.tagSources?.manual ?? '' }
const draftFields = [
['title', draft.title],
['thumbnailBase', draft.thumbnailBase],
['originalCreatedFrom', draft.originalCreatedFrom],
['originalCreatedBefore', draft.originalCreatedBefore],
['duration', draft.duration],
['parentPostIds', draft.parentPostIds]] as const
draftFields.forEach (([field, value]) => {
nextAttributes[field] = value
nextProvenance[field] =
value !== String (editingRow.attributes[field] ?? '')
? 'manual'
: (editingRow.provenance[field] ?? 'automatic')
})
nextAttributes.tags = draft.tags
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
{
nextProvenance.tags = 'manual'
nextTagSources.manual = draft.tags
}
else
{
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
}
return {
...editingRow,
url: draft.url,
attributes: nextAttributes,
provenance: {
...nextProvenance,
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
tagSources: nextTagSources,
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
importErrors: undefined }
}
export default PostImportReviewPage