このコミットが含まれているのは:
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import Form from '@/components/common/Form'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiPost } from '@/lib/api'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
type Column = { key: string, label: string }
|
||||
type ImportRow = {
|
||||
sourceRow: number
|
||||
url: string
|
||||
attributes: Record<string, string>
|
||||
warnings: string[]
|
||||
errors: Record<string, string[]>
|
||||
status: 'ready' | 'warning' | 'error'
|
||||
existing: boolean }
|
||||
|
||||
|
||||
const detectFormat = (source: string): 'csv' | 'tsv' =>
|
||||
source.includes ('\t') ? 'tsv' : (source.includes (',') ? 'csv' : 'tsv')
|
||||
|
||||
const publicGoogleSheetURL = (source: string): boolean =>
|
||||
/^https:\/\/docs\.google\.com\/spreadsheets\/d\/e\/[A-Za-z0-9_-]+\/pubhtml#gid=\d+$/.test (
|
||||
source.trim ())
|
||||
|
||||
|
||||
const PostImportPage: FC<Props> = ({ user }) => {
|
||||
const [source, setSource] = useState ('')
|
||||
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 [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 editable = canEditContent (user)
|
||||
|
||||
const preview = async () => {
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const data = await apiPost<{ columns: Column[], rows: ImportRow[] }> ('/posts/import/preview', {
|
||||
source,
|
||||
format,
|
||||
has_header: hasHeader,
|
||||
json_path: jsonPath,
|
||||
url_column: urlColumn,
|
||||
mappings,
|
||||
existing })
|
||||
setColumns (data.columns)
|
||||
setRows (data.rows)
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '読込みに失敗しました', description: '入力形式を確認してください。' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAttribute = (index: number, field: string, value: string) => {
|
||||
setRows (current => current.map ((row, rowIndex) =>
|
||||
rowIndex === index ? { ...row, attributes: { ...row.attributes, [field]: value } } : row))
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const targets = rows.filter (row => row.status !== 'error' && !(row.existing && existing === 'skip'))
|
||||
if (targets.length === 0)
|
||||
return
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const result = await apiPost<{ created: number, skipped: number, failed: number }> ('/posts/import',
|
||||
{ rows: targets })
|
||||
toast ({ title: `登録完了: ${result.created} 件`,
|
||||
description: `スキップ ${result.skipped} 件、失敗 ${result.failed} 件` })
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '登録に失敗しました' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet><title>{`投稿インポート | ${SITE_TITLE}`}</title></Helmet>
|
||||
<PageTitle>投稿インポート</PageTitle>
|
||||
{rows.length === 0 ? (
|
||||
<Form>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
URL を一行に一件ずつ貼り付けるか、CSV・TSV・JSON を入力してください。
|
||||
</p>
|
||||
{publicGoogleSheetURL (source) && (
|
||||
<fieldset className="space-y-1 rounded border p-3 dark:border-neutral-700">
|
||||
<legend>公開 Google スプレッドシート URL</legend>
|
||||
<label className="mr-3"><input type="radio" checked={format === 'google_sheets'}
|
||||
onChange={() => setFormat ('google_sheets')}/>シート内データを読み込む</label>
|
||||
<label><input type="radio" checked={format !== 'google_sheets'}
|
||||
onChange={() => setFormat ('tsv')}/>URL 自体を投稿として扱う</label>
|
||||
</fieldset>)}
|
||||
<FormField label="入力形式">
|
||||
{() => <select value={format} onChange={ev => setFormat (ev.target.value as typeof format)}
|
||||
className={inputClass (false)}>
|
||||
<option value="tsv">テキスト・TSV</option><option value="csv">CSV</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="google_sheets">公開 Google スプレッドシート</option>
|
||||
</select>}
|
||||
</FormField>
|
||||
<FormField label="入力データ">
|
||||
{() => <textarea value={source} rows={12} className={inputClass (false)}
|
||||
onChange={ev => { setSource (ev.target.value); if (format !== 'json' && format !== 'google_sheets') setFormat (detectFormat (ev.target.value)) }}/>}
|
||||
</FormField>
|
||||
<input type="file" accept=".txt,.csv,.tsv,.json" onChange={ev => {
|
||||
const file = ev.target.files?.[0]
|
||||
if (file) file.text ().then (text => { setSource (text); setFormat (file.name.endsWith ('.json') ? 'json' : detectFormat (text)) })
|
||||
}}/>
|
||||
{format !== 'json' && <label className="flex gap-2"><input type="checkbox" checked={hasHeader}
|
||||
onChange={ev => setHasHeader (ev.target.checked)}/>見出し行を使用する</label>}
|
||||
{format === 'json' && <FormField label="投稿候補の位置(例: data.posts)">
|
||||
{() => <input value={jsonPath} onChange={ev => setJsonPath (ev.target.value)} className={inputClass (false)}/>}
|
||||
</FormField>}
|
||||
{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>
|
||||
</Form>) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3 rounded border p-3 dark:border-neutral-700">
|
||||
<label>URL 列 <select value={urlColumn} onChange={ev => setUrlColumn (ev.target.value)}>
|
||||
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
|
||||
</select></label>
|
||||
<label>既存投稿 <select value={existing} onChange={ev => setExisting (ev.target.value as typeof existing)}>
|
||||
<option value="skip">スキップ</option><option value="error">エラー</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" 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>
|
||||
<Button type="button" onClick={submit} disabled={loading}>有効な投稿を一括登録</Button>
|
||||
</div>)}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
const RowCard = ({ row, index, update }: { row: ImportRow, index: number, update: (index: number, field: string, value: string) => void }) => (
|
||||
<article className="space-y-2 rounded border bg-white p-3 text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100">
|
||||
<p>行 {row.sourceRow}・{row.status}</p><input value={row.url} readOnly className={inputClass (false)}/>
|
||||
<label>タイトル<input value={row.attributes.title ?? ''} onChange={ev => update (index, 'title', ev.target.value)} className={inputClass (false)}/></label>
|
||||
<label>タグ<input value={row.attributes.tags ?? ''} onChange={ev => update (index, 'tags', ev.target.value)} className={inputClass (false)}/></label>
|
||||
<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/>
|
||||
</article>)
|
||||
|
||||
export default PostImportPage
|
||||
@@ -49,6 +49,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
const [url, setURL] = useState ('')
|
||||
|
||||
const thumbnailPreviewRef = useRef ('')
|
||||
const urlRef = useRef ('')
|
||||
const videoFlg =
|
||||
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
||||
[tags])
|
||||
@@ -84,11 +85,13 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
}
|
||||
|
||||
const fetchTitle = useCallback (async () => {
|
||||
setTitleLoading (true)
|
||||
const requestedURL = url
|
||||
setTitleLoading (true)
|
||||
try
|
||||
{
|
||||
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
|
||||
setTitle (data.title || '')
|
||||
if (requestedURL === urlRef.current)
|
||||
setTitle (current => current || data.title || '')
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -97,6 +100,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
}, [url])
|
||||
|
||||
const fetchThumbnail = useCallback (async () => {
|
||||
const requestedURL = url
|
||||
setThumbnailPreview ('')
|
||||
setThumbnailFile (null)
|
||||
setThumbnailLoading (true)
|
||||
@@ -107,10 +111,13 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
const data = await apiGet<Blob> ('/preview/thumbnail',
|
||||
{ params: { url }, responseType: 'blob' })
|
||||
const imageURL = URL.createObjectURL (data)
|
||||
setThumbnailPreview (imageURL)
|
||||
setThumbnailFile (new File ([data],
|
||||
'thumbnail.png',
|
||||
{ type: data.type || 'image/png' }))
|
||||
if (requestedURL === urlRef.current)
|
||||
{
|
||||
setThumbnailPreview (current => current || imageURL)
|
||||
setThumbnailFile (current => current || new File ([data],
|
||||
'thumbnail.png',
|
||||
{ type: data.type || 'image/png' }))
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -122,6 +129,20 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
thumbnailPreviewRef.current = thumbnailPreview
|
||||
}, [thumbnailPreview])
|
||||
|
||||
useEffect (() => {
|
||||
urlRef.current = url
|
||||
}, [url])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(url))
|
||||
return
|
||||
const timer = window.setTimeout (() => {
|
||||
fetchTitle ().catch (() => undefined)
|
||||
fetchThumbnail ().catch (() => undefined)
|
||||
}, 500)
|
||||
return () => window.clearTimeout (timer)
|
||||
}, [fetchThumbnail, fetchTitle, url])
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
@@ -159,7 +180,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
onChange={ev => setTitle (ev.target.value)}
|
||||
disabled={titleLoading}/>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>必要なタイミングで URL から取得できます.</span>
|
||||
<span>URL 入力後に自動取得します.</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -176,7 +197,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>必要なタイミングで URL から取得できます.</span>
|
||||
<span>URL 入力後に自動取得します.</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
新しい課題から参照
ユーザをブロックする