広場投稿追加画面の刷新 (#399) #413
@@ -529,6 +529,11 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
||||
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
|
||||
make dense controls wrap or scroll intentionally, and keep tag/filter
|
||||
controls usable without horizontal page overflow.
|
||||
- Frontend のスマホ/PC表示境界は原則 `md` とする。
|
||||
- button stack、footer action、dialogue action は `md` 未満で縦並び、
|
||||
`md` 以上で横並びとする。
|
||||
- 同じ画面内で `sm` と `md` を混在させて中間 layout を作らない。
|
||||
- 明確に別の responsive 要件がある component だけを例外とする。
|
||||
- For mobile horizontal scrollers, make the scroll direction and item sizing
|
||||
explicit, and ensure chip text remains readable in both light and dark modes.
|
||||
- In TypeScript and TSX, prefer direct comparison operators such as `===` and
|
||||
|
||||
@@ -137,6 +137,7 @@ class PostsController < ApplicationController
|
||||
video_ms: nil,
|
||||
field_warnings: { },
|
||||
base_warnings: [],
|
||||
existing_post_id: existing_post.id,
|
||||
existing_post: compact_post(existing_post.id) }
|
||||
end
|
||||
|
||||
@@ -158,6 +159,7 @@ class PostsController < ApplicationController
|
||||
video_ms: metadata[:video_ms],
|
||||
field_warnings: field_warnings,
|
||||
base_warnings: [],
|
||||
existing_post_id: nil,
|
||||
existing_post: nil }
|
||||
rescue ArgumentError => e
|
||||
render_bad_request e.message
|
||||
@@ -177,6 +179,7 @@ class PostsController < ApplicationController
|
||||
video_ms: nil,
|
||||
field_warnings: { url: ['自動取得に失敗しました.'] },
|
||||
base_warnings: [],
|
||||
existing_post_id: nil,
|
||||
existing_post: nil }
|
||||
end
|
||||
|
||||
@@ -212,7 +215,7 @@ class PostsController < ApplicationController
|
||||
attributes: post_create_attributes,
|
||||
thumbnail: params[:thumbnail]).run
|
||||
return render json: dry_run_json(preflight) if bool?(:dry)
|
||||
if preflight[:existing_post].present?
|
||||
if preflight[:existing_post_id].present?
|
||||
post = Post.new(url: preflight[:url])
|
||||
post.errors.add :url, :taken
|
||||
return render_post_form_record_invalid post
|
||||
@@ -637,6 +640,7 @@ class PostsController < ApplicationController
|
||||
:video_ms,
|
||||
:field_warnings,
|
||||
:base_warnings,
|
||||
:existing_post_id,
|
||||
:existing_post)
|
||||
end
|
||||
|
||||
|
||||
@@ -20,13 +20,19 @@ class Post < ApplicationRecord
|
||||
|
||||
def self.resized_thumbnail_attachment(upload, content_type: nil)
|
||||
upload.rewind
|
||||
image = image_for_thumbnail_upload(upload.read, content_type:)
|
||||
image.resize '180x180'
|
||||
image.format 'jpg'
|
||||
bytes = upload.read
|
||||
blob = Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
|
||||
image = image_for_thumbnail_upload(bytes, content_type:)
|
||||
image.resize '180x180'
|
||||
image.format 'jpg'
|
||||
image.to_blob
|
||||
end
|
||||
|
||||
{ io: StringIO.new(image.to_blob),
|
||||
{ io: StringIO.new(blob),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg' }
|
||||
rescue Timeout::Error
|
||||
raise MiniMagick::Error, 'サムネイル画像の変換に失敗しました.'
|
||||
ensure
|
||||
upload.rewind
|
||||
end
|
||||
@@ -41,6 +47,7 @@ class Post < ApplicationRecord
|
||||
Preview::HttpFetcher::FetchFailed,
|
||||
Preview::HttpFetcher::FetchTimeout,
|
||||
Preview::HttpFetcher::ResponseTooLarge,
|
||||
Timeout::Error,
|
||||
MiniMagick::Error => e
|
||||
raise RemoteThumbnailFetchFailed, e.message
|
||||
end
|
||||
@@ -240,13 +247,11 @@ class Post < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.decode_raster_thumbnail(bytes)
|
||||
Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) { MiniMagick::Image.read(bytes) }
|
||||
MiniMagick::Image.read(bytes)
|
||||
end
|
||||
|
||||
def self.decode_svg_thumbnail(bytes)
|
||||
Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
|
||||
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
|
||||
end
|
||||
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
|
||||
end
|
||||
|
||||
def self.sanitised_svg_bytes(bytes)
|
||||
@@ -303,7 +308,7 @@ class Post < ApplicationRecord
|
||||
stripped = value.to_s.strip
|
||||
return false if stripped.blank? || stripped.start_with?('#')
|
||||
|
||||
stripped.match?(/\A(?:https?:|data:|\/\/)/i)
|
||||
true
|
||||
end
|
||||
|
||||
def self.style_contains_disallowed_urls?(value)
|
||||
@@ -325,6 +330,8 @@ class Post < ApplicationRecord
|
||||
|
||||
view_box = root['viewBox'].to_s.strip.split(/\s+/).map { Float(_1) rescue nil }
|
||||
return [nil, nil] if view_box.length != 4 || view_box.any?(&:nil?)
|
||||
return [nil, nil] unless view_box[2].finite? && view_box[2].positive?
|
||||
return [nil, nil] unless view_box[3].finite? && view_box[3].positive?
|
||||
|
||||
[view_box[2], view_box[3]]
|
||||
end
|
||||
|
||||
@@ -65,9 +65,10 @@ class PostBulkCreator
|
||||
PostCreatePreflight.new(
|
||||
attributes: attributes,
|
||||
thumbnail: thumbnail_for(index, attributes)).run
|
||||
if preflight[:existing_post].present?
|
||||
if preflight[:existing_post_id].present?
|
||||
return {
|
||||
status: 'skipped',
|
||||
existing_post_id: preflight[:existing_post_id],
|
||||
existing_post: preflight[:existing_post] }
|
||||
end
|
||||
|
||||
@@ -91,6 +92,7 @@ class PostBulkCreator
|
||||
if existing_post.present?
|
||||
return {
|
||||
status: 'skipped',
|
||||
existing_post_id: existing_post[:id],
|
||||
existing_post: existing_post }
|
||||
end
|
||||
|
||||
@@ -104,6 +106,7 @@ class PostBulkCreator
|
||||
existing_post = existing_post_for_race(attributes)
|
||||
return {
|
||||
status: 'skipped',
|
||||
existing_post_id: existing_post[:id],
|
||||
existing_post: existing_post } if existing_post.present?
|
||||
end
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class PostCreatePreflight
|
||||
video_ms: preview[:attributes]['video_ms'],
|
||||
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
|
||||
base_warnings: preview[:base_warnings],
|
||||
existing_post_id: preview[:existing_post_id],
|
||||
existing_post: existing_post_compact(preview[:existing_post_id]) }
|
||||
end
|
||||
|
||||
@@ -70,6 +71,7 @@ class PostCreatePreflight
|
||||
normalised_parent_post_ids: plan[:normalised_parent_post_ids],
|
||||
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
|
||||
base_warnings: preview[:base_warnings],
|
||||
existing_post_id: preview[:existing_post_id],
|
||||
existing_post: existing_post_compact(preview[:existing_post_id]) }
|
||||
end
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class PostThumbnailUploadValidator
|
||||
|
||||
attachment = Post.resized_thumbnail_attachment(thumbnail, content_type: thumbnail.content_type)
|
||||
attachment[:io].close if attachment[:io].respond_to?(:close)
|
||||
rescue MiniMagick::Error
|
||||
rescue MiniMagick::Error, Timeout::Error
|
||||
raise InvalidUpload, 'サムネイル画像の変換に失敗しました.'
|
||||
ensure
|
||||
thumbnail&.rewind if thumbnail.respond_to?(:rewind)
|
||||
|
||||
@@ -602,6 +602,12 @@ offsets, or footer offsets, inspect existing layout components such as
|
||||
Do not create a second layout shell before checking whether the current layout
|
||||
can be reused or minimally extended.
|
||||
|
||||
- Frontend のスマホ/PC表示境界は原則 `md` とする。
|
||||
- button stack、footer action、dialogue action は `md` 未満で縦並び、
|
||||
`md` 以上で横並びとする。
|
||||
- 同じ画面内で `sm` と `md` を混在させて中間 layout を作らない。
|
||||
- 明確に別の responsive 要件がある component だけを例外とする。
|
||||
|
||||
### Delimiter decision table
|
||||
|
||||
Use this table before accepting any edited TypeScript or TSX hunk. The table is
|
||||
|
||||
@@ -215,12 +215,12 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
<DialogFooter
|
||||
className="shrink-0 flex-col gap-2 pt-4
|
||||
sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
md:flex-row md:justify-between md:gap-0 md:space-x-0">
|
||||
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
|
||||
{startActions.map (action => (
|
||||
<Button
|
||||
key={action.label}
|
||||
className="w-full sm:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
variant={action.variant === 'danger'
|
||||
? 'destructive'
|
||||
: 'default'}
|
||||
@@ -232,9 +232,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
||||
</Button>))}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
variant="outline"
|
||||
onClick={() => closeRequest (active.id)}
|
||||
disabled={pendingIds.includes (active.id)
|
||||
@@ -245,7 +245,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
||||
{endActions.map (action => (
|
||||
<Button
|
||||
key={action.label}
|
||||
className="w-full sm:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
variant={action.variant === 'danger'
|
||||
? 'destructive'
|
||||
: 'default'}
|
||||
|
||||
@@ -173,10 +173,11 @@ const PostImportRowSummary: FC<Props> = (
|
||||
</div>
|
||||
</div>
|
||||
{showActions && (editVisible || retryAllowed) && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
{editVisible && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:w-auto"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={editDisabled === true || !(editAllowed)}>
|
||||
@@ -185,6 +186,7 @@ const PostImportRowSummary: FC<Props> = (
|
||||
{retryAllowed && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:w-auto"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={retryDisabled === true}>
|
||||
|
||||
@@ -69,7 +69,7 @@ const DialogHeader = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
"flex flex-col space-y-1.5 text-center md:text-left",
|
||||
className)}
|
||||
{...props}
|
||||
/>)
|
||||
@@ -81,7 +81,7 @@ const DialogFooter = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
"flex flex-col-reverse md:flex-row md:justify-end md:space-x-2",
|
||||
className)}
|
||||
{...props}
|
||||
/>)
|
||||
|
||||
@@ -76,6 +76,7 @@ type PostMetadataResponse = {
|
||||
fieldWarnings?: Record<string, string[]>
|
||||
baseWarnings?: string[]
|
||||
validationErrors?: Record<string, string[]>
|
||||
existingPostId?: number
|
||||
existingPost?: {
|
||||
id: number
|
||||
title: string
|
||||
@@ -85,6 +86,7 @@ type PostMetadataResponse = {
|
||||
type BulkApiRow = {
|
||||
status: 'created' | 'skipped' | 'failed'
|
||||
post?: { id: number }
|
||||
existingPostId?: number
|
||||
existingPost?: {
|
||||
id: number
|
||||
title: string
|
||||
@@ -114,20 +116,20 @@ const rowMetadataPending = (row: PostImportRow): boolean =>
|
||||
const rowOperationBusy = (
|
||||
row: PostImportRow,
|
||||
submitting: boolean,
|
||||
loadingRow: number | null,
|
||||
busyRowIds: Set<number>,
|
||||
): boolean =>
|
||||
submitting
|
||||
|| loadingRow === row.sourceRow
|
||||
|| busyRowIds.has (row.sourceRow)
|
||||
|| rowMetadataPending (row)
|
||||
|
||||
|
||||
const rowSkipBusy = (
|
||||
row: PostImportRow,
|
||||
submitting: boolean,
|
||||
loadingRow: number | null,
|
||||
busyRowIds: Set<number>,
|
||||
): boolean =>
|
||||
submitting
|
||||
|| loadingRow === row.sourceRow
|
||||
|| busyRowIds.has (row.sourceRow)
|
||||
|| (rowMetadataPending (row) && !(isManualSkipRow (row)))
|
||||
|
||||
const shouldFetchMetadata = (row: PostImportRow): boolean =>
|
||||
@@ -242,8 +244,11 @@ const mergeDryRunRow = (
|
||||
hasWarnings
|
||||
? 'warning'
|
||||
: 'ready',
|
||||
skipReason: result.existingPost?.id != null ? 'existing' : undefined,
|
||||
existingPostId: result.existingPost?.id,
|
||||
skipReason:
|
||||
result.existingPostId != null || result.existingPost?.id != null
|
||||
? 'existing'
|
||||
: undefined,
|
||||
existingPostId: result.existingPostId ?? result.existingPost?.id,
|
||||
existingPost: result.existingPost ?? undefined,
|
||||
importStatus:
|
||||
currentRow.importStatus === 'created'
|
||||
@@ -306,12 +311,16 @@ const indexedBulkResults = (
|
||||
baseWarnings: result.baseWarnings,
|
||||
errors }
|
||||
}
|
||||
if (result?.status === 'skipped' && result.existingPost != null)
|
||||
if (result?.status === 'skipped')
|
||||
{
|
||||
return {
|
||||
sourceRow: row.sourceRow,
|
||||
status: 'skipped',
|
||||
existingPostId: result.existingPost.id,
|
||||
existingPostId:
|
||||
result.existingPostId
|
||||
?? result.existingPost?.id
|
||||
?? row.existingPostId
|
||||
?? row.sourceRow,
|
||||
existingPost: result.existingPost,
|
||||
fieldWarnings: result.fieldWarnings,
|
||||
baseWarnings: result.baseWarnings,
|
||||
@@ -350,10 +359,10 @@ const mergePreviewRow = (
|
||||
fieldWarnings,
|
||||
baseWarnings,
|
||||
validationErrors,
|
||||
existingPostId: preview.existingPost?.id,
|
||||
existingPostId: preview.existingPostId ?? preview.existingPost?.id,
|
||||
existingPost: preview.existingPost ?? undefined,
|
||||
skipReason:
|
||||
preview.existingPost?.id != null
|
||||
preview.existingPostId != null || preview.existingPost?.id != null
|
||||
? 'existing'
|
||||
: currentRow.skipReason === 'manual'
|
||||
? 'manual'
|
||||
@@ -468,7 +477,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||
const [metadataLoading, setMetadataLoading] = useState (false)
|
||||
const [submitting, setSubmitting] = useState (false)
|
||||
const [loadingRow, setLoadingRow] = useState<number | null> (null)
|
||||
const [busyRowIds, setBusyRowIds] = useState<Set<number>> (new Set ())
|
||||
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
|
||||
const [showExistingRows, setShowExistingRows] = useState (false)
|
||||
const sessionRef = useRef<PostImportSession | null> (null)
|
||||
@@ -476,18 +485,31 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const persistedSearchRef = useRef<string | null> (null)
|
||||
const persistSequenceRef = useRef (0)
|
||||
const previewSequenceRef = useRef (0)
|
||||
const metadataSequenceRef = useRef (0)
|
||||
|
||||
const commitSessionState = (
|
||||
nextSession: PostImportSession,
|
||||
): PostImportSession => {
|
||||
): {
|
||||
session: PostImportSession
|
||||
sessionRoundTripSucceeded: boolean } => {
|
||||
const sessionId =
|
||||
sessionIdRef.current ?? generatePostImportSessionId ()
|
||||
sessionIdRef.current = sessionId
|
||||
const persistedSession = buildSession (nextSession.rows)
|
||||
savePostImportSession (sessionId, persistedSession)
|
||||
const saved = savePostImportSession (sessionId, persistedSession, showStorageError)
|
||||
const loadedSession =
|
||||
saved
|
||||
? loadPostImportSession (sessionId, showStorageError)
|
||||
: null
|
||||
sessionRef.current = persistedSession
|
||||
setSession (persistedSession)
|
||||
return persistedSession
|
||||
return {
|
||||
session: persistedSession,
|
||||
sessionRoundTripSucceeded:
|
||||
saved
|
||||
&& serialisedPostImportRowsEqual (
|
||||
loadedSession?.rows ?? [],
|
||||
persistedSession.rows) }
|
||||
}
|
||||
|
||||
const syncSessionUrl = async (
|
||||
@@ -503,7 +525,16 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
if (persistSequenceRef.current !== sequence)
|
||||
return null
|
||||
|
||||
const persistedSession = commitSessionState (nextSession)
|
||||
const { session: persistedSession,
|
||||
sessionRoundTripSucceeded } = commitSessionState (nextSession)
|
||||
const canNavigate =
|
||||
serialised.complete
|
||||
|| sessionRoundTripSucceeded
|
||||
if (!(canNavigate))
|
||||
{
|
||||
showStorageError ('ブラウザへ保存できませんでした.')
|
||||
return null
|
||||
}
|
||||
|
||||
const path = appendPostNewSessionId (serialised.path, sessionId)
|
||||
persistedSearchRef.current = (new URL (path, window.location.origin)).search
|
||||
@@ -530,6 +561,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
useEffect (() => {
|
||||
let active = true
|
||||
const previewSequence = ++previewSequenceRef.current
|
||||
metadataSequenceRef.current = previewSequence
|
||||
const controllers = new Set<AbortController> ()
|
||||
|
||||
if (sessionRef.current != null
|
||||
@@ -637,7 +669,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (active && previewSequenceRef.current === previewSequence)
|
||||
if (active && metadataSequenceRef.current === previewSequence)
|
||||
setMetadataLoading (false)
|
||||
}
|
||||
}
|
||||
@@ -651,6 +683,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
return
|
||||
if (parsed == null && loadedSession == null)
|
||||
{
|
||||
setMetadataLoading (false)
|
||||
navigate ('/posts/new', { replace: true })
|
||||
return
|
||||
}
|
||||
@@ -709,7 +742,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const currentRow = currentRowBySource (sourceRow, currentSession)
|
||||
if (currentRow == null)
|
||||
return
|
||||
if (rowSkipBusy (currentRow, submitting, loadingRow))
|
||||
if (rowSkipBusy (currentRow, submitting, busyRowIds))
|
||||
return
|
||||
|
||||
const nextRows = currentSession.rows.map (row => {
|
||||
@@ -720,9 +753,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
return row
|
||||
return {
|
||||
...row,
|
||||
skipReason: checked ? 'manual' : undefined,
|
||||
existingPostId: checked ? undefined : row.existingPostId,
|
||||
existingPost: checked ? undefined : row.existingPost }
|
||||
skipReason:
|
||||
checked
|
||||
? 'manual'
|
||||
: row.existingPostId != null
|
||||
? 'existing'
|
||||
: undefined }
|
||||
})
|
||||
const nextSession = buildSession (applyDuplicateUrlErrors (nextRows))
|
||||
if (metadataLoading)
|
||||
@@ -787,7 +823,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
replaceImportRow (latestSession.rows, mergedRow))
|
||||
const nextSession =
|
||||
metadataLoading
|
||||
? commitSessionState (buildSession (mergedRows))
|
||||
? commitSessionState (buildSession (mergedRows)).session
|
||||
: await syncSessionUrl (buildSession (mergedRows))
|
||||
if (nextSession == null)
|
||||
return { saved: false, row: null }
|
||||
@@ -852,7 +888,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
return
|
||||
if (!(canEditReviewRow (currentRow)))
|
||||
return
|
||||
if (rowOperationBusy (currentRow, submitting, loadingRow))
|
||||
if (rowOperationBusy (currentRow, submitting, busyRowIds))
|
||||
return
|
||||
|
||||
setEditingRow (currentRow)
|
||||
@@ -877,16 +913,16 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
|
||||
if (originalRow == null)
|
||||
return
|
||||
if (rowOperationBusy (originalRow, submitting, loadingRow))
|
||||
if (rowOperationBusy (originalRow, submitting, busyRowIds))
|
||||
return
|
||||
|
||||
setLoadingRow (sourceRow)
|
||||
setBusyRowIds (current => new Set ([...current, sourceRow]))
|
||||
try
|
||||
{
|
||||
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
|
||||
const pendingSession =
|
||||
metadataLoading
|
||||
? commitSessionState (buildSession (pendingRows))
|
||||
? commitSessionState (buildSession (pendingRows)).session
|
||||
: await syncSessionUrl (buildSession (pendingRows))
|
||||
if (pendingSession == null)
|
||||
return
|
||||
@@ -915,7 +951,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const nextRows = applyDuplicateUrlErrors (
|
||||
mergeImportResults (latestAfterImport.rows, indexedResults))
|
||||
.map (row => applyThumbnailWarning (row))
|
||||
const persisted = await syncSessionUrl (buildSession (nextRows))
|
||||
const persisted =
|
||||
metadataLoading
|
||||
? commitSessionState (buildSession (nextRows)).session
|
||||
: await syncSessionUrl (buildSession (nextRows))
|
||||
if (persisted != null
|
||||
&& persisted.rows.every (row => isCompletedReviewRow (row)))
|
||||
finishImport ()
|
||||
@@ -932,13 +971,20 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoadingRow (null)
|
||||
setBusyRowIds (current => {
|
||||
const next = new Set (current)
|
||||
next.delete (sourceRow)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null || metadataLoading || submitting || loadingRow != null)
|
||||
if (currentSession == null
|
||||
|| metadataLoading
|
||||
|| submitting
|
||||
|| busyRowIds.size > 0)
|
||||
return
|
||||
if (currentSession.rows.every (row => isCompletedReviewRow (row)))
|
||||
{
|
||||
@@ -1043,10 +1089,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
row={row}
|
||||
displayNumber={index + 1}
|
||||
showSkipToggle={true}
|
||||
editDisabled={rowOperationBusy (row, submitting, loadingRow)}
|
||||
retryDisabled={rowOperationBusy (row, submitting, loadingRow)}
|
||||
editDisabled={rowOperationBusy (row, submitting, busyRowIds)}
|
||||
retryDisabled={rowOperationBusy (row, submitting, busyRowIds)}
|
||||
skipDisabled={
|
||||
rowSkipBusy (row, submitting, loadingRow)
|
||||
rowSkipBusy (row, submitting, busyRowIds)
|
||||
|| isExistingSkipRow (row)
|
||||
|| row.importStatus === 'created'}
|
||||
onEdit={() => editRow (row)}
|
||||
@@ -1061,6 +1107,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
<PostImportFooter
|
||||
metadataLoading={metadataLoading}
|
||||
submitting={submitting}
|
||||
busyRowsPresent={busyRowIds.size > 0}
|
||||
canSubmit={canSubmit}
|
||||
creatableCount={counts.creatable}
|
||||
manualSkippedCount={counts.manualSkipped}
|
||||
@@ -1074,6 +1121,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const PostImportFooter = (
|
||||
{ metadataLoading,
|
||||
submitting,
|
||||
busyRowsPresent,
|
||||
canSubmit,
|
||||
creatableCount,
|
||||
manualSkippedCount,
|
||||
@@ -1082,6 +1130,7 @@ const PostImportFooter = (
|
||||
onBack,
|
||||
onSubmit }: { metadataLoading: boolean
|
||||
submitting: boolean
|
||||
busyRowsPresent: boolean
|
||||
canSubmit: boolean
|
||||
creatableCount: number
|
||||
manualSkippedCount: number
|
||||
@@ -1101,18 +1150,20 @@ const PostImportFooter = (
|
||||
<span>既存投稿による自動スキップ {existingSkippedCount}件</span>
|
||||
<span>登録不可/未処理 {pendingOrErrorCount}件</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={onBack}
|
||||
disabled={submitting}>
|
||||
URL リスト入力へ戻る
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:w-auto"
|
||||
onClick={onSubmit}
|
||||
disabled={metadataLoading || submitting || !(canSubmit)}>
|
||||
disabled={metadataLoading || submitting || busyRowsPresent || !(canSubmit)}>
|
||||
{creatableCount > 1 ? '一括追加' : '追加'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
新しい課題から参照
ユーザをブロックする