237 行
7.4 KiB
TypeScript
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
|