このコミットが含まれているのは:
@@ -1,3 +1,4 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams } from 'react-router-dom'
|
||||
@@ -13,248 +14,254 @@ import MainArea from '@/components/layout/MainArea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiGet, apiPatch, apiPut } from '@/lib/api'
|
||||
import {
|
||||
suppressMaterialFile,
|
||||
fetchMaterial,
|
||||
updateMaterial,
|
||||
} from '@/lib/materials'
|
||||
import { materialsKeys } from '@/lib/queryKeys'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { Material, Tag, User } from '@/types'
|
||||
|
||||
type MaterialWithTag = Material & { tag: Tag }
|
||||
import type { User } from '@/types'
|
||||
|
||||
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
|
||||
|
||||
|
||||
const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
const { id } = useParams ()
|
||||
const qc = useQueryClient ()
|
||||
|
||||
const [exportPath, setExportPath] = useState ('')
|
||||
const [file, setFile] = useState<File | null> (null)
|
||||
const [filePreview, setFilePreview] = useState ('')
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [material, setMaterial] = useState<MaterialWithTag | null> (null)
|
||||
const [sending, setSending] = useState (false)
|
||||
const [tag, setTag] = useState ('')
|
||||
const [url, setURL] = useState ('')
|
||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||
useValidationErrors<MaterialFormField> ()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
clearValidationErrors ()
|
||||
const { data: material, isError, isLoading } = useQuery ({
|
||||
queryKey: materialsKeys.show (id ?? ''),
|
||||
queryFn: () => fetchMaterial (id ?? ''),
|
||||
enabled: id != null,
|
||||
})
|
||||
|
||||
const formData = new FormData
|
||||
if (tag.trim ())
|
||||
formData.append ('tag', tag)
|
||||
if (file)
|
||||
formData.append ('file', file)
|
||||
if (url.trim ())
|
||||
formData.append ('url', url)
|
||||
formData.append ('export_paths[legacy_drive]', exportPath)
|
||||
useEffect (() => {
|
||||
if (!(material))
|
||||
return
|
||||
|
||||
try
|
||||
{
|
||||
setSending (true)
|
||||
const data = await apiPut<Material> (`/materials/${ id }`, formData)
|
||||
setMaterial (data)
|
||||
toast ({ title: '更新成功!' })
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
applyValidationError (e)
|
||||
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSending (false)
|
||||
}
|
||||
setTag (material.tag.name)
|
||||
setURL (material.url ?? '')
|
||||
setExportPath (material.exportPaths.legacyDrive ?? '')
|
||||
if (material.file && material.contentType)
|
||||
{
|
||||
setFilePreview (material.file)
|
||||
setFile (null)
|
||||
}
|
||||
}, [material])
|
||||
|
||||
const invalidateMaterialQueries = async () => {
|
||||
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
|
||||
}
|
||||
|
||||
const handleSuppress = async () => {
|
||||
const updateMutation = useMutation ({
|
||||
mutationFn: async () => {
|
||||
const formData = new FormData
|
||||
if (tag.trim ())
|
||||
formData.append ('tag', tag)
|
||||
if (file)
|
||||
formData.append ('file', file)
|
||||
if (url.trim ())
|
||||
formData.append ('url', url)
|
||||
formData.append ('export_paths[legacy_drive]', exportPath)
|
||||
|
||||
return await updateMaterial (id ?? '', formData)
|
||||
},
|
||||
onSuccess: async data => {
|
||||
qc.setQueryData (materialsKeys.show (id ?? ''), data)
|
||||
await invalidateMaterialQueries ()
|
||||
toast ({ title: '更新成功!' })
|
||||
},
|
||||
onError: error => {
|
||||
applyValidationError (error)
|
||||
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
|
||||
},
|
||||
})
|
||||
|
||||
const suppressMutation = useMutation ({
|
||||
mutationFn: async (reason: string) =>
|
||||
await suppressMaterialFile (id ?? '', { reason }),
|
||||
onSuccess: async data => {
|
||||
qc.setQueryData (materialsKeys.show (id ?? ''), data)
|
||||
setFile (null)
|
||||
setFilePreview ('')
|
||||
await invalidateMaterialQueries ()
|
||||
toast ({ title: '抑止しました' })
|
||||
},
|
||||
onError: () => {
|
||||
toast ({ title: '抑止に失敗しました' })
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
clearValidationErrors ()
|
||||
updateMutation.mutate ()
|
||||
}
|
||||
|
||||
const handleSuppress = () => {
|
||||
const reason = window.prompt ('抑止理由を入力してください。')
|
||||
if (reason == null || reason.trim () === '')
|
||||
return
|
||||
if (!window.confirm ('素材ファイルを抑止します。表示と ZIP export から除外されます。'))
|
||||
return
|
||||
|
||||
try
|
||||
{
|
||||
const data = await apiPatch<Material> (
|
||||
`/materials/${ id }/suppress_file`,
|
||||
{ reason },
|
||||
)
|
||||
setMaterial (data)
|
||||
setFile (null)
|
||||
setFilePreview ('')
|
||||
toast ({ title: '抑止しました' })
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '抑止に失敗しました' })
|
||||
}
|
||||
suppressMutation.mutate (reason)
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
if (!(id))
|
||||
return
|
||||
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
setLoading (true)
|
||||
const data = await apiGet<MaterialWithTag> (`/materials/${ id }`)
|
||||
setMaterial (data)
|
||||
setTag (data.tag.name)
|
||||
if (data.file && data.contentType)
|
||||
{
|
||||
setFilePreview (data.file)
|
||||
setFile (null)
|
||||
}
|
||||
setURL (data.url ?? '')
|
||||
setExportPath (data.exportPaths.legacyDrive ?? '')
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}) ()
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
{material && (
|
||||
<Helmet>
|
||||
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>)}
|
||||
{material && (
|
||||
<Helmet>
|
||||
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>)}
|
||||
|
||||
{loading ? 'Loading...' : (material && (
|
||||
<>
|
||||
<PageTitle>
|
||||
<TagLink
|
||||
tag={material.tag}
|
||||
withWiki={false}
|
||||
withCount={false}/>
|
||||
</PageTitle>
|
||||
{isLoading ? 'Loading...' : isError ? (
|
||||
<p className="text-red-600 dark:text-red-300">
|
||||
素材の取得に失敗しました.
|
||||
</p>
|
||||
) : material == null ? (
|
||||
<p className="text-stone-700 dark:text-stone-300">
|
||||
素材が見つかりませんでした.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<PageTitle>
|
||||
<TagLink
|
||||
tag={material.tag}
|
||||
withWiki={false}
|
||||
withCount={false}/>
|
||||
</PageTitle>
|
||||
|
||||
{material.fileSuppressedAt && (
|
||||
<div className="mb-4 rounded border border-red-300 bg-red-50 p-3 text-red-700">
|
||||
<span>素材ファイルは抑止済みです。</span>
|
||||
{material.fileSuppressionReason && (
|
||||
<span> 理由: {material.fileSuppressionReason}</span>)}
|
||||
</div>)}
|
||||
{material.fileSuppressedAt && (
|
||||
<div className="mb-4 rounded border border-red-300 bg-red-50 p-3
|
||||
text-red-900 dark:border-red-800 dark:bg-red-950
|
||||
dark:text-red-100">
|
||||
<span>素材ファイルは抑止済みです。</span>
|
||||
{material.fileSuppressionReason && (
|
||||
<span> 理由: {material.fileSuppressionReason}</span>)}
|
||||
</div>)}
|
||||
|
||||
{(!material.fileSuppressedAt && material.file && material.contentType) && (
|
||||
(/image\/.*/.test (material.contentType) && (
|
||||
<img src={material.file} alt={material.tag.name || undefined}/>))
|
||||
|| (/video\/.*/.test (material.contentType) && (
|
||||
<video src={material.file} controls/>))
|
||||
|| (/audio\/.*/.test (material.contentType) && (
|
||||
<audio src={material.file} controls/>)))}
|
||||
{(!material.fileSuppressedAt && material.file && material.contentType) && (
|
||||
(/image\/.*/.test (material.contentType) && (
|
||||
<img src={material.file} alt={material.tag.name || undefined}/>))
|
||||
|| (/video\/.*/.test (material.contentType) && (
|
||||
<video src={material.file} controls/>))
|
||||
|| (/audio\/.*/.test (material.contentType) && (
|
||||
<audio src={material.file} controls/>)))}
|
||||
|
||||
<TabGroup>
|
||||
<Tab name="Wiki">
|
||||
<WikiBody
|
||||
title={material.tag.name}
|
||||
body={material.wikiPageBody ?? undefined}/>
|
||||
</Tab>
|
||||
<TabGroup>
|
||||
<Tab name="Wiki">
|
||||
<WikiBody
|
||||
title={material.tag.name}
|
||||
body={material.wikiPageBody ?? undefined}/>
|
||||
</Tab>
|
||||
|
||||
<Tab name="編輯">
|
||||
<div className="max-w-wl pt-2 space-y-4">
|
||||
<FieldError messages={baseErrors}/>
|
||||
<Tab name="編輯">
|
||||
<div className="max-w-wl space-y-4 pt-2">
|
||||
<FieldError messages={baseErrors}/>
|
||||
|
||||
{/* タグ */}
|
||||
<FormField label="タグ" messages={fieldErrors.tag}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<TagInput
|
||||
describedBy={describedBy}
|
||||
invalid={invalid}
|
||||
value={tag}
|
||||
setValue={setTag}/>)}
|
||||
</FormField>
|
||||
<FormField label="タグ" messages={fieldErrors.tag}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<TagInput
|
||||
describedBy={describedBy}
|
||||
invalid={invalid}
|
||||
value={tag}
|
||||
setValue={setTag}/>)}
|
||||
</FormField>
|
||||
|
||||
{/* ファイル */}
|
||||
<FormField label="ファイル" messages={fieldErrors.file}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0]
|
||||
setFile (f ?? null)
|
||||
setFilePreview (f ? URL.createObjectURL (f) : '')
|
||||
}}/>
|
||||
{(file && filePreview) && (
|
||||
(/image\/.*/.test (file.type) && (
|
||||
<img
|
||||
src={filePreview}
|
||||
alt="preview"
|
||||
className="mt-2 max-h-48 rounded border"/>))
|
||||
|| (/video\/.*/.test (file.type) && (
|
||||
<video
|
||||
src={filePreview}
|
||||
controls
|
||||
className="mt-2 max-h-48 rounded border"/>))
|
||||
|| (/audio\/.*/.test (file.type) && (
|
||||
<audio
|
||||
src={filePreview}
|
||||
controls
|
||||
className="mt-2 max-h-48"/>))
|
||||
|| (
|
||||
<p className="text-red-600 dark:text-red-400">
|
||||
その形式のファイルには対応していません.
|
||||
</p>))}
|
||||
</>)}
|
||||
</FormField>
|
||||
<FormField label="ファイル" messages={fieldErrors.file}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
onChange={e => {
|
||||
const nextFile = e.target.files?.[0]
|
||||
setFile (nextFile ?? null)
|
||||
setFilePreview (
|
||||
nextFile ? URL.createObjectURL (nextFile) : '')
|
||||
}}/>
|
||||
{(file && filePreview) && (
|
||||
(/image\/.*/.test (file.type) && (
|
||||
<img
|
||||
src={filePreview}
|
||||
alt="preview"
|
||||
className="mt-2 max-h-48 rounded border"/>))
|
||||
|| (/video\/.*/.test (file.type) && (
|
||||
<video
|
||||
src={filePreview}
|
||||
controls
|
||||
className="mt-2 max-h-48 rounded border"/>))
|
||||
|| (/audio\/.*/.test (file.type) && (
|
||||
<audio
|
||||
src={filePreview}
|
||||
controls
|
||||
className="mt-2 max-h-48"/>))
|
||||
|| (
|
||||
<p className="text-red-600 dark:text-red-400">
|
||||
その形式のファイルには対応していません.
|
||||
</p>))}
|
||||
</>)}
|
||||
</FormField>
|
||||
|
||||
{/* 参考 URL */}
|
||||
<FormField label="参考 URL" messages={fieldErrors.url}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={e => setURL (e.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
<FormField label="参考 URL" messages={fieldErrors.url}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={e => setURL (e.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="ZIP 出力パス"
|
||||
messages={fieldErrors.exportPaths}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
value={exportPath}
|
||||
onChange={e => setExportPath (e.target.value)}
|
||||
placeholder="伊地知ニジカ/表情/泣き.png"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
<FormField label="ZIP 出力パス" messages={fieldErrors.exportPaths}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
value={exportPath}
|
||||
onChange={e => setExportPath (e.target.value)}
|
||||
placeholder="伊地知ニジカ/表情/泣き.png"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
|
||||
{/* 送信 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
||||
disabled={sending}>
|
||||
更新
|
||||
</Button>
|
||||
{user?.role === 'admin' && !material.fileSuppressedAt && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleSuppress}>
|
||||
ファイルを抑止
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</TabGroup>
|
||||
</>))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-white
|
||||
disabled:bg-gray-400"
|
||||
disabled={updateMutation.isPending}>
|
||||
更新
|
||||
</Button>
|
||||
{user?.role === 'admin' && !material.fileSuppressedAt && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleSuppress}
|
||||
disabled={suppressMutation.isPending}>
|
||||
ファイルを抑止
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</TabGroup>
|
||||
</>)}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
新しい課題から参照
ユーザをブロックする