227 行
6.7 KiB
TypeScript
227 行
6.7 KiB
TypeScript
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'
|
||
|
||
import TagLink from '@/components/TagLink'
|
||
import WikiBody from '@/components/WikiBody'
|
||
import FieldError from '@/components/common/FieldError'
|
||
import FormField from '@/components/common/FormField'
|
||
import PageTitle from '@/components/common/PageTitle'
|
||
import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||
import TagInput from '@/components/common/TagInput'
|
||
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 {
|
||
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'
|
||
|
||
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
|
||
|
||
|
||
const MaterialDetailPage: FC = () => {
|
||
const { id } = useParams ()
|
||
const qc = useQueryClient ()
|
||
|
||
const [exportPath, setExportPath] = useState ('')
|
||
const [file, setFile] = useState<File | null> (null)
|
||
const [filePreview, setFilePreview] = useState ('')
|
||
const [tag, setTag] = useState ('')
|
||
const [url, setURL] = useState ('')
|
||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||
useValidationErrors<MaterialFormField> ()
|
||
|
||
const { data: material, isError, isLoading } = useQuery ({
|
||
queryKey: materialsKeys.show (id ?? ''),
|
||
queryFn: () => fetchMaterial (id ?? ''),
|
||
enabled: id != null})
|
||
const materialTitle = material
|
||
? material.tag?.name ?? `素材 #${ material.id }`
|
||
: ''
|
||
|
||
useEffect (() => {
|
||
if (!(material))
|
||
return
|
||
|
||
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 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 handleSubmit = () => {
|
||
clearValidationErrors ()
|
||
updateMutation.mutate ()
|
||
}
|
||
|
||
return (
|
||
<MainArea>
|
||
{material && (
|
||
<Helmet>
|
||
<title>{`${ materialTitle } 素材照会 | ${ SITE_TITLE }`}</title>
|
||
</Helmet>)}
|
||
|
||
{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>
|
||
{material.tag
|
||
? (
|
||
<TagLink
|
||
tag={material.tag}
|
||
withWiki={false}
|
||
withCount={false}/>)
|
||
: materialTitle}
|
||
</PageTitle>
|
||
|
||
{(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>
|
||
{material.tag && (
|
||
<Tab name="Wiki">
|
||
<WikiBody
|
||
title={material.tag.name}
|
||
body={material.wikiPageBody ?? undefined}/>
|
||
</Tab>)}
|
||
|
||
<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.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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</Tab>
|
||
</TabGroup>
|
||
</>)}
|
||
</MainArea>)
|
||
}
|
||
|
||
export default MaterialDetailPage
|