このコミットが含まれているのは:
@@ -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>)
|
||||
|
||||
新しい課題から参照
ユーザをブロックする