From 3f75994bd4124a16d649bba551abfa2ee40f9784 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 16 Jul 2026 23:12:14 +0900 Subject: [PATCH] #399 --- .../controllers/post_imports_controller.rb | 7 +- backend/app/controllers/preview_controller.rb | 21 + backend/config/routes.rb | 1 + frontend/src/App.tsx | 11 +- .../src/components/posts/PostTextField.tsx | 7 +- .../posts/import/PostImportRowForm.tsx | 10 +- .../import/PostImportThumbnailPreview.tsx | 11 +- frontend/src/lib/api.ts | 1 + frontend/src/lib/postNewQueryState.ts | 410 ++++++++++++++++++ .../src/pages/posts/PostImportReviewPage.tsx | 99 ++++- .../pages/posts/PostImportSourcePage.test.tsx | 12 +- .../src/pages/posts/PostImportSourcePage.tsx | 20 +- frontend/src/pages/posts/PostNewPage.test.tsx | 172 +------- frontend/src/pages/posts/PostNewPage.tsx | 214 +-------- 14 files changed, 592 insertions(+), 404 deletions(-) create mode 100644 frontend/src/lib/postNewQueryState.ts diff --git a/backend/app/controllers/post_imports_controller.rb b/backend/app/controllers/post_imports_controller.rb index 5aefd2b..9f3a6c8 100644 --- a/backend/app/controllers/post_imports_controller.rb +++ b/backend/app/controllers/post_imports_controller.rb @@ -11,7 +11,12 @@ class PostImportsController < ApplicationController def validate rows = normalised_import_rows allow_warning_fields: true - changed_row = Integer(params[:changed_row], exception: false) + changed_row = + if params[:changed_row].to_s == 'all' + true + else + Integer(params[:changed_row], exception: false) + end result = PostImportPreviewer.new.preview_rows(rows:, fetch_metadata: changed_row, diff --git a/backend/app/controllers/preview_controller.rb b/backend/app/controllers/preview_controller.rb index 4829e2b..80e4f7a 100644 --- a/backend/app/controllers/preview_controller.rb +++ b/backend/app/controllers/preview_controller.rb @@ -38,6 +38,27 @@ class PreviewController < ApplicationController render_unprocessable_entity(e.message) end + def image + return render_bad_request('URL は必須です.') if params[:url].blank? + + attachment = Post.resized_thumbnail_attachment( + StringIO.new(Preview::ThumbnailFetcher.fetch_image_response(params[:url]).body)) + attachment[:io].rewind + send_data attachment[:io].read, + type: attachment[:content_type], + disposition: 'inline' + rescue Preview::UrlSafety::UnsafeUrl => e + render_bad_request(e.message) + rescue Preview::HttpFetcher::FetchTimeout => e + render_preview_error(e.message, :gateway_timeout) + rescue Preview::HttpFetcher::ResponseTooLarge => e + render_preview_error(e.message, :payload_too_large) + rescue Preview::HttpFetcher::FetchFailed => e + render_preview_error(e.message, :bad_gateway) + rescue Preview::ThumbnailFetcher::GenerationFailed, MiniMagick::Error => e + render_unprocessable_entity(e.message) + end + private def require_member! diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 2f3484d..cd87bec 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -31,6 +31,7 @@ Rails.application.routes.draw do scope :preview, controller: :preview do get :title get :thumbnail + get :image end scope 'posts/import', controller: :post_imports do diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c1876c3..c3cfd17 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,8 +41,7 @@ import NotFound from '@/pages/NotFound' import TOSPage from '@/pages/TOSPage.mdx' import PostDetailPage from '@/pages/posts/PostDetailPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage' -import PostImportReviewPage from '@/pages/posts/PostImportReviewPage' -import PostImportSourcePage from '@/pages/posts/PostImportSourcePage' +import PostNewPage from '@/pages/posts/PostNewPage' import PostListPage from '@/pages/posts/PostListPage' import PostSearchPage from '@/pages/posts/PostSearchPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' @@ -75,9 +74,9 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: { }/> }/> - }/> - }/> + }/> }/> + }/> }/> }/> }/> @@ -118,9 +117,9 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: { }/> }/> - }/> - }/> + }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/posts/PostTextField.tsx b/frontend/src/components/posts/PostTextField.tsx index 6441f87..bdc6b0d 100644 --- a/frontend/src/components/posts/PostTextField.tsx +++ b/frontend/src/components/posts/PostTextField.tsx @@ -14,7 +14,8 @@ type Props = { type?: string placeholder?: string className?: string - after?: ReactNode } + after?: ReactNode + onBlur?: () => void } const PostTextField: FC = ( @@ -27,7 +28,8 @@ const PostTextField: FC = ( type = 'text', placeholder, className, - after }, + after, + onBlur }, ) => ( {({ describedBy, invalid }) => ( @@ -37,6 +39,7 @@ const PostTextField: FC = ( value={value} disabled={disabled} placeholder={placeholder} + onBlur={onBlur} onChange={ev => onChange (ev.target.value)} aria-describedby={describedBy} aria-invalid={invalid} diff --git a/frontend/src/components/posts/import/PostImportRowForm.tsx b/frontend/src/components/posts/import/PostImportRowForm.tsx index 3247de0..b3487a6 100644 --- a/frontend/src/components/posts/import/PostImportRowForm.tsx +++ b/frontend/src/components/posts/import/PostImportRowForm.tsx @@ -74,12 +74,15 @@ const PostImportRowForm: FC = ( const [messageRow, setMessageRow] = useState (null) const [saving, setSaving] = useState (false) const [resetRequested, setResetRequested] = useState (false) + const [committedThumbnailBase, setCommittedThumbnailBase] = useState ( + () => String (row.attributes.thumbnailBase ?? '')) useEffect (() => { const nextDraft = buildDraft (row) setDraft (nextDraft) setMessageRow (null) setResetRequested (false) + setCommittedThumbnailBase (String (row.attributes.thumbnailBase ?? '')) }, [row]) const displayRow = messageRow ?? row @@ -118,6 +121,7 @@ const PostImportRowForm: FC = ( setDraft (resetDraft) setResetRequested (true) setMessageRow (null) + setCommittedThumbnailBase (resetDraft.thumbnailBase) return false }, [controls, resetDisabled, resetDraft]) @@ -159,7 +163,7 @@ const PostImportRowForm: FC = (
@@ -183,6 +187,10 @@ const PostImportRowForm: FC = ( errors={groupedMessages ( displayRow.validationErrors.thumbnailBase, displayRow.importErrors?.thumbnailBase)} + onBlur={() => { + if (draft.thumbnailBase !== committedThumbnailBase) + setCommittedThumbnailBase (draft.thumbnailBase) + }} onChange={value => update ('thumbnailBase', value)}/> } core={{ diff --git a/frontend/src/components/posts/import/PostImportThumbnailPreview.tsx b/frontend/src/components/posts/import/PostImportThumbnailPreview.tsx index b692c92..04caff7 100644 --- a/frontend/src/components/posts/import/PostImportThumbnailPreview.tsx +++ b/frontend/src/components/posts/import/PostImportThumbnailPreview.tsx @@ -30,15 +30,16 @@ const PostImportThumbnailPreview: FC = ( if (!(url)) return - let active = true + const controller = new AbortController () const loadPreview = async () => { try { - const blob = await apiGet ('/preview/thumbnail', { + const blob = await apiGet ('/preview/image', { params: { url }, + signal: controller.signal, responseType: 'blob' }) - if (!(active)) + if (controller.signal.aborted) return const nextPreviewUrl = URL.createObjectURL (blob) @@ -47,7 +48,7 @@ const PostImportThumbnailPreview: FC = ( } catch { - if (active) + if (!(controller.signal.aborted)) setPreviewUrl ('') } } @@ -55,7 +56,7 @@ const PostImportThumbnailPreview: FC = ( void loadPreview () return () => { - active = false + controller.abort () if (previewUrlRef.current) { URL.revokeObjectURL (previewUrlRef.current) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 654c51f..7f9148d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -8,6 +8,7 @@ import type { AxiosError, AxiosRequestConfig } from 'axios' type Opt = { params?: AxiosRequestConfig['params'] headers?: Record + signal?: AbortSignal responseType?: 'blob' } const client = axios.create ({ baseURL: API_BASE_URL }) diff --git a/frontend/src/lib/postNewQueryState.ts b/frontend/src/lib/postNewQueryState.ts new file mode 100644 index 0000000..bd5a034 --- /dev/null +++ b/frontend/src/lib/postNewQueryState.ts @@ -0,0 +1,410 @@ +import { resultRepairMode } from '@/lib/postImportRows' + +import type { + PostImportOrigin, + PostImportRepairMode, + PostImportRow, + PostImportSkipReason, +} from '@/lib/postImportTypes' + +type QueryRowState = { + url: string + title: string + thumbnail_base: string + original_created_from: string + original_created_before: string + duration: string + tags: string + parent_post_ids: string + manual: string[] + skip: PostImportSkipReason | null + existing_post_id: number | null + import_status: PostImportRow['importStatus'] | null + created_post_id: number | null + recoverable: boolean | null } + +type MultiRowMetaState = { + v: 1 + rows: QueryRowState[] } + +type FullState = { + v: 1 + rows: Array } + +type ParsedPostNewState = { + rows: PostImportRow[] + source: string + repairMode: PostImportRepairMode + shortcut: boolean } + +const BASIC_KEYS = [ + 'url', + 'title', + 'thumbnail_base', + 'original_created_from', + 'original_created_before', + 'duration', + 'tags', + 'parent_post_ids'] as const +const STATE_KEYS = [ + 'manual', + 'skip', + 'existing_post_id', + 'import_status', + 'created_post_id', + 'recoverable'] as const +const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS] as const +const MAX_REGULAR_URL_LENGTH = 768 + +const textEncoder = new TextEncoder () +const textDecoder = new TextDecoder () + +const isValidSkipReason = (value: unknown): value is PostImportSkipReason => + value === 'existing' || value === 'manual' + + +const isValidImportStatus = ( + value: unknown, +): value is PostImportRow['importStatus'] => + value === 'pending' + || value === 'created' + || value === 'skipped' + || value === 'failed' + + +const parsePositiveInt = (value: string | null): number | null => { + if (value == null || value === '') + return null + + const parsed = Number.parseInt (value, 10) + return Number.isInteger (parsed) && parsed > 0 ? parsed : null +} + + +const decodeBase64Url = (value: string): string | null => { + try + { + const padded = value.replaceAll ('-', '+').replaceAll ('_', '/') + const remainder = padded.length % 4 + const base64 = + remainder === 0 + ? padded + : `${ padded }${ '='.repeat (4 - remainder) }` + const binary = window.atob (base64) + const bytes = Uint8Array.from (binary, char => char.charCodeAt (0)) + return textDecoder.decode (bytes) + } + catch + { + return null + } +} + + +const encodeBase64Url = (value: string): string => { + const bytes = textEncoder.encode (value) + const binary = Array.from (bytes, byte => String.fromCharCode (byte)).join ('') + return window.btoa (binary).replaceAll ('+', '-').replaceAll ('/', '_').replaceAll ('=', '') +} + + +const manualFields = (row: PostImportRow): string[] => + ['title', + 'thumbnailBase', + 'originalCreatedFrom', + 'originalCreatedBefore', + 'tags', + 'parentPostIds'] + .filter (field => row.provenance[field] === 'manual') + + +const baseRowState = (row: PostImportRow): QueryRowState => ({ + url: row.url, + title: String (row.attributes.title ?? ''), + thumbnail_base: String (row.attributes.thumbnailBase ?? ''), + original_created_from: String (row.attributes.originalCreatedFrom ?? ''), + original_created_before: String (row.attributes.originalCreatedBefore ?? ''), + duration: String (row.attributes.duration ?? ''), + tags: String (row.attributes.tags ?? ''), + parent_post_ids: String (row.attributes.parentPostIds ?? ''), + manual: manualFields (row), + skip: row.skipReason ?? null, + existing_post_id: row.existingPostId ?? null, + import_status: row.importStatus ?? null, + created_post_id: row.createdPostId ?? null, + recoverable: row.recoverable ?? null }) + + +const buildRow = ( + sourceRow: number, + state: QueryRowState & { url: string }, +): PostImportRow => { + const manual = new Set (state.manual) + const provenance: Record = { + url: 'manual', + title: manual.has ('title') ? 'manual' : 'automatic', + thumbnailBase: manual.has ('thumbnailBase') ? 'manual' : 'automatic', + originalCreatedFrom: manual.has ('originalCreatedFrom') ? 'manual' : 'automatic', + originalCreatedBefore: manual.has ('originalCreatedBefore') ? 'manual' : 'automatic', + duration: 'automatic', + tags: manual.has ('tags') ? 'manual' : 'automatic', + parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' } + const tagSources = { + automatic: '', + manual: provenance.tags === 'manual' ? state.tags : '' } + const attributes = { + title: state.title, + thumbnailBase: state.thumbnail_base, + originalCreatedFrom: state.original_created_from, + originalCreatedBefore: state.original_created_before, + duration: state.duration, + tags: state.tags, + parentPostIds: state.parent_post_ids } + + return { + sourceRow, + url: state.url, + attributes, + fieldWarnings: { }, + baseWarnings: [], + validationErrors: { }, + provenance, + tagSources, + status: 'ready', + skipReason: state.skip ?? undefined, + existingPostId: state.existing_post_id ?? undefined, + resetSnapshot: { + url: state.url, + attributes: { ...attributes }, + provenance: { ...provenance }, + tagSources: { ...tagSources }, + fieldWarnings: { }, + baseWarnings: [] }, + createdPostId: state.created_post_id ?? undefined, + importStatus: state.import_status ?? undefined, + recoverable: state.recoverable ?? undefined } +} + + +const parseManual = (value: string | null): string[] => + (value ?? '') + .split (',') + .map (entry => entry.trim ()) + .filter (entry => entry !== '') + + +const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => { + const url = params.get ('url') + if (url == null || url === '') + return null + + const hasOnlyUrl = Array.from (params.keys ()).every (key => key === 'url') + if (hasOnlyUrl) + { + const row = buildRow (1, { + url, + title: '', + thumbnail_base: '', + original_created_from: '', + original_created_before: '', + duration: '', + tags: '', + parent_post_ids: '', + manual: [], + skip: null, + existing_post_id: null, + import_status: null, + created_post_id: null, + recoverable: null }) + return { + rows: [row], + source: url, + repairMode: 'all', + shortcut: true } + } + + if (!(Array.from (params.keys ()).every (key => SINGLE_ROW_KEYS.includes (key as never)))) + return null + + const skipValue = params.get ('skip') + const importStatus = params.get ('import_status') + const recoverable = + params.get ('recoverable') == null + ? null + : params.get ('recoverable') === 'true' + if (skipValue != null && !(isValidSkipReason (skipValue))) + return null + if (importStatus != null && !(isValidImportStatus (importStatus))) + return null + + const row = buildRow (1, { + url, + title: params.get ('title') ?? '', + thumbnail_base: params.get ('thumbnail_base') ?? '', + original_created_from: params.get ('original_created_from') ?? '', + original_created_before: params.get ('original_created_before') ?? '', + duration: params.get ('duration') ?? '', + tags: params.get ('tags') ?? '', + parent_post_ids: params.get ('parent_post_ids') ?? '', + manual: parseManual (params.get ('manual')), + skip: skipValue, + existing_post_id: parsePositiveInt (params.get ('existing_post_id')), + import_status: importStatus, + created_post_id: parsePositiveInt (params.get ('created_post_id')), + recoverable }) + + return { + rows: [row], + source: url, + repairMode: resultRepairMode ([row]), + shortcut: false } +} + + +const parseMetaRows = ( + urlsValue: string, + metaValue: string, +): ParsedPostNewState | null => { + const decoded = decodeBase64Url (metaValue) + if (decoded == null) + return null + + let parsed: MultiRowMetaState + try + { + parsed = JSON.parse (decoded) as MultiRowMetaState + } + catch + { + return null + } + + if (parsed.v !== 1 || !(Array.isArray (parsed.rows))) + return null + + const urls = urlsValue.split (' ').filter (url => url !== '') + if (urls.length < 2 || urls.length !== parsed.rows.length) + return null + + const rows = parsed.rows.map ((row, index) => buildRow (index + 1, { + ...row, + url: urls[index] ?? '' })) + + return { + rows, + source: urls.join ('\n'), + repairMode: resultRepairMode (rows), + shortcut: false } +} + + +const parseFullState = (stateValue: string): ParsedPostNewState | null => { + const decoded = decodeBase64Url (stateValue) + if (decoded == null) + return null + + let parsed: FullState + try + { + parsed = JSON.parse (decoded) as FullState + } + catch + { + return null + } + + if (parsed.v !== 1 || !(Array.isArray (parsed.rows))) + return null + + const rows = parsed.rows.map ((row, index) => buildRow (index + 1, row)) + return { + rows, + source: rows.map (row => row.url).join ('\n'), + repairMode: resultRepairMode (rows), + shortcut: false } +} + + +const regularQuery = (rows: PostImportRow[]): URLSearchParams => { + if (rows.length === 1) + { + const row = rows[0] + const params = new URLSearchParams () + params.set ('url', row?.url ?? '') + params.set ('title', String (row?.attributes.title ?? '')) + params.set ('thumbnail_base', String (row?.attributes.thumbnailBase ?? '')) + params.set ('original_created_from', String (row?.attributes.originalCreatedFrom ?? '')) + params.set ('original_created_before', String (row?.attributes.originalCreatedBefore ?? '')) + params.set ('duration', String (row?.attributes.duration ?? '')) + params.set ('tags', String (row?.attributes.tags ?? '')) + params.set ('parent_post_ids', String (row?.attributes.parentPostIds ?? '')) + const manual = manualFields (row).join (',') + if (manual !== '') + params.set ('manual', manual) + if (row.skipReason != null) + params.set ('skip', row.skipReason) + if (row.existingPostId != null) + params.set ('existing_post_id', String (row.existingPostId)) + if (row.importStatus != null) + params.set ('import_status', row.importStatus) + if (row.createdPostId != null) + params.set ('created_post_id', String (row.createdPostId)) + if (row.recoverable != null) + params.set ('recoverable', row.recoverable ? 'true' : 'false') + return params + } + + const urls = rows.map (row => row.url).join (' ') + const metaRows: QueryRowState[] = rows.map (row => baseRowState (row)) + const meta = encodeBase64Url (JSON.stringify ({ + v: 1, + rows: metaRows })) + const params = new URLSearchParams () + params.set ('urls', urls) + params.set ('meta', meta) + return params +} + + +export const hasPostNewReviewState = (search: string): boolean => { + const params = new URLSearchParams (search) + return params.has ('state') + || params.has ('urls') + || params.has ('meta') + || params.has ('url') +} + + +export const parsePostNewState = (search: string): ParsedPostNewState | null => { + const params = new URLSearchParams (search) + const stateValue = params.get ('state') + if (stateValue != null) + return parseFullState (stateValue) + + const urlsValue = params.get ('urls') + const metaValue = params.get ('meta') + if (urlsValue != null || metaValue != null) + { + if (urlsValue == null || metaValue == null) + return null + return parseMetaRows (urlsValue, metaValue) + } + + return parseSingleRow (params) +} + + +export const serialisePostNewState = (rows: PostImportRow[]): string => { + const regular = regularQuery (rows).toString () + const regularPath = `/posts/new?${ regular }` + if (regularPath.length < MAX_REGULAR_URL_LENGTH) + return `/posts/new?${ regular }` + + const stateRows: FullState['rows'] = rows.map (row => ({ + ...baseRowState (row), + url: row.url })) + const state = encodeBase64Url (JSON.stringify ({ + v: 1, + rows: stateRows })) + return `/posts/new?state=${ state }` +} diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 7bf370b..2be4405 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' @@ -11,9 +11,8 @@ import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPost } from '@/lib/api' import useDialogue from '@/lib/dialogues/useDialogue' -import { clearPostImportSession, - clearPostImportSourceDraft, - loadPostImportSession, +import { parsePostNewState, serialisePostNewState } from '@/lib/postNewQueryState' +import { clearPostImportSourceDraft, buildNextEditedRow, canEditReviewRow, canRetryResultRow, @@ -31,7 +30,6 @@ import { clearPostImportSession, resultRowMessages, reviewSummaryCounts, retryImportRow, - savePostImportSession, validatableImportRows } from '@/lib/postImportSession' import { canEditContent } from '@/lib/users' import Forbidden from '@/pages/Forbidden' @@ -56,6 +54,7 @@ const isRepairRow = (row: PostImportRow): boolean => const PostImportReviewPage: FC = ({ user }) => { const editable = canEditContent (user) const dialogue = useDialogue () + const location = useLocation () const navigate = useNavigate () const [session, setSession] = useState (null) @@ -64,17 +63,84 @@ const PostImportReviewPage: FC = ({ user }) => { const [editingRow, setEditingRow] = useState (null) const [showExistingRows, setShowExistingRows] = useState (false) const sessionRef = useRef (null) + const persistedSearchRef = useRef (null) useEffect (() => { - const loaded = loadPostImportSession (message => - toast ({ title: '取込状態を復元できませんでした', description: message })) - if (loaded == null) + if (sessionRef.current != null && persistedSearchRef.current === location.search) + return + + const parsed = parsePostNewState (location.search) + if (parsed == null) { navigate ('/posts/new', { replace: true }) return } + const loaded = { + version: 2, + savedAt: new Date ().toISOString (), + source: parsed.source, + rows: parsed.rows, + repairMode: parsed.repairMode } + persistedSearchRef.current = location.search + sessionRef.current = loaded setSession (loaded) - }, [navigate]) + + const hydrate = async () => { + if (parsed.shortcut) + { + try + { + const preview = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', { + source: parsed.source }) + const rows = initialisePreviewRows (preview.rows) + const nextSession = { + ...loaded, + source: parsed.source, + rows, + repairMode: resultRepairMode (rows) } + persistSession (nextSession) + } + catch + { + navigate ('/posts/new', { replace: true }) + } + return + } + + const requestedRows = validatableImportRows (parsed.rows) + if (requestedRows.length === 0) + return + + try + { + const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { + rows: requestedRows.map (row => ({ + sourceRow: row.sourceRow, + url: row.url, + attributes: row.attributes, + provenance: row.provenance, + tagSources: row.tagSources, + metadataUrl: row.metadataUrl })), + changed_row: 'all' }) + const validatedRows = initialisePreviewRows (validated.rows) + if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows))) + return + const latest = sessionRef.current ?? loaded + const rows = mergeValidatedImportRows (latest.rows, validatedRows) + const nextSession = { + ...latest, + rows, + repairMode: resultRepairMode (rows) } + sessionRef.current = nextSession + setSession (nextSession) + } + catch + { + } + } + + void hydrate () + }, [location.search, navigate]) useEffect (() => { sessionRef.current = session @@ -96,15 +162,18 @@ const PostImportReviewPage: FC = ({ user }) => { const busy = loading || loadingRow != null const persistSession = (nextSession: PostImportSession) => { - sessionRef.current = nextSession - setSession (nextSession) - savePostImportSession (nextSession, message => - toast ({ title: '取込状態を保存できませんでした', description: message })) + const source = nextSession.rows.map (row => row.url).join ('\n') + const persistedSession = { + ...nextSession, + source } + const nextPath = serialisePostNewState (persistedSession.rows) + persistedSearchRef.current = nextPath.replace ('/posts/new', '') + sessionRef.current = persistedSession + setSession (persistedSession) + navigate (nextPath, { replace: true }) } const finishImport = () => { - clearPostImportSession (message => - toast ({ title: '取込状態を削除できませんでした', description: message })) clearPostImportSourceDraft (message => toast ({ title: '入力内容を削除できませんでした', description: message })) navigate ('/posts') diff --git a/frontend/src/pages/posts/PostImportSourcePage.test.tsx b/frontend/src/pages/posts/PostImportSourcePage.test.tsx index 658fd28..2591401 100644 --- a/frontend/src/pages/posts/PostImportSourcePage.test.tsx +++ b/frontend/src/pages/posts/PostImportSourcePage.test.tsx @@ -75,7 +75,7 @@ describe ('PostImportSourcePage', () => { expect.stringMatching (/^post-import-session:/)) }) - it ('stores a successful preview before navigating to the review route', async () => { + it ('navigates to /posts/new with query state after a successful preview', async () => { api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] }) renderWithProviders () @@ -84,11 +84,11 @@ describe ('PostImportSourcePage', () => { fireEvent.click (screen.getByRole ('button', { name: '次へ' })) await waitFor (() => { - expect (router.navigate).toHaveBeenCalledWith ('/posts/new/review') + expect (router.navigate).toHaveBeenCalledWith ( + expect.stringMatching (/^\/posts\/new\?/)) }) - const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) => - sessionStorage.key (index)).filter ( - key => key?.startsWith ('post-import-session:')) - expect (sessionKeys).toEqual (['post-import-session:current']) + expect (Array.from ({ length: sessionStorage.length }, (_, index) => + sessionStorage.key (index))).not.toContainEqual( + expect.stringMatching (/^post-import-session:/)) }) }) diff --git a/frontend/src/pages/posts/PostImportSourcePage.tsx b/frontend/src/pages/posts/PostImportSourcePage.tsx index 23d3803..e2ba1d2 100644 --- a/frontend/src/pages/posts/PostImportSourcePage.tsx +++ b/frontend/src/pages/posts/PostImportSourcePage.tsx @@ -12,14 +12,12 @@ import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPost, isApiError } from '@/lib/api' +import { serialisePostNewState } from '@/lib/postNewQueryState' import { canEditContent } from '@/lib/users' import { countImportSourceLines, cleanupExpiredPostImportSessions, - clearPostImportSession, initialisePreviewRows, - loadPostImportSession, loadPostImportSourceDraft, - savePostImportSession, savePostImportSourceDraft, validateImportSource } from '@/lib/postImportSession' import Forbidden from '@/pages/Forbidden' @@ -68,8 +66,6 @@ const PostImportSourcePage: FC = ({ user }) => { cleanupExpiredPostImportSessions (message => toast ({ title: '保存済みデータを整理できませんでした', description: message })) - const currentSession = loadPostImportSession (message => - toast ({ title: '保存済み入力を復元できませんでした', description: message })) const draft = loadPostImportSourceDraft (message => toast ({ title: '保存済み入力を復元できませんでした', description: message })) @@ -77,7 +73,7 @@ const PostImportSourcePage: FC = ({ user }) => { { setSource (current => current === '' - ? (currentSession?.source || draft.source) + ? draft.source : current) } }, []) @@ -125,16 +121,8 @@ const PostImportSourcePage: FC = ({ user }) => { setSourceIssues (urlIssues) return } - clearPostImportSession (message => - toast ({ title: '取込状態を保存できませんでした', description: message })) - const saved = savePostImportSession ( - { source, - rows: initialisePreviewRows (data.rows), - repairMode: 'all' }, - message => toast ({ title: '取込状態を保存できませんでした', description: message })) - if (!(saved)) - return - navigate ('/posts/new/review') + const nextRows = initialisePreviewRows (data.rows) + navigate (serialisePostNewState (nextRows)) } catch (requestError) { diff --git a/frontend/src/pages/posts/PostNewPage.test.tsx b/frontend/src/pages/posts/PostNewPage.test.tsx index f424e7d..215acb9 100644 --- a/frontend/src/pages/posts/PostNewPage.test.tsx +++ b/frontend/src/pages/posts/PostNewPage.test.tsx @@ -1,172 +1,44 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField' import PostNewPage from '@/pages/posts/PostNewPage' import { buildUser } from '@/test/factories' import { renderWithProviders } from '@/test/render' const api = vi.hoisted (() => ({ - apiGet: vi.fn (), - apiPost: vi.fn (), - isApiError: vi.fn (), -})) - -const toastApi = vi.hoisted (() => ({ - toast: vi.fn (), -})) + apiPost: vi.fn (), + isApiError: vi.fn () })) vi.mock ('@/lib/api', () => api) -vi.mock ('@/components/ui/use-toast', () => toastApi) describe ('PostNewPage', () => { beforeEach (() => { vi.clearAllMocks () api.isApiError.mockReturnValue (false) + sessionStorage.clear () }) - it ('blocks guests', () => { - renderWithProviders () + it ('shows the source page on /posts/new', () => { + renderWithProviders ( + + + }/> + + ) - expect (screen.getByText ('403')).toBeInTheDocument () + expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument () }) - it ('submits a new post with manual title and thumbnail fetch UI', async () => { - api.apiPost.mockResolvedValueOnce ({}) - api.apiGet.mockResolvedValue ([]) + it ('shows the review page when query state is present', () => { + renderWithProviders ( + + + }/> + + ) - renderWithProviders () - - fireEvent.change (screen.getByLabelText ('URL'), { - target: { value: 'https://example.com/post' } }) - fireEvent.change (screen.getByLabelText ('タイトル'), { - target: { value: '投稿タイトル' } }) - fireEvent.change (screen.getByLabelText ('親投稿'), { - target: { value: '1 2' } }) - fireEvent.change (screen.getByLabelText ('タグ'), { - target: { value: 'tag1 tag2' } }) - fireEvent.click (screen.getByRole ('button', { name: '追加' })) - - await waitFor (() => { - expect (api.apiPost).toHaveBeenCalledWith ( - '/posts', - expect.any (FormData), - { headers: { 'Content-Type': 'multipart/form-data' } }, - ) - }) - const formData = api.apiPost.mock.calls[0]?.[1] as FormData - expect (formData.get ('url')).toBe ('https://example.com/post') - expect (formData.get ('title')).toBe ('投稿タイトル') - expect (formData.get ('parent_post_ids')).toBe ('1 2') - expect (formData.get ('tags')).toBe ('tag1 tag2') - expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' }) + expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument () + expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument () }) - - it ('shows the shared creation field order without duration or unrequested helper copy', () => { - api.apiGet.mockResolvedValue ([]) - - const { container } = - renderWithProviders () - - const labels = Array.from (container.querySelectorAll ('label')) - .map (node => node.textContent?.trim ()) - - expect (labels.slice (0, 6)).toEqual ([ - 'URL', - 'サムネール', - 'タイトル', - 'オリジナルの作成日時', - 'タグ', - '親投稿']) - expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument () - expect (screen.queryByText ('必要なタイミングで URL から取得できます.')) - .not.toBeInTheDocument () - expect (screen.getAllByRole ('button', { name: '取得' })).toHaveLength (2) - expect (screen.queryByRole ('button', { name: '変更をリセット' })).not.toBeInTheDocument () - }) - - it ('shows 422 validation errors for post fields', async () => { - api.apiGet.mockResolvedValue ([]) - api.isApiError.mockReturnValue (true) - api.apiPost.mockRejectedValueOnce ({ - response: { - status: 422, - data: { - type: 'validation_error', - message: '入力内容を確認してください.', - errors: { tags: ['ニコニコ・タグは直接指定できません.'] }, - base_errors: ['投稿内容を確認してください.'], - }, - }, - }) - - renderWithProviders () - - fireEvent.change (screen.getByLabelText ('URL'), { - target: { value: 'https://example.com/post' } }) - fireEvent.change (screen.getByLabelText ('タイトル'), { - target: { value: '投稿タイトル' } }) - fireEvent.change (screen.getByLabelText ('タグ'), { - target: { value: 'nico:nico_tag' } }) - fireEvent.click (screen.getByRole ('button', { name: '追加' })) - - expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument () - expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument () - expect (screen.getByLabelText ('タグ')).toHaveAttribute ('aria-invalid', 'true') - }) - - it ('keeps shared tags and datetime submission values without showing duration', async () => { - api.apiPost.mockResolvedValueOnce ({}) - api.apiGet.mockResolvedValue ([]) - - const { container } = renderWithProviders ( - ) - - fireEvent.change (screen.getByLabelText ('URL'), { - target: { value: 'https://example.com/post' } }) - fireEvent.change (screen.getByLabelText ('タイトル'), { - target: { value: '投稿タイトル' } }) - fireEvent.change (screen.getByLabelText ('タグ'), { - target: { value: '動画 tag1 tag2' } }) - const datetimeInputs = container.querySelectorAll ('input[type="datetime-local"]') - fireEvent.change (datetimeInputs[0] as HTMLInputElement, { - target: { value: '2024-01-01T12:34' } }) - fireEvent.click (screen.getByRole ('button', { name: '追加' })) - - await waitFor (() => expect (api.apiPost).toHaveBeenCalled ()) - - const formData = api.apiPost.mock.calls[0]?.[1] as FormData - expect (container.querySelector ('input[type="file"]')).not.toBeNull () - expect (formData.get ('duration')).toBeNull () - expect (formData.get ('tags')).toBe ('動画 tag1 tag2') - 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 9255beb..8429b86 100644 --- a/frontend/src/pages/posts/PostNewPage.tsx +++ b/frontend/src/pages/posts/PostNewPage.tsx @@ -1,21 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Helmet } from 'react-helmet-async' -import { useNavigate } from 'react-router-dom' +import { useMemo } from 'react' +import { useLocation } from 'react-router-dom' -import FieldError from '@/components/common/FieldError' -import Form from '@/components/common/Form' -import PageTitle from '@/components/common/PageTitle' -import MainArea from '@/components/layout/MainArea' -import PostCreationDataFields from '@/components/posts/PostCreationDataFields' -import PostTextField from '@/components/posts/PostTextField' -import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' -import { Button } from '@/components/ui/button' -import { toast } from '@/components/ui/use-toast' -import { SITE_TITLE } from '@/config' -import { apiGet, apiPost } from '@/lib/api' -import { canEditContent } from '@/lib/users' -import { useValidationErrors } from '@/lib/useValidationErrors' -import Forbidden from '@/pages/Forbidden' +import PostImportReviewPage from '@/pages/posts/PostImportReviewPage' +import PostImportSourcePage from '@/pages/posts/PostImportSourcePage' +import { hasPostNewReviewState } from '@/lib/postNewQueryState' import type { FC } from 'react' @@ -23,194 +11,16 @@ import type { User } from '@/types' type Props = { user: User | null } -type PostFormField = - 'url' | 'title' | 'tags' | 'parentPostIds' - | 'originalCreatedAt' | 'originalCreatedFrom' - | 'originalCreatedBefore' | 'thumbnail' - const PostNewPage: FC = ({ user }) => { - const editable = canEditContent (user) + const location = useLocation () + const reviewMode = useMemo ( + () => hasPostNewReviewState (location.search), + [location.search]) - const navigate = useNavigate () - - const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = - useValidationErrors () - - const [originalCreatedBefore, setOriginalCreatedBefore] = - useState (null) - const [originalCreatedFrom, setOriginalCreatedFrom] = - useState (null) - const [parentPostIds, setParentPostIds] = useState ('') - const [tags, setTags] = useState ('') - const [thumbnailFile, setThumbnailFile] = useState (null) - const [thumbnailLoading, setThumbnailLoading] = useState (false) - const [thumbnailPreview, setThumbnailPreview] = useState ('') - const [title, setTitle] = useState ('') - const [titleLoading, setTitleLoading] = useState (false) - const [url, setURL] = useState ('') - - const thumbnailPreviewRef = useRef ('') - - const handleSubmit = async () => { - clearValidationErrors () - - const formData = new FormData - formData.append ('title', title) - formData.append ('url', url) - formData.append ('tags', tags) - formData.append ('parent_post_ids', parentPostIds) - if (thumbnailFile) - formData.append ('thumbnail', thumbnailFile) - if (originalCreatedFrom) - formData.append ('original_created_from', originalCreatedFrom) - if (originalCreatedBefore) - formData.append ('original_created_before', originalCreatedBefore) - - try - { - await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) - toast ({ title: '投稿成功!' }) - navigate ('/posts') - } - catch (e) - { - applyValidationError (e) - toast ({ title: '投稿失敗', description: '入力を確認してください.' }) - } - } - - const fetchTitle = useCallback (async () => { - setTitleLoading (true) - try - { - const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) - setTitle (data.title || '') - } - finally - { - setTitleLoading (false) - } - }, [url]) - - const fetchThumbnail = useCallback (async () => { - setThumbnailPreview ('') - setThumbnailFile (null) - setThumbnailLoading (true) - if (thumbnailPreviewRef.current) - URL.revokeObjectURL (thumbnailPreviewRef.current) - try - { - const data = await apiGet ('/preview/thumbnail', - { params: { url }, - responseType: 'blob' }) - const imageURL = URL.createObjectURL (data) - setThumbnailPreview (imageURL) - setThumbnailFile (new File ([data], - 'thumbnail.png', - { type: data.type || 'image/png' })) - } - finally - { - setThumbnailLoading (false) - } - }, [url]) - - useEffect (() => { - thumbnailPreviewRef.current = thumbnailPreview - }, [thumbnailPreview]) - - if (!(editable)) - return - - return ( - - - {`広場に投稿を追加 | ${ SITE_TITLE }`} - -
- 広場に投稿を追加する - - - - {}} - after={ - }/> - { - const file = e.target.files?.[0] - if (file) - { - setThumbnailFile (file) - setThumbnailPreview (URL.createObjectURL (file)) - } - }}/> - -
} - core={{ - title: { - value: title, - onChange: setTitle, - errors: fieldErrors.title, - disabled: titleLoading, - after: ( - ) }, - originalCreated: { - originalCreatedFrom, - setOriginalCreatedFrom, - originalCreatedBefore, - setOriginalCreatedBefore, - errors: { - originalCreatedAt: fieldErrors.originalCreatedAt, - originalCreatedFrom: fieldErrors.originalCreatedFrom, - originalCreatedBefore: fieldErrors.originalCreatedBefore } }, - tags: { - value: tags, - onChange: setTags, - errors: fieldErrors.tags }, - parentPostIds: { - value: parentPostIds, - onChange: setParentPostIds, - errors: fieldErrors.parentPostIds } }}/> - - {/* 送信 */} - - - ) + return reviewMode + ? + : } export default PostNewPage