このコミットが含まれているのは:
2026-07-15 23:29:19 +09:00
コミット 9af4670c6c
5個のファイルの変更183行の追加187行の削除
+2 -1
ファイルの表示
@@ -148,7 +148,8 @@ class PostsController < ApplicationController
original_created_from: params[:original_created_from], original_created_from: params[:original_created_from],
original_created_before: params[:original_created_before], original_created_before: params[:original_created_before],
parent_post_ids: parse_parent_post_ids, parent_post_ids: parse_parent_post_ids,
video_ms: params[:video_ms] }).create! video_ms: params[:video_ms],
duration: params[:duration] }).create!
post.reload post.reload
render json: PostRepr.base(post), status: :created render json: PostRepr.base(post), status: :created
+1 -1
ファイルの表示
@@ -18,7 +18,7 @@ import type { FC, MouseEvent } from 'react'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types' import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null, }
export const menuOutline = ( export const menuOutline = (
+4 -3
ファイルの表示
@@ -4,6 +4,7 @@ import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeFi
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning' import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import TextArea from '@/components/common/TextArea'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview' import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
@@ -259,13 +260,13 @@ const PostImportAreaField = (
<FormField label={label} messages={errors}> <FormField label={label} messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<textarea <TextArea
value={value} value={value}
rows={4} rows={4}
onChange={ev => onChange (ev.target.value)} onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} invalid={invalid}
className={inputClass (invalid)}/> className="h-auto"/>
<FieldWarning messages={warnings}/> <FieldWarning messages={warnings}/>
</>)} </>)}
</FormField>) </FormField>)
+172 -176
ファイルの表示
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useNavigate, import { useNavigate, useParams } from 'react-router-dom'
useParams } from 'react-router-dom'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
@@ -26,9 +25,8 @@ import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react' import type { FC } from 'react'
import type { PostImportResultRow, import type { PostImportResultRow, PostImportRow } from '@/lib/postImportSession'
PostImportRow, import type { PostImportSession } from '@/lib/postImportSession'
PostImportSession } from '@/lib/postImportSession'
import type { User } from '@/types' import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
@@ -38,184 +36,184 @@ const rowMessages = (row: PostImportRow): string[] =>
const PostImportResultPage: FC<Props> = ({ user }) => { const PostImportResultPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const navigate = useNavigate () const navigate = useNavigate ()
const { sessionId } = useParams () const { sessionId } = useParams ()
const [session, setSession] = useState<PostImportSession | null> (null) const [session, setSession] = useState<PostImportSession | null> (null)
const [missing, setMissing] = useState (false) const [missing, setMissing] = useState (false)
const [loadingRow, setLoadingRow] = useState<number | null> (null) const [loadingRow, setLoadingRow] = useState<number | null> (null)
useEffect (() => { useEffect (() => {
if (sessionId == null) if (sessionId == null)
return return
const loaded = loadPostImportSession (sessionId, message => const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message })) toast ({ title: '取込状態を復元できませんでした', description: message }))
setSession (loaded) setSession (loaded)
setMissing (loaded == null) setMissing (loaded == null)
}, [sessionId]) }, [sessionId])
useEffect (() => { useEffect (() => {
if (sessionId == null || session == null) if (sessionId == null || session == null)
return return
savePostImportSession (sessionId, session, message => savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId]) }, [session, sessionId])
const counts = useMemo ( const counts = useMemo (
() => resultSummaryCounts (session?.rows ?? []), () => resultSummaryCounts (session?.rows ?? []),
[session]) [session])
const retry = async (sourceRow: number) => { const retry = async (sourceRow: number) => {
if (session == null || sessionId == null) if (session == null || sessionId == null)
return return
setLoadingRow (sourceRow) setLoadingRow (sourceRow)
try try
{
const pendingRows = retryImportRow (session.rows, sourceRow)
setSession ({ ...session, rows: pendingRows })
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: pendingRows
.filter (row => row.importStatus !== 'created')
.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = mergeValidatedImportRows (
pendingRows,
initialisePreviewRows (validated.rows))
const nextSession = {
...session,
rows: validatedRows,
repairMode: 'failed' as const }
setSession (nextSession)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null)
return
if (Object.keys (target.validationErrors ?? { }).length > 0)
{
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
return
}
const result = await apiPost<{
created: number
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 }] })
const mergedRows = mergeImportResults (nextSession.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map (row => {
const recoverable = recoverableRows.find (_1 => _1.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
...row,
importStatus: 'pending',
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const recoverableTarget = nextRows.find (_1 => _1.sourceRow === sourceRow)
const resultSession = {
...nextSession,
rows: nextRows,
repairMode: recoverableTarget == null ? 'all' as const : 'failed' as const }
setSession (resultSession)
if (recoverableTarget != null
&& Object.keys (recoverableTarget.validationErrors).length > 0)
{
const saved = savePostImportSession (sessionId, resultSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
return
}
}
catch
{
setSession (session)
toast ({ title: '再試行に失敗しました' })
}
finally
{
setLoadingRow (null)
}
}
const openRepair = (sourceRow: number) => {
if (session == null || sessionId == null)
return
const nextSession = { ...session, repairMode: 'failed' as const }
setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
}
if (!(editable))
return <Forbidden/>
if (missing || sessionId == null || session == null)
{ {
return ( const pendingRows = retryImportRow (session.rows, sourceRow)
<MainArea> setSession ({ ...session, rows: pendingRows })
<div className="mx-auto max-w-4xl space-y-4 p-4"> const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
<PageTitle>稿</PageTitle> rows: pendingRows
<FieldError messages={['取込状態が見つかりません.']}/> .filter (row => row.importStatus !== 'created')
<Button type="button" onClick={() => navigate ('/posts/import')}> .map (row => ({
URL sourceRow: row.sourceRow,
</Button> url: row.url,
</div> attributes: row.attributes,
</MainArea>) provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = mergeValidatedImportRows (
pendingRows,
initialisePreviewRows (validated.rows))
const nextSession = {
...session,
rows: validatedRows,
repairMode: 'failed' as const }
setSession (nextSession)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null)
return
if (Object.keys (target.validationErrors ?? { }).length > 0)
{
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
return
}
const result = await apiPost<{
created: number
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 }] })
const mergedRows = mergeImportResults (nextSession.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map (row => {
const recoverable = recoverableRows.find (_1 => _1.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
...row,
importStatus: 'pending',
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const recoverableTarget = nextRows.find (_1 => _1.sourceRow === sourceRow)
const resultSession = {
...nextSession,
rows: nextRows,
repairMode: recoverableTarget == null ? 'all' as const : 'failed' as const }
setSession (resultSession)
if (recoverableTarget != null
&& Object.keys (recoverableTarget.validationErrors).length > 0)
{
const saved = savePostImportSession (sessionId, resultSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
return
}
} }
catch
{
setSession (session)
toast ({ title: '再試行に失敗しました' })
}
finally
{
setLoadingRow (null)
}
}
return ( const openRepair = (sourceRow: number) => {
<MainArea> if (session == null || sessionId == null)
<Helmet> return
<title>{`投稿インポート結果 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="mx-auto max-w-5xl space-y-4 p-4"> const nextSession = { ...session, repairMode: 'failed' as const }
<PageTitle></PageTitle> setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
}
<div className="text-sm text-neutral-700 dark:text-neutral-200"> if (!(editable))
{counts.created}  {counts.skipped}  {counts.failed} return <Forbidden/>
if (missing || sessionId == null || session == null)
{
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> </div>
</MainArea>)
}
<div className="space-y-3"> return (
{session.rows.map (row => { <MainArea>
const displayStatus = displayPostImportStatus (row) <Helmet>
const canEdit = row.importStatus === 'failed' <title>{`投稿インポート結果 | ${ SITE_TITLE }`}</title>
const canRetry = </Helmet>
row.importStatus === 'failed'
&& Object.keys (row.validationErrors).length === 0 <div className="mx-auto max-w-5xl space-y-4 p-4">
return ( <PageTitle></PageTitle>
<div
key={row.sourceRow} <div className="text-sm text-neutral-700 dark:text-neutral-200">
className="rounded-lg border p-4 transition-shadow hover:shadow-sm"> {counts.created}  {counts.skipped}  {counts.failed}
</div>
<div className="space-y-3">
{session.rows.map (row => {
const displayStatus = displayPostImportStatus (row)
const canEdit = row.importStatus === 'failed'
const canRetry =
row.importStatus === 'failed'
&& Object.keys (row.validationErrors).length === 0
return (
<div
key={row.sourceRow}
className="rounded-lg border p-4 transition-shadow hover:shadow-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-start <div className="flex flex-col gap-3 md:flex-row md:items-start
md:justify-between"> md:justify-between">
<div className="space-y-2"> <div className="space-y-2">
@@ -248,14 +246,12 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
</Button>)} </Button>)}
{canRetry && ( {canRetry && (
<> <Button
<Button type="button"
type="button" onClick={() => retry (row.sourceRow)}
onClick={() => retry (row.sourceRow)} disabled={loadingRow === row.sourceRow}>
disabled={loadingRow === row.sourceRow}>
</Button>)}
</Button>
</>)}
</div> </div>
</div> </div>
</div>)})} </div>)})}
+4 -6
ファイルの表示
@@ -6,13 +6,13 @@ import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form' import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import TextArea from '@/components/common/TextArea'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { countImportSourceLines, import { countImportSourceLines,
cleanupExpiredPostImportSessions, cleanupExpiredPostImportSessions,
createPostImportSessionId, createPostImportSessionId,
@@ -158,14 +158,12 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
<PageTitle>稿</PageTitle> <PageTitle>稿</PageTitle>
<FormField label="URL リスト"> <FormField label="URL リスト">
{() => ( {() => (
<textarea <TextArea
value={source} value={source}
rows={14} rows={14}
aria-invalid={messages.length > 0 || sourceIssues.length > 0} invalid={messages.length > 0 || sourceIssues.length > 0}
aria-describedby={sourceDescribedBy || undefined} aria-describedby={sourceDescribedBy || undefined}
className={inputClass ( className="h-80 font-mono text-sm"
messages.length > 0 || sourceIssues.length > 0,
'font-mono text-sm')}
onBlur={() => { onBlur={() => {
savePostImportSourceDraft (source, message => savePostImportSourceDraft (source, message =>
toast ({ title: '入力内容を保存できませんでした', toast ({ title: '入力内容を保存できませんでした',