このコミットが含まれているのは:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
image.resize '180x180'
|
blob = Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
|
||||||
image.format 'jpg'
|
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',
|
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,13 +247,11 @@ 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>)
|
/>)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする