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

マージ済み
みてるぞ が 103 個のコミットを feature/399 から main へマージ 2026-07-19 00:03:11 +09:00
13個のファイルの変更85行の追加196行の削除
コミット d2f1b1ed48 の変更だけを表示してゐます - すべてのコミットを表示
+1 -2
ファイルの表示
@@ -148,8 +148,7 @@ class PostsController < ApplicationController
original_created_from: params[:original_created_from],
original_created_before: params[:original_created_before],
parent_post_ids: parse_parent_post_ids,
video_ms: params[:video_ms],
duration: params[:duration] }).create!
video_ms: params[:video_ms] }).create!
post.reload
render json: PostRepr.base(post), status: :created
+19 -6
ファイルの表示
@@ -10,7 +10,6 @@ class PostImportPreviewer
'tags',
'parent_post_ids'].freeze
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
EXISTING_SKIP_WARNING = '既存投稿のためスキップします.'.freeze
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze
@@ -91,7 +90,6 @@ class PostImportPreviewer
end
if validation_errors.blank? && normal_url.present? && existing_post
add_field_warning!(field_warnings, 'url', EXISTING_SKIP_WARNING)
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present?
return { source_row: row[:source_row],
@@ -113,9 +111,13 @@ class PostImportPreviewer
if should_fetch
clear_fetch_warnings!(field_warnings)
metadata = metadata_for(url_for_metadata, metadata_cache)
if metadata[:validation_errors].present?
validation_errors.merge!(metadata[:validation_errors])
else
apply_metadata!(attributes, provenance, tag_sources, metadata[:data])
apply_fetch_warnings!(field_warnings, metadata[:warnings])
end
end
attributes['url'] = url
validate_basic_data(attributes, validation_errors)
@@ -200,7 +202,8 @@ class PostImportPreviewer
end
def fetch_metadata url
return { data: { }, warnings: { 'url' => ['URL が空です.'] } } if url.blank?
return { data: { }, warnings: { 'url' => ['URL が空です.'] },
validation_errors: { } } if url.blank?
data = sanitise_metadata(PostMetadataFetcher.fetch(url).stringify_keys.compact)
warnings = { }
@@ -208,13 +211,18 @@ class PostImportPreviewer
if data['thumbnail_base'].blank?
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING)
end
{ data:, warnings: }
{ data:, warnings:, validation_errors: { } }
rescue Preview::UrlSafety::UnsafeUrl => e
Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e
Rails.logger.info(
"post_import_metadata_fetch_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } }
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
def sanitise_metadata metadata
@@ -295,11 +303,16 @@ class PostImportPreviewer
def safe_fetch_metadata url
fetch_metadata(url)
rescue Preview::UrlSafety::UnsafeUrl => e
Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue StandardError => e
Rails.logger.error(
"post_import_metadata_fetch_unexpected_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } }
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
def preload_known_tags prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
+6 -23
ファイルの表示
@@ -3,12 +3,8 @@ import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
export type StatusBadgeTone =
'danger'
| 'default'
| 'info'
| 'manual'
| 'ready'
| 'skipped'
'success'
| 'neutral'
| 'warning'
type Props = {
@@ -16,35 +12,22 @@ type Props = {
tone: StatusBadgeTone }
const TONES: Record<StatusBadgeTone, string[]> = {
default: [
'border-slate-300 bg-slate-50 text-slate-700',
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
ready: [
success: [
'border-emerald-300 bg-emerald-50 text-emerald-700',
'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'],
warning: [
'border-amber-300 bg-amber-50 text-amber-700',
'dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'],
danger: [
'border-rose-300 bg-rose-50 text-rose-700',
'dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200'],
info: [
'border-sky-300 bg-sky-50 text-sky-700',
'dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200'],
skipped: [
neutral: [
'border-stone-300 bg-stone-50 text-stone-700',
'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'],
manual: [
'border-purple-300 bg-purple-50 text-purple-700',
'dark:border-purple-900 dark:bg-purple-950 dark:text-purple-200'] }
'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'] }
const StatusBadge: FC<Props> = ({ children, tone }) => (
<span
className={cn (
'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium',
TONES[tone],
)}>
TONES[tone])}>
{children}
</span>)
+14 -21
ファイルの表示
@@ -70,10 +70,14 @@ const PostImportRowDialog: FC<Props> = (
{ open, row, messageRow, saving, onOpenChange, onSave },
) => {
const [draft, setDraft] = useState<Draft | null> (null)
const [resetRequested, setResetRequested] = useState (false)
useEffect (() => {
if (open && row)
{
setDraft (buildDraft (row))
setResetRequested (false)
}
}, [open, row])
if (row == null || draft == null)
@@ -90,12 +94,10 @@ const PostImportRowDialog: FC<Props> = (
}
const save = async () => {
if (
await onSave ({
if (await onSave ({
draft,
resetRequested: false,
resetSnapshot: row.resetSnapshot })
)
resetRequested,
resetSnapshot: row.resetSnapshot }))
onOpenChange (false)
}
@@ -139,8 +141,7 @@ const PostImportRowDialog: FC<Props> = (
warnings={displayRow.fieldWarnings.thumbnailBase}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase,
)}
displayRow.importErrors?.thumbnailBase)}
onChange={value => update ('thumbnailBase', value)}/>
<PostOriginalCreatedTimeField
originalCreatedFrom={draft.originalCreatedFrom || null}
@@ -153,8 +154,7 @@ const PostImportRowDialog: FC<Props> = (
displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.originalCreatedAt,
displayRow.importErrors?.originalCreatedFrom,
displayRow.importErrors?.originalCreatedBefore,
)}/>
displayRow.importErrors?.originalCreatedBefore)}/>
<DialogTextField
label="動画時間"
value={draft.duration}
@@ -162,8 +162,7 @@ const PostImportRowDialog: FC<Props> = (
displayRow.validationErrors.duration,
displayRow.validationErrors.videoMs,
displayRow.importErrors?.duration,
displayRow.importErrors?.videoMs,
)}
displayRow.importErrors?.videoMs)}
onChange={value => update ('duration', value)}/>
<DialogAreaField
label="タグ"
@@ -177,8 +176,7 @@ const PostImportRowDialog: FC<Props> = (
value={draft.parentPostIds}
errors={groupedMessages (
displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds,
)}
displayRow.importErrors?.parentPostIds)}
onChange={value => update ('parentPostIds', value)}/>
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
@@ -191,14 +189,9 @@ const PostImportRowDialog: FC<Props> = (
<Button
type="button"
variant="outline"
onClick={async () => {
const resetDraft = buildResetDraft (row)
setDraft (resetDraft)
if (await onSave ({
draft: resetDraft,
resetRequested: true,
resetSnapshot: row.resetSnapshot }))
onOpenChange (false)
onClick={() => {
setDraft (buildResetDraft (row))
setResetRequested (true)
}}>
</Button>
+2 -6
ファイルの表示
@@ -4,7 +4,6 @@ import { cn } from '@/lib/utils'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { importPanelToneForRow } from '@/components/posts/import/postImportTone'
import type { FC } from 'react'
@@ -35,9 +34,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
className={cn (
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
importPanelToneForRow (row),
'transition-shadow hover:shadow-sm',
)}>
'transition-shadow hover:shadow-sm')}>
<div className="space-y-1">
<div className="text-sm font-medium">#{row.sourceRow}</div>
</div>
@@ -80,8 +77,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div
className={cn (
'space-y-3 rounded-lg border p-4 md:hidden',
importPanelToneForRow (row),
)}>
'transition-shadow hover:shadow-sm')}>
<div className="flex items-start gap-3">
<ThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')}
+2 -2
ファイルの表示
@@ -14,9 +14,9 @@ const LABELS: Record<PostImportBadgeValue, string> = {
skipped: 'スキップ' }
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
ready: 'ready',
ready: 'success',
warning: 'warning',
skipped: 'skipped' }
skipped: 'neutral' }
const PostImportStatusBadge: FC<Props> = ({ value }) => (
-19
ファイルの表示
@@ -1,19 +0,0 @@
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import type { FC } from 'react'
import type { PostImportBadgeValue } from '@/components/posts/import/postImportRowStatus'
type Props = {
label: string
value: number
badge?: PostImportBadgeValue }
const PostImportSummaryChip: FC<Props> = ({ label, value, badge }) => (
<div className="flex items-center gap-2 rounded-full border border-border
bg-background px-3 py-1 text-sm">
{badge != null && <PostImportStatusBadge value={badge}/>}
<span>{label} {value}</span>
</div>)
export default PostImportSummaryChip
+8 -15
ファイルの表示
@@ -10,20 +10,13 @@ const hasWarnings = (row: PostImportRow): boolean =>
export const displayPostImportStatus = (
row: PostImportRow,
): PostImportDisplayStatus | null => {
const skipped =
row.skipReason === 'existing'
|| row.importStatus === 'skipped'
const hidden =
Object.keys (row.validationErrors ?? { }).length > 0
): PostImportDisplayStatus | null =>
(row.skipReason === 'existing' || row.importStatus === 'skipped')
? 'skipped'
: ((Object.keys (row.validationErrors ?? { }).length > 0
|| row.importStatus === 'failed'
|| row.importStatus === 'created'
if (skipped)
return 'skipped'
if (hidden)
return null
return hasWarnings (row) || row.status === 'warning'
|| row.importStatus === 'created')
? null
: ((hasWarnings (row) || row.status === 'warning')
? 'warning'
: 'ready'
}
: 'ready'))
-36
ファイルの表示
@@ -1,36 +0,0 @@
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import type { PostImportRow } from '@/lib/postImportSession'
export const importPanelToneClass = (
status: 'created' | 'error' | 'failed' | 'invalid' | 'skipped' | 'warning' | 'ready',
): string[] => {
switch (status)
{
case 'created':
return [
'border-sky-200 bg-sky-50',
'dark:border-sky-900 dark:bg-sky-950/30']
case 'error':
case 'failed':
case 'invalid':
return [
'border-rose-200 bg-rose-50',
'dark:border-rose-900 dark:bg-rose-950/30']
case 'skipped':
return [
'border-stone-200 bg-stone-50',
'dark:border-stone-800 dark:bg-stone-900/60']
case 'warning':
return [
'border-amber-200 bg-amber-50',
'dark:border-amber-900 dark:bg-amber-950/30']
default:
return [
'border-border bg-white',
'dark:border-neutral-700 dark:bg-neutral-900']
}
}
export const importPanelToneForRow = (row: PostImportRow): string[] =>
importPanelToneClass (displayPostImportStatus (row) ?? 'ready')
+4 -19
ファイルの表示
@@ -9,8 +9,6 @@ import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { importPanelToneClass } from '@/components/posts/import/postImportTone'
import PostImportSummaryChip from '@/components/posts/import/PostImportSummaryChip'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
@@ -71,7 +69,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
[session])
const retry = async (sourceRow: number) => {
if (session == null)
if (session == null || sessionId == null)
return
setLoadingRow (sourceRow)
@@ -177,13 +175,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="mx-auto max-w-5xl space-y-4 p-4">
<PageTitle></PageTitle>
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
dark:bg-neutral-900">
<div className="flex flex-wrap gap-2">
<PostImportSummaryChip label="登録成功" value={counts.created}/>
<PostImportSummaryChip label="スキップ" value={counts.skipped} badge="skipped"/>
<PostImportSummaryChip label="失敗" value={counts.failed}/>
</div>
<div className="text-sm text-neutral-700 dark:text-neutral-200">
{counts.created}  {counts.skipped}  {counts.failed}
</div>
<div className="space-y-3">
@@ -193,18 +186,10 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const canRetry =
row.importStatus === 'failed'
&& Object.keys (row.validationErrors).length === 0
const panelStatus =
row.importStatus === 'created'
? 'created'
: (row.importStatus === 'failed'
? 'failed'
: displayStatus ?? 'ready')
return (
<div
key={row.sourceRow}
className={[
'rounded-lg border p-4',
...importPanelToneClass (panelStatus)].join (' ')}>
className="rounded-lg border p-4 transition-shadow hover:shadow-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-start
md:justify-between">
<div className="space-y-2">
+8 -23
ファイルの表示
@@ -8,8 +8,6 @@ import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog'
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import PostImportSummaryChip from '@/components/posts/import/PostImportSummaryChip'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
@@ -130,8 +128,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (editingRow == null)
return false
const urlChanged = draft.url !== editingRow.url
const nextRow =
const baseRow =
resetRequested
? {
...editingRow,
@@ -139,10 +136,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
attributes: { ...resetSnapshot.attributes },
provenance: { ...resetSnapshot.provenance },
tagSources: { ...resetSnapshot.tagSources },
metadataUrl: resetSnapshot.metadataUrl,
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
importErrors: undefined }
: buildNextEditedRow (editingRow, draft, urlChanged)
metadataUrl: resetSnapshot.metadataUrl }
: editingRow
const urlChanged = draft.url !== baseRow.url
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
setSavingRow (editingRow.sourceRow)
const nextRows = session.rows.map (row =>
@@ -272,16 +269,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<div className="mx-auto max-w-6xl space-y-4 p-4">
<PageTitle>稿</PageTitle>
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
dark:bg-neutral-900">
<div className="flex flex-wrap items-center gap-2">
<PostImportSummaryChip label="全件" value={counts.total}/>
<PostImportSummaryChip label="登録対象" value={counts.submittable} badge="ready"/>
<PostImportSummaryChip label="スキップ予定" value={counts.skipPlanned}
badge="skipped"/>
</div>
</div>
<div className="space-y-3">
{reviewRows.map (row => (
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
@@ -336,11 +323,9 @@ const PostImportFooter = (
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> {processableCount} </span>
<PostImportStatusBadge value="ready"/>
<span> {creatableCount} </span>
<PostImportStatusBadge value="skipped"/>
<span> {skipPlannedCount} </span>
<span> {processableCount}</span>
<span> {creatableCount}</span>
<span> {skipPlannedCount}</span>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={onBack}>
+9 -7
ファイルの表示
@@ -34,12 +34,15 @@ const MAX_ROWS = 100
const SOURCE_ERROR_ID = 'post-import-source-error'
const SOURCE_ISSUES_ID = 'post-import-source-issues'
const urlIssuesFromRows = (rows: PostImportRow[]) =>
rows.flatMap (row =>
const urlIssuesFromRows = (rows: PostImportRow[], source: string) => {
const sourceLines = source.split (/\r\n|\n|\r/)
return rows.flatMap (row =>
(row.validationErrors.url ?? []).map (message => ({
sourceRow: row.sourceRow,
message,
url: row.url })))
url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
}
const PostImportSourcePage: FC<Props> = ({ user }) => {
@@ -108,7 +111,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
{
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
source })
const urlIssues = urlIssuesFromRows (data.rows)
const urlIssues = urlIssuesFromRows (data.rows, source)
if (urlIssues.length > 0)
{
setSourceIssues (urlIssues)
@@ -151,7 +154,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
</Helmet>
<Form className="max-w-4xl p-0">
<Form className="max-w-4xl">
<PageTitle>稿</PageTitle>
<FormField label="URL リスト">
{() => (
@@ -162,8 +165,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
aria-describedby={sourceDescribedBy || undefined}
className={inputClass (
messages.length > 0 || sourceIssues.length > 0,
'font-mono text-sm',
)}
'font-mono text-sm')}
onBlur={() => {
savePostImportSourceDraft (source, message =>
toast ({
+7 -12
ファイルの表示
@@ -25,13 +25,8 @@ import type { User } from '@/types'
type Props = { user: User | null }
type PostFormField =
'url'
| 'title'
| 'tags'
| 'parentPostIds'
| 'videoMs'
| 'originalCreatedAt'
| 'thumbnail'
'url' | 'title' | 'tags' | 'parentPostIds'
| 'videoMs' | 'originalCreatedAt' | 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => {
@@ -42,8 +37,10 @@ const PostNewPage: FC<Props> = ({ user }) => {
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('')
@@ -56,9 +53,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
const thumbnailPreviewRef = useRef ('')
const videoFlg =
useMemo (() =>
tags.split (/\s+/).some (
tag => tag.replace (/\[.*\]$/, '') === '動画'),
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const handleSubmit = async () => {