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

マージ済み
みてるぞ が 103 個のコミットを feature/399 から main へマージ 2026-07-19 00:03:11 +09:00
14個のファイルの変更592行の追加404行の削除
コミット 3f75994bd4 の変更だけを表示してゐます - すべてのコミットを表示
+6 -1
ファイルの表示
@@ -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,
+21
ファイルの表示
@@ -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!
+1
ファイルの表示
@@ -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
+5 -6
ファイルの表示
@@ -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 }: {
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/new/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new/review" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/review" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts/new/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
@@ -118,9 +117,9 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/new/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new/review" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/review" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts/new/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
+5 -2
ファイルの表示
@@ -14,7 +14,8 @@ type Props = {
type?: string
placeholder?: string
className?: string
after?: ReactNode }
after?: ReactNode
onBlur?: () => void }
const PostTextField: FC<Props> = (
@@ -27,7 +28,8 @@ const PostTextField: FC<Props> = (
type = 'text',
placeholder,
className,
after },
after,
onBlur },
) => (
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
@@ -37,6 +39,7 @@ const PostTextField: FC<Props> = (
value={value}
disabled={disabled}
placeholder={placeholder}
onBlur={onBlur}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
+9 -1
ファイルの表示
@@ -74,12 +74,15 @@ const PostImportRowForm: FC<Props> = (
const [messageRow, setMessageRow] = useState<PostImportRow | null> (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<Props> = (
setDraft (resetDraft)
setResetRequested (true)
setMessageRow (null)
setCommittedThumbnailBase (resetDraft.thumbnailBase)
return false
}, [controls, resetDisabled, resetDraft])
@@ -159,7 +163,7 @@ const PostImportRowForm: FC<Props> = (
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
<div className="space-y-3 md:sticky md:top-0 md:self-start">
<PostImportThumbnailPreview
url={draft.thumbnailBase}
url={committedThumbnailBase}
className="h-28 w-28"/>
</div>
@@ -183,6 +187,10 @@ const PostImportRowForm: FC<Props> = (
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
onBlur={() => {
if (draft.thumbnailBase !== committedThumbnailBase)
setCommittedThumbnailBase (draft.thumbnailBase)
}}
onChange={value => update ('thumbnailBase', value)}/>
</>}
core={{
+6 -5
ファイルの表示
@@ -30,15 +30,16 @@ const PostImportThumbnailPreview: FC<Props> = (
if (!(url))
return
let active = true
const controller = new AbortController ()
const loadPreview = async () => {
try
{
const blob = await apiGet<Blob> ('/preview/thumbnail', {
const blob = await apiGet<Blob> ('/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<Props> = (
}
catch
{
if (active)
if (!(controller.signal.aborted))
setPreviewUrl ('')
}
}
@@ -55,7 +56,7 @@ const PostImportThumbnailPreview: FC<Props> = (
void loadPreview ()
return () => {
active = false
controller.abort ()
if (previewUrlRef.current)
{
URL.revokeObjectURL (previewUrlRef.current)
+1
ファイルの表示
@@ -8,6 +8,7 @@ import type { AxiosError, AxiosRequestConfig } from 'axios'
type Opt = {
params?: AxiosRequestConfig['params']
headers?: Record<string, string>
signal?: AbortSignal
responseType?: 'blob' }
const client = axios.create ({ baseURL: API_BASE_URL })
+410
ファイルの表示
@@ -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<QueryRowState & { url: string }> }
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<string, PostImportOrigin> = {
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 }`
}
+84 -15
ファイルの表示
@@ -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<Props> = ({ user }) => {
const editable = canEditContent (user)
const dialogue = useDialogue ()
const location = useLocation ()
const navigate = useNavigate ()
const [session, setSession] = useState<PostImportSession | null> (null)
@@ -64,17 +63,84 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
const [showExistingRows, setShowExistingRows] = useState (false)
const sessionRef = useRef<PostImportSession | null> (null)
const persistedSearchRef = useRef<string | null> (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<Props> = ({ 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')
+6 -6
ファイルの表示
@@ -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 (<PostImportSourcePage user={buildUser ()}/>)
@@ -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:/))
})
})
+4 -16
ファイルの表示
@@ -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<Props> = ({ 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<Props> = ({ user }) => {
{
setSource (current =>
current === ''
? (currentSession?.source || draft.source)
? draft.source
: current)
}
}, [])
@@ -125,16 +121,8 @@ const PostImportSourcePage: FC<Props> = ({ 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)
{
+22 -150
ファイルの表示
@@ -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 (<PostNewPage user={buildUser ({ role: 'guest' })}/>)
it ('shows the source page on /posts/new', () => {
renderWithProviders (
<MemoryRouter initialEntries={['/posts/new']}>
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
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 (
<MemoryRouter initialEntries={['/posts/new?url=https%3A%2F%2Fexample.com%2Fpost']}>
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
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 (<PostNewPage user={buildUser ({ role: 'member' })}/>)
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 (<PostNewPage user={buildUser ({ role: 'member' })}/>)
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 (
<PostNewPage user={buildUser ({ role: 'member' })}/>)
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 (<PostNewPage user={buildUser ({ role: 'member' })}/>)
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
})
})
+12 -202
ファイルの表示
@@ -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<Props> = ({ 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<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('')
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
const [thumbnailLoading, setThumbnailLoading] = useState (false)
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
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<Blob> ('/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 <Forbidden/>
return (
<MainArea>
<Helmet>
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
</Helmet>
<Form>
<PageTitle>稿</PageTitle>
<FieldError messages={baseErrors}/>
<PostCreationDataFields
url={{
value: url,
onChange: setURL,
errors: fieldErrors.url,
type: 'url',
placeholder: '例:https://www.nicovideo.jp/watch/...' }}
thumbnailField={
<div className="space-y-2">
<PostTextField
label="サムネール"
value={thumbnailFile?.name ?? ''}
errors={fieldErrors.thumbnail}
disabled
onChange={() => {}}
after={
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>}/>
<input
type="file"
accept="image/*"
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>
<PostThumbnailPreview
url={thumbnailPreview}
alt="preview"
className="h-28 w-28"/>
</div>}
core={{
title: {
value: title,
onChange: setTitle,
errors: fieldErrors.title,
disabled: titleLoading,
after: (
<Button
type="button"
variant="outline"
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>) },
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 } }}/>
{/* 送信 */}
<Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={titleLoading || thumbnailLoading}>
</Button>
</Form>
</MainArea>)
return reviewMode
? <PostImportReviewPage user={user}/>
: <PostImportSourcePage user={user}/>
}
export default PostNewPage