このコミットが含まれているのは:
+9
-3
@@ -41,9 +41,11 @@ import NotFound from '@/pages/NotFound'
|
||||
import TOSPage from '@/pages/TOSPage.mdx'
|
||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
||||
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
||||
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
|
||||
import PostListPage from '@/pages/posts/PostListPage'
|
||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||
import PostImportPage from '@/pages/posts/PostImportPage'
|
||||
import PostSearchPage from '@/pages/posts/PostSearchPage'
|
||||
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||
import SettingPage from '@/pages/users/SettingPage'
|
||||
@@ -76,7 +78,9 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||
<Route path="/posts" element={<PostListPage/>}/>
|
||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||
<Route path="/posts/import" element={<PostImportPage user={user}/>}/>
|
||||
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
|
||||
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
|
||||
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
|
||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||
@@ -115,7 +119,9 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||
<Route path="/posts" element={<PostListPage/>}/>
|
||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||
<Route path="/posts/import" element={<PostImportPage user={user}/>}/>
|
||||
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
|
||||
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
|
||||
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
|
||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle } from '@/components/ui/dialog'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { PostImportOrigin,
|
||||
PostImportRow } from '@/lib/postImportSession'
|
||||
|
||||
type Draft = {
|
||||
url: string
|
||||
title: string
|
||||
thumbnailBase: string
|
||||
originalCreatedFrom: string
|
||||
originalCreatedBefore: string
|
||||
duration: string
|
||||
tags: string
|
||||
parentPostIds: string }
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
row: 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 buildDraft = (row: PostImportRow): Draft => ({
|
||||
url: row.url,
|
||||
title: String (row.attributes.title ?? ''),
|
||||
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
|
||||
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
|
||||
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
|
||||
duration: String (row.attributes.duration ?? ''),
|
||||
tags: String (row.attributes.tags ?? ''),
|
||||
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
||||
|
||||
|
||||
const PostImportRowDialog: FC<Props> = (
|
||||
{ open, row, saving, onOpenChange, onSave },
|
||||
) => {
|
||||
const [draft, setDraft] = useState<Draft | null> (null)
|
||||
|
||||
useEffect (() => {
|
||||
if (open && row)
|
||||
setDraft (buildDraft (row))
|
||||
}, [open, row])
|
||||
|
||||
if (!(row) || !(draft))
|
||||
return null
|
||||
|
||||
const update = <Key extends keyof Draft,> (
|
||||
key: Key,
|
||||
value: Draft[Key],
|
||||
) => {
|
||||
setDraft (current =>
|
||||
current ? { ...current, [key]: value } : current)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (await onSave (draft))
|
||||
onOpenChange (false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl px-6 pb-6 pt-7">
|
||||
<DialogHeader className="pl-8">
|
||||
<DialogTitle>投稿 {row.sourceRow} を編集</DialogTitle>
|
||||
<DialogDescription>
|
||||
自動取得結果を確認し、必要な項目だけ修正してください.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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 className="flex flex-wrap gap-2">
|
||||
<PostImportStatusBadge value={row.status}/>
|
||||
<PostImportStatusBadge value={row.importStatus ?? 'pending'}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<DialogField
|
||||
label="URL"
|
||||
value={draft.url}
|
||||
origin={originOf (row, 'url')}
|
||||
onChange={value => update ('url', value)}/>
|
||||
<DialogField
|
||||
label="タイトル"
|
||||
value={draft.title}
|
||||
origin={originOf (row, 'title')}
|
||||
onChange={value => update ('title', value)}/>
|
||||
<DialogField
|
||||
label="サムネール基底 URL"
|
||||
value={draft.thumbnailBase}
|
||||
origin={originOf (row, 'thumbnailBase')}
|
||||
onChange={value => update ('thumbnailBase', value)}/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DialogField
|
||||
label="作成日時 From"
|
||||
value={draft.originalCreatedFrom}
|
||||
origin={originOf (row, 'originalCreatedFrom')}
|
||||
onChange={value => update ('originalCreatedFrom', value)}/>
|
||||
<DialogField
|
||||
label="作成日時 Before"
|
||||
value={draft.originalCreatedBefore}
|
||||
origin={originOf (row, 'originalCreatedBefore')}
|
||||
onChange={value => update ('originalCreatedBefore', value)}/>
|
||||
</div>
|
||||
<DialogField
|
||||
label="動画時間"
|
||||
value={draft.duration}
|
||||
origin={originOf (row, 'duration')}
|
||||
onChange={value => update ('duration', value)}/>
|
||||
<DialogArea
|
||||
label="タグ"
|
||||
value={draft.tags}
|
||||
origin={originOf (row, 'tags')}
|
||||
onChange={value => update ('tags', value)}/>
|
||||
<DialogField
|
||||
label="親投稿"
|
||||
value={draft.parentPostIds}
|
||||
origin={originOf (row, 'parentPostIds')}
|
||||
onChange={value => update ('parentPostIds', value)}/>
|
||||
<FieldError messages={Object.values (row.validationErrors ?? { }).flat ()}/>
|
||||
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange (false)}>
|
||||
取消し
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>)
|
||||
}
|
||||
|
||||
const DialogField = (
|
||||
{ label, value, origin, onChange }: {
|
||||
label: string
|
||||
value: string
|
||||
origin: PostImportOrigin
|
||||
onChange: (value: string) => void },
|
||||
) => (
|
||||
<label className="block space-y-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
{label}
|
||||
<PostImportStatusBadge value={origin}/>
|
||||
</span>
|
||||
<input
|
||||
value={value}
|
||||
onChange={ev => onChange (ev.target.value)}
|
||||
className={inputClass (false)}/>
|
||||
</label>)
|
||||
|
||||
const DialogArea = (
|
||||
{ label, value, origin, onChange }: {
|
||||
label: string
|
||||
value: string
|
||||
origin: PostImportOrigin
|
||||
onChange: (value: string) => void },
|
||||
) => (
|
||||
<label className="block space-y-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
{label}
|
||||
<PostImportStatusBadge value={origin}/>
|
||||
</span>
|
||||
<textarea
|
||||
value={value}
|
||||
rows={4}
|
||||
onChange={ev => onChange (ev.target.value)}
|
||||
className={inputClass (false)}/>
|
||||
</label>)
|
||||
|
||||
export default PostImportRowDialog
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
|
||||
type Props = {
|
||||
row: PostImportRow
|
||||
onEdit: () => void }
|
||||
|
||||
const toneClass = (row: PostImportRow): string[] => {
|
||||
const value = row.importStatus ?? row.status
|
||||
switch (value)
|
||||
{
|
||||
case 'created':
|
||||
return [
|
||||
'border-sky-200 bg-sky-50',
|
||||
'dark:border-sky-900 dark:bg-sky-950/30']
|
||||
case 'skipped':
|
||||
return [
|
||||
'border-stone-200 bg-stone-50',
|
||||
'dark:border-stone-800 dark:bg-stone-900/60']
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return [
|
||||
'border-rose-200 bg-rose-50',
|
||||
'dark:border-rose-900 dark:bg-rose-950/30']
|
||||
case 'warning':
|
||||
return [
|
||||
'border-amber-200 bg-amber-50',
|
||||
'dark:border-amber-900 dark:bg-amber-950/30']
|
||||
default:
|
||||
return [
|
||||
'border-border bg-white',
|
||||
'dark:border-neutral-700 dark:bg-neutral-900']
|
||||
}
|
||||
}
|
||||
|
||||
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 summaryDate = (row: PostImportRow): string =>
|
||||
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore]
|
||||
.filter (_1 => typeof _1 === 'string' && _1 !== '')
|
||||
.join (' ~ ')
|
||||
|
||||
|
||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||
const error = summaryError (row)
|
||||
const importStatus = row.importStatus ?? 'pending'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn (
|
||||
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
|
||||
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto_auto]',
|
||||
toneClass (row),
|
||||
'hover:bg-slate-50',
|
||||
'dark:hover:bg-neutral-800',
|
||||
)}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
||||
<PostImportStatusBadge value={row.status}/>
|
||||
</div>
|
||||
<ThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-16 w-16"/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{String (row.attributes.tags ?? '') || 'タグなし'}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row) || '日時未取得'}
|
||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<PostImportStatusBadge value={importStatus}/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{error
|
||||
? <PostImportStatusBadge value="error"/>
|
||||
: <PostImportStatusBadge value="ready"/>}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={row.importStatus === 'created'}>
|
||||
編集
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn (
|
||||
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||
toneClass (row),
|
||||
)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<ThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-20 w-20 shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PostImportStatusBadge value={row.status}/>
|
||||
<PostImportStatusBadge value={importStatus}/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-red-700 dark:text-red-200">
|
||||
{error}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={row.importStatus === 'created'}>
|
||||
編集
|
||||
</Button>
|
||||
</div>
|
||||
</>)
|
||||
}
|
||||
|
||||
export default PostImportRowSummary
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportOrigin } from '@/lib/postImportSession'
|
||||
|
||||
type BadgeValue =
|
||||
'ready'
|
||||
| 'warning'
|
||||
| 'error'
|
||||
| 'pending'
|
||||
| 'created'
|
||||
| 'skipped'
|
||||
| 'failed'
|
||||
| PostImportOrigin
|
||||
|
||||
type Props = {
|
||||
value: BadgeValue }
|
||||
|
||||
const LABELS: Record<BadgeValue, string> = {
|
||||
ready: '登録可能',
|
||||
warning: '警告',
|
||||
error: '要修正',
|
||||
pending: '未登録',
|
||||
created: '登録済み',
|
||||
skipped: 'スキップ',
|
||||
failed: '失敗',
|
||||
automatic: '自動取得',
|
||||
manual: '手修正' }
|
||||
|
||||
const STYLES: Record<BadgeValue, string[]> = {
|
||||
ready: [
|
||||
'border-emerald-300 bg-emerald-50 text-emerald-700',
|
||||
'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'],
|
||||
warning: [
|
||||
'border-amber-300 bg-amber-50 text-amber-700',
|
||||
'dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'],
|
||||
error: [
|
||||
'border-red-300 bg-red-50 text-red-700',
|
||||
'dark:border-red-900 dark:bg-red-950 dark:text-red-200'],
|
||||
pending: [
|
||||
'border-slate-300 bg-slate-50 text-slate-700',
|
||||
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
|
||||
created: [
|
||||
'border-sky-300 bg-sky-50 text-sky-700',
|
||||
'dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200'],
|
||||
skipped: [
|
||||
'border-stone-300 bg-stone-50 text-stone-700',
|
||||
'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'],
|
||||
failed: [
|
||||
'border-rose-300 bg-rose-50 text-rose-700',
|
||||
'dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200'],
|
||||
automatic: [
|
||||
'border-slate-300 bg-slate-50 text-slate-700',
|
||||
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
|
||||
manual: [
|
||||
'border-purple-300 bg-purple-50 text-purple-700',
|
||||
'dark:border-purple-900 dark:bg-purple-950 dark:text-purple-200'] }
|
||||
|
||||
|
||||
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
||||
<span
|
||||
className={cn (
|
||||
'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||
STYLES[value],
|
||||
)}>
|
||||
{LABELS[value]}
|
||||
</span>)
|
||||
|
||||
export default PostImportStatusBadge
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
alt?: string
|
||||
className?: string }
|
||||
|
||||
|
||||
const ThumbnailPreview: FC<Props> = ({ url, alt = 'サムネール', className = 'h-16 w-16' }) => {
|
||||
const [failed, setFailed] = useState (false)
|
||||
|
||||
useEffect (() => {
|
||||
setFailed (false)
|
||||
}, [url])
|
||||
|
||||
if (!(url))
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-border bg-muted text-xs text-muted-foreground`}>
|
||||
なし
|
||||
</div>)
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-red-300 bg-red-50 p-2 text-center text-xs text-red-700
|
||||
dark:border-red-900 dark:bg-red-950 dark:text-red-200`}>
|
||||
表示不可
|
||||
</div>)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
className={`${ className } rounded border border-border object-cover`}
|
||||
onError={() => setFailed (true)}/>)
|
||||
}
|
||||
|
||||
export default ThumbnailPreview
|
||||
@@ -0,0 +1,221 @@
|
||||
export type PostImportOrigin = 'automatic' | 'manual'
|
||||
export type PostImportRepairMode = 'all' | 'failed'
|
||||
export type PostImportStatus =
|
||||
'pending'
|
||||
| 'created'
|
||||
| 'skipped'
|
||||
| 'failed'
|
||||
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
||||
export type PostImportAttributeValue = string | number
|
||||
|
||||
export type PostImportRow = {
|
||||
sourceRow: number
|
||||
url: string
|
||||
attributes: Record<string, PostImportAttributeValue>
|
||||
warnings: string[]
|
||||
validationErrors: Record<string, string[]>
|
||||
importErrors?: Record<string, string[]>
|
||||
provenance: Record<string, PostImportOrigin>
|
||||
tagSources?: Record<PostImportOrigin, string>
|
||||
status: 'ready' | 'warning' | 'error'
|
||||
existing: boolean
|
||||
metadataUrl?: string
|
||||
createdPostId?: number
|
||||
importStatus?: PostImportStatus }
|
||||
|
||||
export type PostImportResultRow = {
|
||||
sourceRow: number
|
||||
status: PostImportResultStatus
|
||||
post?: { id: number }
|
||||
errors?: Record<string, string[]> }
|
||||
|
||||
export type PostImportSession = {
|
||||
version: number
|
||||
savedAt: string
|
||||
source: string
|
||||
existing: 'skip' | 'error'
|
||||
rows: PostImportRow[]
|
||||
repairMode: PostImportRepairMode }
|
||||
|
||||
const SESSION_VERSION = 1
|
||||
const SESSION_PREFIX = 'post-import-session:'
|
||||
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
|
||||
|
||||
|
||||
export const createPostImportSessionId = (): string =>
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID ()
|
||||
: `${ Date.now () }-${ Math.random ().toString (36).slice (2) }`
|
||||
|
||||
|
||||
export const loadPostImportSourceDraft = (): {
|
||||
source: string
|
||||
existing: 'skip' | 'error' } => {
|
||||
if (typeof window === 'undefined')
|
||||
return { source: '', existing: 'skip' }
|
||||
|
||||
try
|
||||
{
|
||||
const raw = sessionStorage.getItem (SOURCE_DRAFT_KEY)
|
||||
if (raw == null)
|
||||
return { source: '', existing: 'skip' }
|
||||
|
||||
const value = JSON.parse (raw) as {
|
||||
source?: string
|
||||
existing?: 'skip' | 'error' }
|
||||
return {
|
||||
source: typeof value.source === 'string' ? value.source : '',
|
||||
existing: value.existing === 'error' ? 'error' : 'skip' }
|
||||
}
|
||||
catch
|
||||
{
|
||||
return { source: '', existing: 'skip' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const savePostImportSourceDraft = (
|
||||
source: string,
|
||||
existing: 'skip' | 'error',
|
||||
) => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
|
||||
sessionStorage.setItem (SOURCE_DRAFT_KEY, JSON.stringify ({ source, existing }))
|
||||
}
|
||||
|
||||
|
||||
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
|
||||
|
||||
|
||||
export const savePostImportSession = (
|
||||
sessionId: string,
|
||||
session: Omit<PostImportSession, 'version' | 'savedAt'>,
|
||||
) => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
|
||||
sessionStorage.setItem (sessionKey (sessionId), JSON.stringify ({
|
||||
...session,
|
||||
version: SESSION_VERSION,
|
||||
savedAt: new Date ().toISOString () }))
|
||||
}
|
||||
|
||||
|
||||
export const loadPostImportSession = (
|
||||
sessionId: string,
|
||||
): PostImportSession | null => {
|
||||
if (typeof window === 'undefined')
|
||||
return null
|
||||
|
||||
try
|
||||
{
|
||||
const raw = sessionStorage.getItem (sessionKey (sessionId))
|
||||
if (raw == null)
|
||||
return null
|
||||
|
||||
const value = JSON.parse (raw) as Partial<PostImportSession>
|
||||
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
|
||||
return null
|
||||
|
||||
return {
|
||||
version: SESSION_VERSION,
|
||||
savedAt: typeof value.savedAt === 'string' ? value.savedAt : '',
|
||||
source: typeof value.source === 'string' ? value.source : '',
|
||||
existing: value.existing === 'error' ? 'error' : 'skip',
|
||||
rows: value.rows as PostImportRow[],
|
||||
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const mergeValidatedImportRows = (
|
||||
current: PostImportRow[],
|
||||
validated: PostImportRow[],
|
||||
): PostImportRow[] =>
|
||||
current
|
||||
.map (previous => {
|
||||
if (previous.importStatus === 'created')
|
||||
return previous
|
||||
|
||||
const row = validated.find (_1 => _1.sourceRow === previous.sourceRow)
|
||||
return row
|
||||
? {
|
||||
...row,
|
||||
importStatus: previous.importStatus,
|
||||
createdPostId: previous.createdPostId,
|
||||
importErrors: previous.importErrors,
|
||||
validationErrors: row.validationErrors ?? { } }
|
||||
: previous
|
||||
})
|
||||
.concat (validated.filter (row =>
|
||||
!(current.some (_1 => _1.sourceRow === row.sourceRow))))
|
||||
|
||||
|
||||
export const mergeImportResults = (
|
||||
rows: PostImportRow[],
|
||||
results: PostImportResultRow[],
|
||||
): PostImportRow[] =>
|
||||
rows.map (row => {
|
||||
const result = results.find (_1 => _1.sourceRow === row.sourceRow)
|
||||
return result
|
||||
? {
|
||||
...row,
|
||||
importStatus: result.status,
|
||||
createdPostId: result.post?.id,
|
||||
importErrors: result.errors,
|
||||
validationErrors: row.validationErrors }
|
||||
: row
|
||||
})
|
||||
|
||||
|
||||
export const retryImportRow = (
|
||||
rows: PostImportRow[],
|
||||
sourceRow: number,
|
||||
): PostImportRow[] =>
|
||||
rows.map (row =>
|
||||
row.sourceRow === sourceRow && row.importStatus === 'failed'
|
||||
? { ...row, importStatus: 'pending', importErrors: undefined }
|
||||
: row)
|
||||
|
||||
|
||||
export const rowMessages = (row: PostImportRow): string[] => [
|
||||
...row.warnings,
|
||||
...Object.values (row.validationErrors ?? { }).flat (),
|
||||
...Object.values (row.importErrors ?? { }).flat ()]
|
||||
|
||||
|
||||
export const countImportSourceLines = (source: string): number =>
|
||||
source
|
||||
.split (/\r\n|\n|\r/)
|
||||
.map (_1 => _1.trim ())
|
||||
.filter (_1 => _1 !== '')
|
||||
.length
|
||||
|
||||
|
||||
export const countImportSourceValidLines = (source: string): number =>
|
||||
source
|
||||
.split (/\r\n|\n|\r/)
|
||||
.map (_1 => _1.trim ())
|
||||
.filter (_1 => /^https?:\/\//.test (_1))
|
||||
.length
|
||||
|
||||
|
||||
export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||
rows.filter (row => row.importStatus == null || row.importStatus === 'pending')
|
||||
|
||||
|
||||
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
||||
total: rows.length,
|
||||
submittable: rows.filter (row =>
|
||||
(row.importStatus == null || row.importStatus === 'pending')
|
||||
&& Object.keys (row.validationErrors ?? { }).length === 0).length,
|
||||
invalid: rows.filter (row => Object.keys (row.validationErrors ?? { }).length > 0).length,
|
||||
skipPlanned: rows.filter (row =>
|
||||
row.existing && row.importStatus !== 'created' && row.status !== 'error').length,
|
||||
created: rows.filter (row => row.importStatus === 'created').length,
|
||||
failed: rows.filter (row => row.importStatus === 'failed').length })
|
||||
@@ -1,636 +0,0 @@
|
||||
import { useEffect, useRef, 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 PrefetchLink from '@/components/PrefetchLink'
|
||||
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, isApiError } 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 Origin = 'automatic' | 'manual'
|
||||
type Phase = 'source' | 'preview' | 'result'
|
||||
type RepairMode = 'all' | 'failed'
|
||||
type ImportStatus = 'pending' | 'created' | 'skipped' | 'failed'
|
||||
type ImportResultStatus = 'created' | 'skipped' | 'failed'
|
||||
type AttributeValue = string | number
|
||||
|
||||
type ImportRow = {
|
||||
sourceRow: number
|
||||
url: string
|
||||
attributes: Record<string, AttributeValue>
|
||||
warnings: string[]
|
||||
validationErrors: Record<string, string[]>
|
||||
importErrors?: Record<string, string[]>
|
||||
provenance: Record<string, Origin>
|
||||
tagSources?: Record<Origin, string>
|
||||
status: 'ready' | 'warning' | 'error'
|
||||
existing: boolean
|
||||
metadataUrl?: string
|
||||
createdPostId?: number
|
||||
importStatus?: ImportStatus }
|
||||
|
||||
type ImportResultRow = {
|
||||
sourceRow: number
|
||||
status: ImportResultStatus
|
||||
post?: { id: number }
|
||||
errors?: Record<string, string[]> }
|
||||
|
||||
const RESULT_STATUSES: ImportResultStatus[] = ['created', 'skipped', 'failed']
|
||||
|
||||
const ORIGIN_LABELS: Record<Origin, string> = {
|
||||
automatic: '自動取得',
|
||||
manual: '手修正' }
|
||||
|
||||
|
||||
const mergeValidatedRows = (
|
||||
current: ImportRow[],
|
||||
validated: ImportRow[],
|
||||
): ImportRow[] =>
|
||||
current
|
||||
.map (previous => {
|
||||
if (previous.importStatus === 'created')
|
||||
return previous
|
||||
|
||||
const row = validated.find (_1 => _1.sourceRow === previous.sourceRow)
|
||||
return row
|
||||
? {
|
||||
...row,
|
||||
importStatus: previous.importStatus,
|
||||
createdPostId: previous.createdPostId,
|
||||
importErrors: previous.importErrors,
|
||||
validationErrors: row.validationErrors ?? { } }
|
||||
: previous
|
||||
})
|
||||
.concat (validated.filter (row =>
|
||||
!(current.some (_1 => _1.sourceRow === row.sourceRow))))
|
||||
|
||||
|
||||
const rowMessages = (row: ImportRow): string[] => [
|
||||
...row.warnings,
|
||||
...Object.values (row.validationErrors ?? { }).flat (),
|
||||
...Object.values (row.importErrors ?? { }).flat ()]
|
||||
|
||||
|
||||
const PostImportPage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
|
||||
const [source, setSource] = useState ('')
|
||||
const [phase, setPhase] = useState<Phase> ('source')
|
||||
const [rows, setRows] = useState<ImportRow[]> ([])
|
||||
const [existing, setExisting] = useState<'skip' | 'error'> ('skip')
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [repairMode, setRepairMode] = useState<RepairMode> ('all')
|
||||
|
||||
const validationGeneration = useRef (0)
|
||||
const rowRefs = useRef<Record<number, HTMLElement | null>> ({ })
|
||||
|
||||
const preview = async () => {
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const data = await apiPost<{ rows: ImportRow[] }> ('/posts/import/preview', {
|
||||
source,
|
||||
existing })
|
||||
setRows (data.rows)
|
||||
setRepairMode ('all')
|
||||
setPhase ('preview')
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
const message =
|
||||
isApiError<{ message?: string, baseErrors?: string[] }> (error)
|
||||
? error.response?.data?.message
|
||||
?? error.response?.data?.baseErrors?.[0]
|
||||
: undefined
|
||||
toast ({
|
||||
title: '投稿情報の取得に失敗しました',
|
||||
description: message ?? '入力を確認してください.' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
const validateRows = async (
|
||||
nextRows: ImportRow[],
|
||||
changedRow: number,
|
||||
existingValue = existing,
|
||||
) => {
|
||||
const generation = ++validationGeneration.current
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const validated = await apiPost<{ rows: ImportRow[] }> ('/posts/import/validate', {
|
||||
rows: nextRows.filter (row => row.importStatus !== 'created'),
|
||||
existing: existingValue,
|
||||
changed_row: changedRow })
|
||||
if (generation === validationGeneration.current)
|
||||
setRows (current => mergeValidatedRows (current, validated.rows))
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (generation === validationGeneration.current)
|
||||
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' as const },
|
||||
importStatus: 'pending' as const,
|
||||
importErrors: undefined }
|
||||
const nextRows = rows.map ((currentRow, rowIndex) =>
|
||||
rowIndex === index ? next : currentRow)
|
||||
setRows (nextRows)
|
||||
try
|
||||
{
|
||||
await validateRows (nextRows, -1)
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '行の再検証に失敗しました' })
|
||||
}
|
||||
}
|
||||
|
||||
const updateURL = async (index: number, value: string) => {
|
||||
const row = rows[index]
|
||||
const next = {
|
||||
...row,
|
||||
url: value,
|
||||
provenance: { ...row.provenance, url: 'manual' as const },
|
||||
importStatus: 'pending' as const,
|
||||
importErrors: undefined }
|
||||
const nextRows = rows.map ((currentRow, rowIndex) =>
|
||||
rowIndex === index ? next : currentRow)
|
||||
setRows (nextRows)
|
||||
try
|
||||
{
|
||||
await validateRows (nextRows, row.sourceRow)
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: 'URL の再検証に失敗しました' })
|
||||
}
|
||||
}
|
||||
|
||||
const updateExisting = async (value: 'skip' | 'error') => {
|
||||
setExisting (value)
|
||||
const pendingRows = rows.map (row =>
|
||||
row.importStatus === 'created'
|
||||
? row
|
||||
: {
|
||||
...row,
|
||||
importStatus: 'pending' as const,
|
||||
importErrors: undefined })
|
||||
setRows (pendingRows)
|
||||
try
|
||||
{
|
||||
await validateRows (pendingRows, -1, value)
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '既存投稿の再検証に失敗しました' })
|
||||
}
|
||||
}
|
||||
|
||||
const retryRow = (sourceRow: number) =>
|
||||
setRows (current => current.map (row =>
|
||||
row.sourceRow === sourceRow && row.importStatus === 'failed'
|
||||
? { ...row, importStatus: 'pending', importErrors: undefined }
|
||||
: row))
|
||||
|
||||
const retryRowFromResult = (sourceRow: number) => {
|
||||
retryRow (sourceRow)
|
||||
setRepairMode ('failed')
|
||||
setPhase ('preview')
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const targets = rows.filter (row =>
|
||||
row.importStatus == null || row.importStatus === 'pending')
|
||||
if (targets.length === 0)
|
||||
return
|
||||
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
rows: ImportResultRow[] }> ('/posts/import', {
|
||||
rows: targets.map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
existing })
|
||||
if (result.rows.some (row => !(RESULT_STATUSES.includes (row.status))))
|
||||
throw new Error ('不正な登録結果です.')
|
||||
|
||||
toast ({
|
||||
title: `登録完了: ${ result.created } 件`,
|
||||
description: `スキップ ${ result.skipped } 件、失敗 ${ result.failed } 件` })
|
||||
setRepairMode ('all')
|
||||
setPhase ('result')
|
||||
setRows (current => current.map (row => {
|
||||
const resultRow = result.rows.find (_1 => _1.sourceRow === row.sourceRow)
|
||||
return resultRow
|
||||
? {
|
||||
...row,
|
||||
importStatus: resultRow.status,
|
||||
createdPostId: resultRow.post?.id,
|
||||
importErrors: resultRow.errors,
|
||||
validationErrors: row.validationErrors }
|
||||
: row
|
||||
}))
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '登録に失敗しました' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
const resultRows = rows.filter (row =>
|
||||
row.importStatus != null && RESULT_STATUSES.includes (row.importStatus))
|
||||
const failedCount = rows.filter (row => row.importStatus === 'failed').length
|
||||
const previewRows =
|
||||
repairMode === 'failed'
|
||||
? rows.filter (row =>
|
||||
row.importStatus == null
|
||||
|| row.importStatus === 'pending'
|
||||
|| row.importStatus === 'failed')
|
||||
: rows
|
||||
|
||||
useEffect (() => {
|
||||
if (phase !== 'preview' || repairMode !== 'failed')
|
||||
return
|
||||
|
||||
const failedRow = previewRows.find (row => row.importStatus === 'failed')
|
||||
if (failedRow == null)
|
||||
return
|
||||
|
||||
rowRefs.current[failedRow.sourceRow]?.scrollIntoView ({
|
||||
block: 'start',
|
||||
behaviour: 'smooth' })
|
||||
}, [phase, previewRows, repairMode])
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<PageTitle>投稿インポート</PageTitle>
|
||||
{phase === 'source'
|
||||
? (
|
||||
<Form>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
URL を一行に一件ずつ入力してください.
|
||||
</p>
|
||||
<FormField label="URL リスト">
|
||||
{() => (
|
||||
<textarea
|
||||
value={source}
|
||||
rows={12}
|
||||
className={inputClass (false)}
|
||||
onChange={ev => setSource (ev.target.value)}/>)}
|
||||
</FormField>
|
||||
<label>
|
||||
既存投稿
|
||||
<select
|
||||
value={existing}
|
||||
onChange={ev => setExisting (ev.target.value as typeof existing)}>
|
||||
<option value="skip">スキップ</option>
|
||||
<option value="error">エラー</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={preview}
|
||||
disabled={loading || !(source.trim ())}>
|
||||
投稿情報を取得
|
||||
</Button>
|
||||
</Form>)
|
||||
: phase === 'preview'
|
||||
? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3 rounded border p-3 dark:border-neutral-700">
|
||||
<label>
|
||||
既存投稿
|
||||
<select
|
||||
value={existing}
|
||||
onChange={ev => updateExisting (ev.target.value as typeof existing)}>
|
||||
<option value="skip">スキップ</option>
|
||||
<option value="error">エラー</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{repairMode === 'failed' && (
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
失敗 {failedCount} 件を表示しています.
|
||||
</p>)}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{previewRows.map (row => (
|
||||
<RowCard
|
||||
key={row.sourceRow}
|
||||
row={row}
|
||||
index={rows.findIndex (_1 => _1.sourceRow === row.sourceRow)}
|
||||
setRef={node => {
|
||||
rowRefs.current[row.sourceRow] = node
|
||||
}}
|
||||
update={updateAttribute}
|
||||
updateURL={updateURL}
|
||||
retryRow={retryRow}/>))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setRepairMode ('all')
|
||||
setPhase ('source')
|
||||
}}>
|
||||
URL リスト入力へ戻る
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={loading}>
|
||||
有効な投稿を一括登録
|
||||
</Button>
|
||||
</div>)
|
||||
: (
|
||||
<div className="space-y-3">
|
||||
<p>インポート処理が完了しました.</p>
|
||||
{resultRows.map (row => (
|
||||
<div
|
||||
key={row.sourceRow}
|
||||
className="rounded border p-2 dark:border-neutral-700">
|
||||
行 {row.sourceRow}:{row.importStatus}
|
||||
{row.createdPostId && (
|
||||
<>
|
||||
{' '}
|
||||
<PrefetchLink to={`/posts/${ row.createdPostId }`}>
|
||||
投稿を開く
|
||||
</PrefetchLink>
|
||||
</>)}
|
||||
{row.importStatus === 'failed' && (
|
||||
<>
|
||||
{' '}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => retryRowFromResult (row.sourceRow)}>
|
||||
再試行
|
||||
</Button>
|
||||
</>)}
|
||||
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
|
||||
</div>))}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRepairMode ('all')
|
||||
setPhase ('preview')
|
||||
}}>
|
||||
結果を確認
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setRepairMode ('failed')
|
||||
setPhase ('preview')
|
||||
}}>
|
||||
失敗行を修正
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setRepairMode ('all')
|
||||
setPhase ('source')
|
||||
}}>
|
||||
URL リスト入力へ戻る
|
||||
</Button>
|
||||
</div>)}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
const RowCard = (
|
||||
{ row, index, setRef, update, updateURL, retryRow }: {
|
||||
row: ImportRow
|
||||
index: number
|
||||
setRef: (node: HTMLElement | null) => void
|
||||
update: (index: number, field: string, value: string) => void
|
||||
updateURL: (index: number, value: string) => void
|
||||
retryRow: (sourceRow: number) => void },
|
||||
) => (
|
||||
<article
|
||||
ref={setRef}
|
||||
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>
|
||||
{row.importStatus === 'created'
|
||||
? (
|
||||
<p className="rounded bg-green-100 p-2 dark:bg-green-900">
|
||||
登録済み.この行は編輯・再登録できません.
|
||||
{row.createdPostId && (
|
||||
<PrefetchLink to={`/posts/${ row.createdPostId }`}>
|
||||
投稿を開く
|
||||
</PrefetchLink>)}
|
||||
</p>)
|
||||
: (
|
||||
<>
|
||||
<EditableValue
|
||||
label="URL"
|
||||
value={row.url}
|
||||
origin={row.provenance.url}
|
||||
onCommit={value => updateURL (index, value)}/>
|
||||
<EditableValue
|
||||
label="タイトル"
|
||||
value={String (row.attributes.title ?? '')}
|
||||
origin={row.provenance.title}
|
||||
onCommit={value => update (index, 'title', value)}/>
|
||||
<EditableValue
|
||||
label="サムネール基底 URL"
|
||||
value={String (row.attributes.thumbnailBase ?? '')}
|
||||
origin={row.provenance.thumbnailBase}
|
||||
onCommit={value => update (index, 'thumbnailBase', value)}/>
|
||||
<ThumbnailPreview url={String (row.attributes.thumbnailBase ?? '')}/>
|
||||
<EditableValue
|
||||
label="作成日時 From"
|
||||
value={String (row.attributes.originalCreatedFrom ?? '')}
|
||||
origin={row.provenance.originalCreatedFrom}
|
||||
onCommit={value => update (index, 'originalCreatedFrom', value)}/>
|
||||
<EditableValue
|
||||
label="作成日時 Before"
|
||||
value={String (row.attributes.originalCreatedBefore ?? '')}
|
||||
origin={row.provenance.originalCreatedBefore}
|
||||
onCommit={value => update (index, 'originalCreatedBefore', value)}/>
|
||||
<EditableValue
|
||||
label="動画時間"
|
||||
value={String (row.attributes.duration ?? '')}
|
||||
origin={row.provenance.duration}
|
||||
onCommit={value => update (index, 'duration', value)}/>
|
||||
<EditableValue
|
||||
label="タグ"
|
||||
value={String (row.attributes.tags ?? '')}
|
||||
origin={row.provenance.tags}
|
||||
onCommit={value => update (index, 'tags', value)}/>
|
||||
<EditableValue
|
||||
label="親投稿"
|
||||
value={String (row.attributes.parentPostIds ?? '')}
|
||||
origin={row.provenance.parentPostIds}
|
||||
onCommit={value => update (index, 'parentPostIds', value)}/>
|
||||
{row.importStatus === 'failed' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => retryRow (row.sourceRow)}>
|
||||
この行を再試行
|
||||
</Button>)}
|
||||
</>)}
|
||||
<FieldError messages={rowMessages (row)}/>
|
||||
</article>)
|
||||
|
||||
|
||||
const EditableValue = (
|
||||
{ label, value, origin, onCommit }: {
|
||||
label: string
|
||||
value: string
|
||||
origin: Origin | undefined
|
||||
onCommit: (value: string) => void },
|
||||
) => {
|
||||
const [editing, setEditing] = useState (false)
|
||||
const [draft, setDraft] = useState (value)
|
||||
const finished = useRef (false)
|
||||
|
||||
useEffect (() => {
|
||||
if (!(editing))
|
||||
setDraft (value)
|
||||
}, [editing, value])
|
||||
|
||||
const begin = () => {
|
||||
finished.current = false
|
||||
setDraft (value)
|
||||
setEditing (true)
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
if (finished.current)
|
||||
return
|
||||
|
||||
finished.current = true
|
||||
setEditing (false)
|
||||
if (draft !== value)
|
||||
onCommit (draft)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (finished.current)
|
||||
return
|
||||
|
||||
finished.current = true
|
||||
setDraft (value)
|
||||
setEditing (false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
origin === 'manual'
|
||||
? 'rounded bg-amber-100 p-1 dark:bg-amber-900'
|
||||
: 'p-1'
|
||||
}>
|
||||
<span className="text-xs">
|
||||
{label}・{ORIGIN_LABELS[origin ?? 'automatic']}
|
||||
</span>
|
||||
{editing
|
||||
? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onBlur={commit}
|
||||
onChange={ev => setDraft (ev.target.value)}
|
||||
onKeyDown={ev => {
|
||||
if (ev.key === 'Escape')
|
||||
cancel ()
|
||||
if (ev.key === 'Enter')
|
||||
{
|
||||
ev.preventDefault ()
|
||||
commit ()
|
||||
}
|
||||
}}
|
||||
className={inputClass (false)}/>)
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full text-left"
|
||||
onClick={begin}
|
||||
onDoubleClick={begin}>
|
||||
{value || '(未指定)'} 編輯
|
||||
</button>)}
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
const ThumbnailPreview = ({ url }: { url: string }) => {
|
||||
const [failed, setFailed] = useState (false)
|
||||
|
||||
useEffect (() => {
|
||||
setFailed (false)
|
||||
}, [url])
|
||||
|
||||
if (!(url))
|
||||
return <p className="text-sm">サムネール未指定</p>
|
||||
|
||||
if (failed)
|
||||
{
|
||||
return (
|
||||
<p className="text-sm text-red-600 dark:text-red-300">
|
||||
サムネールを表示できません
|
||||
</p>)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt="サムネール"
|
||||
className="max-h-32"
|
||||
onError={() => setFailed (true)}/>)
|
||||
}
|
||||
|
||||
export default PostImportPage
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate,
|
||||
useParams } from 'react-router-dom'
|
||||
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
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 { 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 { loadPostImportSession,
|
||||
mergeImportResults,
|
||||
retryImportRow,
|
||||
savePostImportSession,
|
||||
type PostImportResultRow,
|
||||
type PostImportSession } from '@/lib/postImportSession'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
const resultToneClass = (status: string | undefined): string[] => {
|
||||
switch (status)
|
||||
{
|
||||
case 'created':
|
||||
return [
|
||||
'border-sky-200 bg-sky-50',
|
||||
'dark:border-sky-900 dark:bg-sky-950/30']
|
||||
case 'skipped':
|
||||
return [
|
||||
'border-stone-200 bg-stone-50',
|
||||
'dark:border-stone-800 dark:bg-stone-900/60']
|
||||
case 'failed':
|
||||
return [
|
||||
'border-rose-200 bg-rose-50',
|
||||
'dark:border-rose-900 dark:bg-rose-950/30']
|
||||
default:
|
||||
return [
|
||||
'border-border bg-white',
|
||||
'dark:border-neutral-700 dark:bg-neutral-900']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const navigate = useNavigate ()
|
||||
const { sessionId } = useParams ()
|
||||
|
||||
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||
const [missing, setMissing] = useState (false)
|
||||
const [loadingRow, setLoadingRow] = useState<number | null> (null)
|
||||
|
||||
useEffect (() => {
|
||||
if (!(sessionId))
|
||||
return
|
||||
|
||||
const loaded = loadPostImportSession (sessionId)
|
||||
setSession (loaded)
|
||||
setMissing (loaded == null)
|
||||
}, [sessionId])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(sessionId) || !(session))
|
||||
return
|
||||
|
||||
savePostImportSession (sessionId, session)
|
||||
}, [session, sessionId])
|
||||
|
||||
const counts = useMemo (() => ({
|
||||
created: session?.rows.filter (_1 => _1.importStatus === 'created').length ?? 0,
|
||||
skipped: session?.rows.filter (_1 => _1.importStatus === 'skipped').length ?? 0,
|
||||
failed: session?.rows.filter (_1 => _1.importStatus === 'failed').length ?? 0 }), [session])
|
||||
|
||||
const retry = async (sourceRow: number) => {
|
||||
if (!(session))
|
||||
return
|
||||
|
||||
setLoadingRow (sourceRow)
|
||||
try
|
||||
{
|
||||
const pendingRows = retryImportRow (session.rows, sourceRow)
|
||||
setSession ({ ...session, rows: pendingRows })
|
||||
const target = pendingRows.find (_1 => _1.sourceRow === sourceRow)
|
||||
if (target == null)
|
||||
return
|
||||
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||
rows: [{
|
||||
sourceRow: target.sourceRow,
|
||||
url: target.url,
|
||||
attributes: target.attributes,
|
||||
provenance: target.provenance,
|
||||
tagSources: target.tagSources,
|
||||
metadataUrl: target.metadataUrl }],
|
||||
existing: session.existing })
|
||||
setSession (current =>
|
||||
current
|
||||
? { ...current, rows: mergeImportResults (current.rows, result.rows) }
|
||||
: current)
|
||||
}
|
||||
catch
|
||||
{
|
||||
setSession (session)
|
||||
toast ({ title: '再試行に失敗しました' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoadingRow (null)
|
||||
}
|
||||
}
|
||||
|
||||
const openRepair = (sourceRow: number) => {
|
||||
const nextSession = { ...session, repairMode: 'failed' as const }
|
||||
setSession (nextSession)
|
||||
if (sessionId)
|
||||
savePostImportSession (sessionId, nextSession)
|
||||
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
|
||||
}
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
if (missing || !(sessionId) || !(session))
|
||||
{
|
||||
return (
|
||||
<MainArea>
|
||||
<div className="mx-auto max-w-4xl space-y-4 p-4">
|
||||
<PageTitle>投稿インポート結果</PageTitle>
|
||||
<FieldError messages={['取込状態が見つかりません.']}/>
|
||||
<Button type="button" onClick={() => navigate ('/posts/import')}>
|
||||
URL リスト入力へ戻る
|
||||
</Button>
|
||||
</div>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`投稿インポート結果 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-5xl space-y-4 p-4">
|
||||
<PageTitle>登録結果</PageTitle>
|
||||
|
||||
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||
dark:bg-neutral-900">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SummaryChip label="登録成功" value={counts.created} badge="created"/>
|
||||
<SummaryChip label="スキップ" value={counts.skipped} badge="skipped"/>
|
||||
<SummaryChip label="失敗" value={counts.failed} badge="failed"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{session.rows.map (row => (
|
||||
<div
|
||||
key={row.sourceRow}
|
||||
className={[
|
||||
'rounded-lg border p-4',
|
||||
...resultToneClass (row.importStatus)].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={row.importStatus ?? 'pending'}/>
|
||||
</div>
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-200">
|
||||
{String (row.attributes.title ?? '') || row.url}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{row.url}
|
||||
</div>
|
||||
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{row.createdPostId && (
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<PrefetchLink to={`/posts/${ row.createdPostId }`}>
|
||||
投稿を開く
|
||||
</PrefetchLink>
|
||||
</Button>)}
|
||||
{row.importStatus === 'failed' && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => openRepair (row.sourceRow)}>
|
||||
修正
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => retry (row.sourceRow)}
|
||||
disabled={loadingRow === row.sourceRow}>
|
||||
再試行
|
||||
</Button>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const nextSession = { ...session, repairMode: 'all' as const }
|
||||
setSession (nextSession)
|
||||
savePostImportSession (sessionId, nextSession)
|
||||
navigate (`/posts/import/${ sessionId }/review`)
|
||||
}}>
|
||||
確認画面へ戻る
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate ('/posts/import')}>
|
||||
新しい URL リストを入力
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
const SummaryChip = (
|
||||
{ label, value, badge }: {
|
||||
label: string
|
||||
value: number
|
||||
badge: 'created' | 'skipped' | 'failed' },
|
||||
) => (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border
|
||||
bg-background px-3 py-1 text-sm">
|
||||
<PostImportStatusBadge value={badge}/>
|
||||
<span>{label} {value}</span>
|
||||
</div>)
|
||||
|
||||
export default PostImportResultPage
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate,
|
||||
useParams,
|
||||
useSearchParams } from 'react-router-dom'
|
||||
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog'
|
||||
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
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 { loadPostImportSession,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRows,
|
||||
reviewSummaryCounts,
|
||||
rowMessages,
|
||||
savePostImportSession,
|
||||
submittableImportRows,
|
||||
type PostImportResultRow,
|
||||
type PostImportRow,
|
||||
type PostImportSession } from '@/lib/postImportSession'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
type Draft = {
|
||||
url: string
|
||||
title: string
|
||||
thumbnailBase: string
|
||||
originalCreatedFrom: string
|
||||
originalCreatedBefore: string
|
||||
duration: string
|
||||
tags: string
|
||||
parentPostIds: string }
|
||||
|
||||
|
||||
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const navigate = useNavigate ()
|
||||
const { sessionId } = useParams ()
|
||||
const [searchParams, setSearchParams] = useSearchParams ()
|
||||
|
||||
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [missing, setMissing] = useState (false)
|
||||
const [savingRow, setSavingRow] = useState<number | null> (null)
|
||||
|
||||
useEffect (() => {
|
||||
if (!(sessionId))
|
||||
return
|
||||
|
||||
const loaded = loadPostImportSession (sessionId)
|
||||
setSession (loaded)
|
||||
setMissing (loaded == null)
|
||||
}, [sessionId])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(sessionId) || !(session))
|
||||
return
|
||||
|
||||
savePostImportSession (sessionId, session)
|
||||
}, [session, sessionId])
|
||||
|
||||
const rows = session?.rows ?? []
|
||||
const editingSourceRow = Number (searchParams.get ('edit') ?? '')
|
||||
const editingRow =
|
||||
Number.isFinite (editingSourceRow)
|
||||
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null
|
||||
: null
|
||||
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
||||
const submittable = useMemo (
|
||||
() => submittableImportRows (rows).filter (row =>
|
||||
Object.keys (row.validationErrors ?? { }).length === 0),
|
||||
[rows],
|
||||
)
|
||||
const reviewRows =
|
||||
session?.repairMode === 'failed'
|
||||
? [...rows].sort ((a, b) => {
|
||||
const aFailed = a.importStatus === 'failed' ? 0 : 1
|
||||
const bFailed = b.importStatus === 'failed' ? 0 : 1
|
||||
return aFailed - bFailed || a.sourceRow - b.sourceRow
|
||||
})
|
||||
: rows
|
||||
|
||||
useEffect (() => {
|
||||
if (editingRow == null || session?.repairMode !== 'failed')
|
||||
return
|
||||
|
||||
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
|
||||
element?.scrollIntoView ({ block: 'center', behaviour: 'smooth' })
|
||||
}, [editingRow, session?.repairMode])
|
||||
|
||||
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
||||
setSession (current =>
|
||||
current ? { ...current, rows: nextRows } : current)
|
||||
|
||||
const saveDraft = async (draft: Draft): Promise<boolean> => {
|
||||
if (!(session))
|
||||
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 ?? ''
|
||||
}
|
||||
|
||||
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 }
|
||||
: row)
|
||||
try
|
||||
{
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows: nextRows.filter (row => row.importStatus !== 'created'),
|
||||
existing: session.existing,
|
||||
changed_row: urlChanged ? editingRow.sourceRow : -1 })
|
||||
updateSessionRows (mergeValidatedImportRows (nextRows, validated.rows))
|
||||
setSearchParams ({ })
|
||||
return true
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '行の再検証に失敗しました' })
|
||||
return false
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSavingRow (null)
|
||||
}
|
||||
}
|
||||
|
||||
const updateExisting = async (value: 'skip' | 'error') => {
|
||||
if (!(session))
|
||||
return
|
||||
|
||||
const pendingRows = session.rows.map (row =>
|
||||
row.importStatus === 'created'
|
||||
? row
|
||||
: {
|
||||
...row,
|
||||
importStatus: 'pending' as const,
|
||||
importErrors: undefined })
|
||||
setSession ({ ...session, existing: value, rows: pendingRows })
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows: pendingRows.filter (row => row.importStatus !== 'created'),
|
||||
existing: value,
|
||||
changed_row: -1 })
|
||||
updateSessionRows (mergeValidatedImportRows (pendingRows, validated.rows))
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!(sessionId) || !(session) || submittable.length === 0)
|
||||
return
|
||||
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||
rows: submittable.map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
existing: session.existing })
|
||||
const nextRows = mergeImportResults (session.rows, result.rows)
|
||||
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
|
||||
setSession (nextSession)
|
||||
savePostImportSession (sessionId, nextSession)
|
||||
navigate (`/posts/import/${ sessionId }/result`)
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '登録に失敗しました' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
if (missing || !(sessionId) || !(session))
|
||||
{
|
||||
return (
|
||||
<MainArea>
|
||||
<div className="mx-auto max-w-4xl space-y-4 p-4">
|
||||
<PageTitle>投稿インポート</PageTitle>
|
||||
<FieldError messages={['取込状態が見つかりません.']}/>
|
||||
<Button type="button" onClick={() => navigate ('/posts/import')}>
|
||||
URL リスト入力へ戻る
|
||||
</Button>
|
||||
</div>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-6xl space-y-4 p-4 pb-28">
|
||||
<PageTitle>投稿情報の確認</PageTitle>
|
||||
|
||||
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||
dark:bg-neutral-900">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SummaryChip label="全件" value={counts.total}/>
|
||||
<SummaryChip label="登録対象" value={counts.submittable}/>
|
||||
<SummaryChip label="要修正" value={counts.invalid}/>
|
||||
<SummaryChip label="スキップ予定" value={counts.skipPlanned}/>
|
||||
<SummaryChip label="登録済み" value={counts.created}/>
|
||||
<SummaryChip label="失敗" value={counts.failed}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||
dark:bg-neutral-900">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
URL {rows.length} 件
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span>既存投稿</span>
|
||||
<select
|
||||
value={session.existing}
|
||||
onChange={ev => updateExisting (ev.target.value as 'skip' | 'error')}
|
||||
className="rounded border border-border bg-background px-3 py-2">
|
||||
<option value="skip">スキップ</option>
|
||||
<option value="error">エラー</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{reviewRows.map (row => (
|
||||
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
||||
<PostImportRowSummary
|
||||
row={row}
|
||||
onEdit={() => {
|
||||
setSearchParams ({ edit: String (row.sourceRow) })
|
||||
}}/>
|
||||
{rowMessages (row).length > 0 && (
|
||||
<div className="mt-2 md:max-w-3xl">
|
||||
<FieldError messages={rowMessages (row)}/>
|
||||
</div>)}
|
||||
</div>))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-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">
|
||||
<PostImportStatusBadge value="pending"/>
|
||||
<span>登録対象 {submittable.length} 件</span>
|
||||
<PostImportStatusBadge value="error"/>
|
||||
<span>要修正 {counts.invalid} 件</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate ('/posts/import')}>
|
||||
URL リスト入力へ戻る
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={loading || submittable.length === 0}>
|
||||
有効な投稿を一括登録
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PostImportRowDialog
|
||||
open={editingRow != null}
|
||||
row={editingRow}
|
||||
saving={savingRow != null}
|
||||
onOpenChange={open => {
|
||||
if (!(open))
|
||||
setSearchParams ({ })
|
||||
}}
|
||||
onSave={saveDraft}/>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
const SummaryChip = ({ label, value }: { label: string
|
||||
value: number }) => (
|
||||
<div className="rounded-full border border-border bg-background px-3 py-1 text-sm">
|
||||
{label} {value}
|
||||
</div>)
|
||||
|
||||
export default PostImportReviewPage
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
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, isApiError } from '@/lib/api'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { countImportSourceLines,
|
||||
countImportSourceValidLines,
|
||||
createPostImportSessionId,
|
||||
loadPostImportSourceDraft,
|
||||
savePostImportSession,
|
||||
savePostImportSourceDraft,
|
||||
type PostImportRow } from '@/lib/postImportSession'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
const MAX_ROWS = 100
|
||||
|
||||
|
||||
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const navigate = useNavigate ()
|
||||
const draft = useMemo (() => loadPostImportSourceDraft (), [])
|
||||
|
||||
const [source, setSource] = useState (draft.source)
|
||||
const [existing, setExisting] = useState<'skip' | 'error'> (draft.existing)
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [error, setError] = useState<string | null> (null)
|
||||
|
||||
const lineCount = countImportSourceLines (source)
|
||||
const validLineCount = countImportSourceValidLines (source)
|
||||
const messages = [
|
||||
...(lineCount === 0 ? ['URL を入力してください.'] : []),
|
||||
...(lineCount > MAX_ROWS ? [`最大 ${ MAX_ROWS } 件までです.`] : []),
|
||||
...(error ? [error] : [])]
|
||||
|
||||
useEffect (() => {
|
||||
savePostImportSourceDraft (source, existing)
|
||||
}, [existing, source])
|
||||
|
||||
const preview = async () => {
|
||||
setLoading (true)
|
||||
setError (null)
|
||||
try
|
||||
{
|
||||
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
|
||||
source,
|
||||
existing })
|
||||
const sessionId = createPostImportSessionId ()
|
||||
savePostImportSession (sessionId, {
|
||||
source,
|
||||
existing,
|
||||
rows: data.rows,
|
||||
repairMode: 'all' })
|
||||
navigate (`/posts/import/${ sessionId }/review`)
|
||||
}
|
||||
catch (requestError)
|
||||
{
|
||||
const message =
|
||||
isApiError<{ message?: string, baseErrors?: string[] }> (requestError)
|
||||
? requestError.response?.data?.message
|
||||
?? requestError.response?.data?.baseErrors?.[0]
|
||||
: undefined
|
||||
setError (message ?? '入力を確認してください.')
|
||||
toast ({
|
||||
title: '投稿情報の取得に失敗しました',
|
||||
description: message ?? '入力を確認してください.' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-4xl space-y-4 p-4">
|
||||
<PageTitle>投稿インポート</PageTitle>
|
||||
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||
dark:bg-neutral-900 md:p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">URL リストを入力</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
一行につき一つの URL を入力してください.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField label="URL リスト">
|
||||
{() => (
|
||||
<textarea
|
||||
value={source}
|
||||
rows={14}
|
||||
className={inputClass (
|
||||
messages.length > 0,
|
||||
'font-mono text-sm',
|
||||
)}
|
||||
onChange={ev => setSource (ev.target.value)}/>)}
|
||||
</FormField>
|
||||
<FieldError messages={messages}/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm
|
||||
text-neutral-600 dark:text-neutral-300">
|
||||
<div>有効行数: {validLineCount}</div>
|
||||
<div>最大 {MAX_ROWS} 件</div>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium">既存投稿</span>
|
||||
<select
|
||||
value={existing}
|
||||
onChange={ev => setExisting (ev.target.value as typeof existing)}
|
||||
className={inputClass (false, 'w-full md:w-auto')}>
|
||||
<option value="skip">スキップ</option>
|
||||
<option value="error">エラー</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={preview}
|
||||
disabled={loading || lineCount === 0 || lineCount > MAX_ROWS}>
|
||||
投稿情報を取得
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
export default PostImportSourcePage
|
||||
新しい課題から参照
ユーザをブロックする