広場投稿追加画面の刷新 (#399) #413

マージ済み
みてるぞ が 103 個のコミットを feature/399 から main へマージ 2026-07-19 00:03:11 +09:00
11個のファイルの変更134行の追加54行の削除
コミット e6c3c635b8 の変更だけを表示してゐます - すべてのコミットを表示
+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,
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
+5 -1
ファイルの表示
@@ -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
+16 -9
ファイルの表示
@@ -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
+4 -1
ファイルの表示
@@ -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
+2
ファイルの表示
@@ -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
+1 -1
ファイルの表示
@@ -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)
+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
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
+6 -6
ファイルの表示
@@ -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'}
+3 -1
ファイルの表示
@@ -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}>
+2 -2
ファイルの表示
@@ -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}
/>)
+84 -33
ファイルの表示
@@ -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>