diff --git a/backend/app/controllers/post_imports_controller.rb b/backend/app/controllers/post_imports_controller.rb index 289eaed..eb1487f 100644 --- a/backend/app/controllers/post_imports_controller.rb +++ b/backend/app/controllers/post_imports_controller.rb @@ -2,7 +2,7 @@ class PostImportsController < ApplicationController before_action :require_member! def preview - rows = PostImportPreviewer.new(existing: params[:existing]).preview_rows( + rows = PostImportPreviewer.new.preview_rows( rows: PostImportUrlListParser.parse(params[:source])) render json: { rows: } rescue ArgumentError => e @@ -13,18 +13,17 @@ class PostImportsController < ApplicationController rows = normalised_import_rows(allow_warning_fields: true) changed_row = Integer(params[:changed_row], exception: false) result = - PostImportPreviewer.new(existing: params[:existing]) - .preview_rows(rows:, fetch_metadata: changed_row, metadata_cache: {}) + PostImportPreviewer.new.preview_rows(rows:, + fetch_metadata: changed_row, + metadata_cache: { }) render json: { rows: result } rescue ArgumentError => e render_bad_request(e.message) end def create - result = PostImportRunner.new( - actor: current_user, - rows: normalised_import_rows, - existing: params[:existing]).run + result = PostImportRunner.new(actor: current_user, + rows: normalised_import_rows).run render json: result, status: result[:created].positive? ? :created : :ok rescue ArgumentError => e render_bad_request(e.message) diff --git a/backend/app/services/post_import_previewer.rb b/backend/app/services/post_import_previewer.rb index 5397fac..526b235 100644 --- a/backend/app/services/post_import_previewer.rb +++ b/backend/app/services/post_import_previewer.rb @@ -8,9 +8,11 @@ class PostImportPreviewer 'tags', 'parent_post_ids', ].freeze - def initialize existing: 'skip' - @existing = existing == 'error' ? 'error' : 'skip' - end + FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze + EXISTING_SKIP_WARNING = '既存投稿のためスキップします.'.freeze + TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze + THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze + METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze def preview_rows rows:, fetch_metadata: true, metadata_cache: { } urls = {} @@ -30,48 +32,60 @@ class PostImportPreviewer attributes = initial_attributes(row) provenance = initial_provenance(row) tag_sources = initial_tag_sources(row, attributes, provenance) + field_warnings = initial_field_warnings(row) + base_warnings = initial_base_warnings(row) url = row[:url].to_s.strip provenance['url'] = 'manual' normal_url = normalised_url(url) url_for_metadata = normal_url || url existing_post = normal_url.present? && Post.exists?(url: normal_url) - validation_warnings = [] validation_errors = {} validation_errors[:url] = ['URL が不正です.'] if normal_url.blank? validation_errors[:url] = ['URL が重複しています.'] if normal_url.present? && urls.key?(normal_url) urls[normal_url] = true if normal_url.present? - if normal_url.present? && existing_post - if @existing == 'error' - validation_errors[:url] = ['既存投稿です.'] - else - validation_warnings << '既存投稿のためスキップします.' - end - end if row[:metadata_url].present? && row[:metadata_url] != url_for_metadata clear_automatic_values!(attributes, provenance, tag_sources) + clear_fetch_warnings!(field_warnings) + end + + if normal_url.present? && existing_post + add_field_warning!(field_warnings, 'url', EXISTING_SKIP_WARNING) + attributes['tags'] = merged_tags(tag_sources, provenance['tags']) + warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present? + return { + source_row: row[:source_row], + url: normal_url, + attributes:, + provenance:, + tag_sources:, + metadata_url: url_for_metadata, + field_warnings:, + base_warnings:, + validation_errors:, + status: validation_errors.present? ? 'error' : (warnings_present ? 'warning' : 'ready'), + } end - fetch_warnings = Array(row[:fetch_warnings]).dup should_fetch = should_fetch_metadata?(fetch_metadata, row[:source_row].to_i) - if should_fetch && !(existing_post && @existing == 'skip') - metadata, fetch_warnings = - metadata_for(url_for_metadata, metadata_cache, fetch_warnings) - apply_metadata!(attributes, provenance, tag_sources, metadata) + if should_fetch + clear_fetch_warnings!(field_warnings) + metadata = metadata_for(url_for_metadata, metadata_cache) + apply_metadata!(attributes, provenance, tag_sources, metadata[:data]) + apply_fetch_warnings!(field_warnings, metadata[:warnings]) end attributes['url'] = url validate_basic_data(attributes, validation_errors) - validate_preview_tags( - merged_tags(tag_sources, provenance['tags']), - validation_errors, - validation_warnings, - ) + validate_preview_tags(merged_tags(tag_sources, provenance['tags']), + validation_errors, + field_warnings) validate_parents(attributes['parent_post_ids'], validation_errors) attributes.delete('url') attributes['tags'] = merged_tags(tag_sources, provenance['tags']) + warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present? { source_row: row[:source_row], url: normal_url || url, @@ -79,14 +93,10 @@ class PostImportPreviewer provenance:, tag_sources:, metadata_url: url_for_metadata, - fetch_warnings:, - validation_warnings:, - warnings: fetch_warnings + validation_warnings, + field_warnings:, + base_warnings:, validation_errors:, - status: validation_errors.present? \ - ? 'error' \ - : ((fetch_warnings + validation_warnings).present? ? 'warning' : 'ready'), - existing: existing_post, + status: validation_errors.present? ? 'error' : (warnings_present ? 'warning' : 'ready'), } end @@ -100,12 +110,12 @@ class PostImportPreviewer end def initial_attributes row - attributes = row[:attributes]&.stringify_keys || {} + attributes = row[:attributes]&.stringify_keys || { } FIELDS.to_h { |field| [field, attributes[field].to_s] } end def initial_provenance row - provenance = row[:provenance]&.stringify_keys || {} + provenance = row[:provenance]&.stringify_keys || { } FIELDS.to_h { |field| [field, provenance[field].presence || 'automatic'] } .merge('url' => 'manual') end @@ -118,6 +128,14 @@ class PostImportPreviewer sources end + def initial_field_warnings row + (row[:field_warnings] || { }).stringify_keys.transform_values { |value| Array(value).map(&:to_s) } + end + + def initial_base_warnings row + Array(row[:base_warnings]).map(&:to_s) + end + def clear_automatic_values! attributes, provenance, tag_sources ['title', 'thumbnail_base', 'original_created_from', 'original_created_before', 'duration'].each do |field| @@ -127,18 +145,24 @@ class PostImportPreviewer attributes['tags'] = merged_tags(tag_sources, provenance['tags']) end - def metadata_for url, cache, previous_warnings + def clear_fetch_warnings! field_warnings + FETCH_WARNING_FIELDS.each do |field| + field_warnings.delete(field) + end + end + + def metadata_for url, cache cache[url] ||= fetch_metadata(url) end def fetch_metadata url - return [{}, ['URL が空です.']] if url.blank? + return { data: { }, warnings: { 'url' => ['URL が空です.'] } } if url.blank? - data = PostMetadataFetcher.fetch(url).stringify_keys - warnings = [] - warnings << 'タイトルを取得できませんでした.' if data['title'].blank? - warnings << 'サムネールを取得できませんでした.' if data['thumbnail_base'].blank? - [data.compact, warnings] + data = PostMetadataFetcher.fetch(url).stringify_keys.compact + warnings = { } + add_field_warning!(warnings, 'title', TITLE_FETCH_WARNING) if data['title'].blank? + add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING) if data['thumbnail_base'].blank? + { data:, warnings: } rescue Preview::UrlSafety::UnsafeUrl, Preview::HttpFetcher::FetchFailed, Preview::HttpFetcher::ResponseTooLarge => e @@ -146,7 +170,7 @@ class PostImportPreviewer "post_import_metadata_fetch_failure "\ "#{ { error: e.class.name, message: e.message }.to_json }", ) - [{}, ['メタデータを取得できませんでした.']] + { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } } end def apply_metadata! attributes, provenance, tag_sources, metadata @@ -165,13 +189,26 @@ class PostImportPreviewer end end + def apply_fetch_warnings! field_warnings, warnings + warnings.each do |field, values| + values.each do |value| + add_field_warning!(field_warnings, field, value) + end + end + end + + def add_field_warning! field_warnings, field, message + field_warnings[field] ||= [] + field_warnings[field] << message unless field_warnings[field].include?(message) + end + def merged_tags sources, origin = nil return sources['manual'].to_s if origin == 'manual' sources['automatic'].to_s end - def validate_preview_tags raw, errors, warnings + def validate_preview_tags raw, errors, field_warnings names = raw.to_s.split return if names.empty? if names.any? { _1.downcase.start_with?('nico:') } @@ -185,7 +222,9 @@ class PostImportPreviewer errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present? known = existing.reject(&:deprecated?).map(&:name) new_tags = parsed.uniq - known - warnings << "新規タグを作成します: #{ new_tags.join(' ') }" if new_tags.present? + if new_tags.present? + add_field_warning!(field_warnings, 'tags', "新規タグを作成します: #{ new_tags.join(' ') }") + end rescue Tag::SectionLiteralParseError errors[:tags] = ['タグ区間の記法が不正です.'] end diff --git a/backend/app/services/post_import_row_normaliser.rb b/backend/app/services/post_import_row_normaliser.rb index 92a41db..9172cb1 100644 --- a/backend/app/services/post_import_row_normaliser.rb +++ b/backend/app/services/post_import_row_normaliser.rb @@ -64,10 +64,10 @@ class PostImportRowNormaliser return keys unless allow_warning_fields keys + [ - { fetch_warnings: [] }, - { fetchWarnings: [] }, - { validation_warnings: [] }, - { validationWarnings: [] }, + { field_warnings: {} }, + { fieldWarnings: {} }, + { base_warnings: [] }, + { baseWarnings: [] }, ] end private_class_method :permitted_keys @@ -141,10 +141,12 @@ class PostImportRowNormaliser def self.normalise_warning_values! normalised, allow_warning_fields: return unless allow_warning_fields - %w[fetch_warnings validation_warnings].each do |field| - values = normalised[field] - next if values.nil? - + field_warnings = normalised['field_warnings'] + unless field_warnings.nil? || field_warnings.is_a?(Hash) + raise ArgumentError, '警告の形式が不正です.' + end + field_warnings&.each do |key, values| + raise ArgumentError, '警告の形式が不正です.' unless ATTRIBUTE_FIELDS.include?(key) || key == 'url' unless values.is_a?(Array) && values.all? { _1.is_a?(String) } raise ArgumentError, '警告の形式が不正です.' end @@ -152,6 +154,14 @@ class PostImportRowNormaliser raise ArgumentError, '警告が大きすぎます.' end end + + base_warnings = normalised['base_warnings'] + return if base_warnings.nil? + + unless base_warnings.is_a?(Array) && base_warnings.all? { _1.is_a?(String) } + raise ArgumentError, '警告の形式が不正です.' + end + raise ArgumentError, '警告が大きすぎます.' if base_warnings.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES } end private_class_method :normalise_warning_values! diff --git a/backend/app/services/post_import_runner.rb b/backend/app/services/post_import_runner.rb index 19def46..0fe4ef8 100644 --- a/backend/app/services/post_import_runner.rb +++ b/backend/app/services/post_import_runner.rb @@ -1,8 +1,7 @@ class PostImportRunner - def initialize actor:, rows:, existing: 'skip' + def initialize actor:, rows: @actor = actor @rows = rows - @existing = existing == 'error' ? 'error' : 'skip' end def run @@ -21,8 +20,7 @@ class PostImportRunner def run_row row attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore } - preview = PostImportPreviewer.new(existing: @existing) - .preview_rows( + preview = PostImportPreviewer.new.preview_rows( rows: [{ source_row: row['source_row'], url: row['url'], @@ -31,7 +29,7 @@ class PostImportRunner tag_sources: row['tag_sources'], }], fetch_metadata: false).first - if @existing == 'skip' && preview[:existing] + if Array(preview.dig(:field_warnings, 'url')).include?(PostImportPreviewer::EXISTING_SKIP_WARNING) return row.slice('source_row').merge(status: 'skipped') end diff --git a/backend/app/services/post_import_url_list_parser.rb b/backend/app/services/post_import_url_list_parser.rb index 80b78a5..af3623a 100644 --- a/backend/app/services/post_import_url_list_parser.rb +++ b/backend/app/services/post_import_url_list_parser.rb @@ -11,7 +11,7 @@ class PostImportUrlListParser url = line.strip next if url.blank? - raise ArgumentError, 'URL が長すぎます.' if url.bytesize > MAX_URL_BYTES + raise ArgumentError, "#{ index + 1 } 行目: URL が長すぎます." if url.bytesize > MAX_URL_BYTES { source_row: index + 1, url: } } diff --git a/frontend/src/components/posts/import/PostImportRowDialog.tsx b/frontend/src/components/posts/import/PostImportRowDialog.tsx index 232d308..0ac35f5 100644 --- a/frontend/src/components/posts/import/PostImportRowDialog.tsx +++ b/frontend/src/components/posts/import/PostImportRowDialog.tsx @@ -49,6 +49,10 @@ const buildDraft = (row: PostImportRow): Draft => ({ tags: String (row.attributes.tags ?? ''), parentPostIds: String (row.attributes.parentPostIds ?? '') }) +const warningClass = + 'mt-1 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm ' + + 'text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200' + const PostImportRowDialog: FC = ( { open, row, saving, onOpenChange, onSave }, @@ -102,46 +106,81 @@ const PostImportRowDialog: FC = ( label="URL" value={draft.url} origin={originOf (row, 'url')} + warnings={row.fieldWarnings.url} + errors={[ + ...(row.validationErrors.url ?? []), + ...(row.importErrors?.url ?? [])]} onChange={value => update ('url', value)}/> update ('title', value)}/> update ('thumbnailBase', value)}/>
update ('originalCreatedFrom', value)}/> update ('originalCreatedBefore', value)}/>
update ('duration', value)}/> update ('tags', value)}/> update ('parentPostIds', value)}/> - - + {row.baseWarnings.length > 0 && ( +
+ {row.baseWarnings.map ((message, index) => ( +
{message}
))} +
)} + + @@ -164,10 +203,12 @@ const PostImportRowDialog: FC = ( } const DialogField = ( - { label, value, origin, onChange }: { + { label, value, origin, warnings, errors, onChange }: { label: string value: string origin: PostImportOrigin + warnings?: string[] + errors?: string[] onChange: (value: string) => void }, ) => ( ) const DialogArea = ( - { label, value, origin, onChange }: { + { label, value, origin, warnings, errors, onChange }: { label: string value: string origin: PostImportOrigin + warnings?: string[] + errors?: string[] onChange: (value: string) => void }, ) => ( ) export default PostImportRowDialog diff --git a/frontend/src/components/posts/import/PostImportRowSummary.tsx b/frontend/src/components/posts/import/PostImportRowSummary.tsx index c9f2339..622e051 100644 --- a/frontend/src/components/posts/import/PostImportRowSummary.tsx +++ b/frontend/src/components/posts/import/PostImportRowSummary.tsx @@ -1,6 +1,7 @@ import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import PrefetchLink from '@/components/PrefetchLink' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview' @@ -48,6 +49,11 @@ const summaryError = (row: PostImportRow): string | null => { return Object.values (row.importErrors ?? { }).flat ()[0] ?? null } +const summaryWarning = (row: PostImportRow): string | null => + Object.values (row.fieldWarnings ?? { }).flat ()[0] + ?? row.baseWarnings?.[0] + ?? null + const summaryDate = (row: PostImportRow): string => [row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore] .filter (_1 => typeof _1 === 'string' && _1 !== '') @@ -56,6 +62,7 @@ const summaryDate = (row: PostImportRow): string => const PostImportRowSummary: FC = ({ row, onEdit }) => { const error = summaryError (row) + const warning = error == null ? summaryWarning (row) : null const importStatus = row.importStatus ?? 'pending' return ( @@ -89,6 +96,21 @@ const PostImportRowSummary: FC = ({ row, onEdit }) => { {summaryDate (row) || '日時未取得'} {row.attributes.duration ? ` / ${ row.attributes.duration }` : ''} + {row.createdPostId && ( + + 投稿 #{row.createdPostId} + )} + {warning && ( +
+ {warning} +
)} + {error && ( +
+ {error} +
)}
@@ -96,7 +118,7 @@ const PostImportRowSummary: FC = ({ row, onEdit }) => {
{error ? - : } + : (warning ? : )}
diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 12dbfdd..6a67cf8 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -19,7 +19,6 @@ import { loadPostImportSession, mergeImportResults, mergeValidatedImportRows, reviewSummaryCounts, - rowMessages, savePostImportSession, submittableImportRows, type PostImportResultRow, @@ -59,7 +58,8 @@ const PostImportReviewPage: FC = ({ user }) => { if (!(sessionId)) return - const loaded = loadPostImportSession (sessionId) + const loaded = loadPostImportSession (sessionId, message => + toast ({ title: '取込状態を復元できませんでした', description: message })) setSession (loaded) setMissing (loaded == null) }, [sessionId]) @@ -68,7 +68,8 @@ const PostImportReviewPage: FC = ({ user }) => { if (!(sessionId) || !(session)) return - savePostImportSession (sessionId, session) + savePostImportSession (sessionId, session, message => + toast ({ title: '取込状態を保存できませんでした', description: message })) }, [session, sessionId]) const rows = session?.rows ?? [] @@ -160,8 +161,15 @@ const PostImportReviewPage: FC = ({ user }) => { try { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { - rows: nextRows.filter (row => row.importStatus !== 'created'), - existing: session.existing, + rows: nextRows + .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: urlChanged ? editingRow.sourceRow : -1 }) updateSessionRows (mergeValidatedImportRows (nextRows, validated.rows)) setSearchParams ({ }) @@ -178,33 +186,6 @@ const PostImportReviewPage: FC = ({ user }) => { } } - 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 @@ -223,12 +204,14 @@ const PostImportReviewPage: FC = ({ user }) => { attributes: row.attributes, provenance: row.provenance, tagSources: row.tagSources, - metadataUrl: row.metadataUrl })), - existing: session.existing }) + metadataUrl: row.metadataUrl })) }) const nextRows = mergeImportResults (session.rows, result.rows) const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const } setSession (nextSession) - savePostImportSession (sessionId, nextSession) + const saved = savePostImportSession (sessionId, nextSession, message => + toast ({ title: '取込状態を保存できませんでした', description: message })) + if (!(saved)) + return navigate (`/posts/import/${ sessionId }/result`) } catch @@ -264,7 +247,7 @@ const PostImportReviewPage: FC = ({ user }) => { {`投稿インポート確認 | ${ SITE_TITLE }`} -
+
投稿情報の確認
-
-
- URL {rows.length} 件 -
- -
-
- -
+
{reviewRows.map (row => (
= ({ user }) => { onEdit={() => { setSearchParams ({ edit: String (row.sourceRow) }) }}/> - {rowMessages (row).length > 0 && ( -
- -
)}
))}
diff --git a/frontend/src/pages/posts/PostImportSourcePage.tsx b/frontend/src/pages/posts/PostImportSourcePage.tsx index 5569fb8..efb6653 100644 --- a/frontend/src/pages/posts/PostImportSourcePage.tsx +++ b/frontend/src/pages/posts/PostImportSourcePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useNavigate } from 'react-router-dom' @@ -13,11 +13,12 @@ import { apiPost, isApiError } from '@/lib/api' import { canEditContent } from '@/lib/users' import { inputClass } from '@/lib/utils' import { countImportSourceLines, - countImportSourceValidLines, + cleanupExpiredPostImportSessions, createPostImportSessionId, loadPostImportSourceDraft, savePostImportSession, savePostImportSourceDraft, + validateImportSource, type PostImportRow } from '@/lib/postImportSession' import Forbidden from '@/pages/Forbidden' @@ -33,38 +34,58 @@ const MAX_ROWS = 100 const PostImportSourcePage: FC = ({ user }) => { const editable = canEditContent (user) const navigate = useNavigate () - const draft = useMemo (() => loadPostImportSourceDraft (), []) + const draft = useMemo (() => loadPostImportSourceDraft (message => + toast ({ title: '保存済み入力を復元できませんでした', description: message })), []) const [source, setSource] = useState (draft.source) - const [existing, setExisting] = useState<'skip' | 'error'> (draft.existing) const [loading, setLoading] = useState (false) const [error, setError] = useState (null) + const [touched, setTouched] = useState (false) + const saveTimer = useRef (null) const lineCount = countImportSourceLines (source) - const validLineCount = countImportSourceValidLines (source) + const issues = useMemo (() => validateImportSource (source), [source]) const messages = [ - ...(lineCount === 0 ? ['URL を入力してください.'] : []), - ...(lineCount > MAX_ROWS ? [`最大 ${ MAX_ROWS } 件までです.`] : []), + ...(((touched) && lineCount === 0) ? ['URL を入力してください.'] : []), ...(error ? [error] : [])] useEffect (() => { - savePostImportSourceDraft (source, existing) - }, [existing, source]) + cleanupExpiredPostImportSessions (message => + toast ({ title: '保存済みデータを整理できませんでした', description: message })) + }, []) + + useEffect (() => { + if (saveTimer.current != null) + window.clearTimeout (saveTimer.current) + saveTimer.current = window.setTimeout (() => { + savePostImportSourceDraft (source, message => + toast ({ title: '入力内容を保存できませんでした', description: message })) + }, 300) + return () => { + if (saveTimer.current != null) + window.clearTimeout (saveTimer.current) + } + }, [source]) const preview = async () => { + setTouched (true) + if (lineCount === 0 || issues.length > 0) + return + setLoading (true) setError (null) try { const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', { - source, - existing }) + source }) const sessionId = createPostImportSessionId () - savePostImportSession (sessionId, { + const saved = savePostImportSession (sessionId, { source, - existing, rows: data.rows, - repairMode: 'all' }) + repairMode: 'all' }, message => + toast ({ title: '取込状態を保存できませんでした', description: message })) + if (!(saved)) + return navigate (`/posts/import/${ sessionId }/review`) } catch (requestError) @@ -99,48 +120,50 @@ const PostImportSourcePage: FC = ({ user }) => {
-
-

URL リストを入力

-

- 一行につき一つの URL を入力してください. -

-
- {() => (