このコミットが含まれているのは:
@@ -0,0 +1,203 @@
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle } from '@/components/ui/dialog'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { PostImportOrigin,
|
||||
PostImportRow } from '@/lib/postImportSession'
|
||||
|
||||
type Draft = {
|
||||
url: string
|
||||
title: string
|
||||
thumbnailBase: string
|
||||
originalCreatedFrom: string
|
||||
originalCreatedBefore: string
|
||||
duration: string
|
||||
tags: string
|
||||
parentPostIds: string }
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
row: PostImportRow | null
|
||||
saving: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (draft: Draft) => Promise<boolean> }
|
||||
|
||||
const originOf = (
|
||||
row: PostImportRow,
|
||||
field: string,
|
||||
): PostImportOrigin =>
|
||||
row.provenance[field] ?? 'automatic'
|
||||
|
||||
const buildDraft = (row: PostImportRow): Draft => ({
|
||||
url: row.url,
|
||||
title: String (row.attributes.title ?? ''),
|
||||
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
|
||||
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
|
||||
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
|
||||
duration: String (row.attributes.duration ?? ''),
|
||||
tags: String (row.attributes.tags ?? ''),
|
||||
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
||||
|
||||
|
||||
const PostImportRowDialog: FC<Props> = (
|
||||
{ open, row, saving, onOpenChange, onSave },
|
||||
) => {
|
||||
const [draft, setDraft] = useState<Draft | null> (null)
|
||||
|
||||
useEffect (() => {
|
||||
if (open && row)
|
||||
setDraft (buildDraft (row))
|
||||
}, [open, row])
|
||||
|
||||
if (!(row) || !(draft))
|
||||
return null
|
||||
|
||||
const update = <Key extends keyof Draft,> (
|
||||
key: Key,
|
||||
value: Draft[Key],
|
||||
) => {
|
||||
setDraft (current =>
|
||||
current ? { ...current, [key]: value } : current)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (await onSave (draft))
|
||||
onOpenChange (false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl px-6 pb-6 pt-7">
|
||||
<DialogHeader className="pl-8">
|
||||
<DialogTitle>投稿 {row.sourceRow} を編集</DialogTitle>
|
||||
<DialogDescription>
|
||||
自動取得結果を確認し、必要な項目だけ修正してください.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<ThumbnailPreview
|
||||
url={draft.thumbnailBase}
|
||||
className="h-28 w-28"/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PostImportStatusBadge value={row.status}/>
|
||||
<PostImportStatusBadge value={row.importStatus ?? 'pending'}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<DialogField
|
||||
label="URL"
|
||||
value={draft.url}
|
||||
origin={originOf (row, 'url')}
|
||||
onChange={value => update ('url', value)}/>
|
||||
<DialogField
|
||||
label="タイトル"
|
||||
value={draft.title}
|
||||
origin={originOf (row, 'title')}
|
||||
onChange={value => update ('title', value)}/>
|
||||
<DialogField
|
||||
label="サムネール基底 URL"
|
||||
value={draft.thumbnailBase}
|
||||
origin={originOf (row, 'thumbnailBase')}
|
||||
onChange={value => update ('thumbnailBase', value)}/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DialogField
|
||||
label="作成日時 From"
|
||||
value={draft.originalCreatedFrom}
|
||||
origin={originOf (row, 'originalCreatedFrom')}
|
||||
onChange={value => update ('originalCreatedFrom', value)}/>
|
||||
<DialogField
|
||||
label="作成日時 Before"
|
||||
value={draft.originalCreatedBefore}
|
||||
origin={originOf (row, 'originalCreatedBefore')}
|
||||
onChange={value => update ('originalCreatedBefore', value)}/>
|
||||
</div>
|
||||
<DialogField
|
||||
label="動画時間"
|
||||
value={draft.duration}
|
||||
origin={originOf (row, 'duration')}
|
||||
onChange={value => update ('duration', value)}/>
|
||||
<DialogArea
|
||||
label="タグ"
|
||||
value={draft.tags}
|
||||
origin={originOf (row, 'tags')}
|
||||
onChange={value => update ('tags', value)}/>
|
||||
<DialogField
|
||||
label="親投稿"
|
||||
value={draft.parentPostIds}
|
||||
origin={originOf (row, 'parentPostIds')}
|
||||
onChange={value => update ('parentPostIds', value)}/>
|
||||
<FieldError messages={Object.values (row.validationErrors ?? { }).flat ()}/>
|
||||
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange (false)}>
|
||||
取消し
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>)
|
||||
}
|
||||
|
||||
const DialogField = (
|
||||
{ label, value, origin, onChange }: {
|
||||
label: string
|
||||
value: string
|
||||
origin: PostImportOrigin
|
||||
onChange: (value: string) => void },
|
||||
) => (
|
||||
<label className="block space-y-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
{label}
|
||||
<PostImportStatusBadge value={origin}/>
|
||||
</span>
|
||||
<input
|
||||
value={value}
|
||||
onChange={ev => onChange (ev.target.value)}
|
||||
className={inputClass (false)}/>
|
||||
</label>)
|
||||
|
||||
const DialogArea = (
|
||||
{ label, value, origin, onChange }: {
|
||||
label: string
|
||||
value: string
|
||||
origin: PostImportOrigin
|
||||
onChange: (value: string) => void },
|
||||
) => (
|
||||
<label className="block space-y-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
{label}
|
||||
<PostImportStatusBadge value={origin}/>
|
||||
</span>
|
||||
<textarea
|
||||
value={value}
|
||||
rows={4}
|
||||
onChange={ev => onChange (ev.target.value)}
|
||||
className={inputClass (false)}/>
|
||||
</label>)
|
||||
|
||||
export default PostImportRowDialog
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
|
||||
type Props = {
|
||||
row: PostImportRow
|
||||
onEdit: () => void }
|
||||
|
||||
const toneClass = (row: PostImportRow): string[] => {
|
||||
const value = row.importStatus ?? row.status
|
||||
switch (value)
|
||||
{
|
||||
case 'created':
|
||||
return [
|
||||
'border-sky-200 bg-sky-50',
|
||||
'dark:border-sky-900 dark:bg-sky-950/30']
|
||||
case 'skipped':
|
||||
return [
|
||||
'border-stone-200 bg-stone-50',
|
||||
'dark:border-stone-800 dark:bg-stone-900/60']
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return [
|
||||
'border-rose-200 bg-rose-50',
|
||||
'dark:border-rose-900 dark:bg-rose-950/30']
|
||||
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']
|
||||
}
|
||||
}
|
||||
|
||||
const summaryError = (row: PostImportRow): string | null => {
|
||||
const validation = Object.values (row.validationErrors ?? { }).flat ()[0]
|
||||
if (validation)
|
||||
return validation
|
||||
|
||||
return Object.values (row.importErrors ?? { }).flat ()[0] ?? null
|
||||
}
|
||||
|
||||
const summaryDate = (row: PostImportRow): string =>
|
||||
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore]
|
||||
.filter (_1 => typeof _1 === 'string' && _1 !== '')
|
||||
.join (' ~ ')
|
||||
|
||||
|
||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||
const error = summaryError (row)
|
||||
const importStatus = row.importStatus ?? 'pending'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn (
|
||||
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
|
||||
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto_auto]',
|
||||
toneClass (row),
|
||||
'hover:bg-slate-50',
|
||||
'dark:hover:bg-neutral-800',
|
||||
)}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
||||
<PostImportStatusBadge value={row.status}/>
|
||||
</div>
|
||||
<ThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-16 w-16"/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{String (row.attributes.tags ?? '') || 'タグなし'}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row) || '日時未取得'}
|
||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<PostImportStatusBadge value={importStatus}/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{error
|
||||
? <PostImportStatusBadge value="error"/>
|
||||
: <PostImportStatusBadge value="ready"/>}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={row.importStatus === 'created'}>
|
||||
編集
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn (
|
||||
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||
toneClass (row),
|
||||
)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<ThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-20 w-20 shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PostImportStatusBadge value={row.status}/>
|
||||
<PostImportStatusBadge value={importStatus}/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-red-700 dark:text-red-200">
|
||||
{error}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={row.importStatus === 'created'}>
|
||||
編集
|
||||
</Button>
|
||||
</div>
|
||||
</>)
|
||||
}
|
||||
|
||||
export default PostImportRowSummary
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportOrigin } from '@/lib/postImportSession'
|
||||
|
||||
type BadgeValue =
|
||||
'ready'
|
||||
| 'warning'
|
||||
| 'error'
|
||||
| 'pending'
|
||||
| 'created'
|
||||
| 'skipped'
|
||||
| 'failed'
|
||||
| PostImportOrigin
|
||||
|
||||
type Props = {
|
||||
value: BadgeValue }
|
||||
|
||||
const LABELS: Record<BadgeValue, string> = {
|
||||
ready: '登録可能',
|
||||
warning: '警告',
|
||||
error: '要修正',
|
||||
pending: '未登録',
|
||||
created: '登録済み',
|
||||
skipped: 'スキップ',
|
||||
failed: '失敗',
|
||||
automatic: '自動取得',
|
||||
manual: '手修正' }
|
||||
|
||||
const STYLES: Record<BadgeValue, string[]> = {
|
||||
ready: [
|
||||
'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'],
|
||||
error: [
|
||||
'border-red-300 bg-red-50 text-red-700',
|
||||
'dark:border-red-900 dark:bg-red-950 dark:text-red-200'],
|
||||
pending: [
|
||||
'border-slate-300 bg-slate-50 text-slate-700',
|
||||
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
|
||||
created: [
|
||||
'border-sky-300 bg-sky-50 text-sky-700',
|
||||
'dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200'],
|
||||
skipped: [
|
||||
'border-stone-300 bg-stone-50 text-stone-700',
|
||||
'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'],
|
||||
failed: [
|
||||
'border-rose-300 bg-rose-50 text-rose-700',
|
||||
'dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200'],
|
||||
automatic: [
|
||||
'border-slate-300 bg-slate-50 text-slate-700',
|
||||
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
|
||||
manual: [
|
||||
'border-purple-300 bg-purple-50 text-purple-700',
|
||||
'dark:border-purple-900 dark:bg-purple-950 dark:text-purple-200'] }
|
||||
|
||||
|
||||
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
||||
<span
|
||||
className={cn (
|
||||
'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||
STYLES[value],
|
||||
)}>
|
||||
{LABELS[value]}
|
||||
</span>)
|
||||
|
||||
export default PostImportStatusBadge
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
alt?: string
|
||||
className?: string }
|
||||
|
||||
|
||||
const ThumbnailPreview: FC<Props> = ({ url, alt = 'サムネール', className = 'h-16 w-16' }) => {
|
||||
const [failed, setFailed] = useState (false)
|
||||
|
||||
useEffect (() => {
|
||||
setFailed (false)
|
||||
}, [url])
|
||||
|
||||
if (!(url))
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-border bg-muted text-xs text-muted-foreground`}>
|
||||
なし
|
||||
</div>)
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-red-300 bg-red-50 p-2 text-center text-xs text-red-700
|
||||
dark:border-red-900 dark:bg-red-950 dark:text-red-200`}>
|
||||
表示不可
|
||||
</div>)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
className={`${ className } rounded border border-border object-cover`}
|
||||
onError={() => setFailed (true)}/>)
|
||||
}
|
||||
|
||||
export default ThumbnailPreview
|
||||
新しい課題から参照
ユーザをブロックする