@aiquants/menu-bar 1.18.2 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/MenuBar.tsx DELETED
@@ -1,248 +0,0 @@
1
- /**
2
- * @module MenuBar
3
- * @description Main menu bar component for @aiquants/menu-bar.
4
- * @description @aiquants/menu-bar のメインメニューバーコンポーネント。
5
- */
6
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
7
-
8
- const useSafeLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
9
-
10
- import { MenuContent } from "./contents/MenuContent"
11
- import type { IMenu, MenuNavigationPayload } from "./contents/MenuList"
12
- import { OverlayShadow } from "./contents/OverlayShadow"
13
- import { MenuSwitch } from "./elements/MenuSwitch"
14
- import { dispatchMenuPinChange } from "./events/pinEvent"
15
- import type { Theme } from "./types"
16
-
17
- /**
18
- * Props interface for the MenuBar component.
19
- * MenuBar コンポーネントのプロップスインターフェース。
20
- */
21
- export interface IMenuBarProps {
22
- /** List of menu items. メニュー項目のリスト。 */
23
- menuList: IMenu[]
24
- /** Optional header content rendered at top of menu drawer. メニューヘッダー要素。 */
25
- menuHeader?: React.ReactNode
26
- /** Callback handler for sign out button click. サインアウト処理ハンドラー。 */
27
- onClickSignout(): void
28
- /** Whether to hide the menu bar (legacy camelCase). メニュー非表示フラグ(旧表記)。 */
29
- isMenuhide?: boolean
30
- /** Whether to hide the menu bar. メニュー非表示フラグ。 */
31
- isMenuHide?: boolean
32
- /** Current active color theme. 現在のテーマ。 */
33
- theme: Theme
34
- /** Toggle handler for switching color theme. テーマ切り替えハンドラー。 */
35
- toggleTheme: () => void
36
- /** Navigation callback when a menu item is selected. メニュー遷移ハンドラー。 */
37
- onMenuNavigate?(payload: MenuNavigationPayload): void
38
- /** Controlled pinning state (optional). ピン留め状態制御プロップ。 */
39
- isPinned?: boolean
40
- /** Custom width in pixels when menu is pinned (default: 240). ピン留め時の幅(px)。 */
41
- pinnedWidth?: number
42
- /** Callback invoked when menu pin state changes. ピン留め状態変更時のコールバック。 */
43
- onPinChange?(pinned: boolean, widthPx: number): void
44
- /** Controlled list of pinned submenu IDs. ピン留めサブメニューIDリスト(制御)。 */
45
- pinnedSubmenuIds?: number[]
46
- /** Initial list of pinned submenu IDs for uncontrolled mode. ピン留めサブメニューID初期リスト。 */
47
- initialPinnedSubmenuIds?: number[]
48
- /** Callback invoked when submenu pin state changes. サブメニューピン留め変更時コールバック。 */
49
- onSubmenuPinChange?(pinnedIds: number[]): void
50
- /** Controls whether the Escape key closes the menu drawer. Escape キーによるメニュー閉鎖制御。 */
51
- closeOnEscape?: boolean | "auto"
52
- }
53
-
54
- /**
55
- * Renders the primary collapsible and pinnable menu bar with drawer navigation.
56
- * 折りたたみ・ピン留め対応のプライマリメニューバーコンポーネント。
57
- *
58
- * @param props - MenuBarProps configuration.
59
- * @returns Rendered JSX element or null.
60
- */
61
- export const MenuBar = (props: IMenuBarProps): React.JSX.Element | null => {
62
- const {
63
- menuList,
64
- menuHeader,
65
- onClickSignout,
66
- isMenuhide,
67
- isMenuHide,
68
- theme,
69
- toggleTheme,
70
- onMenuNavigate,
71
- isPinned: propIsPinned,
72
- pinnedWidth = 240,
73
- onPinChange,
74
- pinnedSubmenuIds: propPinnedSubmenuIds,
75
- initialPinnedSubmenuIds,
76
- onSubmenuPinChange,
77
- closeOnEscape = "auto",
78
- } = props
79
-
80
- const shouldHideMenu = isMenuHide ?? isMenuhide ?? false
81
-
82
- const [internalIsPinned, setInternalIsPinned] = useState(() => {
83
- try {
84
- return localStorage.getItem("aiquants-menu-pinned") === "true"
85
- } catch {
86
- return false
87
- }
88
- })
89
-
90
- const isPinned = propIsPinned !== undefined ? propIsPinned : internalIsPinned
91
- const [isMenuOpen, setIsMenuOpen] = useState(isPinned)
92
-
93
- // isPinned が true に変化した場合は自動的にメニューを開いた状態にする
94
- useEffect(() => {
95
- if (isPinned) {
96
- setIsMenuOpen(true)
97
- }
98
- }, [isPinned])
99
-
100
- const [pinnedSubmenuIds, setPinnedSubmenuIds] = useState<number[]>(() => {
101
- if (initialPinnedSubmenuIds) {
102
- return initialPinnedSubmenuIds
103
- }
104
- try {
105
- const saved = localStorage.getItem("aiquants-submenu-pinned")
106
- if (saved) {
107
- const parsed = JSON.parse(saved)
108
- if (Array.isArray(parsed)) {
109
- return parsed.filter((id) => typeof id === "number")
110
- }
111
- }
112
- } catch {}
113
- return []
114
- })
115
-
116
- const activePinnedSubmenuIds = propPinnedSubmenuIds !== undefined ? propPinnedSubmenuIds : pinnedSubmenuIds
117
-
118
- const handleToggleSubmenuPin = useCallback(
119
- (id: number) => {
120
- const currentIds = propPinnedSubmenuIds !== undefined ? propPinnedSubmenuIds : pinnedSubmenuIds
121
- const nextIds = currentIds.includes(id) ? currentIds.filter((x) => x !== id) : [...currentIds, id]
122
-
123
- if (propPinnedSubmenuIds === undefined) {
124
- setPinnedSubmenuIds(nextIds)
125
- try {
126
- localStorage.setItem("aiquants-submenu-pinned", JSON.stringify(nextIds))
127
- } catch {}
128
- }
129
-
130
- if (onSubmenuPinChange) {
131
- onSubmenuPinChange(nextIds)
132
- }
133
- },
134
- [propPinnedSubmenuIds, pinnedSubmenuIds, onSubmenuPinChange],
135
- )
136
-
137
- // コールバック関数参照を useRef で最新保持し、エフェクト再実行のインライン関数連鎖を遮断
138
- const onPinChangeRef = useRef(onPinChange)
139
- useSafeLayoutEffect(() => {
140
- onPinChangeRef.current = onPinChange
141
- }, [onPinChange])
142
-
143
- // ピン状態変化時に CSS 変数をセットし、CustomEvent を自動発行
144
- useSafeLayoutEffect(() => {
145
- const effectiveWidth = isPinned ? pinnedWidth : 0
146
- const widthCss = `${effectiveWidth}px`
147
- document.documentElement.style.setProperty("--aiquants-menu-pinned-width", widthCss)
148
-
149
- // CustomEvent 発行
150
- dispatchMenuPinChange(isPinned, effectiveWidth)
151
- }, [isPinned, pinnedWidth])
152
-
153
- // アンマウント時に CSS 変数をリセットしピン解除通知を発行
154
- useSafeLayoutEffect(() => {
155
- return () => {
156
- document.documentElement.style.setProperty("--aiquants-menu-pinned-width", "0px")
157
- dispatchMenuPinChange(false, 0)
158
- }
159
- }, [])
160
-
161
- const handleMenuOpen = useCallback(() => {
162
- setIsMenuOpen(true)
163
- }, [])
164
-
165
- const handleMenuClose = useCallback(() => {
166
- setIsMenuOpen(false)
167
- if (propIsPinned === undefined) {
168
- setInternalIsPinned(false)
169
- try {
170
- localStorage.removeItem("aiquants-menu-pinned")
171
- } catch {}
172
- }
173
- onPinChangeRef.current?.(false, 0)
174
- }, [propIsPinned])
175
-
176
- const handleTogglePin = useCallback(() => {
177
- const nextPinned = !isPinned
178
- const nextWidth = nextPinned ? pinnedWidth : 0
179
-
180
- if (propIsPinned === undefined) {
181
- setInternalIsPinned(nextPinned)
182
- try {
183
- if (nextPinned) {
184
- localStorage.setItem("aiquants-menu-pinned", "true")
185
- } else {
186
- localStorage.removeItem("aiquants-menu-pinned")
187
- }
188
- } catch {}
189
- }
190
-
191
- onPinChangeRef.current?.(nextPinned, nextWidth)
192
- }, [isPinned, propIsPinned, pinnedWidth])
193
-
194
- const handleMenuSelected = useCallback(
195
- (payload: MenuNavigationPayload) => {
196
- if (onMenuNavigate) {
197
- onMenuNavigate(payload)
198
- }
199
- if (!isPinned) {
200
- setIsMenuOpen(false)
201
- }
202
- },
203
- [onMenuNavigate, isPinned],
204
- )
205
-
206
- const handleEscKey = useCallback(
207
- (e: KeyboardEvent) => {
208
- if (e.key === "Escape") {
209
- handleMenuClose()
210
- }
211
- },
212
- [handleMenuClose],
213
- )
214
-
215
- const escapeCloses = closeOnEscape === true || (closeOnEscape === "auto" && !isPinned)
216
-
217
- useEffect(() => {
218
- if (isMenuOpen && escapeCloses) {
219
- window.addEventListener("keydown", handleEscKey)
220
- } else {
221
- window.removeEventListener("keydown", handleEscKey)
222
- }
223
-
224
- return () => {
225
- window.removeEventListener("keydown", handleEscKey)
226
- }
227
- }, [isMenuOpen, escapeCloses, handleEscKey])
228
-
229
- return shouldHideMenu ? null : (
230
- <div className={`aiquants-menu-bar ${theme}`}>
231
- <MenuSwitch onMenuClose={handleMenuClose} onMenuOpen={handleMenuOpen} open={!isMenuOpen} theme={theme} />
232
- <MenuContent
233
- menuList={menuList}
234
- menuHeader={menuHeader}
235
- onClickSignout={onClickSignout}
236
- onMenuSelected={handleMenuSelected}
237
- open={isMenuOpen}
238
- theme={theme}
239
- toggleTheme={toggleTheme}
240
- isPinned={isPinned}
241
- onTogglePin={handleTogglePin}
242
- pinnedSubmenuIds={activePinnedSubmenuIds}
243
- onToggleSubmenuPin={handleToggleSubmenuPin}
244
- />
245
- <OverlayShadow isOverlay={isMenuOpen && !isPinned} onOverlayClick={handleMenuClose} />
246
- </div>
247
- )
248
- }
@@ -1,68 +0,0 @@
1
- import { act, render, renderHook } from "@testing-library/react"
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
3
- import { dispatchMenuPinChange, getInitialPinState, subscribeMenuPinChange } from "../events/pinEvent"
4
- import { useMenuPinState } from "../hooks/useMenuPinState"
5
- import { MenuBar } from "../MenuBar"
6
-
7
- describe("pinEvent module & useMenuPinState hook", () => {
8
- beforeEach(() => {
9
- localStorage.clear()
10
- document.documentElement.style.removeProperty("--aiquants-menu-pinned-width")
11
- })
12
-
13
- afterEach(() => {
14
- vi.restoreAllMocks()
15
- })
16
-
17
- it("getInitialPinState reads document style or localStorage safely", () => {
18
- expect(getInitialPinState(240)).toEqual({ pinned: false, width: 0 })
19
-
20
- localStorage.setItem("aiquants-menu-pinned", "true")
21
- expect(getInitialPinState(240)).toEqual({ pinned: true, width: 240 })
22
-
23
- document.documentElement.style.setProperty("--aiquants-menu-pinned-width", "280px")
24
- expect(getInitialPinState(240)).toEqual({ pinned: true, width: 280 })
25
- })
26
-
27
- it("subscribeMenuPinChange fires immediately when immediate: true", () => {
28
- localStorage.setItem("aiquants-menu-pinned", "true")
29
- const listener = vi.fn()
30
-
31
- const unsubscribe = subscribeMenuPinChange(listener, { immediate: true })
32
- expect(listener).toHaveBeenCalledWith({ pinned: true, width: 240 })
33
-
34
- dispatchMenuPinChange(false, 0)
35
- expect(listener).toHaveBeenCalledWith({ pinned: false, width: 0 })
36
-
37
- unsubscribe()
38
- })
39
-
40
- it("useMenuPinState hook syncs state correctly and avoids hydration mismatch", () => {
41
- localStorage.setItem("aiquants-menu-pinned", "true")
42
- const { result } = renderHook(() => useMenuPinState({ defaultPinnedWidth: 240 }))
43
-
44
- expect(result.current).toEqual({ pinned: true, width: 240 })
45
-
46
- act(() => {
47
- dispatchMenuPinChange(false, 0)
48
- })
49
-
50
- expect(result.current).toEqual({ pinned: false, width: 0 })
51
- })
52
-
53
- it("MenuBar triggers onPinChange on toggle and dispatches CustomEvent", () => {
54
- const onPinChange = vi.fn()
55
- const eventListener = vi.fn()
56
- const unsubscribe = subscribeMenuPinChange(eventListener, { immediate: false })
57
-
58
- const { unmount } = render(<MenuBar menuList={[]} onClickSignout={() => {}} theme="light" toggleTheme={() => {}} isPinned={true} pinnedWidth={240} onPinChange={onPinChange} />)
59
-
60
- expect(eventListener).toHaveBeenCalledWith({ pinned: true, width: 240 })
61
- expect(document.documentElement.style.getPropertyValue("--aiquants-menu-pinned-width")).toBe("240px")
62
-
63
- unmount()
64
- expect(document.documentElement.style.getPropertyValue("--aiquants-menu-pinned-width")).toBe("0px")
65
-
66
- unsubscribe()
67
- })
68
- })
@@ -1,95 +0,0 @@
1
- import ExternalLink from "lucide-react/dist/esm/icons/external-link.js"
2
- import User from "lucide-react/dist/esm/icons/user.js"
3
- import type { UserProfile } from "../types/user"
4
-
5
- const DEFAULT_USER_ICON =
6
- "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2NiZDVlMSI+PHBhdGggZD0iTTEyIDEyYzIuMjEgMCA0LTEuNzkgNC00cy0xLjc5LTQtNC00LTQgMS43OS00IDQgMS43OSA0IDQgNHptMCAyYy0yLjY3IDAtOCAxLjM0LTggNHYyaDE2di0yYzAtMi42Ni01LjMzLTQtOC00eiIvPjwvc3ZnPg=="
7
-
8
- /**
9
- * Link component props interface.
10
- * リンクコンポーネントのプロパティ。
11
- */
12
- export interface LinkProps {
13
- /** Link destination URL */
14
- to: string
15
- /** CSS class name */
16
- className?: string
17
- /** Link content */
18
- children: React.ReactNode
19
- }
20
-
21
- /**
22
- * Generic Link component that can be replaced with router-specific implementation.
23
- * ルーター固有の実装で置き換え可能な汎用リンクコンポーネント。
24
- */
25
- export const DefaultLink = ({ to, className, children }: LinkProps): React.JSX.Element => (
26
- <a href={to} className={className}>
27
- {children}
28
- </a>
29
- )
30
-
31
- /**
32
- * SessionInfo component props interface.
33
- * ユーザーセッション情報を表示するコンポーネントのプロパティ。
34
- */
35
- export interface SessionInfoProps {
36
- /** User profile information from Google OAuth */
37
- user: UserProfile | null
38
- /** Custom Link component (optional, defaults to DefaultLink) */
39
- LinkComponent?: React.ComponentType<LinkProps>
40
- }
41
-
42
- /**
43
- * SessionInfo component for displaying user session information.
44
- * Displays user profile photo, navigation links, and user name when authenticated.
45
- *
46
- * ユーザーセッション情報を表示するコンポーネント。
47
- * 認証時にプロフィール写真、ナビゲーションリンク、ユーザー名を表示する。
48
- */
49
- export const SessionInfo = (props: SessionInfoProps): React.JSX.Element => {
50
- const { user, LinkComponent = DefaultLink } = props
51
-
52
- return (
53
- <div className="w-full">
54
- {user ? (
55
- <div className="aiquants-session-card group relative flex flex-col gap-2 rounded-xl border border-white/20 bg-white/10 p-2.5 shadow-sm backdrop-blur-md transition-all duration-200 hover:border-white/30 hover:bg-white/15 dark:border-slate-700/60 dark:bg-slate-800/60 dark:hover:border-slate-600/80 dark:hover:bg-slate-800/90">
56
- {/* アバター & ユーザー名 */}
57
- <div className="flex min-w-0 items-center gap-2.5">
58
- <div className="relative shrink-0">
59
- <div className="h-8 w-8 overflow-hidden rounded-full border border-white/40 shadow-xs dark:border-slate-500">
60
- <img className="h-full w-full rounded-full object-cover" src={user.photo || DEFAULT_USER_ICON} alt={user.displayName || "user profile"} />
61
- </div>
62
- <span className="absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-slate-900 bg-emerald-400" title="Online" />
63
- </div>
64
- <div className="min-w-0 flex-1 leading-tight">
65
- <div className="truncate font-semibold text-white text-xs tracking-tight dark:text-slate-100" title={user.displayName}>
66
- {user.displayName}
67
- </div>
68
- <div className="mt-0.5 font-medium text-[10px] text-white/75 tracking-tight dark:text-slate-400">Google Account</div>
69
- </div>
70
- </div>
71
-
72
- {/* アクションボタン */}
73
- <div className="grid grid-cols-2 gap-1.5 border-white/15 border-t pt-1.5 dark:border-slate-700/50">
74
- <LinkComponent
75
- className="flex items-center justify-center gap-1 rounded-md border border-white/15 bg-white/10 px-2 py-1 font-medium text-[11px] text-white transition-all duration-150 hover:bg-white/25 active:scale-95 dark:border-slate-600/50 dark:bg-slate-700/50 dark:text-slate-200 dark:hover:bg-slate-600/80"
76
- to="/">
77
- <User size={11} className="shrink-0 opacity-80" />
78
- <span className="truncate">プロフィール</span>
79
- </LinkComponent>
80
- <LinkComponent
81
- className="flex items-center justify-center gap-1 rounded-md border border-white/15 bg-white/10 px-2 py-1 font-medium text-[11px] text-white transition-all duration-150 hover:bg-white/25 active:scale-95 dark:border-slate-600/50 dark:bg-slate-700/50 dark:text-slate-200 dark:hover:bg-slate-600/80"
82
- to="https://myaccount.google.com/">
83
- <ExternalLink size={11} className="shrink-0 opacity-80" />
84
- <span className="truncate">Google</span>
85
- </LinkComponent>
86
- </div>
87
- </div>
88
- ) : (
89
- <div className="aiquants-session-card flex items-center justify-between rounded-xl border border-white/20 bg-white/10 p-2.5 shadow-xs dark:border-slate-700/60 dark:bg-slate-800/60">
90
- <span className="font-medium text-white/90 text-xs dark:text-slate-200">ログインしてください</span>
91
- </div>
92
- )}
93
- </div>
94
- )
95
- }
@@ -1,79 +0,0 @@
1
- /**
2
- * @module MenuContent
3
- * @description This module provides the content for the menu bar.
4
- * @description このモジュールは、メニューバーのコンテンツを提供します。
5
- */
6
-
7
- import LogOut from "lucide-react/dist/esm/icons/log-out.js"
8
- import Pin from "lucide-react/dist/esm/icons/pin.js"
9
- import PinOff from "lucide-react/dist/esm/icons/pin-off.js"
10
- import type { Theme } from "../types"
11
- import { type IMenu, MenuList, type MenuNavigationPayload } from "./MenuList"
12
-
13
- /**
14
- * @interface IMenuContentProps
15
- * @description Props for the MenuContent component.
16
- * @description MenuContent コンポーネントの Props。
17
- */
18
- interface IMenuContentProps {
19
- menuList: IMenu[]
20
- menuHeader?: React.ReactNode
21
- open: boolean
22
- onMenuSelected(payload: MenuNavigationPayload): void
23
- onClickSignout(): void
24
- theme: Theme
25
- toggleTheme: () => void
26
- isPinned: boolean
27
- onTogglePin(): void
28
- pinnedSubmenuIds: number[]
29
- onToggleSubmenuPin(id: number): void
30
- }
31
-
32
- /**
33
- * Renders the content of the menu bar.
34
- * メニューバーのコンテンツを描画します。
35
- * @param {IMenuContentProps} props - The properties for the MenuContent component.
36
- * @returns {React.JSX.Element} The rendered menu content.
37
- */
38
- export const MenuContent = (props: IMenuContentProps): React.JSX.Element => {
39
- const { menuList, menuHeader, open, onMenuSelected, onClickSignout, theme, toggleTheme, isPinned, onTogglePin, pinnedSubmenuIds, onToggleSubmenuPin } = props
40
-
41
- return (
42
- <div className={`aiquants-menu-content fixed top-0 right-0 z-2147483642 flex min-h-dvh w-[240px] select-none flex-col transition-transform duration-500 ${open ? "aiquants-menu-translate-x-0" : "aiquants-menu-translate-x-250"}`}>
43
- <div className="flex shrink-0 flex-col justify-center border-white/15 border-b px-2.5 py-2 dark:border-slate-700/60">{menuHeader}</div>
44
- <div className="flex grow flex-col overflow-y-auto">
45
- <MenuList menuList={menuList} onMenuSelected={onMenuSelected} pinnedSubmenuIds={pinnedSubmenuIds} onToggleSubmenuPin={onToggleSubmenuPin} />
46
- </div>
47
- <div className="border-white/15 border-t px-2.5 py-1.5 dark:border-slate-700/60">
48
- <div className="flex items-center justify-between gap-1.5">
49
- <button
50
- type="button"
51
- onClick={onTogglePin}
52
- data-aqmb-pin-button
53
- className={`flex h-7 items-center justify-center rounded-md px-2 text-[11px] text-white shadow-2xs transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/60 ${isPinned ? "bg-amber-500 hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-500" : "border border-white/20 bg-white/10 hover:bg-white/20 dark:border-slate-600/60 dark:bg-slate-700/60 dark:hover:bg-slate-600"}`}
54
- title={isPinned ? "ピン留め解除" : "ピン留め"}
55
- aria-label={isPinned ? "ピン留め解除" : "ピン留め"}>
56
- {isPinned ? <Pin size={12} /> : <PinOff size={12} />}
57
- </button>
58
- <button
59
- type="button"
60
- onClick={toggleTheme}
61
- data-aqmb-theme-button
62
- className="group relative flex h-7 items-center justify-center rounded-md border border-white/20 bg-white/10 px-2 text-[11px] text-white shadow-2xs backdrop-blur-xs transition-all duration-200 hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 dark:border-slate-600/60 dark:bg-slate-700/60 dark:hover:bg-slate-600"
63
- title={theme === "dark" ? "ライトモードに切り替え" : "ダークモードに切り替え"}
64
- aria-label={theme === "dark" ? "ライトモードに切り替え" : "ダークモードに切り替え"}>
65
- <span className="text-xs transition-transform duration-200 group-hover:scale-110">{theme === "dark" ? "🌞" : "🌙"}</span>
66
- </button>
67
- <button
68
- type="button"
69
- data-aqmb-signout-button
70
- className="aiquants-signout-button flex h-7 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md bg-red-500/90 px-2.5 font-medium text-[11px] text-white shadow-2xs transition-colors duration-200 hover:bg-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-300/60 dark:bg-red-600/90 dark:hover:bg-red-500"
71
- onClick={onClickSignout}>
72
- <LogOut size={11} className="shrink-0" />
73
- <span>サインアウト</span>
74
- </button>
75
- </div>
76
- </div>
77
- </div>
78
- )
79
- }