+
- >))}
+ >)}
)
}
diff --git a/frontend/src/pages/materials/MaterialHistoryPage.tsx b/frontend/src/pages/materials/MaterialHistoryPage.tsx
new file mode 100644
index 0000000..82876db
--- /dev/null
+++ b/frontend/src/pages/materials/MaterialHistoryPage.tsx
@@ -0,0 +1,189 @@
+import { useQuery } from '@tanstack/react-query'
+import { useEffect, useState } from 'react'
+import { Helmet } from 'react-helmet-async'
+import { useLocation, useNavigate } from 'react-router-dom'
+
+import PrefetchLink from '@/components/PrefetchLink'
+import FormField from '@/components/common/FormField'
+import PageTitle from '@/components/common/PageTitle'
+import Pagination from '@/components/common/Pagination'
+import MainArea from '@/components/layout/MainArea'
+import { SITE_TITLE } from '@/config'
+import { fetchMaterialChanges } from '@/lib/materials'
+import { materialsKeys } from '@/lib/queryKeys'
+import { dateString, inputClass } from '@/lib/utils'
+
+import type { FC, FormEvent } from 'react'
+
+
+const MaterialHistoryPage: FC = () => {
+ const location = useLocation ()
+ const navigate = useNavigate ()
+ const query = new URLSearchParams (location.search)
+
+ const materialId = query.get ('material_id') ?? ''
+ const tag = query.get ('tag') ?? ''
+ const eventType = query.get ('event_type') ?? ''
+ const page = Number (query.get ('page') ?? 1)
+ const limit = Number (query.get ('limit') ?? 20)
+
+ const [materialIdInput, setMaterialIdInput] = useState (materialId)
+ const [tagInput, setTagInput] = useState (tag)
+ const [eventTypeInput, setEventTypeInput] = useState (eventType)
+
+ useEffect (() => {
+ setMaterialIdInput (materialId)
+ setTagInput (tag)
+ setEventTypeInput (eventType)
+ }, [eventType, materialId, tag])
+
+ const { data, isLoading, isError } = useQuery ({
+ queryKey: materialsKeys.changes ({
+ ...(materialId && { materialId }),
+ ...(tag && { tag }),
+ ...(eventType && { eventType }),
+ page,
+ limit }),
+ queryFn: () => fetchMaterialChanges ({
+ ...(materialId && { materialId }),
+ ...(tag && { tag }),
+ ...(eventType && { eventType }),
+ page,
+ limit }) })
+
+ const versions = data?.versions ?? []
+ const totalPages = data ? Math.ceil (data.count / limit) : 0
+
+ useEffect (() => {
+ document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
+ }, [location.search])
+
+ const handleSearch = (event: FormEvent) => {
+ event.preventDefault ()
+
+ const qs = new URLSearchParams ()
+ if (materialIdInput.trim ())
+ qs.set ('material_id', materialIdInput.trim ())
+ if (tagInput.trim ())
+ qs.set ('tag', tagInput.trim ())
+ if (eventTypeInput.trim ())
+ qs.set ('event_type', eventTypeInput.trim ())
+ qs.set ('page', '1')
+ qs.set ('limit', String (limit))
+ navigate (`/materials/changes?${ qs.toString () }`)
+ }
+
+ return (
+
+
+ {`素材履歴 | ${ SITE_TITLE }`}
+
+
+
+
素材履歴
+
+
+
+ {isLoading
+ ? 'Loading...'
+ : isError
+ ? (
+
+ 素材履歴の取得に失敗しました.
+
)
+ : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 日時 |
+ 素材 ID |
+ 版 |
+ タグ名 |
+ ファイル名 |
+ URL |
+ export_path |
+ 操作者 |
+
+
+
+
+ {versions.map (version => (
+
+ | {dateString (version.createdAt)} |
+
+
+ #{version.materialId}
+
+ |
+ {version.versionNo} |
+ {version.tagName} |
+ {version.fileFilename} |
+ {version.url} |
+
+ {version.exportPathsJson.legacy_drive}
+ |
+
+ {version.createdByUser
+ ? version.createdByUser.name || `#${ version.createdByUser.id }`
+ : 'bot 操作'}
+ |
+
))}
+
+
+
+
+
+ >)}
+
+ )
+}
+
+
+export default MaterialHistoryPage
diff --git a/frontend/src/pages/materials/MaterialListPage.test.tsx b/frontend/src/pages/materials/MaterialListPage.test.tsx
index cc10674..663921d 100644
--- a/frontend/src/pages/materials/MaterialListPage.test.tsx
+++ b/frontend/src/pages/materials/MaterialListPage.test.tsx
@@ -1,5 +1,5 @@
-import { screen, waitFor } from '@testing-library/react'
-import { describe, expect, it, vi } from 'vitest'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import MaterialListPage from '@/pages/materials/MaterialListPage'
import { buildMaterial, buildTag } from '@/test/factories'
@@ -12,51 +12,169 @@ const api = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
describe ('MaterialListPage', () => {
- it ('shows the empty selection guide without a tag query', () => {
- renderWithProviders (
, { route: '/materials' })
-
- expect (screen.getByText ('左のリストから照会したいタグを選択してください。')).toBeInTheDocument ()
- expect (screen.getByRole ('link', { name: '素材を新規追加する' })).toHaveAttribute (
- 'href',
- '/materials/new',
- )
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ api.apiGet.mockResolvedValue ({ materials: [], count: 0 })
})
- it ('loads materials for a tag query', async () => {
- const tag = {
- ...buildTag ({
- id: 4,
- name: '素材タグ',
- category: 'material',
- }),
- material: buildMaterial ({ id: 8, contentType: 'image/png', file: 'image.png' }),
- children: [],
- }
- api.apiGet.mockResolvedValueOnce (tag)
-
- renderWithProviders (
, { route: '/materials?tag=%E7%B4%A0%E6%9D%90' })
+ it ('loads the material index on the initial page', async () => {
+ renderWithProviders (
, { route: '/materials' })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
- '/tags/name/%E7%B4%A0%E6%9D%90/materials',
+ '/materials',
+ { params: {
+ tag_state: 'all',
+ media_kind: 'all',
+ sort: 'created_at',
+ direction: 'desc',
+ page: 1,
+ limit: 20,
+ } },
)
})
- expect (await screen.findByRole ('link', { name: '素材タグ' })).toBeInTheDocument ()
- expect (screen.getByRole ('link', { name: '' })).toHaveAttribute ('href', '/materials/8')
+ expect (await screen.findByText ('素材はありません。')).toBeInTheDocument ()
})
- it ('offers adding a missing non-meme material', async () => {
+ it ('shows materials in the default card view', async () => {
api.apiGet.mockResolvedValueOnce ({
- ...buildTag ({ name: '未登録', category: 'material' }),
- material: null,
- children: [],
+ materials: [
+ buildMaterial ({
+ id: 8,
+ tag: buildTag ({ name: '素材タグ', category: 'material' }),
+ thumbnail: 'thumb.png',
+ mediaKind: 'image',
+ contentType: 'image/png',
+ }),
+ ],
+ count: 1,
})
- renderWithProviders (
, { route: '/materials?tag=x' })
+ renderWithProviders (
, { route: '/materials' })
- expect (await screen.findByRole ('link', { name: '追加' })).toHaveAttribute (
+ expect ((await screen.findByText ('素材タグ')).closest ('a'))
+ .toHaveAttribute ('href', '/materials/8')
+ expect (screen.getByText (/画像 \/ 令和8年1月2日/)).toBeInTheDocument ()
+ })
+
+ it ('submits list filters as material index params', async () => {
+ renderWithProviders (
, { route: '/materials' })
+
+ fireEvent.change (screen.getByPlaceholderText ('タグ名 / URL / ファイル名'), {
+ target: { value: '虹夏' },
+ })
+ const selects = screen.getAllByRole ('combobox')
+ fireEvent.change (selects[0], {
+ target: { value: 'untagged' },
+ })
+ fireEvent.change (selects[1], {
+ target: { value: 'image' },
+ })
+ fireEvent.click (screen.getByRole ('button', { name: '検索' }))
+
+ await waitFor (() => {
+ expect (api.apiGet).toHaveBeenCalledWith (
+ '/materials',
+ expect.objectContaining ({ params: expect.objectContaining ({
+ q: '虹夏',
+ tag_state: 'untagged',
+ media_kind: 'image',
+ }) }),
+ )
+ })
+ })
+
+ it ('keeps a route back to the material search top for untagged materials', async () => {
+ renderWithProviders (
+
,
+ { route: '/materials?tag_state=untagged&material_filter=any' },
+ )
+
+ expect (await screen.findByRole (
+ 'link',
+ { name: '素材検索トップへ戻る' },
+ )).toHaveAttribute ('href', '/materials?material_filter=any')
+ })
+
+ it ('uses list view for detailed material rows', async () => {
+ api.apiGet.mockResolvedValueOnce ({
+ materials: [
+ buildMaterial ({
+ id: 9,
+ tag: buildTag ({ name: '詳細素材', category: 'material' }),
+ mediaKind: 'file_other',
+ fileByteSize: 2048,
+ }),
+ ],
+ count: 1,
+ })
+
+ renderWithProviders (
, { route: '/materials?view=list' })
+
+ expect (await screen.findByRole ('link', { name: '詳細素材' })).toHaveAttribute (
'href',
- '/materials/new?tag=%E6%9C%AA%E7%99%BB%E9%8C%B2',
+ '/materials/9',
+ )
+ expect (screen.getByText ('サイズ:')).toBeInTheDocument ()
+ expect (screen.getByText ('2.0 KB')).toBeInTheDocument ()
+ })
+
+ it ('shows the selected tag scope and tag-specific add links', async () => {
+ api.apiGet.mockResolvedValueOnce ({
+ materials: [
+ buildMaterial ({
+ id: 10,
+ tag: buildTag ({ id: 30, name: '泣き', category: 'material' }),
+ }),
+ ],
+ count: 1,
+ tagScope: {
+ tag: { id: 20, name: '伊地知ニジカ', category: 'material' },
+ includeDescendants: true,
+ },
+ groups: [
+ {
+ key: 'tag:30',
+ tag: { id: 30, name: '泣き', category: 'material' },
+ materialIds: [10],
+ count: 1,
+ },
+ ],
+ })
+
+ renderWithProviders (
+
,
+ { route: '/materials?tag_id=20&include_descendants=1&group_by=parent_tag' },
+ )
+
+ expect (await screen.findByText (
+ (_, element) => element?.textContent === '伊地知ニジカ 配下の素材を表示中',
+ )).toBeInTheDocument ()
+
+ expect (screen.getByRole ('link', { name: '泣き' })).toHaveAttribute (
+ 'href',
+ '/materials?tag_id=30&include_descendants=1&group_by=parent_tag&material_filter=present',
+ )
+ expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
+ 'href',
+ '/materials?material_filter=present',
+ )
+ })
+
+ it ('shows a clear link when tag_id does not resolve to tag_scope', async () => {
+ api.apiGet.mockResolvedValueOnce ({
+ materials: [],
+ count: 0,
+ tagScope: null,
+ groups: [],
+ })
+
+ renderWithProviders (
, { route: '/materials?tag_id=999' })
+
+ expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument ()
+ expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
+ 'href',
+ '/materials?material_filter=present',
)
})
})
diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx
index aa14ec8..9629733 100644
--- a/frontend/src/pages/materials/MaterialListPage.tsx
+++ b/frontend/src/pages/materials/MaterialListPage.tsx
@@ -1,75 +1,395 @@
-import { Fragment, useEffect, useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
-import { useLocation } from 'react-router-dom'
+import { useLocation, useNavigate } from 'react-router-dom'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import PrefetchLink from '@/components/PrefetchLink'
-import TagLink from '@/components/TagLink'
+import DateTimeField from '@/components/common/DateTimeField'
+import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
-import SectionTitle from '@/components/common/SectionTitle'
-import SubsectionTitle from '@/components/common/SubsectionTitle'
+import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
-import { apiGet } from '@/lib/api'
+import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
+import { materialsKeys } from '@/lib/queryKeys'
+import { dateString, inputClass } from '@/lib/utils'
-import type { FC } from 'react'
+import type { FC, FormEvent } from 'react'
-import type { Material, Tag } from '@/types'
+import type { FetchMaterialsParams,
+ Material,
+ MaterialFilter,
+ MaterialIndexGroup,
+ MaterialIndexGroupBy,
+ MaterialIndexDirection,
+ MaterialIndexMediaKind,
+ MaterialIndexSort,
+ MaterialIndexTagState,
+ MaterialIndexView } from '@/types'
-type TagWithMaterial = Omit
& {
- children: TagWithMaterial[]
- material: Material | null }
+const MEDIA_KIND_LABELS: Record = {
+ image: '画像',
+ video: '動画',
+ audio: '音声',
+ file_other: 'その他ファイル',
+ url_only: 'URL のみ'}
+
+const MEDIA_FILTER_LABELS: Record = {
+ all: 'すべて',
+ image: '画像',
+ video: '動画',
+ audio: '音声',
+ file_other: 'その他ファイル',
+ url_only: '外部リンクのみ'}
+
+const SORT_LABELS: Record = {
+ created_at: '作成日時',
+ updated_at: '更新日時',
+ tag_name: 'タグ名',
+ media_kind: '種類',
+ file_byte_size: '容量',
+ version_no: '版',
+ id: 'Id.'}
-const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
- if (!(tag.material))
- return
-
- return (
-
-
- {(tag.material.contentType && /image\/.*/.test (tag.material.contentType))
- ?

- :
照会}
-
- )
+const setIf = (qs: URLSearchParams, key: string, value: string | null) => {
+ const next = value?.trim ()
+ if (next)
+ qs.set (key, next)
}
-const MaterialListPage: FC = () => {
- const [loading, setLoading] = useState (false)
- const [tag, setTag] = useState (null)
+const parseOption = (
+ value: string | null,
+ allowed: readonly T[],
+ fallback: T): T => allowed.includes (value as T) ? value as T : fallback
+
+const fileSizeText = (bytes: number | null): string => {
+ if (bytes == null)
+ return ''
+ if (bytes < 1024)
+ return `${ bytes } B`
+ if (bytes < 1024 * 1024)
+ return `${ (bytes / 1024).toFixed (1) } KB`
+ return `${ (bytes / 1024 / 1024).toFixed (1) } MB`
+}
+
+
+const materialTitle = (material: Material): string =>
+ material.tag?.name ?? `素材 #${ material.id }`
+
+
+const groupedTagPath = (
+ tagId: number,
+ materialFilter: MaterialFilter,
+): string =>
+ `/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag`
+ + `&material_filter=${ materialFilter }`
+
+
+const materialNewPath = (
+ tagName: string,
+ returnTo: string,
+): string =>
+ `/materials/new?tag=${ encodeURIComponent (tagName) }`
+ + `&return_to=${ encodeURIComponent (returnTo) }`
+
+
+const clearedTagSelectionPath = (
+ locationSearch: string,
+ materialFilter: MaterialFilter,
+): string => {
+ const qs = new URLSearchParams (locationSearch)
+ qs.delete ('tag_id')
+ qs.delete ('include_descendants')
+ qs.delete ('group_by')
+ qs.delete ('page')
+ qs.set ('material_filter', materialFilter)
+ return `/materials?${ qs.toString () }`
+}
+
+
+const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
+
+ {material.thumbnail
+ ?

+ : (
+
+ {material.thumbnailFallbackText}
+ )}
+
)
+
+
+const MaterialCard: FC<{ material: Material }> = ({ material }) => (
+
+
+
+
+
+ {materialTitle (material)}
+
+
+ {MEDIA_KIND_LABELS[material.mediaKind]} / {dateString (material.createdAt)}
+
+
+
+ )
+
+
+const MaterialListItem: FC<{ material: Material }> = ({ material }) => (
+
+
+
+
+
+
+ {materialTitle (material)}
+
+
+
+
+
- 種類:
+ - {MEDIA_KIND_LABELS[material.mediaKind]}
+
+ {material.fileByteSize != null && (
+
+
- サイズ:
+ - {fileSizeText (material.fileByteSize)}
+ )}
+ {material.url && (
+
+
- URL:
+ - {material.url}
+ )}
+
+
- 作成:
+ - {dateString (material.createdAt)}
+
+
+
+
+ )
+
+
+const GroupHeading: FC<{
+ count: number
+ materialFilter: MaterialFilter
+ title: string
+ tagId: number
+}> = ({ count, materialFilter, title, tagId }) => (
+
+
+ {title}
+
+
+
+ {count} 件
+
+
+
)
+
+
+const MaterialListPage: FC = () => {
const location = useLocation ()
- const query = new URLSearchParams (location.search)
- const tagQuery = query.get ('tag') ?? ''
+ const navigate = useNavigate ()
+ const query = useMemo (() => new URLSearchParams (location.search), [location.search])
+ const materialFilter = parseMaterialFilter (query.get ('material_filter'), 'present')
+
+ const page = Number (query.get ('page') ?? 1)
+ const limit = Number (query.get ('limit') ?? 20)
+ const qQuery = query.get ('q') ?? ''
+ const tagIdQuery = Number (query.get ('tag_id') ?? 0)
+ const tagId = tagIdQuery > 0 ? tagIdQuery : null
+ const includeDescendants = query.get ('include_descendants') === '1'
+ const tagState = query.get ('unclassified') === '1'
+ ? 'untagged'
+ : parseOption (
+ query.get ('tag_state'),
+ ['all', 'tagged', 'untagged'],
+ 'all')
+ const mediaKind = parseOption (
+ query.get ('media_kind'),
+ ['all', 'image', 'video', 'audio', 'file_other', 'url_only'],
+ 'all')
+ const sort = parseOption (
+ query.get ('sort'),
+ ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size',
+ 'version_no', 'id'],
+ 'created_at')
+ const direction = parseOption (
+ query.get ('direction'),
+ ['asc', 'desc'],
+ 'desc')
+ const groupByQuery = parseOption (
+ query.get ('group_by'),
+ ['none', 'parent_tag'],
+ 'none')
+ const groupBy = tagId == null ? 'none' : groupByQuery
+ const view = parseOption (query.get ('view'), ['card', 'list'], 'card')
+ const createdFromQuery = query.get ('created_from') ?? ''
+ const createdToQuery = query.get ('created_to') ?? ''
+ const updatedFromQuery = query.get ('updated_from') ?? ''
+ const updatedToQuery = query.get ('updated_to') ?? ''
+
+ const [q, setQ] = useState ('')
+ const [tagStateInput, setTagStateInput] = useState ('all')
+ const [mediaKindInput, setMediaKindInput] = useState ('all')
+ const [groupByInput, setGroupByInput] = useState ('none')
+ const [createdFrom, setCreatedFrom] = useState (null)
+ const [createdTo, setCreatedTo] = useState (null)
+ const [updatedFrom, setUpdatedFrom] = useState (null)
+ const [updatedTo, setUpdatedTo] = useState (null)
+
+ const keys: FetchMaterialsParams = {
+ q: qQuery,
+ tagState,
+ mediaKind,
+ tagId,
+ includeDescendants,
+ groupBy,
+ createdFrom: createdFromQuery,
+ createdTo: createdToQuery,
+ updatedFrom: updatedFromQuery,
+ updatedTo: updatedToQuery,
+ sort,
+ direction,
+ view,
+ page,
+ limit}
+ const { data, isLoading, isError } = useQuery ({
+ queryKey: materialsKeys.index (keys),
+ queryFn: () => fetchMaterials (keys)})
+ const tagScope = data?.tagScope ?? null
+ const materials = data?.materials ?? []
+ const groups = data?.groups ?? []
+ const totalPages = data ? Math.ceil (data.count / limit) : 0
+ const materialsById = useMemo (
+ () => new Map (materials.map (material => [material.id, material])),
+ [materials],
+ )
+ const groupedMaterialIds = useMemo (
+ () => new Set (groups.flatMap (group => group.materialIds)),
+ [groups],
+ )
+ const ungroupedMaterials = useMemo (
+ () => materials.filter (material => !groupedMaterialIds.has (material.id)),
+ [groupedMaterialIds, materials],
+ )
useEffect (() => {
- if (!(tagQuery))
- {
- setTag (null)
- return
- }
+ setQ (qQuery)
+ setTagStateInput (tagState)
+ setMediaKindInput (mediaKind)
+ setGroupByInput (groupBy)
+ setCreatedFrom (createdFromQuery)
+ setCreatedTo (createdToQuery)
+ setUpdatedFrom (updatedFromQuery)
+ setUpdatedTo (updatedToQuery)
+ }, [createdFromQuery, createdToQuery, groupBy, mediaKind, qQuery, tagState,
+ updatedFromQuery, updatedToQuery])
- void (async () => {
- try
- {
- setLoading (true)
- setTag (
- await apiGet (
- `/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
- }
- finally
- {
- setLoading (false)
- }
- }) ()
- }, [location.search, tagQuery])
+ const search = (e: FormEvent) => {
+ e.preventDefault ()
+
+ const qs = new URLSearchParams ()
+ setIf (qs, 'q', q)
+ qs.set ('tag_state', tagStateInput)
+ qs.set ('media_kind', mediaKindInput)
+ setIf (qs, 'created_from', createdFrom)
+ setIf (qs, 'created_to', createdTo)
+ setIf (qs, 'updated_from', updatedFrom)
+ setIf (qs, 'updated_to', updatedTo)
+ qs.set ('sort', sort)
+ qs.set ('direction', direction)
+ qs.set ('group_by', tagId == null ? 'none' : groupByInput)
+ qs.set ('view', view)
+ qs.set ('page', '1')
+ qs.set ('limit', String (limit))
+ qs.set ('material_filter', materialFilter)
+ if (tagId != null)
+ qs.set ('tag_id', String (tagId))
+
+ if (includeDescendants)
+ qs.set ('include_descendants', '1')
+
+ navigate (`/materials?${ qs.toString () }`)
+ }
+
+ const updateQuery = (changes: Record) => {
+ const qs = new URLSearchParams (location.search)
+ Object.entries (changes).forEach (([key, value]) => {
+ if (value === '')
+ qs.delete (key)
+ else
+ qs.set (key, value)
+ })
+ navigate (`/materials?${ qs.toString () }`)
+ }
+
+ const renderMaterialCollection = (rows: Material[]) =>
+ view === 'card'
+ ? (
+
+ {rows.map (material => (
+ ))}
+
)
+ : (
+
+ {rows.map (material => (
+ ))}
+
)
+
+ const renderGroupedMaterials = (materialGroups: MaterialIndexGroup[]) => (
+
+ {materialGroups.map (group => {
+ const groupMaterials =
+ group.materialIds
+ .map (materialId => materialsById.get (materialId))
+ .filter ((material): material is Material => material != null)
+
+ if (groupMaterials.length === 0)
+ return null
+
+ return (
+
+
+ {renderMaterialCollection (groupMaterials)}
+ )
+ })}
+ {ungroupedMaterials.length > 0 && (
+
+
+
+ その他
+
+
+ {renderMaterialCollection (ungroupedMaterials)}
+ )}
+
)
return (
@@ -82,86 +402,205 @@ const MaterialListPage: FC = () => {
src: url(${ nikumaru }) format('opentype');
}`}
- {`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}
+ {`素材管理 | ${ SITE_TITLE }`}
- {loading ? 'Loading...' : (
- tag
- ? (
- <>
-
-
-
- {(!(tag.material) && tag.category !== 'meme') && (
- )}
+
+
-
+ {tagScope && (
+
+
+ {tagScope.tag.name}
+ {tagScope.includeDescendants ? ' 配下の素材を表示中' : ' の素材を表示中'}
+
+
+ タグ選択を解除
+
+
)}
+ {(tagId != null && tagScope == null) && (
+
+
選択中タグが見つかりません.
+
+ タグ選択を解除
+
+
)}
-
- {tag.children.map (c2 => (
-
-
-
-
- {(!(c2.material) && c2.category !== 'meme') && (
- )}
+ {tagState === 'untagged' && (
+
+ 素材検索トップへ戻る
+ )}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isLoading && Loading...
}
+ {isError && (
+ 素材一覧の取得に失敗しました.
)}
+ {(!isLoading && !isError && materials.length === 0) && (
+
+ 素材はありません。
+ {(tagScope && ['character', 'material'].includes (tagScope.tag.category)) && (
+ <>
+
+ 追加してください
+ 。
+ >)}
+
)}
+ {materials.length > 0 && (
+ groupBy === 'parent_tag' && groups.length > 0
+ ? renderGroupedMaterials (groups)
+ : renderMaterialCollection (materials))}
+
+
)
}
diff --git a/frontend/src/pages/materials/MaterialNewPage.test.tsx b/frontend/src/pages/materials/MaterialNewPage.test.tsx
index c67e55b..1d3a1e8 100644
--- a/frontend/src/pages/materials/MaterialNewPage.test.tsx
+++ b/frontend/src/pages/materials/MaterialNewPage.test.tsx
@@ -1,5 +1,6 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { Route, Routes, useLocation } from 'react-router-dom'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import { renderWithProviders } from '@/test/render'
@@ -17,6 +18,11 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
+const LocationView = () => {
+ const location = useLocation ()
+ return {location.pathname + location.search}
+}
+
describe ('MaterialNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
@@ -69,4 +75,50 @@ describe ('MaterialNewPage', () => {
expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2)
expect (screen.getAllByRole ('textbox')[1]).toHaveAttribute ('aria-invalid', 'true')
})
+
+ it ('returns only to safe material URLs after successful creation', async () => {
+ api.apiPost.mockResolvedValueOnce ({})
+
+ renderWithProviders (
+
+ }/>
+ }/>
+ ,
+ {
+ route:
+ '/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ + '&return_to=%2Fmaterials%3Ftag_id%3D20',
+ },
+ )
+
+ fireEvent.change (screen.getAllByRole ('textbox')[1], {
+ target: { value: 'https://example.com/ref' },
+ })
+ fireEvent.click (screen.getByRole ('button', { name: '追加' }))
+
+ expect (await screen.findByText ('/materials?tag_id=20')).toBeInTheDocument ()
+ })
+
+ it ('ignores unsafe return_to values after successful creation', async () => {
+ api.apiPost.mockResolvedValueOnce ({})
+
+ renderWithProviders (
+
+ }/>
+ }/>
+ ,
+ {
+ route:
+ '/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ + '&return_to=https%3A%2F%2Fexample.com%2Fevil',
+ },
+ )
+
+ fireEvent.change (screen.getAllByRole ('textbox')[1], {
+ target: { value: 'https://example.com/ref' },
+ })
+ fireEvent.click (screen.getByRole ('button', { name: '追加' }))
+
+ expect (await screen.findByText ('/materials')).toBeInTheDocument ()
+ })
})
diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx
index bfbb357..40f233f 100644
--- a/frontend/src/pages/materials/MaterialNewPage.tsx
+++ b/frontend/src/pages/materials/MaterialNewPage.tsx
@@ -1,3 +1,4 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
@@ -11,57 +12,60 @@ 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 { apiPost } from '@/lib/api'
+import { createMaterial } 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'
+type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialNewPage: FC = () => {
+ const qc = useQueryClient ()
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
+ const returnToQuery = query.get ('return_to') ?? ''
+ const safeReturnTo = returnToQuery.startsWith ('/materials') ? returnToQuery : ''
const navigate = useNavigate ()
const [file, setFile] = useState (null)
const [filePreview, setFilePreview] = useState ('')
- const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('')
+ const [exportPath, setExportPath] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors ()
- const handleSubmit = async () => {
+ const createMutation = useMutation ({
+ mutationFn: async () => {
+ const formData = new FormData
+ if (tag)
+ formData.append ('tag', tag)
+ if (file)
+ formData.append ('file', file)
+ if (url)
+ formData.append ('url', url)
+ formData.append ('export_paths[legacy_drive]', exportPath)
+
+ return await createMaterial (formData)
+ },
+ onSuccess: async () => {
+ await qc.invalidateQueries ({ queryKey: materialsKeys.root })
+ toast ({ title: '送信成功!' })
+ navigate (safeReturnTo || '/materials')
+ },
+ onError: error => {
+ applyValidationError (error)
+ toast ({ title: '送信失敗……', description: '入力を見直してください.' })
+ }})
+
+ const handleSubmit = () => {
clearValidationErrors ()
-
- const formData = new FormData
- if (tag)
- formData.append ('tag', tag)
- if (file)
- formData.append ('file', file)
- if (url)
- formData.append ('url', url)
-
- try
- {
- setSending (true)
- await apiPost ('/materials', formData)
- toast ({ title: '送信成功!' })
- navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
- }
- catch (e)
- {
- applyValidationError (e)
- toast ({ title: '送信失敗……', description: '入力を見直してください.' })
- }
- finally
- {
- setSending (false)
- }
+ createMutation.mutate ()
}
return (
@@ -133,11 +137,25 @@ const MaterialNewPage: FC = () => {
className={inputClass (invalid)}/>)}
+
+ {({ describedBy, invalid }) => (
+ setExportPath (e.target.value)}
+ placeholder="伊地知ニジカ/表情/泣き.png"
+ aria-describedby={describedBy}
+ aria-invalid={invalid}
+ className={inputClass (invalid)}/>)}
+
+
{/* 送信 */}
diff --git a/frontend/src/pages/materials/MaterialSearchPage.tsx b/frontend/src/pages/materials/MaterialSearchPage.tsx
deleted file mode 100644
index ca5888a..0000000
--- a/frontend/src/pages/materials/MaterialSearchPage.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { useState } from 'react'
-import { Helmet } from 'react-helmet-async'
-
-import FormField from '@/components/common/FormField'
-import PageTitle from '@/components/common/PageTitle'
-import TagInput from '@/components/common/TagInput'
-import MainArea from '@/components/layout/MainArea'
-import { SITE_TITLE } from '@/config'
-
-import type { FC, FormEvent } from 'react'
-
-
-const MaterialSearchPage: FC = () => {
- const [tagName, setTagName] = useState ('')
- const [parentTagName, setParentTagName] = useState ('')
-
- const handleSearch = (e: FormEvent) => {
- e.preventDefault ()
- }
-
- return (
-
-
- 素材集 | {SITE_TITLE}
-
-
-
- )
-}
-
-export default MaterialSearchPage
diff --git a/frontend/src/pages/materials/MaterialSyncSuppressionsPage.tsx b/frontend/src/pages/materials/MaterialSyncSuppressionsPage.tsx
new file mode 100644
index 0000000..f398bbc
--- /dev/null
+++ b/frontend/src/pages/materials/MaterialSyncSuppressionsPage.tsx
@@ -0,0 +1,202 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+import { Helmet } from 'react-helmet-async'
+
+import FormField from '@/components/common/FormField'
+import PageTitle from '@/components/common/PageTitle'
+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 { createMaterialSyncSuppression, fetchMaterialSyncSuppressions } from '@/lib/materials'
+import { materialsKeys } from '@/lib/queryKeys'
+import { dateString, inputClass } from '@/lib/utils'
+
+import type { FC, FormEvent } from 'react'
+
+import type { MaterialSyncSuppressionSourceKind } from '@/types'
+
+const SOURCE_KIND_LABELS: Record = {
+ uri: 'URI',
+ google_drive_path: 'Google Drive ファイル',
+ google_drive_path_prefix: 'Google Drive フォルダ',
+ google_drive_file: 'Google Drive ファイル Id.',
+ legacy_drive_path: '汎用ファイル',
+ legacy_drive_path_prefix: '汎用フォルダ' }
+
+const REASONS = [
+ 'copyright_high_risk',
+ 'copyright_takedown',
+ 'adult_or_sensitive',
+ 'personal_information',
+ 'malware_or_dangerous_file',
+ 'duplicate_or_low_quality',
+ 'source_owner_request',
+ 'other'] as const
+
+type MaterialSyncSuppressionReason = typeof REASONS[number]
+
+const REASON_NAMES: Record = {
+ ['copyright_high_risk']: '著作権への懸念',
+ ['copyright_takedown']: '著作者からの申出',
+ ['adult_or_sensitive']: '成人向け',
+ ['personal_information']: '個人情報',
+ ['malware_or_dangerous_file']: '危険なソフトウェア',
+ ['duplicate_or_low_quality']: '重複',
+ ['source_owner_request']: '同期元管理者からの申出',
+ ['other']: 'その他' } as const
+
+
+const reasonName = (reason: string): string =>
+ REASON_NAMES[reason as MaterialSyncSuppressionReason] ?? reason
+
+
+const MaterialSyncSuppressionsPage: FC = () => {
+ const qc = useQueryClient ()
+ const [sourceKind, setSourceKind] =
+ useState ('uri')
+ const [sourceUri, setSourceUri] = useState ('')
+ const [drivePath, setDrivePath] = useState ('')
+ const [driveFileId, setDriveFileId] = useState ('')
+ const [reason, setReason] = useState (REASONS[0])
+
+ const { data, isError, isLoading } = useQuery ({
+ queryKey: materialsKeys.suppressions (),
+ queryFn: fetchMaterialSyncSuppressions})
+
+ const createMutation = useMutation ({
+ mutationFn: async () =>
+ await createMaterialSyncSuppression ({
+ sourceKind,
+ sourceUri,
+ drivePath,
+ driveFileId,
+ reason}),
+ onSuccess: async () => {
+ await qc.invalidateQueries ({ queryKey: materialsKeys.suppressions () })
+ await qc.invalidateQueries ({ queryKey: materialsKeys.root })
+ setSourceUri ('')
+ setDrivePath ('')
+ setDriveFileId ('')
+ toast ({ title: '同期元抑止を登録しました' })
+ },
+ onError: () => {
+ toast ({ title: '同期元抑止の登録に失敗しました' })
+ }})
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault ()
+ createMutation.mutate ()
+ }
+
+ const suppressions = data?.suppressions ?? []
+
+ return (
+
+
+ {`素材同期抑止 | ${ SITE_TITLE }`}
+
+
+
+
同期抑止
+
+
+
+ {isLoading &&
Loading...
}
+ {isError && (
+
+ 同期元抑止一覧の取得に失敗しました.
+
)}
+
+
+ {suppressions.map (suppression => (
+
+
+ {SOURCE_KIND_LABELS[suppression.sourceKind]}
+
+
+ {suppression.normalizedSourceKey}
+
+
+ 事由: {reasonName (suppression.reason)} /
+ 登録: {dateString (suppression.createdAt)}
+
+ ))}
+
+
+ )
+}
+
+export default MaterialSyncSuppressionsPage
diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx
index 7c7f958..e5e823d 100644
--- a/frontend/src/pages/posts/PostDetailPage.tsx
+++ b/frontend/src/pages/posts/PostDetailPage.tsx
@@ -62,8 +62,7 @@ const PostDetailPage: FC = ({ user }) => {
toast ({ title: '失敗……', description: '通信に失敗しました……' })
},
onSuccess: () => {
- qc.invalidateQueries ({ queryKey: postsKeys.root })
- } })
+ qc.invalidateQueries ({ queryKey: postsKeys.root })} })
useEffect (() => {
if (!(errorFlg))
@@ -114,8 +113,7 @@ const PostDetailPage: FC = ({ user }) => {
({
...p, parentPosts: [{ } as Post] }))]}/>
-
- )}
+ )}
{(post.parentPosts ?? []).map (pp => {
const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`]
if (!(siblings))
diff --git a/frontend/src/pages/tags/NicoTagListPage.tsx b/frontend/src/pages/tags/NicoTagListPage.tsx
index 39ebd93..5cbaadc 100644
--- a/frontend/src/pages/tags/NicoTagListPage.tsx
+++ b/frontend/src/pages/tags/NicoTagListPage.tsx
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, LoaderCircle, Pencil, X } from 'lucide-react'
-import { useEffect, useMemo, useState } from 'react'
+import { Fragment, useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
@@ -87,8 +87,7 @@ const NicoTagListPage: FC