このコミットが含まれているのは:
2026-06-23 22:05:11 +09:00
コミット 507ce1680e
25個のファイルの変更1148行の追加111行の削除
+1 -1
ファイルの表示
@@ -69,7 +69,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
<Route path=":id" element ={<MaterialDetailPage user={user}/>}/>
</Route>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
<Route path="/wiki" element={<WikiSearchPage/>}/>
+4 -1
ファイルの表示
@@ -8,6 +8,7 @@ import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPatch: vi.fn (),
apiPut: vi.fn (),
}))
@@ -26,7 +27,7 @@ vi.mock ('@/components/ui/use-toast', () => toastApi)
const renderPage = () =>
renderWithProviders (
<Routes>
<Route path="/materials/:id" element={<MaterialDetailPage/>}/>
<Route path="/materials/:id" element={<MaterialDetailPage user={null}/>}/>
</Routes>,
{ route: '/materials/8' },
)
@@ -73,6 +74,7 @@ describe ('MaterialDetailPage', () => {
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'new' } })
fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } })
fireEvent.change (textboxes[2], { target: { value: '素材/new.png' } })
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
await waitFor (() => {
@@ -81,6 +83,7 @@ describe ('MaterialDetailPage', () => {
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('tag')).toBe ('new')
expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' })
})
})
+69 -14
ファイルの表示
@@ -13,22 +13,23 @@ 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, apiPut } from '@/lib/api'
import { apiGet, apiPatch, apiPut } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react'
import type { Material, Tag } from '@/types'
import type { Material, Tag, User } from '@/types'
type MaterialWithTag = Material & { tag: Tag }
type MaterialFormField = 'tag' | 'file' | 'url'
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialDetailPage: FC = () => {
const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
const { id } = useParams ()
const [exportPath, setExportPath] = useState ('')
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [loading, setLoading] = useState (false)
@@ -49,6 +50,7 @@ const MaterialDetailPage: FC = () => {
formData.append ('file', file)
if (url.trim ())
formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
try
{
@@ -68,6 +70,30 @@ const MaterialDetailPage: FC = () => {
}
}
const handleSuppress = async () => {
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: '抑止に失敗しました' })
}
}
useEffect (() => {
if (!(id))
return
@@ -82,11 +108,10 @@ const MaterialDetailPage: FC = () => {
if (data.file && data.contentType)
{
setFilePreview (data.file)
setFile (new File ([await (await fetch (data.file)).blob ()],
data.file,
{ type: data.contentType }))
setFile (null)
}
setURL (data.url ?? '')
setExportPath (data.exportPaths.legacyDrive ?? '')
}
finally
{
@@ -111,7 +136,14 @@ const MaterialDetailPage: FC = () => {
withCount={false}/>
</PageTitle>
{(material.file && material.contentType) && (
{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 && material.file && material.contentType) && (
(/image\/.*/.test (material.contentType) && (
<img src={material.file} alt={material.tag.name || undefined}/>))
|| (/video\/.*/.test (material.contentType) && (
@@ -189,13 +221,36 @@ const MaterialDetailPage: FC = () => {
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>
{/* 送信 */}
<Button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
</Button>
<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>
+15 -6
ファイルの表示
@@ -9,7 +9,7 @@ import PageTitle from '@/components/common/PageTitle'
import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { API_BASE_URL, SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import type { FC } from 'react'
@@ -30,10 +30,15 @@ const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
to={`/materials/${ tag.material.id }`}
className="block w-40 h-40">
<div
className="w-full h-full overflow-hidden rounded-xl shadow
text-center content-center text-4xl"
className={`w-full h-full overflow-hidden rounded-xl shadow
text-center content-center text-4xl ${
tag.material.fileSuppressedAt
? 'border-2 border-red-300 bg-red-50 text-base text-red-700'
: '' }`}
style={{ fontFamily: 'Nikumaru' }}>
{(tag.material.contentType && /image\/.*/.test (tag.material.contentType))
{tag.material.fileSuppressedAt
? <span></span>
: (tag.material.contentType && /image\/.*/.test (tag.material.contentType))
? <img src={tag.material.file || undefined}/>
: <span></span>}
</div>
@@ -108,7 +113,7 @@ const MaterialListPage: FC = () => {
<MaterialCard tag={tag}/>
<div className="ml-2">
<div className="ml-2 overflow-x-auto pb-2">
{tag.children.map (c2 => (
<Fragment key={c2.id}>
<SectionTitle>
@@ -159,7 +164,11 @@ const MaterialListPage: FC = () => {
<p></p>
<ul>
<li><PrefetchLink to="/materials/new"></PrefetchLink></li>
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */}
<li>
<a href={`${ API_BASE_URL }/materials/download.zip?profile=legacy_drive`}>
</a>
</li>
</ul>
</>))}
</MainArea>)
+17 -1
ファイルの表示
@@ -17,7 +17,7 @@ import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react'
type MaterialFormField = 'tag' | 'file' | 'url'
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialNewPage: FC = () => {
@@ -32,6 +32,7 @@ const MaterialNewPage: FC = () => {
const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('')
const [exportPath, setExportPath] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
@@ -45,6 +46,7 @@ const MaterialNewPage: FC = () => {
formData.append ('file', file)
if (url)
formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
try
{
@@ -133,6 +135,20 @@ const MaterialNewPage: FC = () => {
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>
{/* 送信 */}
<Button
onClick={handleSubmit}
+15 -10
ファイルの表示
@@ -71,16 +71,21 @@ export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
})
export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
id: 1,
tag: buildTag (),
file: null,
url: null,
wikiPageBody: null,
contentType: null,
createdAt: '2026-01-02T03:04:05.000Z',
createdByUser: { id: 1, name: 'creator' },
updatedAt: '2026-01-03T03:04:05.000Z',
updatedByUser: { id: 2, name: 'updater' },
id: 1,
versionNo: 1,
tag: buildTag (),
file: null,
url: null,
wikiPageBody: null,
contentType: null,
fileSuppressedAt: null,
fileSuppressionReason: null,
exportPaths: {},
exportItems: [],
createdAt: '2026-01-02T03:04:05.000Z',
createdByUser: { id: 1, name: 'creator' },
updatedAt: '2026-01-03T03:04:05.000Z',
updatedByUser: { id: 2, name: 'updater' },
...overrides,
})
+21 -10
ファイルの表示
@@ -66,16 +66,27 @@ export type FetchNicoTagsOrder = `${ FetchNicoTagsOrderField }:${ 'asc' | 'desc'
export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at'
export type Material = {
id: number
tag: Tag
file: string | null
url: string | null
wikiPageBody?: string | null
contentType: string | null
createdAt: string
createdByUser: { id: number; name: string }
updatedAt: string
updatedByUser: { id: number; name: string } }
id: number
versionNo: number
tag: Tag
file: string | null
url: string | null
wikiPageBody?: string | null
contentType: string | null
fileSuppressedAt: string | null
fileSuppressionReason: string | null
exportPaths: Record<string, string>
exportItems: MaterialExportItem[]
createdAt: string
createdByUser: { id: number; name: string }
updatedAt: string
updatedByUser: { id: number; name: string } }
export type MaterialExportItem = {
id: number
profile: string
exportPath: string
enabled: boolean }
export type Menu = MenuItem[]