このコミットが含まれているのは:
@@ -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,
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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/>}/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }`
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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:/))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
新しい課題から参照
ユーザをブロックする