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>
460 行
15 KiB
TypeScript
460 行
15 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
||
import { AnimatePresence, motion } from 'framer-motion'
|
||
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||
import { useLocation } from 'react-router-dom'
|
||
|
||
import Separator from '@/components/MenuSeparator'
|
||
import PrefetchLink from '@/components/PrefetchLink'
|
||
import TopNavUser from '@/components/TopNavUser'
|
||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||
import { fetchTag, fetchTagByName } from '@/lib/tags'
|
||
import { fetchMaterial } from '@/lib/materials'
|
||
import { cn } from '@/lib/utils'
|
||
import { fetchWikiPage } from '@/lib/wiki'
|
||
|
||
import type { FC, MouseEvent } from 'react'
|
||
|
||
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||
|
||
type Props = { user: User | null }
|
||
|
||
|
||
export const menuOutline = (
|
||
{ tag, material, wikiId, user, pathName }: {
|
||
tag?: Tag | null
|
||
material?: Material | null
|
||
wikiId: number | null
|
||
user: User | null,
|
||
pathName: string },
|
||
): Menu => {
|
||
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
|
||
|
||
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId)
|
||
const wikiTitle = pathName.split ('/')[2] ?? ''
|
||
|
||
const tagFlg = /^\/tags\/\d+/.test (pathName)
|
||
|
||
const materialFlg = /^\/materials\/\d+/.test (pathName)
|
||
|
||
return [
|
||
{ name: '広場', to: '/posts', subMenu: [
|
||
{ name: '一覧', to: '/posts' },
|
||
{ name: '検索', to: '/posts/search' },
|
||
{ name: '追加', to: '/posts/new' },
|
||
{ name: '全体履歴', to: '/posts/changes' },
|
||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||
{ name: 'タグ', to: '/tags', subMenu: [
|
||
{ name: 'マスタ', to: '/tags' },
|
||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||
{ name: '全体履歴', to: '/tags/changes' },
|
||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
|
||
{ component: <Separator/>, visible: tagFlg },
|
||
{ name: `広場 (${ postCount || 0 })`,
|
||
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
|
||
visible: tagFlg },
|
||
{ name: '履歴', to: `/tags/changes?id=${ tag?.id }`,
|
||
visible: tagFlg && tag?.category !== 'nico' }] },
|
||
{ name: '素材', to: '/materials', visible: true, subMenu: [
|
||
{ name: '一覧', to: '/materials' },
|
||
{ name: '追加', to: '/materials/new' },
|
||
{ name: '抑止', to: '/materials/suppressions' },
|
||
{ name: '全体履歴', to: '/materials/changes' },
|
||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' },
|
||
{ component: <Separator/>, visible: materialFlg },
|
||
{ name: `広場 (${ postCount || 0 })`,
|
||
to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`,
|
||
visible: materialFlg && Boolean (material?.tag) },
|
||
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
|
||
visible: materialFlg }] },
|
||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
||
{ name: '検索', to: '/wiki' },
|
||
{ name: '新規', to: '/wiki/new' },
|
||
{ name: '全体履歴', to: '/wiki/changes' },
|
||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
|
||
{ component: <Separator/>, visible: wikiPageFlg },
|
||
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
|
||
visible: wikiPageFlg },
|
||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
||
{ name: 'おたのしみ', visible: false, subMenu: [
|
||
{ name: '上映会 (β)', to: '/theatres/1' },
|
||
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
||
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
||
{ name: '一覧', to: '/users', visible: false },
|
||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] },
|
||
{ name: '法規', visible: false, subMenu: [
|
||
{ name: '利用規約', to: '/tos' }] }]
|
||
}
|
||
|
||
|
||
const TopNav: FC<Props> = ({ user }) => {
|
||
const location = useLocation ()
|
||
|
||
const dirRef = useRef<(-1) | 1> (1)
|
||
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
||
const navRef = useRef<HTMLDivElement | null> (null)
|
||
|
||
const measure = (idx: number) => {
|
||
const nav = navRef.current
|
||
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
|
||
|
||
if (!(nav) || !(el))
|
||
{
|
||
setHL ({ left: 0, width: 0, visible: true })
|
||
return
|
||
}
|
||
|
||
const navRect = nav.getBoundingClientRect ()
|
||
const elRect = el.getBoundingClientRect ()
|
||
|
||
setHL ({ left: elRect.left - navRect.left,
|
||
width: elRect.width,
|
||
visible: true })
|
||
}
|
||
|
||
const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
|
||
left: 0,
|
||
width: 0,
|
||
visible: false })
|
||
const [menuOpen, setMenuOpen] = useState (false)
|
||
const [moreVsbl, setMoreVsbl] = useState (false)
|
||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
||
|
||
const wikiIdStr = String (wikiId ?? '')
|
||
|
||
const { data: wikiPage } = useQuery ({
|
||
enabled: Boolean (wikiIdStr),
|
||
queryKey: wikiKeys.show (wikiIdStr, { }),
|
||
queryFn: () => fetchWikiPage (wikiIdStr, { }) })
|
||
|
||
const tagFlg = /^\/tags\/\d+/.test (location.pathname)
|
||
const materialFlg = /^\/materials\/\d+/.test (location.pathname)
|
||
const effectiveTitle = (((tagFlg || materialFlg)
|
||
? location.pathname.split ('/')[2]
|
||
: wikiPage?.title)
|
||
?? '')
|
||
|
||
const { data: tag } = useQuery ({
|
||
enabled: Boolean (effectiveTitle),
|
||
queryKey: tagsKeys.show (effectiveTitle),
|
||
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })
|
||
|
||
const { data: material } = useQuery ({
|
||
enabled: Boolean (effectiveTitle),
|
||
queryKey: materialsKeys.show (effectiveTitle),
|
||
queryFn: () => fetchMaterial (effectiveTitle) })
|
||
|
||
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
|
||
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
|
||
const moreMenu = menu.filter (item =>
|
||
!(item.visible ?? true)
|
||
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
|
||
const activeIdx =
|
||
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
||
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
|
||
|
||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||
|
||
if (activeIdx !== prevActiveIdxRef.current)
|
||
{
|
||
dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1
|
||
prevActiveIdxRef.current = activeIdx
|
||
}
|
||
|
||
const dir = dirRef.current
|
||
|
||
useLayoutEffect (() => {
|
||
const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
|
||
const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
|
||
|
||
addEventListener ('resize', onResize)
|
||
return () => {
|
||
cancelAnimationFrame (raf)
|
||
removeEventListener ('resize', onResize)
|
||
}
|
||
})
|
||
|
||
useEffect (() => {
|
||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||
return () => unsubscribe ()
|
||
}, [])
|
||
|
||
useEffect (() => {
|
||
setMenuOpen (false)
|
||
setOpenItemIdx (activeIdx)
|
||
}, [activeIdx, location])
|
||
|
||
return (
|
||
<>
|
||
<nav className="px-3 flex justify-between items-center w-full
|
||
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
|
||
<div className="flex items-center gap-2 h-12">
|
||
<PrefetchLink
|
||
to="/posts"
|
||
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
|
||
dark:text-pink-300 dark:hover:text-pink-100"
|
||
onClick={() => {
|
||
scroll (0, 0)
|
||
}}>
|
||
ぼざクリ タグ広場
|
||
</PrefetchLink>
|
||
|
||
<div ref={navRef} className="relative hidden md:flex h-12 items-center">
|
||
<div aria-hidden
|
||
className={cn ('absolute inset-y-0 h-12',
|
||
'bg-yellow-200 dark:bg-red-950',
|
||
'transition-[transform,width] duration-200 ease-out')}
|
||
style={{ width: hl.width,
|
||
transform: `translateX(${ hl.left }px)`,
|
||
opacity: hl.visible ? 1 : 0 }}/>
|
||
|
||
{visibleMenu.map ((item, i) => (
|
||
<motion.div
|
||
key={item.to}
|
||
layoutId={`menu-${ item.name }`}
|
||
animate={{ opacity: moreVsbl ? 0 : 1 }}
|
||
transition={{ opacity: { duration: .12 },
|
||
layout: { duration: .2, ease: 'easeOut' } }}
|
||
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
|
||
onMouseEnter={() => setMoreVsbl (false)}>
|
||
<PrefetchLink
|
||
to={item.to}
|
||
ref={(el: (HTMLAnchorElement | null)) => {
|
||
itemsRef.current[i] = el
|
||
}}
|
||
className={cn ('relative z-10 flex h-full items-center px-5',
|
||
(i === openItemIdx) && 'font-bold')}>
|
||
{item.name}
|
||
</PrefetchLink>
|
||
</motion.div>))}
|
||
<PrefetchLink
|
||
to="/more"
|
||
ref={(el: (HTMLAnchorElement | null)) => {
|
||
itemsRef.current[visibleMenu.length] = el
|
||
}}
|
||
onClick={() => setMoreVsbl (false)}
|
||
onMouseEnter={() => {
|
||
setMoreVsbl (true)
|
||
measure (-1)
|
||
}}
|
||
className={cn ('relative z-10 flex h-full items-center px-5',
|
||
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
||
その他 »
|
||
</PrefetchLink>
|
||
</div>
|
||
</div>
|
||
|
||
<TopNavUser user={user}/>
|
||
|
||
<a href="#"
|
||
className="md:hidden ml-auto pr-4
|
||
text-pink-600 hover:text-pink-400
|
||
dark:text-pink-300 dark:hover:text-pink-100"
|
||
onClick={ev => {
|
||
ev.preventDefault ()
|
||
setMenuOpen (!(menuOpen))
|
||
}}>
|
||
{menuOpen ? '×' : 'Menu'}
|
||
</a>
|
||
</nav>
|
||
|
||
<AnimatePresence initial={false}>
|
||
<motion.div
|
||
key="submenu-shell"
|
||
layout
|
||
className="relative z-20 hidden md:block overflow-hidden
|
||
bg-yellow-200 dark:bg-red-950"
|
||
animate={{ height: submenuHeight }}
|
||
onMouseLeave={() => {
|
||
if (moreVsbl)
|
||
setMoreVsbl (false)
|
||
}}
|
||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||
onAnimationComplete={() => {
|
||
measure (moreVsbl ? -1 : activeIdx)
|
||
}}>
|
||
{moreVsbl
|
||
? (
|
||
moreMenu.map ((item, i) => (
|
||
<div key={i} className="relative h-[40px]">
|
||
<div className="absolute inset-0 flex items-center px-3">
|
||
<motion.div
|
||
transition={{ duration: .2, ease: 'easeOut' }}
|
||
{...((item.visible ?? true)
|
||
? { layoutId: `menu-${ item.name }` }
|
||
: { initial: { x: 40, y: -40, opacity: 0 },
|
||
animate: { x: 0, y: 0, opacity: 1 },
|
||
exit: { x: 40, y: -40, opacity: 0 } })}
|
||
className="z-10 h-full flex items-center px-3 font-bold w-28">
|
||
<h2>{item.name}</h2>
|
||
</motion.div>
|
||
{item.subMenu
|
||
.filter (subItem => subItem.visible ?? true)
|
||
.map ((subItem, j) => (
|
||
'component' in subItem
|
||
? (
|
||
<motion.div
|
||
key={`c-${ i }-${ j }`}
|
||
transition={{ duration: .2, ease: 'easeOut' }}
|
||
{...((visibleMenu[activeIdx]?.name
|
||
=== item.name)
|
||
? { layoutId: `submenu-${ item.name }-${ j }` }
|
||
: { initial: { y: -40, opacity: 0 },
|
||
animate: { y: 0, opacity: 1 },
|
||
exit: { y: -40, opacity: 0 } })}>
|
||
{subItem.component}
|
||
</motion.div>)
|
||
: (
|
||
<motion.div
|
||
key={`l-${ i }-${ j }`}
|
||
transition={{ duration: .2, ease: 'easeOut' }}
|
||
{...((visibleMenu[activeIdx]?.name
|
||
=== item.name)
|
||
? { layoutId: `submenu-${ item.name }-${ j }` }
|
||
: { initial: { y: -40, opacity: 0 },
|
||
animate: { y: 0, opacity: 1 },
|
||
exit: { y: -40, opacity: 0 } })}>
|
||
<PrefetchLink
|
||
to={subItem.to}
|
||
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||
onClick={() => setMoreVsbl (false)}
|
||
className="h-full flex items-center px-3">
|
||
{subItem.name}
|
||
</PrefetchLink>
|
||
</motion.div>)))}
|
||
</div>
|
||
</div>)))
|
||
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
|
||
&& (
|
||
<div className="relative h-[40px]">
|
||
<AnimatePresence initial={false} custom={dir}>
|
||
<motion.div
|
||
key={activeIdx}
|
||
custom={dir}
|
||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||
centre: { y: 0, opacity: 1 },
|
||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||
className="absolute inset-0 flex items-center px-3"
|
||
initial="enter"
|
||
animate="centre"
|
||
exit="exit"
|
||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||
.filter (item => item.visible ?? true)
|
||
.map ((item, i) => (
|
||
'component' in item
|
||
? (
|
||
<motion.div
|
||
key={`c-${ i }`}
|
||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||
{item.component}
|
||
</motion.div>)
|
||
: (
|
||
<motion.div
|
||
key={`l-${ i }`}
|
||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||
<PrefetchLink
|
||
to={item.to}
|
||
target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||
className="h-full flex items-center px-3">
|
||
{item.name}
|
||
</PrefetchLink>
|
||
</motion.div>)))}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</div>))}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
|
||
<AnimatePresence initial={false}>
|
||
{menuOpen && (
|
||
<motion.div
|
||
key="spmenu"
|
||
className={cn ('flex flex-col md:hidden',
|
||
'bg-yellow-200 dark:bg-red-975 items-start')}
|
||
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
||
height: 0 },
|
||
open: { clipPath: 'inset(0 0 0% 0)',
|
||
height: 'auto' } }}
|
||
initial="closed"
|
||
animate="open"
|
||
exit="closed"
|
||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||
<Separator/>
|
||
{visibleMenu.map ((item, i) => (
|
||
<Fragment key={i}>
|
||
<PrefetchLink
|
||
to={i === openItemIdx ? item.to : '#'}
|
||
className={cn ('w-full min-h-[40px] flex items-center pl-8',
|
||
((i === openItemIdx)
|
||
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
|
||
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
|
||
if (i !== openItemIdx)
|
||
{
|
||
ev.preventDefault ()
|
||
setOpenItemIdx (i)
|
||
}
|
||
}}>
|
||
{item.name}
|
||
</PrefetchLink>
|
||
|
||
<AnimatePresence initial={false}>
|
||
{i === openItemIdx && (
|
||
<motion.div
|
||
key={`sp-sub-${ i }`}
|
||
className="w-full bg-yellow-50 dark:bg-red-950"
|
||
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
||
height: 0,
|
||
opacity: 0 },
|
||
open: { clipPath: 'inset(0 0 0% 0)',
|
||
height: 'auto',
|
||
opacity: 1 } }}
|
||
initial="closed"
|
||
animate="open"
|
||
exit="closed"
|
||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||
{item.subMenu
|
||
.filter (subItem => subItem.visible ?? true)
|
||
.map ((subItem, j) => (
|
||
'component' in subItem
|
||
? (
|
||
<Fragment key={`sp-c-${ i }-${ j }`}>
|
||
{subItem.component}
|
||
</Fragment>)
|
||
: (
|
||
<PrefetchLink
|
||
key={`sp-l-${ i }-${ j }`}
|
||
to={subItem.to}
|
||
target={subItem.to.slice (0, 2) === '//'
|
||
? '_blank'
|
||
: undefined}
|
||
className="w-full min-h-[36px] flex items-center pl-12">
|
||
{subItem.name}
|
||
</PrefetchLink>)))}
|
||
</motion.div>)}
|
||
</AnimatePresence>
|
||
</Fragment>))}
|
||
<PrefetchLink
|
||
to="/more"
|
||
ref={(el: (HTMLAnchorElement | null)) => {
|
||
itemsRef.current[visibleMenu.length] = el
|
||
}}
|
||
className={cn ('w-full min-h-[40px] flex items-center pl-8',
|
||
((openItemIdx < 0)
|
||
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}>
|
||
その他 »
|
||
</PrefetchLink>
|
||
<TopNavUser user={user} sp/>
|
||
<Separator/>
|
||
</motion.div>)}
|
||
</AnimatePresence>
|
||
</>)
|
||
}
|
||
|
||
export default TopNav
|