このコミットが含まれているのは:
@@ -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>)
|
||||
|
||||
新しい課題から参照
ユーザをブロックする