このコミットが含まれているのは:
2026-06-25 04:10:43 +09:00
コミット dbc654f346
10個のファイルの変更1117行の追加473行の削除
+205 -198
ファイルの表示
@@ -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>)
}