このコミットが含まれているのは:
2026-07-18 02:29:55 +09:00
コミット e6c3c635b8
11個のファイルの変更134行の追加54行の削除
+5
ファイルの表示
@@ -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, - 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 make dense controls wrap or scroll intentionally, and keep tag/filter
controls usable without horizontal page overflow. 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 - For mobile horizontal scrollers, make the scroll direction and item sizing
explicit, and ensure chip text remains readable in both light and dark modes. explicit, and ensure chip text remains readable in both light and dark modes.
- In TypeScript and TSX, prefer direct comparison operators such as `===` and - In TypeScript and TSX, prefer direct comparison operators such as `===` and
+5 -1
ファイルの表示
@@ -137,6 +137,7 @@ class PostsController < ApplicationController
video_ms: nil, video_ms: nil,
field_warnings: { }, field_warnings: { },
base_warnings: [], base_warnings: [],
existing_post_id: existing_post.id,
existing_post: compact_post(existing_post.id) } existing_post: compact_post(existing_post.id) }
end end
@@ -158,6 +159,7 @@ class PostsController < ApplicationController
video_ms: metadata[:video_ms], video_ms: metadata[:video_ms],
field_warnings: field_warnings, field_warnings: field_warnings,
base_warnings: [], base_warnings: [],
existing_post_id: nil,
existing_post: nil } existing_post: nil }
rescue ArgumentError => e rescue ArgumentError => e
render_bad_request e.message render_bad_request e.message
@@ -177,6 +179,7 @@ class PostsController < ApplicationController
video_ms: nil, video_ms: nil,
field_warnings: { url: ['自動取得に失敗しました.'] }, field_warnings: { url: ['自動取得に失敗しました.'] },
base_warnings: [], base_warnings: [],
existing_post_id: nil,
existing_post: nil } existing_post: nil }
end end
@@ -212,7 +215,7 @@ class PostsController < ApplicationController
attributes: post_create_attributes, attributes: post_create_attributes,
thumbnail: params[:thumbnail]).run thumbnail: params[:thumbnail]).run
return render json: dry_run_json(preflight) if bool?(:dry) 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 = Post.new(url: preflight[:url])
post.errors.add :url, :taken post.errors.add :url, :taken
return render_post_form_record_invalid post return render_post_form_record_invalid post
@@ -637,6 +640,7 @@ class PostsController < ApplicationController
:video_ms, :video_ms,
:field_warnings, :field_warnings,
:base_warnings, :base_warnings,
:existing_post_id,
:existing_post) :existing_post)
end end
+13 -6
ファイルの表示
@@ -20,13 +20,19 @@ class Post < ApplicationRecord
def self.resized_thumbnail_attachment(upload, content_type: nil) def self.resized_thumbnail_attachment(upload, content_type: nil)
upload.rewind upload.rewind
image = image_for_thumbnail_upload(upload.read, content_type:) bytes = upload.read
blob = Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
image = image_for_thumbnail_upload(bytes, content_type:)
image.resize '180x180' image.resize '180x180'
image.format 'jpg' image.format 'jpg'
image.to_blob
end
{ io: StringIO.new(image.to_blob), { io: StringIO.new(blob),
filename: 'resized_thumbnail.jpg', filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' } content_type: 'image/jpeg' }
rescue Timeout::Error
raise MiniMagick::Error, 'サムネイル画像の変換に失敗しました.'
ensure ensure
upload.rewind upload.rewind
end end
@@ -41,6 +47,7 @@ class Post < ApplicationRecord
Preview::HttpFetcher::FetchFailed, Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::FetchTimeout, Preview::HttpFetcher::FetchTimeout,
Preview::HttpFetcher::ResponseTooLarge, Preview::HttpFetcher::ResponseTooLarge,
Timeout::Error,
MiniMagick::Error => e MiniMagick::Error => e
raise RemoteThumbnailFetchFailed, e.message raise RemoteThumbnailFetchFailed, e.message
end end
@@ -240,14 +247,12 @@ class Post < ApplicationRecord
end end
def self.decode_raster_thumbnail(bytes) def self.decode_raster_thumbnail(bytes)
Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) { MiniMagick::Image.read(bytes) } MiniMagick::Image.read(bytes)
end end
def self.decode_svg_thumbnail(bytes) def self.decode_svg_thumbnail(bytes)
Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
MiniMagick::Image.read(sanitised_svg_bytes(bytes)) MiniMagick::Image.read(sanitised_svg_bytes(bytes))
end end
end
def self.sanitised_svg_bytes(bytes) def self.sanitised_svg_bytes(bytes)
parse_options = parse_options =
@@ -303,7 +308,7 @@ class Post < ApplicationRecord
stripped = value.to_s.strip stripped = value.to_s.strip
return false if stripped.blank? || stripped.start_with?('#') return false if stripped.blank? || stripped.start_with?('#')
stripped.match?(/\A(?:https?:|data:|\/\/)/i) true
end end
def self.style_contains_disallowed_urls?(value) 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 } 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] 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]] [view_box[2], view_box[3]]
end end
+4 -1
ファイルの表示
@@ -65,9 +65,10 @@ class PostBulkCreator
PostCreatePreflight.new( PostCreatePreflight.new(
attributes: attributes, attributes: attributes,
thumbnail: thumbnail_for(index, attributes)).run thumbnail: thumbnail_for(index, attributes)).run
if preflight[:existing_post].present? if preflight[:existing_post_id].present?
return { return {
status: 'skipped', status: 'skipped',
existing_post_id: preflight[:existing_post_id],
existing_post: preflight[:existing_post] } existing_post: preflight[:existing_post] }
end end
@@ -91,6 +92,7 @@ class PostBulkCreator
if existing_post.present? if existing_post.present?
return { return {
status: 'skipped', status: 'skipped',
existing_post_id: existing_post[:id],
existing_post: existing_post } existing_post: existing_post }
end end
@@ -104,6 +106,7 @@ class PostBulkCreator
existing_post = existing_post_for_race(attributes) existing_post = existing_post_for_race(attributes)
return { return {
status: 'skipped', status: 'skipped',
existing_post_id: existing_post[:id],
existing_post: existing_post } if existing_post.present? existing_post: existing_post } if existing_post.present?
end end
+2
ファイルの表示
@@ -31,6 +31,7 @@ class PostCreatePreflight
video_ms: preview[:attributes]['video_ms'], video_ms: preview[:attributes]['video_ms'],
field_warnings: final_field_warnings(preview[:field_warnings] || { }), field_warnings: final_field_warnings(preview[:field_warnings] || { }),
base_warnings: preview[:base_warnings], base_warnings: preview[:base_warnings],
existing_post_id: preview[:existing_post_id],
existing_post: existing_post_compact(preview[:existing_post_id]) } existing_post: existing_post_compact(preview[:existing_post_id]) }
end end
@@ -70,6 +71,7 @@ class PostCreatePreflight
normalised_parent_post_ids: plan[:normalised_parent_post_ids], normalised_parent_post_ids: plan[:normalised_parent_post_ids],
field_warnings: final_field_warnings(preview[:field_warnings] || { }), field_warnings: final_field_warnings(preview[:field_warnings] || { }),
base_warnings: preview[:base_warnings], base_warnings: preview[:base_warnings],
existing_post_id: preview[:existing_post_id],
existing_post: existing_post_compact(preview[:existing_post_id]) } existing_post: existing_post_compact(preview[:existing_post_id]) }
end end
+1 -1
ファイルの表示
@@ -16,7 +16,7 @@ class PostThumbnailUploadValidator
attachment = Post.resized_thumbnail_attachment(thumbnail, content_type: thumbnail.content_type) attachment = Post.resized_thumbnail_attachment(thumbnail, content_type: thumbnail.content_type)
attachment[:io].close if attachment[:io].respond_to?(:close) attachment[:io].close if attachment[:io].respond_to?(:close)
rescue MiniMagick::Error rescue MiniMagick::Error, Timeout::Error
raise InvalidUpload, 'サムネイル画像の変換に失敗しました.' raise InvalidUpload, 'サムネイル画像の変換に失敗しました.'
ensure ensure
thumbnail&.rewind if thumbnail.respond_to?(:rewind) thumbnail&.rewind if thumbnail.respond_to?(:rewind)
+6
ファイルの表示
@@ -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 Do not create a second layout shell before checking whether the current layout
can be reused or minimally extended. 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 ### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is Use this table before accepting any edited TypeScript or TSX hunk. The table is
+6 -6
ファイルの表示
@@ -215,12 +215,12 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<DialogFooter <DialogFooter
className="shrink-0 flex-col gap-2 pt-4 className="shrink-0 flex-col gap-2 pt-4
sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0"> md:flex-row md:justify-between md:gap-0 md:space-x-0">
<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">
{startActions.map (action => ( {startActions.map (action => (
<Button <Button
key={action.label} key={action.label}
className="w-full sm:w-auto" className="w-full md:w-auto"
variant={action.variant === 'danger' variant={action.variant === 'danger'
? 'destructive' ? 'destructive'
: 'default'} : 'default'}
@@ -232,9 +232,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
</Button>))} </Button>))}
</div> </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 <Button
className="w-full sm:w-auto" className="w-full md:w-auto"
variant="outline" variant="outline"
onClick={() => closeRequest (active.id)} onClick={() => closeRequest (active.id)}
disabled={pendingIds.includes (active.id) disabled={pendingIds.includes (active.id)
@@ -245,7 +245,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
{endActions.map (action => ( {endActions.map (action => (
<Button <Button
key={action.label} key={action.label}
className="w-full sm:w-auto" className="w-full md:w-auto"
variant={action.variant === 'danger' variant={action.variant === 'danger'
? 'destructive' ? 'destructive'
: 'default'} : 'default'}
+3 -1
ファイルの表示
@@ -173,10 +173,11 @@ const PostImportRowSummary: FC<Props> = (
</div> </div>
</div> </div>
{showActions && (editVisible || retryAllowed) && ( {showActions && (editVisible || retryAllowed) && (
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 md:flex-row">
{editVisible && ( {editVisible && (
<Button <Button
type="button" type="button"
className="w-full md:w-auto"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}> disabled={editDisabled === true || !(editAllowed)}>
@@ -185,6 +186,7 @@ const PostImportRowSummary: FC<Props> = (
{retryAllowed && ( {retryAllowed && (
<Button <Button
type="button" type="button"
className="w-full md:w-auto"
variant="outline" variant="outline"
onClick={onRetry} onClick={onRetry}
disabled={retryDisabled === true}> disabled={retryDisabled === true}>
+2 -2
ファイルの表示
@@ -69,7 +69,7 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( 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)} className)}
{...props} {...props}
/>) />)
@@ -81,7 +81,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( 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)} className)}
{...props} {...props}
/>) />)
+84 -33
ファイルの表示
@@ -76,6 +76,7 @@ type PostMetadataResponse = {
fieldWarnings?: Record<string, string[]> fieldWarnings?: Record<string, string[]>
baseWarnings?: string[] baseWarnings?: string[]
validationErrors?: Record<string, string[]> validationErrors?: Record<string, string[]>
existingPostId?: number
existingPost?: { existingPost?: {
id: number id: number
title: string title: string
@@ -85,6 +86,7 @@ type PostMetadataResponse = {
type BulkApiRow = { type BulkApiRow = {
status: 'created' | 'skipped' | 'failed' status: 'created' | 'skipped' | 'failed'
post?: { id: number } post?: { id: number }
existingPostId?: number
existingPost?: { existingPost?: {
id: number id: number
title: string title: string
@@ -114,20 +116,20 @@ const rowMetadataPending = (row: PostImportRow): boolean =>
const rowOperationBusy = ( const rowOperationBusy = (
row: PostImportRow, row: PostImportRow,
submitting: boolean, submitting: boolean,
loadingRow: number | null, busyRowIds: Set<number>,
): boolean => ): boolean =>
submitting submitting
|| loadingRow === row.sourceRow || busyRowIds.has (row.sourceRow)
|| rowMetadataPending (row) || rowMetadataPending (row)
const rowSkipBusy = ( const rowSkipBusy = (
row: PostImportRow, row: PostImportRow,
submitting: boolean, submitting: boolean,
loadingRow: number | null, busyRowIds: Set<number>,
): boolean => ): boolean =>
submitting submitting
|| loadingRow === row.sourceRow || busyRowIds.has (row.sourceRow)
|| (rowMetadataPending (row) && !(isManualSkipRow (row))) || (rowMetadataPending (row) && !(isManualSkipRow (row)))
const shouldFetchMetadata = (row: PostImportRow): boolean => const shouldFetchMetadata = (row: PostImportRow): boolean =>
@@ -242,8 +244,11 @@ const mergeDryRunRow = (
hasWarnings hasWarnings
? 'warning' ? 'warning'
: 'ready', : 'ready',
skipReason: result.existingPost?.id != null ? 'existing' : undefined, skipReason:
existingPostId: result.existingPost?.id, result.existingPostId != null || result.existingPost?.id != null
? 'existing'
: undefined,
existingPostId: result.existingPostId ?? result.existingPost?.id,
existingPost: result.existingPost ?? undefined, existingPost: result.existingPost ?? undefined,
importStatus: importStatus:
currentRow.importStatus === 'created' currentRow.importStatus === 'created'
@@ -306,12 +311,16 @@ const indexedBulkResults = (
baseWarnings: result.baseWarnings, baseWarnings: result.baseWarnings,
errors } errors }
} }
if (result?.status === 'skipped' && result.existingPost != null) if (result?.status === 'skipped')
{ {
return { return {
sourceRow: row.sourceRow, sourceRow: row.sourceRow,
status: 'skipped', status: 'skipped',
existingPostId: result.existingPost.id, existingPostId:
result.existingPostId
?? result.existingPost?.id
?? row.existingPostId
?? row.sourceRow,
existingPost: result.existingPost, existingPost: result.existingPost,
fieldWarnings: result.fieldWarnings, fieldWarnings: result.fieldWarnings,
baseWarnings: result.baseWarnings, baseWarnings: result.baseWarnings,
@@ -350,10 +359,10 @@ const mergePreviewRow = (
fieldWarnings, fieldWarnings,
baseWarnings, baseWarnings,
validationErrors, validationErrors,
existingPostId: preview.existingPost?.id, existingPostId: preview.existingPostId ?? preview.existingPost?.id,
existingPost: preview.existingPost ?? undefined, existingPost: preview.existingPost ?? undefined,
skipReason: skipReason:
preview.existingPost?.id != null preview.existingPostId != null || preview.existingPost?.id != null
? 'existing' ? 'existing'
: currentRow.skipReason === 'manual' : currentRow.skipReason === 'manual'
? 'manual' ? 'manual'
@@ -468,7 +477,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const [session, setSession] = useState<PostImportSession | null> (null) const [session, setSession] = useState<PostImportSession | null> (null)
const [metadataLoading, setMetadataLoading] = useState (false) const [metadataLoading, setMetadataLoading] = useState (false)
const [submitting, setSubmitting] = 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 [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
const [showExistingRows, setShowExistingRows] = useState (false) const [showExistingRows, setShowExistingRows] = useState (false)
const sessionRef = useRef<PostImportSession | null> (null) const sessionRef = useRef<PostImportSession | null> (null)
@@ -476,18 +485,31 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const persistedSearchRef = useRef<string | null> (null) const persistedSearchRef = useRef<string | null> (null)
const persistSequenceRef = useRef (0) const persistSequenceRef = useRef (0)
const previewSequenceRef = useRef (0) const previewSequenceRef = useRef (0)
const metadataSequenceRef = useRef (0)
const commitSessionState = ( const commitSessionState = (
nextSession: PostImportSession, nextSession: PostImportSession,
): PostImportSession => { ): {
session: PostImportSession
sessionRoundTripSucceeded: boolean } => {
const sessionId = const sessionId =
sessionIdRef.current ?? generatePostImportSessionId () sessionIdRef.current ?? generatePostImportSessionId ()
sessionIdRef.current = sessionId sessionIdRef.current = sessionId
const persistedSession = buildSession (nextSession.rows) const persistedSession = buildSession (nextSession.rows)
savePostImportSession (sessionId, persistedSession) const saved = savePostImportSession (sessionId, persistedSession, showStorageError)
const loadedSession =
saved
? loadPostImportSession (sessionId, showStorageError)
: null
sessionRef.current = persistedSession sessionRef.current = persistedSession
setSession (persistedSession) setSession (persistedSession)
return persistedSession return {
session: persistedSession,
sessionRoundTripSucceeded:
saved
&& serialisedPostImportRowsEqual (
loadedSession?.rows ?? [],
persistedSession.rows) }
} }
const syncSessionUrl = async ( const syncSessionUrl = async (
@@ -503,7 +525,16 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (persistSequenceRef.current !== sequence) if (persistSequenceRef.current !== sequence)
return null 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) const path = appendPostNewSessionId (serialised.path, sessionId)
persistedSearchRef.current = (new URL (path, window.location.origin)).search persistedSearchRef.current = (new URL (path, window.location.origin)).search
@@ -530,6 +561,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
useEffect (() => { useEffect (() => {
let active = true let active = true
const previewSequence = ++previewSequenceRef.current const previewSequence = ++previewSequenceRef.current
metadataSequenceRef.current = previewSequence
const controllers = new Set<AbortController> () const controllers = new Set<AbortController> ()
if (sessionRef.current != null if (sessionRef.current != null
@@ -637,7 +669,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
} }
finally finally
{ {
if (active && previewSequenceRef.current === previewSequence) if (active && metadataSequenceRef.current === previewSequence)
setMetadataLoading (false) setMetadataLoading (false)
} }
} }
@@ -651,6 +683,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return return
if (parsed == null && loadedSession == null) if (parsed == null && loadedSession == null)
{ {
setMetadataLoading (false)
navigate ('/posts/new', { replace: true }) navigate ('/posts/new', { replace: true })
return return
} }
@@ -709,7 +742,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const currentRow = currentRowBySource (sourceRow, currentSession) const currentRow = currentRowBySource (sourceRow, currentSession)
if (currentRow == null) if (currentRow == null)
return return
if (rowSkipBusy (currentRow, submitting, loadingRow)) if (rowSkipBusy (currentRow, submitting, busyRowIds))
return return
const nextRows = currentSession.rows.map (row => { const nextRows = currentSession.rows.map (row => {
@@ -720,9 +753,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return row return row
return { return {
...row, ...row,
skipReason: checked ? 'manual' : undefined, skipReason:
existingPostId: checked ? undefined : row.existingPostId, checked
existingPost: checked ? undefined : row.existingPost } ? 'manual'
: row.existingPostId != null
? 'existing'
: undefined }
}) })
const nextSession = buildSession (applyDuplicateUrlErrors (nextRows)) const nextSession = buildSession (applyDuplicateUrlErrors (nextRows))
if (metadataLoading) if (metadataLoading)
@@ -787,7 +823,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
replaceImportRow (latestSession.rows, mergedRow)) replaceImportRow (latestSession.rows, mergedRow))
const nextSession = const nextSession =
metadataLoading metadataLoading
? commitSessionState (buildSession (mergedRows)) ? commitSessionState (buildSession (mergedRows)).session
: await syncSessionUrl (buildSession (mergedRows)) : await syncSessionUrl (buildSession (mergedRows))
if (nextSession == null) if (nextSession == null)
return { saved: false, row: null } return { saved: false, row: null }
@@ -852,7 +888,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return return
if (!(canEditReviewRow (currentRow))) if (!(canEditReviewRow (currentRow)))
return return
if (rowOperationBusy (currentRow, submitting, loadingRow)) if (rowOperationBusy (currentRow, submitting, busyRowIds))
return return
setEditingRow (currentRow) setEditingRow (currentRow)
@@ -877,16 +913,16 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow) const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
if (originalRow == null) if (originalRow == null)
return return
if (rowOperationBusy (originalRow, submitting, loadingRow)) if (rowOperationBusy (originalRow, submitting, busyRowIds))
return return
setLoadingRow (sourceRow) setBusyRowIds (current => new Set ([...current, sourceRow]))
try try
{ {
const pendingRows = retryImportRow (initialSession.rows, sourceRow) const pendingRows = retryImportRow (initialSession.rows, sourceRow)
const pendingSession = const pendingSession =
metadataLoading metadataLoading
? commitSessionState (buildSession (pendingRows)) ? commitSessionState (buildSession (pendingRows)).session
: await syncSessionUrl (buildSession (pendingRows)) : await syncSessionUrl (buildSession (pendingRows))
if (pendingSession == null) if (pendingSession == null)
return return
@@ -915,7 +951,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const nextRows = applyDuplicateUrlErrors ( const nextRows = applyDuplicateUrlErrors (
mergeImportResults (latestAfterImport.rows, indexedResults)) mergeImportResults (latestAfterImport.rows, indexedResults))
.map (row => applyThumbnailWarning (row)) .map (row => applyThumbnailWarning (row))
const persisted = await syncSessionUrl (buildSession (nextRows)) const persisted =
metadataLoading
? commitSessionState (buildSession (nextRows)).session
: await syncSessionUrl (buildSession (nextRows))
if (persisted != null if (persisted != null
&& persisted.rows.every (row => isCompletedReviewRow (row))) && persisted.rows.every (row => isCompletedReviewRow (row)))
finishImport () finishImport ()
@@ -932,13 +971,20 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
} }
finally finally
{ {
setLoadingRow (null) setBusyRowIds (current => {
const next = new Set (current)
next.delete (sourceRow)
return next
})
} }
} }
const submit = async () => { const submit = async () => {
const currentSession = sessionRef.current const currentSession = sessionRef.current
if (currentSession == null || metadataLoading || submitting || loadingRow != null) if (currentSession == null
|| metadataLoading
|| submitting
|| busyRowIds.size > 0)
return return
if (currentSession.rows.every (row => isCompletedReviewRow (row))) if (currentSession.rows.every (row => isCompletedReviewRow (row)))
{ {
@@ -1043,10 +1089,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
row={row} row={row}
displayNumber={index + 1} displayNumber={index + 1}
showSkipToggle={true} showSkipToggle={true}
editDisabled={rowOperationBusy (row, submitting, loadingRow)} editDisabled={rowOperationBusy (row, submitting, busyRowIds)}
retryDisabled={rowOperationBusy (row, submitting, loadingRow)} retryDisabled={rowOperationBusy (row, submitting, busyRowIds)}
skipDisabled={ skipDisabled={
rowSkipBusy (row, submitting, loadingRow) rowSkipBusy (row, submitting, busyRowIds)
|| isExistingSkipRow (row) || isExistingSkipRow (row)
|| row.importStatus === 'created'} || row.importStatus === 'created'}
onEdit={() => editRow (row)} onEdit={() => editRow (row)}
@@ -1061,6 +1107,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<PostImportFooter <PostImportFooter
metadataLoading={metadataLoading} metadataLoading={metadataLoading}
submitting={submitting} submitting={submitting}
busyRowsPresent={busyRowIds.size > 0}
canSubmit={canSubmit} canSubmit={canSubmit}
creatableCount={counts.creatable} creatableCount={counts.creatable}
manualSkippedCount={counts.manualSkipped} manualSkippedCount={counts.manualSkipped}
@@ -1074,6 +1121,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const PostImportFooter = ( const PostImportFooter = (
{ metadataLoading, { metadataLoading,
submitting, submitting,
busyRowsPresent,
canSubmit, canSubmit,
creatableCount, creatableCount,
manualSkippedCount, manualSkippedCount,
@@ -1082,6 +1130,7 @@ const PostImportFooter = (
onBack, onBack,
onSubmit }: { metadataLoading: boolean onSubmit }: { metadataLoading: boolean
submitting: boolean submitting: boolean
busyRowsPresent: boolean
canSubmit: boolean canSubmit: boolean
creatableCount: number creatableCount: number
manualSkippedCount: number manualSkippedCount: number
@@ -1101,18 +1150,20 @@ const PostImportFooter = (
<span>稿 {existingSkippedCount}</span> <span>稿 {existingSkippedCount}</span>
<span> {pendingOrErrorCount}</span> <span> {pendingOrErrorCount}</span>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 md:flex-row">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full md:w-auto"
onClick={onBack} onClick={onBack}
disabled={submitting}> disabled={submitting}>
URL URL
</Button> </Button>
<Button <Button
type="button" type="button"
className="w-full md:w-auto"
onClick={onSubmit} onClick={onSubmit}
disabled={metadataLoading || submitting || !(canSubmit)}> disabled={metadataLoading || submitting || busyRowsPresent || !(canSubmit)}>
{creatableCount > 1 ? '一括追加' : '追加'} {creatableCount > 1 ? '一括追加' : '追加'}
</Button> </Button>
</div> </div>