このコミットが含まれているのは:
2026-06-26 01:56:01 +09:00
コミット ce28661271
6個のファイルの変更471行の追加52行の削除
+190 -15
ファイルの表示
@@ -20,6 +20,9 @@ import type { FC, FormEvent } from 'react'
import type {
FetchMaterialsParams,
Material,
MaterialFilter,
MaterialIndexGroup,
MaterialIndexGroupBy,
MaterialIndexDirection,
MaterialIndexMediaKind,
MaterialIndexSort,
@@ -50,6 +53,10 @@ const SORT_LABELS: Record<MaterialIndexSort, string> = {
version_no: 'バージョン',
id: 'ID'}
const GROUP_BY_LABELS: Record<MaterialIndexGroupBy, string> = {
none: 'オフ',
parent_tag: '親タグ'}
const setIf = (qs: URLSearchParams, key: string, value: string | null) => {
const next = value?.trim ()
@@ -79,6 +86,33 @@ 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.set ('group_by', 'none')
qs.delete ('page')
qs.set ('material_filter', materialFilter)
return `/materials?${ qs.toString () }`
}
const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
<div
className={`flex aspect-square h-[180px] w-[180px] items-center justify-center
@@ -154,6 +188,33 @@ const MaterialListItem: FC<{ material: Material }> = ({ material }) => (
</article>)
const GroupHeading: FC<{
count: number
materialFilter: MaterialFilter
returnTo: string
title: string
tagId: number
}> = ({ count, materialFilter, returnTo, title, tagId }) => (
<div className="flex flex-wrap items-center gap-2 border-b border-stone-200 pb-2
dark:border-stone-700">
<PrefetchLink
to={groupedTagPath (tagId, materialFilter)}
className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300">
{title}
</PrefetchLink>
<span className="text-sm text-stone-600 dark:text-stone-300">
{count}
</span>
<PrefetchLink
to={materialNewPath (title, returnTo)}
className="text-sm text-sky-700 underline underline-offset-2
dark:text-sky-300">
</PrefetchLink>
</div>)
const MaterialListPage: FC = () => {
const location = useLocation ()
const navigate = useNavigate ()
@@ -162,7 +223,10 @@ const MaterialListPage: FC = () => {
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const qQuery = query.get ('q') ?? query.get ('tag') ?? ''
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<MaterialIndexTagState> (
@@ -182,6 +246,11 @@ const MaterialListPage: FC = () => {
query.get ('direction'),
['asc', 'desc'],
'desc')
const groupByQuery = parseOption<MaterialIndexGroupBy> (
query.get ('group_by'),
['none', 'parent_tag'],
'none')
const groupBy = tagId == null ? 'none' : groupByQuery
const view = parseOption<MaterialIndexView> (query.get ('view'), ['card', 'list'], 'card')
const createdFromQuery = query.get ('created_from') ?? ''
const createdToQuery = query.get ('created_to') ?? ''
@@ -191,6 +260,7 @@ const MaterialListPage: FC = () => {
const [q, setQ] = useState ('')
const [tagStateInput, setTagStateInput] = useState<MaterialIndexTagState> ('all')
const [mediaKindInput, setMediaKindInput] = useState<MaterialIndexMediaKind> ('all')
const [groupByInput, setGroupByInput] = useState<MaterialIndexGroupBy> ('none')
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
@@ -200,6 +270,9 @@ const MaterialListPage: FC = () => {
q: qQuery,
tagState,
mediaKind,
tagId,
includeDescendants,
groupBy,
createdFrom: createdFromQuery,
createdTo: createdToQuery,
updatedFrom: updatedFromQuery,
@@ -212,18 +285,33 @@ const MaterialListPage: FC = () => {
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, mediaKind, qQuery, tagState,
}, [createdFromQuery, createdToQuery, groupBy, mediaKind, qQuery, tagState,
updatedFromQuery, updatedToQuery])
const search = (e: FormEvent) => {
@@ -239,19 +327,78 @@ const MaterialListPage: FC = () => {
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<string, string>) => {
const qs = new URLSearchParams (location.search)
Object.entries (changes).forEach (([key, value]) => qs.set (key, value))
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'
? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(196px,1fr))]
justify-items-center gap-4">
{rows.map (material => (
<MaterialCard key={material.id} material={material}/>))}
</div>)
: (
<div className="space-y-3">
{rows.map (material => (
<MaterialListItem key={material.id} material={material}/>))}
</div>)
const renderGroupedMaterials = (materialGroups: MaterialIndexGroup[]) => (
<div className="space-y-6">
{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 (
<section key={group.key} className="space-y-3">
<GroupHeading
tagId={group.tag.id}
title={group.tag.name}
count={group.count}
materialFilter={materialFilter}
returnTo={location.pathname + location.search}/>
{renderMaterialCollection (groupMaterials)}
</section>)
})}
{ungroupedMaterials.length > 0 && (
<section className="space-y-3">
<div className="border-b border-stone-200 pb-2 dark:border-stone-700">
<span className="font-medium text-stone-900 dark:text-stone-100">
</span>
</div>
{renderMaterialCollection (ungroupedMaterials)}
</section>)}
</div>)
return (
<MainArea>
<Helmet>
@@ -303,6 +450,28 @@ const MaterialListPage: FC = () => {
</div>
</div>
{tagScope && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border
border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900
dark:border-sky-800 dark:bg-sky-950 dark:text-sky-100">
<span>
{tagScope.tag.name}
{tagScope.includeDescendants ? ' 配下の素材を表示中' : ' の素材を表示中'}
</span>
<PrefetchLink
to={materialNewPath (tagScope.tag.name, location.pathname + location.search)}
className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300">
</PrefetchLink>
<PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300">
</PrefetchLink>
</div>)}
{tagState === 'untagged' && (
<PrefetchLink
to={`/materials?material_filter=${ materialFilter }`}
@@ -353,6 +522,21 @@ const MaterialListPage: FC = () => {
</select>)}
</FormField>
<FormField label="グルーピング">
{({ invalid }) => (
<select
value={groupByInput}
onChange={e => setGroupByInput (
e.target.value as MaterialIndexGroupBy)}
disabled={tagId == null}
className={inputClass (invalid)}>
<option value="none">{GROUP_BY_LABELS.none}</option>
<option value="parent_tag" disabled={tagId == null}>
{GROUP_BY_LABELS.parent_tag}
</option>
</select>)}
</FormField>
<FormField label="作成日時">
{() => (
<div className="flex flex-wrap items-center gap-2">
@@ -438,18 +622,9 @@ const MaterialListPage: FC = () => {
{(!isLoading && !isError && materials.length === 0) && (
<p></p>)}
{materials.length > 0 && (
view === 'card'
? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(196px,1fr))]
justify-items-center gap-4">
{materials.map (material => (
<MaterialCard key={material.id} material={material}/>))}
</div>)
: (
<div className="space-y-3">
{materials.map (material => (
<MaterialListItem key={material.id} material={material}/>))}
</div>))}
groupBy === 'parent_tag' && groups.length > 0
? renderGroupedMaterials (groups)
: renderMaterialCollection (materials))}
<Pagination page={page} totalPages={totalPages}/>
</div>
</MainArea>)
+2 -1
ファイルの表示
@@ -27,6 +27,7 @@ const MaterialNewPage: FC = () => {
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
const returnToQuery = query.get ('return_to') ?? ''
const navigate = useNavigate ()
@@ -54,7 +55,7 @@ const MaterialNewPage: FC = () => {
onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
navigate (returnToQuery || `/materials?tag=${ encodeURIComponent (tag) }`)
},
onError: error => {
applyValidationError (error)