diff --git a/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx b/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx
new file mode 100644
index 0000000..21cdd0c
--- /dev/null
+++ b/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx
@@ -0,0 +1,78 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
+
+const api = vi.hoisted (() => ({
+ apiGet: vi.fn (),
+}))
+
+vi.mock ('@/lib/api', () => api)
+
+describe ('PostImportThumbnailPreview', () => {
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
+ globalThis.URL.revokeObjectURL = vi.fn ()
+ })
+
+ it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => {
+ api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' }))
+
+ render (
+
)
+
+ await waitFor (() => {
+ expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
+ })
+ expect (screen.getByRole ('img')).not.toHaveAttribute (
+ 'src',
+ 'https://example.com/thumbnail.jpg')
+ expect (api.apiGet).toHaveBeenCalledWith ('/preview/thumbnail', {
+ params: { url: 'https://example.com/thumbnail.jpg' },
+ responseType: 'blob' })
+ })
+
+ it ('does not render the unsafe URL directly when preview fetching fails', async () => {
+ api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
+
+ render (
+
)
+
+ expect (await screen.findByText ('サムネールを表示できません')).toBeInTheDocument ()
+ expect (screen.queryByRole ('img')).toBeNull ()
+ })
+
+ it ('revokes the old object URL when the source URL changes', async () => {
+ const createObjectUrlMock =
+ globalThis.URL.createObjectURL as unknown as ReturnType
+ createObjectUrlMock
+ .mockReturnValueOnce ('blob:first')
+ .mockReturnValueOnce ('blob:second')
+ api.apiGet
+ .mockResolvedValueOnce (new Blob (['first'], { type: 'image/png' }))
+ .mockResolvedValueOnce (new Blob (['second'], { type: 'image/png' }))
+
+ const { rerender } = render (
+ )
+ await waitFor (() => {
+ expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:first')
+ })
+
+ rerender (
+ )
+
+ await waitFor (() => {
+ expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second')
+ })
+ expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first')
+ })
+})
diff --git a/frontend/src/components/posts/import/PostImportThumbnailPreview.tsx b/frontend/src/components/posts/import/PostImportThumbnailPreview.tsx
new file mode 100644
index 0000000..8fb3980
--- /dev/null
+++ b/frontend/src/components/posts/import/PostImportThumbnailPreview.tsx
@@ -0,0 +1,89 @@
+import { useEffect, useRef, useState } from 'react'
+
+import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
+import { apiGet } from '@/lib/api'
+import { cn } from '@/lib/utils'
+
+import type { FC } from 'react'
+
+type Props = {
+ url: string
+ alt?: string
+ className?: string }
+
+
+const PostImportThumbnailPreview: FC = (
+ { url,
+ alt = 'サムネール',
+ className = 'h-16 w-16' },
+) => {
+ const [previewUrl, setPreviewUrl] = useState ('')
+ const [unavailable, setUnavailable] = useState (false)
+ const previewUrlRef = useRef ('')
+
+ useEffect (() => {
+ if (previewUrlRef.current)
+ {
+ URL.revokeObjectURL (previewUrlRef.current)
+ previewUrlRef.current = ''
+ }
+ setPreviewUrl ('')
+ setUnavailable (false)
+
+ if (!(url))
+ return
+
+ let active = true
+
+ const loadPreview = async () => {
+ try
+ {
+ const blob = await apiGet ('/preview/thumbnail', {
+ params: { url },
+ responseType: 'blob' })
+ if (!(active))
+ return
+
+ const nextPreviewUrl = URL.createObjectURL (blob)
+ previewUrlRef.current = nextPreviewUrl
+ setPreviewUrl (nextPreviewUrl)
+ }
+ catch
+ {
+ if (active)
+ setUnavailable (true)
+ }
+ }
+
+ void loadPreview ()
+
+ return () => {
+ active = false
+ if (previewUrlRef.current)
+ {
+ URL.revokeObjectURL (previewUrlRef.current)
+ previewUrlRef.current = ''
+ }
+ }
+ }, [url])
+
+ if (unavailable)
+ {
+ return (
+
+ サムネールを表示できません
+
)
+ }
+
+ return (
+ )
+}
+
+export default PostImportThumbnailPreview
diff --git a/frontend/src/pages/posts/PostImportResultPage.test.tsx b/frontend/src/pages/posts/PostImportResultPage.test.tsx
index 0fe7f9a..6f72603 100644
--- a/frontend/src/pages/posts/PostImportResultPage.test.tsx
+++ b/frontend/src/pages/posts/PostImportResultPage.test.tsx
@@ -16,6 +16,7 @@ import type { PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
const api = vi.hoisted (() => ({
+ apiGet: vi.fn (),
apiPost: vi.fn (),
}))
@@ -37,6 +38,9 @@ describe ('PostImportResultPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
+ globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
+ globalThis.URL.revokeObjectURL = vi.fn ()
+ api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
})
it ('shows edit and retry only for recoverable failures and shows warnings', () => {
@@ -86,6 +90,35 @@ describe ('PostImportResultPage', () => {
)).toBeInTheDocument ()
})
+ it ('shows hour-precision original-created ranges with the shared utility output', () => {
+ const from = '2024-01-01T12:00:00Z'
+ const before = '2024-01-01T13:00:00Z'
+ savePostImportSession ('result-hour-range', {
+ source: '',
+ repairMode: 'all',
+ rows: [buildPostImportRow ({
+ sourceRow: 1,
+ importStatus: 'created',
+ createdPostId: 1,
+ attributes: {
+ title: 'created row',
+ originalCreatedFrom: from,
+ originalCreatedBefore: before } })] })
+
+ render (
+
+
+
+ }/>
+
+
+ )
+
+ expect (screen.getByText (originalCreatedAtString (from, before))).toBeInTheDocument ()
+ })
+
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
savePostImportSession ('result-pending', {
source: '',
diff --git a/frontend/src/pages/posts/PostImportReviewPage.test.tsx b/frontend/src/pages/posts/PostImportReviewPage.test.tsx
index de16bca..51a44eb 100644
--- a/frontend/src/pages/posts/PostImportReviewPage.test.tsx
+++ b/frontend/src/pages/posts/PostImportReviewPage.test.tsx
@@ -12,6 +12,7 @@ import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/u
import type { PostImportRow } from '@/lib/postImportSession'
const api = vi.hoisted (() => ({
+ apiGet: vi.fn (),
apiPost: vi.fn (),
}))
@@ -33,6 +34,9 @@ describe ('PostImportReviewPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
+ globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
+ globalThis.URL.revokeObjectURL = vi.fn ()
+ api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
})
it ('navigates to the result route after an initial recoverable failure', async () => {
@@ -257,4 +261,35 @@ describe ('PostImportReviewPage', () => {
.toBe ('edited title')
})
})
+
+ it ('sorts recoverable pending validation rows to the top in repair mode', () => {
+ savePostImportSession ('review-repair-sort', {
+ source: '',
+ repairMode: 'failed',
+ rows: [
+ buildPostImportRow ({
+ sourceRow: 2,
+ attributes: { title: 'normal row' } }),
+ buildPostImportRow ({
+ sourceRow: 1,
+ attributes: { title: 'repair row' },
+ importStatus: 'pending',
+ recoverable: true,
+ validationErrors: { title: ['invalid'] } })] })
+
+ const { container } = render (
+
+
+
+ }/>
+
+
+ )
+
+ const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
+ node => node.textContent)
+ expect (titles[0]).toBe ('repair row')
+ })
})
diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx
index fce3b22..13e6db2 100644
--- a/frontend/src/pages/posts/PostImportReviewPage.tsx
+++ b/frontend/src/pages/posts/PostImportReviewPage.tsx
@@ -38,6 +38,12 @@ import type { User } from '@/types'
type Props = { user: User | null }
+const isRepairRow = (row: PostImportRow): boolean =>
+ row.recoverable === true
+ && (row.importStatus === 'failed'
+ || (row.importStatus === 'pending'
+ && Object.keys (row.validationErrors).length > 0))
+
const PostImportReviewPage: FC = ({ user }) => {
const editable = canEditContent (user)
@@ -85,9 +91,9 @@ const PostImportReviewPage: FC = ({ user }) => {
session?.repairMode === 'failed'
? (
[...rows].sort ((a, b) => {
- const aFailed = a.importStatus === 'failed' ? 0 : 1
- const bFailed = b.importStatus === 'failed' ? 0 : 1
- return aFailed - bFailed || a.sourceRow - b.sourceRow
+ const aRepair = isRepairRow (a) ? 0 : 1
+ const bRepair = isRepairRow (b) ? 0 : 1
+ return aRepair - bRepair || a.sourceRow - b.sourceRow
}))
: rows
@@ -228,21 +234,19 @@ const PostImportReviewPage: FC = ({ user }) => {
setLoading (true)
try
{
+ const validatableRows =
+ currentSession.rows.filter (row => row.importStatus !== 'created')
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows:
- currentSession.rows
- .filter (row => row.importStatus !== 'created')
- .map (row => ({ sourceRow: row.sourceRow,
- url: row.url,
- attributes: row.attributes,
- provenance: row.provenance,
- tagSources: row.tagSources,
- metadataUrl: row.metadataUrl })),
+ validatableRows.map (row => ({ sourceRow: row.sourceRow,
+ url: row.url,
+ attributes: row.attributes,
+ provenance: row.provenance,
+ tagSources: row.tagSources,
+ metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows)
- const expectedSourceRows = currentSession.rows
- .filter (row => row.importStatus !== 'created')
- .map (row => row.sourceRow)
+ const expectedSourceRows = validatableRows.map (row => row.sourceRow)
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
{
toast ({ title: '再検証結果が不完全でした' })
diff --git a/frontend/src/pages/posts/PostNewPage.test.tsx b/frontend/src/pages/posts/PostNewPage.test.tsx
index 31e8151..e14975f 100644
--- a/frontend/src/pages/posts/PostNewPage.test.tsx
+++ b/frontend/src/pages/posts/PostNewPage.test.tsx
@@ -134,4 +134,29 @@ describe ('PostNewPage', () => {
expect (formData.get ('original_created_from')).toBe (
toMinutePrecisionIsoUtc ('2024-01-01T12:34'))
})
+
+ it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
+ api.apiGet.mockResolvedValue ([])
+ api.isApiError.mockReturnValue (true)
+ api.apiPost.mockRejectedValueOnce ({
+ response: {
+ status: 422,
+ data: {
+ type: 'validation_error',
+ errors: {
+ original_created_at: ['日時を確認してください.'],
+ original_created_from: ['日時を確認してください.'],
+ original_created_before: ['終了を確認してください.'] },
+ },
+ },
+ })
+
+ renderWithProviders ()
+
+ fireEvent.click (screen.getByRole ('button', { name: '追加' }))
+
+ expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
+ expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
+ expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
+ })
})
diff --git a/frontend/src/pages/posts/PostNewPage.tsx b/frontend/src/pages/posts/PostNewPage.tsx
index 4d9e42b..196b555 100644
--- a/frontend/src/pages/posts/PostNewPage.tsx
+++ b/frontend/src/pages/posts/PostNewPage.tsx
@@ -27,7 +27,11 @@ type Props = { user: User | null }
type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds'
- | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
+ | 'videoMs' | 'originalCreatedAt' | 'originalCreatedFrom'
+ | 'originalCreatedBefore' | 'thumbnail'
+
+const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
+ [...new Set (values.flatMap (value => value ?? []))]
const PostNewPage: FC = ({ user }) => {
@@ -216,7 +220,10 @@ const PostNewPage: FC = ({ user }) => {
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}
- errors={fieldErrors.originalCreatedAt}/>
+ errors={groupedMessages (
+ fieldErrors.originalCreatedAt,
+ fieldErrors.originalCreatedFrom,
+ fieldErrors.originalCreatedBefore)}/>
{videoFlg && (