このコミットが含まれているのは:
2026-07-15 20:25:51 +09:00
コミット d2f1b1ed48
13個のファイルの変更85行の追加196行の削除
+1 -2
ファイルの表示
@@ -148,8 +148,7 @@ 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], video_ms: params[:video_ms] }).create!
duration: params[:duration] }).create!
post.reload post.reload
render json: PostRepr.base(post), status: :created render json: PostRepr.base(post), status: :created
+21 -8
ファイルの表示
@@ -10,7 +10,6 @@ class PostImportPreviewer
'tags', 'tags',
'parent_post_ids'].freeze 'parent_post_ids'].freeze
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
EXISTING_SKIP_WARNING = '既存投稿のためスキップします.'.freeze
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze
@@ -91,7 +90,6 @@ class PostImportPreviewer
end end
if validation_errors.blank? && normal_url.present? && existing_post if validation_errors.blank? && normal_url.present? && existing_post
add_field_warning!(field_warnings, 'url', EXISTING_SKIP_WARNING)
attributes['tags'] = merged_tags(tag_sources, provenance['tags']) attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present? warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present?
return { source_row: row[:source_row], return { source_row: row[:source_row],
@@ -113,8 +111,12 @@ class PostImportPreviewer
if should_fetch if should_fetch
clear_fetch_warnings!(field_warnings) clear_fetch_warnings!(field_warnings)
metadata = metadata_for(url_for_metadata, metadata_cache) metadata = metadata_for(url_for_metadata, metadata_cache)
apply_metadata!(attributes, provenance, tag_sources, metadata[:data]) if metadata[:validation_errors].present?
apply_fetch_warnings!(field_warnings, metadata[:warnings]) validation_errors.merge!(metadata[:validation_errors])
else
apply_metadata!(attributes, provenance, tag_sources, metadata[:data])
apply_fetch_warnings!(field_warnings, metadata[:warnings])
end
end end
attributes['url'] = url attributes['url'] = url
@@ -200,7 +202,8 @@ class PostImportPreviewer
end end
def fetch_metadata url def fetch_metadata url
return { data: { }, warnings: { 'url' => ['URL が空です.'] } } if url.blank? return { data: { }, warnings: { 'url' => ['URL が空です.'] },
validation_errors: { } } if url.blank?
data = sanitise_metadata(PostMetadataFetcher.fetch(url).stringify_keys.compact) data = sanitise_metadata(PostMetadataFetcher.fetch(url).stringify_keys.compact)
warnings = { } warnings = { }
@@ -208,13 +211,18 @@ class PostImportPreviewer
if data['thumbnail_base'].blank? if data['thumbnail_base'].blank?
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING) add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING)
end end
{ data:, warnings: } { data:, warnings:, validation_errors: { } }
rescue Preview::UrlSafety::UnsafeUrl => e
Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue Preview::HttpFetcher::FetchFailed, rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e Preview::HttpFetcher::ResponseTooLarge => e
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_failure "\ "post_import_metadata_fetch_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }") "#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
def sanitise_metadata metadata def sanitise_metadata metadata
@@ -295,11 +303,16 @@ class PostImportPreviewer
def safe_fetch_metadata url def safe_fetch_metadata url
fetch_metadata(url) fetch_metadata(url)
rescue Preview::UrlSafety::UnsafeUrl => e
Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue StandardError => e rescue StandardError => e
Rails.logger.error( Rails.logger.error(
"post_import_metadata_fetch_unexpected_failure "\ "post_import_metadata_fetch_unexpected_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }") "#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
def preload_known_tags prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts def preload_known_tags prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
+6 -23
ファイルの表示
@@ -3,12 +3,8 @@ import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
export type StatusBadgeTone = export type StatusBadgeTone =
'danger' 'success'
| 'default' | 'neutral'
| 'info'
| 'manual'
| 'ready'
| 'skipped'
| 'warning' | 'warning'
type Props = { type Props = {
@@ -16,35 +12,22 @@ type Props = {
tone: StatusBadgeTone } tone: StatusBadgeTone }
const TONES: Record<StatusBadgeTone, string[]> = { const TONES: Record<StatusBadgeTone, string[]> = {
default: [ success: [
'border-slate-300 bg-slate-50 text-slate-700',
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
ready: [
'border-emerald-300 bg-emerald-50 text-emerald-700', 'border-emerald-300 bg-emerald-50 text-emerald-700',
'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'], 'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'],
warning: [ warning: [
'border-amber-300 bg-amber-50 text-amber-700', 'border-amber-300 bg-amber-50 text-amber-700',
'dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'], 'dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'],
danger: [ neutral: [
'border-rose-300 bg-rose-50 text-rose-700',
'dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200'],
info: [
'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', 'border-stone-300 bg-stone-50 text-stone-700',
'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'], 'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'] }
manual: [
'border-purple-300 bg-purple-50 text-purple-700',
'dark:border-purple-900 dark:bg-purple-950 dark:text-purple-200'] }
const StatusBadge: FC<Props> = ({ children, tone }) => ( const StatusBadge: FC<Props> = ({ children, tone }) => (
<span <span
className={cn ( className={cn (
'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium', 'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium',
TONES[tone], TONES[tone])}>
)}>
{children} {children}
</span>) </span>)
+16 -23
ファイルの表示
@@ -70,10 +70,14 @@ const PostImportRowDialog: FC<Props> = (
{ open, row, messageRow, saving, onOpenChange, onSave }, { open, row, messageRow, saving, onOpenChange, onSave },
) => { ) => {
const [draft, setDraft] = useState<Draft | null> (null) const [draft, setDraft] = useState<Draft | null> (null)
const [resetRequested, setResetRequested] = useState (false)
useEffect (() => { useEffect (() => {
if (open && row) if (open && row)
setDraft (buildDraft (row)) {
setDraft (buildDraft (row))
setResetRequested (false)
}
}, [open, row]) }, [open, row])
if (row == null || draft == null) if (row == null || draft == null)
@@ -90,12 +94,10 @@ const PostImportRowDialog: FC<Props> = (
} }
const save = async () => { const save = async () => {
if ( if (await onSave ({
await onSave ({ draft,
draft, resetRequested,
resetRequested: false, resetSnapshot: row.resetSnapshot }))
resetSnapshot: row.resetSnapshot })
)
onOpenChange (false) onOpenChange (false)
} }
@@ -139,8 +141,7 @@ const PostImportRowDialog: FC<Props> = (
warnings={displayRow.fieldWarnings.thumbnailBase} warnings={displayRow.fieldWarnings.thumbnailBase}
errors={groupedMessages ( errors={groupedMessages (
displayRow.validationErrors.thumbnailBase, displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase, displayRow.importErrors?.thumbnailBase)}
)}
onChange={value => update ('thumbnailBase', value)}/> onChange={value => update ('thumbnailBase', value)}/>
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
originalCreatedFrom={draft.originalCreatedFrom || null} originalCreatedFrom={draft.originalCreatedFrom || null}
@@ -153,8 +154,7 @@ const PostImportRowDialog: FC<Props> = (
displayRow.validationErrors.originalCreatedBefore, displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.originalCreatedAt, displayRow.importErrors?.originalCreatedAt,
displayRow.importErrors?.originalCreatedFrom, displayRow.importErrors?.originalCreatedFrom,
displayRow.importErrors?.originalCreatedBefore, displayRow.importErrors?.originalCreatedBefore)}/>
)}/>
<DialogTextField <DialogTextField
label="動画時間" label="動画時間"
value={draft.duration} value={draft.duration}
@@ -162,8 +162,7 @@ const PostImportRowDialog: FC<Props> = (
displayRow.validationErrors.duration, displayRow.validationErrors.duration,
displayRow.validationErrors.videoMs, displayRow.validationErrors.videoMs,
displayRow.importErrors?.duration, displayRow.importErrors?.duration,
displayRow.importErrors?.videoMs, displayRow.importErrors?.videoMs)}
)}
onChange={value => update ('duration', value)}/> onChange={value => update ('duration', value)}/>
<DialogAreaField <DialogAreaField
label="タグ" label="タグ"
@@ -177,8 +176,7 @@ const PostImportRowDialog: FC<Props> = (
value={draft.parentPostIds} value={draft.parentPostIds}
errors={groupedMessages ( errors={groupedMessages (
displayRow.validationErrors.parentPostIds, displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds, displayRow.importErrors?.parentPostIds)}
)}
onChange={value => update ('parentPostIds', value)}/> onChange={value => update ('parentPostIds', value)}/>
<FieldWarning messages={displayRow.baseWarnings}/> <FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/> <FieldError messages={displayRow.validationErrors.base}/>
@@ -191,14 +189,9 @@ const PostImportRowDialog: FC<Props> = (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={async () => { onClick={() => {
const resetDraft = buildResetDraft (row) setDraft (buildResetDraft (row))
setDraft (resetDraft) setResetRequested (true)
if (await onSave ({
draft: resetDraft,
resetRequested: true,
resetSnapshot: row.resetSnapshot }))
onOpenChange (false)
}}> }}>
</Button> </Button>
+2 -6
ファイルの表示
@@ -4,7 +4,6 @@ import { cn } from '@/lib/utils'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview' import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus' import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { importPanelToneForRow } from '@/components/posts/import/postImportTone'
import type { FC } from 'react' import type { FC } from 'react'
@@ -35,9 +34,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
className={cn ( className={cn (
'hidden items-center gap-4 rounded-lg border p-4 md:grid', 'hidden items-center gap-4 rounded-lg border p-4 md:grid',
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]', 'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
importPanelToneForRow (row), 'transition-shadow hover:shadow-sm')}>
'transition-shadow hover:shadow-sm',
)}>
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium">#{row.sourceRow}</div> <div className="text-sm font-medium">#{row.sourceRow}</div>
</div> </div>
@@ -80,8 +77,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div <div
className={cn ( className={cn (
'space-y-3 rounded-lg border p-4 md:hidden', 'space-y-3 rounded-lg border p-4 md:hidden',
importPanelToneForRow (row), 'transition-shadow hover:shadow-sm')}>
)}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<ThumbnailPreview <ThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')} url={String (row.attributes.thumbnailBase ?? '')}
+2 -2
ファイルの表示
@@ -14,9 +14,9 @@ const LABELS: Record<PostImportBadgeValue, string> = {
skipped: 'スキップ' } skipped: 'スキップ' }
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = { const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
ready: 'ready', ready: 'success',
warning: 'warning', warning: 'warning',
skipped: 'skipped' } skipped: 'neutral' }
const PostImportStatusBadge: FC<Props> = ({ value }) => ( const PostImportStatusBadge: FC<Props> = ({ value }) => (
-19
ファイルの表示
@@ -1,19 +0,0 @@
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import type { FC } from 'react'
import type { PostImportBadgeValue } from '@/components/posts/import/postImportRowStatus'
type Props = {
label: string
value: number
badge?: PostImportBadgeValue }
const PostImportSummaryChip: FC<Props> = ({ label, value, badge }) => (
<div className="flex items-center gap-2 rounded-full border border-border
bg-background px-3 py-1 text-sm">
{badge != null && <PostImportStatusBadge value={badge}/>}
<span>{label} {value}</span>
</div>)
export default PostImportSummaryChip
+9 -16
ファイルの表示
@@ -10,20 +10,13 @@ const hasWarnings = (row: PostImportRow): boolean =>
export const displayPostImportStatus = ( export const displayPostImportStatus = (
row: PostImportRow, row: PostImportRow,
): PostImportDisplayStatus | null => { ): PostImportDisplayStatus | null =>
const skipped = (row.skipReason === 'existing' || row.importStatus === 'skipped')
row.skipReason === 'existing' ? 'skipped'
|| row.importStatus === 'skipped' : ((Object.keys (row.validationErrors ?? { }).length > 0
const hidden =
Object.keys (row.validationErrors ?? { }).length > 0
|| row.importStatus === 'failed' || row.importStatus === 'failed'
|| row.importStatus === 'created' || row.importStatus === 'created')
? null
if (skipped) : ((hasWarnings (row) || row.status === 'warning')
return 'skipped' ? 'warning'
if (hidden) : 'ready'))
return null
return hasWarnings (row) || row.status === 'warning'
? 'warning'
: 'ready'
}
-36
ファイルの表示
@@ -1,36 +0,0 @@
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import type { PostImportRow } from '@/lib/postImportSession'
export const importPanelToneClass = (
status: 'created' | 'error' | 'failed' | 'invalid' | 'skipped' | 'warning' | 'ready',
): string[] => {
switch (status)
{
case 'created':
return [
'border-sky-200 bg-sky-50',
'dark:border-sky-900 dark:bg-sky-950/30']
case 'error':
case 'failed':
case 'invalid':
return [
'border-rose-200 bg-rose-50',
'dark:border-rose-900 dark:bg-rose-950/30']
case 'skipped':
return [
'border-stone-200 bg-stone-50',
'dark:border-stone-800 dark:bg-stone-900/60']
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']
}
}
export const importPanelToneForRow = (row: PostImportRow): string[] =>
importPanelToneClass (displayPostImportStatus (row) ?? 'ready')
+4 -19
ファイルの表示
@@ -9,8 +9,6 @@ import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus' import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { importPanelToneClass } from '@/components/posts/import/postImportTone'
import PostImportSummaryChip from '@/components/posts/import/PostImportSummaryChip'
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'
@@ -71,7 +69,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
[session]) [session])
const retry = async (sourceRow: number) => { const retry = async (sourceRow: number) => {
if (session == null) if (session == null || sessionId == null)
return return
setLoadingRow (sourceRow) setLoadingRow (sourceRow)
@@ -177,13 +175,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="mx-auto max-w-5xl space-y-4 p-4"> <div className="mx-auto max-w-5xl space-y-4 p-4">
<PageTitle></PageTitle> <PageTitle></PageTitle>
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700 <div className="text-sm text-neutral-700 dark:text-neutral-200">
dark:bg-neutral-900"> {counts.created}  {counts.skipped}  {counts.failed}
<div className="flex flex-wrap gap-2">
<PostImportSummaryChip label="登録成功" value={counts.created}/>
<PostImportSummaryChip label="スキップ" value={counts.skipped} badge="skipped"/>
<PostImportSummaryChip label="失敗" value={counts.failed}/>
</div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@@ -193,18 +186,10 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const canRetry = const canRetry =
row.importStatus === 'failed' row.importStatus === 'failed'
&& Object.keys (row.validationErrors).length === 0 && Object.keys (row.validationErrors).length === 0
const panelStatus =
row.importStatus === 'created'
? 'created'
: (row.importStatus === 'failed'
? 'failed'
: displayStatus ?? 'ready')
return ( return (
<div <div
key={row.sourceRow} key={row.sourceRow}
className={[ className="rounded-lg border p-4 transition-shadow hover:shadow-sm">
'rounded-lg border p-4',
...importPanelToneClass (panelStatus)].join (' ')}>
<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">
+8 -23
ファイルの表示
@@ -8,8 +8,6 @@ import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog' import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog'
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary' import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import PostImportSummaryChip from '@/components/posts/import/PostImportSummaryChip'
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'
@@ -130,8 +128,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (editingRow == null) if (editingRow == null)
return false return false
const urlChanged = draft.url !== editingRow.url const baseRow =
const nextRow =
resetRequested resetRequested
? { ? {
...editingRow, ...editingRow,
@@ -139,10 +136,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
attributes: { ...resetSnapshot.attributes }, attributes: { ...resetSnapshot.attributes },
provenance: { ...resetSnapshot.provenance }, provenance: { ...resetSnapshot.provenance },
tagSources: { ...resetSnapshot.tagSources }, tagSources: { ...resetSnapshot.tagSources },
metadataUrl: resetSnapshot.metadataUrl, metadataUrl: resetSnapshot.metadataUrl }
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending', : editingRow
importErrors: undefined } const urlChanged = draft.url !== baseRow.url
: buildNextEditedRow (editingRow, draft, urlChanged) const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
setSavingRow (editingRow.sourceRow) setSavingRow (editingRow.sourceRow)
const nextRows = session.rows.map (row => const nextRows = session.rows.map (row =>
@@ -272,16 +269,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<div className="mx-auto max-w-6xl space-y-4 p-4"> <div className="mx-auto max-w-6xl space-y-4 p-4">
<PageTitle>稿</PageTitle> <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">
<PostImportSummaryChip label="全件" value={counts.total}/>
<PostImportSummaryChip label="登録対象" value={counts.submittable} badge="ready"/>
<PostImportSummaryChip label="スキップ予定" value={counts.skipPlanned}
badge="skipped"/>
</div>
</div>
<div className="space-y-3"> <div className="space-y-3">
{reviewRows.map (row => ( {reviewRows.map (row => (
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}> <div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
@@ -336,11 +323,9 @@ const PostImportFooter = (
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row <div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between"> md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<span> {processableCount} </span> <span> {processableCount}</span>
<PostImportStatusBadge value="ready"/> <span> {creatableCount}</span>
<span> {creatableCount} </span> <span> {skipPlannedCount}</span>
<PostImportStatusBadge value="skipped"/>
<span> {skipPlannedCount} </span>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={onBack}> <Button type="button" variant="outline" onClick={onBack}>
+9 -7
ファイルの表示
@@ -34,12 +34,15 @@ const MAX_ROWS = 100
const SOURCE_ERROR_ID = 'post-import-source-error' const SOURCE_ERROR_ID = 'post-import-source-error'
const SOURCE_ISSUES_ID = 'post-import-source-issues' const SOURCE_ISSUES_ID = 'post-import-source-issues'
const urlIssuesFromRows = (rows: PostImportRow[]) => const urlIssuesFromRows = (rows: PostImportRow[], source: string) => {
rows.flatMap (row => const sourceLines = source.split (/\r\n|\n|\r/)
return rows.flatMap (row =>
(row.validationErrors.url ?? []).map (message => ({ (row.validationErrors.url ?? []).map (message => ({
sourceRow: row.sourceRow, sourceRow: row.sourceRow,
message, message,
url: row.url }))) url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
}
const PostImportSourcePage: FC<Props> = ({ user }) => { const PostImportSourcePage: FC<Props> = ({ user }) => {
@@ -108,7 +111,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
{ {
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', { const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
source }) source })
const urlIssues = urlIssuesFromRows (data.rows) const urlIssues = urlIssuesFromRows (data.rows, source)
if (urlIssues.length > 0) if (urlIssues.length > 0)
{ {
setSourceIssues (urlIssues) setSourceIssues (urlIssues)
@@ -151,7 +154,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
<title>{`投稿インポート | ${ SITE_TITLE }`}</title> <title>{`投稿インポート | ${ SITE_TITLE }`}</title>
</Helmet> </Helmet>
<Form className="max-w-4xl p-0"> <Form className="max-w-4xl">
<PageTitle>稿</PageTitle> <PageTitle>稿</PageTitle>
<FormField label="URL リスト"> <FormField label="URL リスト">
{() => ( {() => (
@@ -162,8 +165,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
aria-describedby={sourceDescribedBy || undefined} aria-describedby={sourceDescribedBy || undefined}
className={inputClass ( className={inputClass (
messages.length > 0 || sourceIssues.length > 0, messages.length > 0 || sourceIssues.length > 0,
'font-mono text-sm', 'font-mono text-sm')}
)}
onBlur={() => { onBlur={() => {
savePostImportSourceDraft (source, message => savePostImportSourceDraft (source, message =>
toast ({ toast ({
+7 -12
ファイルの表示
@@ -25,13 +25,8 @@ import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
type PostFormField = type PostFormField =
'url' 'url' | 'title' | 'tags' | 'parentPostIds'
| 'title' | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
| 'tags'
| 'parentPostIds'
| 'videoMs'
| 'originalCreatedAt'
| 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => { const PostNewPage: FC<Props> = ({ user }) => {
@@ -42,8 +37,10 @@ const PostNewPage: FC<Props> = ({ user }) => {
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> () useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null) const [originalCreatedBefore, setOriginalCreatedBefore] =
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('') const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('') const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('') const [duration, setDuration] = useState ('')
@@ -56,9 +53,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
const thumbnailPreviewRef = useRef ('') const thumbnailPreviewRef = useRef ('')
const videoFlg = const videoFlg =
useMemo (() => useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
tags.split (/\s+/).some (
tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags]) [tags])
const handleSubmit = async () => { const handleSubmit = async () => {