import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' import nikumaru from '@/assets/fonts/nikumaru.otf' import PrefetchLink from '@/components/PrefetchLink' import DateTimeField from '@/components/common/DateTimeField' 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 { API_BASE_URL, SITE_TITLE } from '@/config' import { fetchMaterials, parseMaterialFilter } from '@/lib/materials' import { materialsKeys } from '@/lib/queryKeys' import { dateString, inputClass } from '@/lib/utils' import type { FC, FormEvent } from 'react' import type { FetchMaterialsParams, Material, MaterialFilter, MaterialIndexGroup, MaterialIndexGroupBy, MaterialIndexDirection, MaterialIndexMediaKind, MaterialIndexSort, MaterialIndexTagState, MaterialIndexView } from '@/types' 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: 'URL のみ'} const SORT_LABELS: Record = { created_at: '作成日時', updated_at: '更新日時', tag_name: 'タグ名', media_kind: '種類', file_byte_size: 'ファイルサイズ', version_no: 'バージョン', id: 'ID'} const setIf = (qs: URLSearchParams, key: string, value: string | null) => { const next = value?.trim () if (next) qs.set (key, next) } 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 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 (() => { setQ (qQuery) setTagStateInput (tagState) setMediaKindInput (mediaKind) setGroupByInput (groupBy) setCreatedFrom (createdFromQuery) setCreatedTo (createdToQuery) setUpdatedFrom (updatedFromQuery) setUpdatedTo (updatedToQuery) }, [createdFromQuery, createdToQuery, groupBy, mediaKind, qQuery, tagState, updatedFromQuery, updatedToQuery]) 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 ( {`素材管理 | ${ SITE_TITLE }`}
素材管理
新規素材を追加 同期元抑止 ZIP をダウンロード
{tagScope && (
{tagScope.tag.name} {tagScope.includeDescendants ? ' 配下の素材を表示中' : ' の素材を表示中'} タグ選択を解除
)} {(tagId != null && tagScope == null) && (
選択中タグが見つかりません. タグ選択を解除
)} {tagState === 'untagged' && ( 素材検索トップへ戻る )}
{({ invalid }) => ( setQ (e.target.value)} placeholder="タグ名 / URL / ファイル名" className={inputClass (invalid)}/>)} {({ invalid }) => ( )} {({ invalid }) => ( )} {() => (
)}
{() => (
)}
{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))}
) } export default MaterialListPage