このコミットが含まれているのは:
2026-07-11 21:45:53 +09:00
コミット 08bf92ff79
7個のファイルの変更276行の追加39行の削除
+104 -13
ファイルの表示
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import FieldError from '@/components/common/FieldError'
@@ -26,8 +26,10 @@ type ImportRow = {
attributes: Record<string, string>
warnings: string[]
errors: Record<string, string[]>
provenance: Record<string, 'automatic' | 'mapped' | 'fixed' | 'manual'>
status: 'ready' | 'warning' | 'error'
existing: boolean }
type ParsedImport = { columns: Column[], rows: { sourceRow: number, values: string[] }[] }
const detectFormat = (source: string): 'csv' | 'tsv' =>
@@ -43,28 +45,30 @@ const PostImportPage: FC<Props> = ({ user }) => {
const [format, setFormat] = useState<'csv' | 'tsv' | 'json' | 'google_sheets'> ('tsv')
const [hasHeader, setHasHeader] = useState (false)
const [jsonPath, setJsonPath] = useState ('')
const [urlColumn, setUrlColumn] = useState ('0')
const [urlColumn, setUrlColumn] = useState ('')
const [parsed, setParsed] = useState<ParsedImport | null> (null)
const [columns, setColumns] = useState<Column[]> ([])
const [rows, setRows] = useState<ImportRow[]> ([])
const [mappings, setMappings] = useState<Record<string, { columns: string[] }>> ({ })
const [existing, setExisting] = useState<'skip' | 'error'> ('skip')
const [loading, setLoading] = useState (false)
const validationGeneration = useRef (0)
const editable = canEditContent (user)
const preview = async () => {
const parseSource = async () => {
setLoading (true)
try
{
const data = await apiPost<{ columns: Column[], rows: ImportRow[] }> ('/posts/import/preview', {
const data = await apiPost<ParsedImport> ('/posts/import/parse', {
source,
format,
has_header: hasHeader,
json_path: jsonPath,
url_column: urlColumn,
mappings,
existing })
setColumns (data.columns)
setRows (data.rows)
setParsed (data)
if (data.columns.length === 1)
setUrlColumn ('0')
}
catch
{
@@ -76,9 +80,81 @@ const PostImportPage: FC<Props> = ({ user }) => {
}
}
const updateAttribute = (index: number, field: string, value: string) => {
setRows (current => current.map ((row, rowIndex) =>
rowIndex === index ? { ...row, attributes: { ...row.attributes, [field]: value } } : row))
const preview = async () => {
if (!parsed || urlColumn === '')
return
setLoading (true)
try
{
const data = await apiPost<{ rows: ImportRow[] }> ('/posts/import/preview', {
parsed: { columns: parsed.columns, rows: parsed.rows },
url_column: urlColumn,
mappings,
existing })
setRows (data.rows)
}
catch
{
toast ({ title: '投稿プレビューに失敗しました' })
}
finally
{
setLoading (false)
}
}
const updateAttribute = async (index: number, field: string, value: string) => {
const row = rows[index]
const next = { ...row,
attributes: { ...row.attributes, [field]: value },
provenance: { ...row.provenance, [field]: 'manual' } }
const nextRows = rows.map ((currentRow, rowIndex) => rowIndex === index ? next : currentRow)
setRows (nextRows)
const generation = ++validationGeneration.current
setLoading (true)
try
{
const validated = await apiPost<{ rows: ImportRow[] }> ('/posts/import/validate',
{ rows: nextRows, existing,
changed_row: -1 })
if (generation === validationGeneration.current)
setRows (validated.rows)
}
catch
{
toast ({ title: '行の再検証に失敗しました' })
}
finally
{
if (generation === validationGeneration.current)
setLoading (false)
}
}
const updateURL = async (index: number, value: string) => {
const row = rows[index]
const next = { ...row, url: value, provenance: { ...row.provenance, url: 'manual' } }
const nextRows = rows.map ((currentRow, rowIndex) => rowIndex === index ? next : currentRow)
setRows (nextRows)
const generation = ++validationGeneration.current
setLoading (true)
try
{
const validated = await apiPost<{ rows: ImportRow[] }> ('/posts/import/validate',
{ rows: nextRows, existing,
changed_row: row.sourceRow })
if (generation === validationGeneration.current)
setRows (validated.rows)
}
catch
{
toast ({ title: 'URL の再検証に失敗しました' })
}
finally
{
if (generation === validationGeneration.current)
setLoading (false)
}
}
const submit = async () => {
@@ -147,7 +223,22 @@ const PostImportPage: FC<Props> = ({ user }) => {
{format === 'google_sheets' && <p className="text-sm text-neutral-600 dark:text-neutral-300">
URL 使CSV
</p>}
<Button type="button" onClick={preview} disabled={loading || !(source)}></Button>
<Button type="button" onClick={parseSource} disabled={loading || !(source)}></Button>
</Form>) : parsed && rows.length === 0 ? (
<Form>
<PageTitle></PageTitle>
<p>URL 稿</p>
<label>URL <select value={urlColumn} onChange={ev => setUrlColumn (ev.target.value)}>
<option value=""></option>
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
</select></label>
{['title', 'thumbnail_base', 'original_created_from', 'original_created_before', 'duration', 'tags', 'parent_post_ids'].map (field =>
<label key={field}>{field}<select value={mappings[field]?.columns?.[0] ?? ''}
onChange={ev => setMappings (current => ({ ...current, [field]: { columns: ev.target.value === '' ? [] : [ev.target.value] } }))}>
<option value=""></option>
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
</select></label>)}
<Button type="button" onClick={preview} disabled={loading || urlColumn === ''}>稿</Button>
</Form>) : (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 rounded border p-3 dark:border-neutral-700">
@@ -162,11 +253,11 @@ const PostImportPage: FC<Props> = ({ user }) => {
onChange={ev => setMappings (current => ({ ...current, [field]: { columns: ev.target.value === '' ? [] : [ev.target.value] } }))}>
<option value=""></option>{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
</select></label>)}
<Button type="button" variant="outline" onClick={preview} disabled={loading}></Button>
<Button type="button" variant="outline" onClick={preview} disabled={loading}></Button>
</div>
<div className="space-y-3 md:hidden">{rows.map ((row, index) => <RowCard key={row.sourceRow} row={row} index={index} update={updateAttribute}/>)}</div>
<div className="hidden overflow-x-auto md:block"><table className="w-full text-sm"><thead><tr><th></th><th>URL</th><th></th><th></th><th></th><th></th><th></th><th>稿</th><th></th></tr></thead>
<tbody>{rows.map ((row, index) => <tr key={row.sourceRow} className="border-t dark:border-neutral-700"><td>{row.sourceRow}</td><td><input value={row.url} onChange={ev => { const next = [...rows]; next[index] = { ...row, url: ev.target.value }; setRows (next) }}/></td><td><input value={row.attributes.title ?? ''} onChange={ev => updateAttribute (index, 'title', ev.target.value)}/></td><td>{row.attributes.thumbnailBase && <img src={row.attributes.thumbnailBase} alt="サムネール" className="h-12"/>}</td><td>{row.attributes.originalCreatedFrom ?? ''}</td><td>{row.attributes.duration ?? ''}</td><td><input value={row.attributes.tags ?? ''} onChange={ev => updateAttribute (index, 'tags', ev.target.value)}/></td><td><input value={row.attributes.parentPostIds ?? ''} onChange={ev => updateAttribute (index, 'parent_post_ids', ev.target.value)}/></td><td>{row.status}<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/></td></tr>)}</tbody></table></div>
<tbody>{rows.map ((row, index) => <tr key={row.sourceRow} className="border-t dark:border-neutral-700"><td>{row.sourceRow}</td><td><input value={row.url} onChange={ev => updateURL (index, ev.target.value)}/></td><td><input value={row.attributes.title ?? ''} onChange={ev => updateAttribute (index, 'title', ev.target.value)}/></td><td><input value={row.attributes.thumbnailBase ?? ''} onChange={ev => updateAttribute (index, 'thumbnailBase', ev.target.value)}/></td><td><input value={row.attributes.originalCreatedFrom ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedFrom', ev.target.value)}/><input value={row.attributes.originalCreatedBefore ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedBefore', ev.target.value)}/></td><td><input value={String (row.attributes.duration ?? '')} onChange={ev => updateAttribute (index, 'duration', ev.target.value)}/></td><td><input value={row.attributes.tags ?? ''} onChange={ev => updateAttribute (index, 'tags', ev.target.value)}/></td><td><input value={row.attributes.parentPostIds ?? ''} onChange={ev => updateAttribute (index, 'parentPostIds', ev.target.value)}/></td><td>{row.status}<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/></td></tr>)}</tbody></table></div>
<Button type="button" onClick={submit} disabled={loading}>稿</Button>
</div>)}
</MainArea>)