ファイル
btrc-hub/frontend/src/pages/materials/MaterialDetailPage.tsx
T
2026-10-20 20:44:14 +09:00

237 行
7.4 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
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 PageHead from '@/components/common/PageHead'
import {
fetchMaterial,
updateMaterial,
} from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { localeLanguage } from '@/lib/locales'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialDetailPage: FC = () => {
const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const { id } = useParams ()
const qc = useQueryClient ()
const { data: material, isError, isLoading } = useQuery ({
queryKey: materialsKeys.show (id ?? '', locale),
queryFn: () => fetchMaterial (id ?? '', locale),
enabled: id != null })
const [exportPath, setExportPath] = useState (material?.exportPaths.legacyDrive ?? '')
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState (
material?.contentType != null ? material.file ?? '' : '')
const [tag, setTag] = useState (material?.tag?.name ?? '')
const [url, setURL] = useState (material?.url ?? '')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
const materialTitle = material
? material.tag?.name ?? message ('unnamed', { id: material.id }).text
: ''
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, locale)
},
onSuccess: async data => {
qc.setQueryData (materialsKeys.show (id ?? '', locale), data)
await invalidateMaterialQueries ()
toast ({ title: label ('updated') })
},
onError: error => {
applyValidationError (error)
toast ({ title: label ('updateFailed'), description: label ('shared:messages.reviewInput') })
}})
const handleSubmit = () => {
clearValidationErrors ()
updateMutation.mutate ()
}
if (!isLoading && !isError && material == null)
return <NotFound/>
return (
<MainArea>
<PageHead title={material
? message ('detailTitle', { name: materialTitle }) : message ('title')}/>
{isLoading ? label ('common:loading') : isError ? (
<p className="text-red-600 dark:text-red-300">
{label ('detailError')}
</p>) : material == null ? (
<p className="text-stone-700 dark:text-stone-300">
{label ('missing')}
</p>) : (
<>
<PageTitle>
{material.tag
? (
<TagLink
tag={material.tag}
withWiki={false}
withCount={false}/>)
: label ('unnamed', { id: material.id })}
</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={label ('navigation:wiki')}>
<WikiBody
title={material.tag.name}
language={localeLanguage (locale)}
body={material.wikiPageBody ?? undefined}/>
</Tab>)}
<Tab name={label ('shared:actions.edit')}>
<div className="max-w-wl space-y-4 pt-2">
<FieldError messages={baseErrors}/>
<FormField label={label ('navigation:tags')} messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
describedBy={describedBy}
invalid={invalid}
value={tag}
setValue={setTag}/>)}
</FormField>
<FormField label={label ('file')} 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={message ('preview').text}
lang={message ('preview').language}
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">
{label ('unsupportedFile')}
</p>))}
</>)}
</FormField>
<FormField label={label ('referenceUrl')} 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={label ('exportPath')} messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => (
<input
type="text"
value={exportPath}
onChange={e => setExportPath (e.target.value)}
placeholder={message ('exportPlaceholder').text}
lang={message ('exportPlaceholder').language}
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}>
{label ('shared:actions.update')}
</Button>
</div>
</div>
</Tab>
</TabGroup>
</>)}
</MainArea>)
}
export default MaterialDetailPage