776dea87d9
開発環境では、**DB を壊さない前提で、migration → API → 画面 → 同期 → ZIP → 履歴**の順に見るのがよいです。今回の差分は素材管理全体に触っているので、単体でチョンチョン見るより、素材の一生を通すのが早いです。
## 0. 先に方針
**やらないこと:**
```sh id="snb3i6"
rails db:drop
rails db:reset
rails db:setup
DISABLE_DATABASE_ENVIRONMENT_CHECK=1 ...
```
これは禁止。
開発 DB に本番データを入れているなら、床板を剥がして耐震確認するようなものです。
---
## 1. migration 確認
まず現在の状態を見る。
```sh id="qsdfpt"
cd backend
RAILS_ENV=development bundle exec rails db:migrate:status
```
その後、通常 migration。
```sh id="a89fir"
RAILS_ENV=development bundle exec rails db:migrate
```
見るポイント:
```txt id="c1u57a"
materials に source_* / normalized_source_key / version_no がある
material_versions に event_type / file snapshot / source snapshot がある
material_export_items がある
material_sync_suppressions がある
material_sync_sources がある
既存 materials に material_versions version_no=1 create が backfill されている
```
確認用:
```sh id="g4vd4m"
RAILS_ENV=development bundle exec rails runner '
puts "materials=#{Material.count}"
puts "versions=#{MaterialVersion.count}"
puts "materials without versions=#{Material.left_joins(:material_versions).where(material_versions: { id: nil }).count}"
puts "sync suppressions table=#{ActiveRecord::Base.connection.table_exists?(:material_sync_suppressions)}"
'
```
ここで `materials without versions=0` になれば、backfill は通っています。
---
## 2. 既存素材一覧の画面確認
フロントを起動して `/materials` を見る。
```sh id="vl28jd"
cd frontend
npm run dev
```
見る観点:
```txt id="i2ovxw"
初期表示で素材が出る
初期表示ではグルーピングがオフ
タグなし素材も出る
カード表示でサムネまたは代替テキストが出る
一覧表示に切り替えられる
q / tag_state / media_kind / sort / direction が効く
```
ここでまず、普通の素材一覧が壊れていないことを確認します。
---
## 3. 左タグバーの確認
`/materials` を開いて左タグバーからタグを選ぶ。
見る観点:
```txt id="n6udul"
URL が tag_id=...&include_descendants=1&group_by=parent_tag になる
選択中タグが左バーで強調される
一覧上部に「選択中」の表示が出る
「タグ選択を解除」で通常表示に戻れる
解除後、tag_id / include_descendants / group_by / page が消える
子タグ・孫タグの素材も一覧に出る
親タググルーピングされる
```
特に重要なのはこれ。
```txt id="d33os0"
親タグ A を選択
A に直接紐づく素材
A > B に紐づく素材
A > B > C に紐づく素材
が同じ一覧に出ること
```
---
## 4. 選択タグから素材追加
左タグからタグを選択した状態で、一覧上部の **このタグに素材を追加** を押す。
見る観点:
```txt id="qdd5uw"
素材追加画面の tag 欄に選択中タグ名が初期入力されている
file または url を指定して保存できる
保存後 return_to で元のタグ選択済み一覧に戻る
戻った一覧に追加した素材が出る
material_versions に create が 1 件できる
```
Rails console でも確認できます。
```sh id="i07rrb"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(id: :desc).first
puts({ id: m.id, tag: m.tag&.name, versions: m.material_versions.count, version_no: m.version_no }.inspect)
'
```
---
## 5. グループ見出しから素材追加
親タググルーピング表示中に、各グループ見出しの **このタグに素材を追加** を押す。
見る観点:
```txt id="fpx8w4"
グループタグ名が tag 欄に初期入力される
保存後、元の一覧に戻る
追加素材がそのグループ内に出る
```
ここは今回の導線の肝です。棚の見出しから直接その棚へ素材を置けるかを見る。
---
## 6. 素材更新と履歴
既存素材の詳細または編集導線から、タグ・URL・ファイル・export path を更新する。
見る観点:
```txt id="w36i61"
更新前 snapshot が無ければ create が補われる
更新後 update version ができる
file_blob_id / file_filename / file_sha256 が material_versions に入る
export_paths_json が履歴に残る
/materials/changes または /materials/versions で履歴が見える
```
console:
```sh id="a3okhv"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(updated_at: :desc).first
puts m.material_versions.order(:version_no).map { |v|
[v.version_no, v.event_type, v.tag_name, v.file_filename, v.file_sha256, v.export_paths_hash]
}.inspect
'
```
---
## 7. サムネイル
画像素材を追加して、一覧にサムネイルが出るか確認。
動画素材があるなら、`ffmpeg` が入っている環境で backfill。
```sh id="qxm8fj"
cd backend
RAILS_ENV=development bundle exec rails materials:thumbnails:backfill
```
見る観点:
```txt id="s9a1vf"
画像は 180x180 のサムネが付く
動画はフレームからサムネが作られる
非対応ファイルは代替テキスト表示になる
ログに result が出る
```
---
## 8. ZIP export
export path がある素材を用意して、ブラウザで確認。
```txt id="aq7f7o"
/materials/download.zip?profile=legacy_drive
```
見る観点:
```txt id="h4rve5"
ZIP が落ちる
entry path が material_export_items.export_path になる
disabled な export item は入らない
ファイル実体が欠けている場合は 422 と missing_files が返る
```
---
## 9. 抑止
`/materials/suppressions` で path prefix 抑止を追加する。
見る観点:
```txt id="w80jbu"
member で作成できる
guest は forbidden / unauthorized
google_drive_path_prefix で既存素材が discard される
discard 履歴が material_versions に残る
同期時に同じ source_path 配下が再作成されない
```
---
## 10. Google Drive 同期
開発環境では、まず小さいフォルダでやるのがよいです。
いきなり本番素材集フォルダを食わせると、ログが藪になります 🌿
必要な ENV:
```sh id="kzbb7f"
GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL=...
GOOGLE_DRIVE_PRIVATE_KEY_PATH=...
MATERIAL_SYNC_SOURCE_KIND=google_drive_path
MATERIAL_SYNC_SOURCE_FILE_ID=<folder_id>
MATERIAL_SYNC_SOURCE_NAME=dev-small-folder
MATERIAL_SYNC_SOURCE_PROFILE=legacy_drive
```
seed で source 作成、または console で作成。
```sh id="sec40c"
RAILS_ENV=development bundle exec rails db:seed
RAILS_ENV=development bundle exec rails materials:sync
```
見る観点:
```txt id="a5ltqu"
imported / updated / unchanged / suppressed / failed がログに出る
2 回目実行で unchanged が増える
tag は nil のままでも保存できる
人手で tag / url を付けた既存同期素材が、再同期で消えない
Google native file は skip される
download 後 sha256 block が効く
```
---
## 11. schema.rb は別途確認
これはテストというより merge gate です。
今回まだ怪しいので、差分に以下が混ざっていないことを確認します。
```txt id="s3k6da"
wiki_assets 削除
wiki_pages.next_asset_no 削除
素材管理と無関係な CHECK constraint 削除
素材管理と無関係な index order 消失
```
ここが残るなら、機能テストが通っても merge は止めた方がいいです。
---
## 最小テスト順
時間がないなら、この順で十分です。
```txt id="c1k2pw"
1. db:migrate
2. materials without versions = 0 を確認
3. /materials 初期表示
4. 左タグ選択 → 子孫込み表示 → グルーピング
5. タグ選択解除
6. 選択タグから素材追加 → return_to で戻る
7. グループ見出しから素材追加
8. 更新して material_versions を確認
9. ZIP export
10. 小さい Drive folder で materials:sync を 2 回
```
これで、今回の差分の主要な導線はほぼ踏めます。
Reviewed-on: #381
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
507 行
16 KiB
TypeScript
507 行
16 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { useLocation, useNavigate } from 'react-router-dom'
|
||
|
||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||
import TagLink from '@/components/TagLink'
|
||
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
|
||
import { materialsKeys } from '@/lib/queryKeys'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
||
|
||
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
|
||
|
||
const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
|
||
const FILTER_LABELS: Record<MaterialFilter, string> = { present: '有', missing: '無', any: '全' }
|
||
|
||
|
||
const px = (value: string): number => {
|
||
const parsed = Number.parseFloat (value)
|
||
return Number.isFinite (parsed) ? parsed : 0
|
||
}
|
||
|
||
|
||
const verticalChrome = (el: HTMLElement): number => {
|
||
const style = window.getComputedStyle (el)
|
||
|
||
return (
|
||
px (style.paddingTop)
|
||
+ px (style.paddingBottom)
|
||
+ px (style.borderTopWidth)
|
||
+ px (style.borderBottomWidth))
|
||
}
|
||
|
||
|
||
const setChildrenById = (
|
||
tags: MaterialSidebarTag[],
|
||
targetId: number,
|
||
children: MaterialSidebarTag[],
|
||
): MaterialSidebarTag[] => (
|
||
tags.map (tag => {
|
||
if (tag.id === targetId)
|
||
return { ...tag, children }
|
||
|
||
if (tag.children.length === 0)
|
||
return tag
|
||
|
||
return { ...tag, children: setChildrenById (tag.children, targetId, children) }
|
||
}))
|
||
|
||
|
||
const materialPath = (
|
||
tagId: number,
|
||
materialFilter: MaterialFilter,
|
||
): string =>
|
||
`/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag`
|
||
+ `&material_filter=${ materialFilter }`
|
||
|
||
|
||
const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({
|
||
id: tag.id,
|
||
name: tag.name,
|
||
category: tag.category,
|
||
deprecatedAt: tag.deprecated ? '' : null,
|
||
aliases: [],
|
||
parents: [],
|
||
postCount: 0,
|
||
createdAt: '',
|
||
updatedAt: '',
|
||
hasWiki: false,
|
||
materialId: null,
|
||
hasDeerjikists: false,
|
||
matchedAlias: null })
|
||
|
||
|
||
const tagSelectionShellClass = (selected: boolean): string =>
|
||
cn (selected
|
||
? ['rounded-md border border-sky-500 bg-sky-50 px-2 py-1 text-sky-700',
|
||
'dark:border-sky-400 dark:bg-sky-950 dark:text-sky-100']
|
||
: 'px-2 py-1')
|
||
|
||
|
||
const updateMaterialFilterQuery = (
|
||
pathname: string,
|
||
locationSearch: string,
|
||
navigate: ReturnType<typeof useNavigate>,
|
||
materialFilter: MaterialFilter,
|
||
) => {
|
||
const qs = new URLSearchParams (locationSearch)
|
||
qs.set ('material_filter', materialFilter)
|
||
navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`)
|
||
}
|
||
|
||
|
||
const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
|
||
onChange: (materialFilter: MaterialFilter) => void }> = (
|
||
{ materialFilter, onChange },
|
||
) => (
|
||
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
|
||
<label className="my-auto text-sm font-bold">素材:</label>
|
||
{FILTERS.map (value => (
|
||
<button
|
||
key={value}
|
||
type="button"
|
||
onClick={() => onChange (value)}
|
||
className={cn (
|
||
'rounded-full border px-3 py-1 text-sm',
|
||
(materialFilter === value
|
||
? ['border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||
'dark:bg-sky-950 dark:text-sky-100']
|
||
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
|
||
'dark:bg-stone-900 dark:text-stone-200']))}>
|
||
{FILTER_LABELS[value]}
|
||
</button>))}
|
||
</div>)
|
||
|
||
|
||
const MaterialTreeNode: FC<{
|
||
materialFilter: MaterialFilter
|
||
selectedTagId: number | null
|
||
nestLevel?: number
|
||
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void
|
||
openTags: Record<number, boolean>
|
||
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
|
||
tag: MaterialSidebarTag
|
||
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
|
||
setOpenTags, tag }) => {
|
||
const open = Boolean (openTags[tag.id])
|
||
const { data } = useQuery ({
|
||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||
|
||
useEffect (() => {
|
||
if (open && data && tag.children.length === 0)
|
||
onChildren (tag.id, data)
|
||
}, [data, onChildren, open, tag.children.length, tag.id])
|
||
|
||
return (
|
||
<>
|
||
<li>
|
||
<div className="flex flex-center">
|
||
<div className="flex-none w-4 my-auto">
|
||
{tag.hasChildren && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
|
||
className="text-neutral-500 dark:text-stone-400">
|
||
{open ? <>−</> : '+'}
|
||
</button>)}
|
||
</div>
|
||
<div className="min-w-0 flex-1 my-auto">
|
||
<div className={cn (tagSelectionShellClass (selectedTagId === tag.id),
|
||
'min-w-0 truncate')}>
|
||
<TagLink
|
||
tag={sidebarTagToTag (tag)}
|
||
nestLevel={nestLevel}
|
||
title={tag.name}
|
||
withCount={false}
|
||
withWiki={false}
|
||
to={materialPath (tag.id, materialFilter)}/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
{open && tag.children.length > 0 && (
|
||
<ul>
|
||
{tag.children.map (child => (
|
||
<MaterialTreeNode
|
||
key={child.id}
|
||
tag={child}
|
||
nestLevel={nestLevel + 1}
|
||
materialFilter={materialFilter}
|
||
selectedTagId={selectedTagId}
|
||
openTags={openTags}
|
||
setOpenTags={setOpenTags}
|
||
onChildren={onChildren}/>))}
|
||
</ul>)}
|
||
</>)
|
||
}
|
||
|
||
|
||
const MobileMaterialTreeNode: FC<{ depth?: number
|
||
availableInlineSizePx?: number | null
|
||
materialFilter: MaterialFilter
|
||
selectedTagId: number | null
|
||
onChildren: (tagId: number, children: MaterialSidebarTag[]) =>
|
||
void
|
||
openTags: Record<number, boolean>
|
||
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
|
||
tag: MaterialSidebarTag }> = (
|
||
{
|
||
depth = 0,
|
||
availableInlineSizePx = null,
|
||
materialFilter,
|
||
onChildren,
|
||
openTags,
|
||
selectedTagId,
|
||
setOpenTags,
|
||
tag,
|
||
},
|
||
) => {
|
||
const open = Boolean (openTags[tag.id])
|
||
const tagColumnRef = useRef<HTMLDivElement | null> (null)
|
||
const chipRef = useRef<HTMLDivElement | null> (null)
|
||
const buttonRef = useRef<HTMLButtonElement | null> (null)
|
||
const expansionSlotRef = useRef<HTMLDivElement | null> (null)
|
||
const expansionBorderRef = useRef<HTMLDivElement | null> (null)
|
||
const [tagChipInlineSizePx, setTagChipInlineSizePx] = useState<number | null> (null)
|
||
const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState<number | null> (null)
|
||
const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null)
|
||
const { data } = useQuery ({
|
||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||
|
||
useEffect (() => {
|
||
if (open && data && tag.children.length === 0)
|
||
onChildren (tag.id, data)
|
||
}, [data, onChildren, open, tag.children.length, tag.id])
|
||
|
||
useEffect (() => {
|
||
const tagColumn = tagColumnRef.current
|
||
const chip = chipRef.current
|
||
if (!(tagColumn) || !(chip))
|
||
return
|
||
|
||
const updateTagInlineSize = () => {
|
||
const buttonHeight = buttonRef.current?.offsetHeight ?? 0
|
||
const gap = tag.hasChildren ? px (window.getComputedStyle (tagColumn).rowGap) : 0
|
||
const columnHeight =
|
||
availableInlineSizePx == null
|
||
? tagColumn.clientHeight
|
||
: Math.min (tagColumn.clientHeight, availableInlineSizePx)
|
||
const nextChipInlineSize = Math.max (24, columnHeight - buttonHeight - gap)
|
||
const nextLinkInlineSize = Math.max (
|
||
16,
|
||
nextChipInlineSize - verticalChrome (chip),
|
||
)
|
||
|
||
setTagChipInlineSizePx (prev => prev === nextChipInlineSize ? prev : nextChipInlineSize)
|
||
setTagLinkInlineSizePx (prev => prev === nextLinkInlineSize ? prev : nextLinkInlineSize)
|
||
}
|
||
|
||
updateTagInlineSize ()
|
||
|
||
const resizeObserver = new ResizeObserver (() => {
|
||
updateTagInlineSize ()
|
||
})
|
||
|
||
resizeObserver.observe (tagColumn)
|
||
resizeObserver.observe (chip)
|
||
|
||
if (buttonRef.current)
|
||
resizeObserver.observe (buttonRef.current)
|
||
|
||
return () => {
|
||
resizeObserver.disconnect ()
|
||
}
|
||
}, [availableInlineSizePx, open, tag.hasChildren, tag.children.length])
|
||
|
||
useEffect (() => {
|
||
const expansionSlot = expansionSlotRef.current
|
||
const expansionBorder = expansionBorderRef.current
|
||
if (!(expansionSlot) || !(expansionBorder))
|
||
return
|
||
|
||
const updateChildInlineSize = () => {
|
||
const base =
|
||
availableInlineSizePx == null
|
||
? expansionSlot.clientHeight
|
||
: availableInlineSizePx
|
||
const chrome = verticalChrome (expansionSlot) + verticalChrome (expansionBorder)
|
||
const nextInlineSize = Math.max (24, base - chrome)
|
||
setChildAvailableInlineSizePx (prev => prev === nextInlineSize ? prev : nextInlineSize)
|
||
}
|
||
|
||
updateChildInlineSize ()
|
||
|
||
const resizeObserver = new ResizeObserver (() => {
|
||
updateChildInlineSize ()
|
||
})
|
||
|
||
resizeObserver.observe (expansionSlot)
|
||
resizeObserver.observe (expansionBorder)
|
||
|
||
return () => {
|
||
resizeObserver.disconnect ()
|
||
}
|
||
}, [availableInlineSizePx, open, tag.children.length])
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 max-h-full flex-row-reverse items-start gap-2
|
||
overflow-hidden">
|
||
<div
|
||
ref={tagColumnRef}
|
||
className="flex h-full min-h-0 max-h-full flex-col items-center gap-1
|
||
overflow-hidden">
|
||
<div
|
||
ref={chipRef}
|
||
className={cn (
|
||
tagSelectionShellClass (selectedTagId === tag.id),
|
||
'box-border rounded-xl border px-3 py-2 text-sm shadow-sm',
|
||
'min-h-0 overflow-hidden [max-inline-size:var(--tag-chip-inline-size)]',
|
||
'[max-height:var(--tag-chip-inline-size)]',
|
||
)}
|
||
style={{
|
||
writingMode: 'vertical-rl',
|
||
'--tag-chip-inline-size': (
|
||
tagChipInlineSizePx == null
|
||
? undefined
|
||
: `${ tagChipInlineSizePx }px`),
|
||
'--tag-link-inline-size': (
|
||
tagLinkInlineSizePx == null
|
||
? undefined
|
||
: `${ tagLinkInlineSizePx }px`),
|
||
} as CSSProperties}>
|
||
<TagLink
|
||
tag={sidebarTagToTag (tag)}
|
||
title={tag.name}
|
||
withCount={false}
|
||
withWiki={false}
|
||
to={materialPath (tag.id, materialFilter)}
|
||
className="block overflow-hidden text-ellipsis whitespace-nowrap
|
||
[max-inline-size:var(--tag-link-inline-size)]
|
||
[max-height:var(--tag-link-inline-size)]"/>
|
||
</div>
|
||
{tag.hasChildren && (
|
||
<button
|
||
ref={buttonRef}
|
||
type="button"
|
||
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
|
||
className="flex-none rounded-full border border-stone-300 bg-white
|
||
px-2 py-0.5 text-sm text-stone-700 dark:border-stone-700
|
||
dark:bg-stone-900 dark:text-stone-100">
|
||
{open ? <>−</> : '+'}
|
||
</button>)}
|
||
</div>
|
||
{open && tag.children.length > 0 && (
|
||
<div
|
||
ref={expansionSlotRef}
|
||
className={cn (
|
||
'h-full min-h-0 max-h-full overflow-hidden box-border',
|
||
depth === 0 ? 'pt-5' : 'pt-3')}>
|
||
<div
|
||
ref={expansionBorderRef}
|
||
className="relative max-h-full overflow-hidden rounded-2xl border border-stone-200
|
||
bg-stone-100/70 py-2 pl-2 pr-2 text-stone-900
|
||
dark:border-stone-700 dark:bg-stone-900/70 dark:text-stone-100">
|
||
<div className="flex h-full min-h-0 max-h-full flex-row-reverse items-start gap-2
|
||
overflow-hidden">
|
||
<span
|
||
aria-hidden="true"
|
||
className="absolute -right-3 top-4 h-px w-3 bg-stone-300 dark:bg-stone-600"/>
|
||
{tag.children.map (child => (
|
||
<div
|
||
key={child.id}
|
||
className="relative h-full min-h-0 max-h-full overflow-hidden">
|
||
<MobileMaterialTreeNode
|
||
tag={child}
|
||
depth={depth + 1}
|
||
availableInlineSizePx={childAvailableInlineSizePx}
|
||
materialFilter={materialFilter}
|
||
selectedTagId={selectedTagId}
|
||
openTags={openTags}
|
||
setOpenTags={setOpenTags}
|
||
onChildren={onChildren}/>
|
||
</div>))}
|
||
</div>
|
||
</div>
|
||
</div>)}
|
||
</div>)
|
||
}
|
||
|
||
|
||
const MaterialSidebar: FC = () => {
|
||
const location = useLocation ()
|
||
const navigate = useNavigate ()
|
||
const qs = new URLSearchParams (location.search)
|
||
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
|
||
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
|
||
|
||
const [desktopTags, setDesktopTags] = useState<MaterialSidebarTag[]> ([])
|
||
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
|
||
const mobileRailRef = useRef<HTMLDivElement | null> (null)
|
||
const [mobileAvailableInlineSizePx, setMobileAvailableInlineSizePx] =
|
||
useState<number | null> (null)
|
||
|
||
const { data: rootTags = [], isLoading, isError } = useQuery ({
|
||
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
|
||
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
|
||
|
||
useEffect (() => {
|
||
setDesktopTags (rootTags)
|
||
}, [rootTags])
|
||
|
||
useEffect (() => {
|
||
const el = mobileRailRef.current
|
||
if (!(el))
|
||
return
|
||
|
||
requestAnimationFrame (() => {
|
||
el.scrollLeft = el.scrollWidth
|
||
})
|
||
}, [rootTags, materialFilter])
|
||
|
||
useEffect (() => {
|
||
const el = mobileRailRef.current
|
||
if (!(el))
|
||
return
|
||
|
||
const updateAvailableInlineSize = () => {
|
||
const nextInlineSize = Math.max (24, el.clientHeight)
|
||
setMobileAvailableInlineSizePx (prev => prev === nextInlineSize ? prev : nextInlineSize)
|
||
}
|
||
|
||
updateAvailableInlineSize ()
|
||
|
||
const resizeObserver = new ResizeObserver (() => {
|
||
updateAvailableInlineSize ()
|
||
})
|
||
|
||
resizeObserver.observe (el)
|
||
|
||
return () => {
|
||
resizeObserver.disconnect ()
|
||
}
|
||
}, [rootTags, materialFilter])
|
||
|
||
const visibleRootTags = desktopTags.length > 0 ? desktopTags : rootTags
|
||
|
||
const setChildren = (tagId: number, children: MaterialSidebarTag[]) => {
|
||
setDesktopTags (prev => {
|
||
const base = prev.length > 0 ? prev : rootTags
|
||
return setChildrenById (base, tagId, children)
|
||
})
|
||
}
|
||
|
||
const handleFilterChange = (value: MaterialFilter) => {
|
||
setDesktopTags ([])
|
||
setOpenTags ({ })
|
||
updateMaterialFilterQuery (location.pathname, location.search, navigate, value)
|
||
}
|
||
|
||
const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => (
|
||
tags.map (tag => (
|
||
<MaterialTreeNode
|
||
key={tag.id}
|
||
tag={tag}
|
||
materialFilter={materialFilter}
|
||
selectedTagId={selectedTagId}
|
||
openTags={openTags}
|
||
setOpenTags={setOpenTags}
|
||
onChildren={setChildren}/>)))
|
||
|
||
return (
|
||
<>
|
||
<div className="border-b bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-950
|
||
dark:text-stone-100 md:hidden flex h-[25dvh] min-h-0 flex-col
|
||
overflow-hidden">
|
||
<MaterialFilterButtons
|
||
materialFilter={materialFilter}
|
||
onChange={handleFilterChange}/>
|
||
<div
|
||
ref={mobileRailRef}
|
||
className="mt-3 min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
|
||
<div className="flex min-w-max flex-row-reverse items-start gap-3 pb-1 h-full
|
||
min-h-0 max-h-full">
|
||
{visibleRootTags.map (tag => (
|
||
<MobileMaterialTreeNode
|
||
key={tag.id}
|
||
tag={tag}
|
||
availableInlineSizePx={mobileAvailableInlineSizePx}
|
||
materialFilter={materialFilter}
|
||
selectedTagId={selectedTagId}
|
||
openTags={openTags}
|
||
setOpenTags={setOpenTags}
|
||
onChildren={setChildren}/>))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hidden md:block">
|
||
<SidebarComponent>
|
||
<div className="space-y-4">
|
||
<MaterialFilterButtons
|
||
materialFilter={materialFilter}
|
||
onChange={handleFilterChange}/>
|
||
{isLoading && (
|
||
<p className="text-sm text-neutral-500 dark:text-stone-400">読込中……</p>)}
|
||
{isError && (
|
||
<p className="text-sm text-red-600 dark:text-red-300">
|
||
タグ一覧の取得に失敗しました.
|
||
</p>)}
|
||
{(!isLoading && !isError) && (
|
||
<ul>
|
||
{renderDesktopTree (visibleRootTags)}
|
||
</ul>)}
|
||
</div>
|
||
</SidebarComponent>
|
||
</div>
|
||
</>)
|
||
}
|
||
|
||
|
||
export default MaterialSidebar
|