@kids-reporter/routing-ui 0.1.0-alpha.1

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.
Files changed (129) hide show
  1. package/.prettierignore +17 -0
  2. package/babel.config.cjs +31 -0
  3. package/dist/components/button.d.ts +23 -0
  4. package/dist/components/button.d.ts.map +1 -0
  5. package/dist/components/button.js +82 -0
  6. package/dist/components/button.js.map +1 -0
  7. package/dist/components/index.d.ts +3 -0
  8. package/dist/components/index.d.ts.map +1 -0
  9. package/dist/components/index.js +21 -0
  10. package/dist/components/index.js.map +1 -0
  11. package/dist/components/input.d.ts +20 -0
  12. package/dist/components/input.d.ts.map +1 -0
  13. package/dist/components/input.js +143 -0
  14. package/dist/components/input.js.map +1 -0
  15. package/dist/constants/default-values.d.ts +12 -0
  16. package/dist/constants/default-values.d.ts.map +1 -0
  17. package/dist/constants/default-values.js +148 -0
  18. package/dist/constants/default-values.js.map +1 -0
  19. package/dist/footer.d.ts +10 -0
  20. package/dist/footer.d.ts.map +1 -0
  21. package/dist/footer.js +144 -0
  22. package/dist/footer.js.map +1 -0
  23. package/dist/header/desktop-header.d.ts +14 -0
  24. package/dist/header/desktop-header.d.ts.map +1 -0
  25. package/dist/header/desktop-header.js +85 -0
  26. package/dist/header/desktop-header.js.map +1 -0
  27. package/dist/header/header-context.d.ts +16 -0
  28. package/dist/header/header-context.d.ts.map +1 -0
  29. package/dist/header/header-context.js +37 -0
  30. package/dist/header/header-context.js.map +1 -0
  31. package/dist/header/index.d.ts +12 -0
  32. package/dist/header/index.d.ts.map +1 -0
  33. package/dist/header/index.js +72 -0
  34. package/dist/header/index.js.map +1 -0
  35. package/dist/header/menu/header-menu-item-group.d.ts +7 -0
  36. package/dist/header/menu/header-menu-item-group.d.ts.map +1 -0
  37. package/dist/header/menu/header-menu-item-group.js +31 -0
  38. package/dist/header/menu/header-menu-item-group.js.map +1 -0
  39. package/dist/header/menu/header-menu-item.d.ts +9 -0
  40. package/dist/header/menu/header-menu-item.d.ts.map +1 -0
  41. package/dist/header/menu/header-menu-item.js +92 -0
  42. package/dist/header/menu/header-menu-item.js.map +1 -0
  43. package/dist/header/menu/index.d.ts +15 -0
  44. package/dist/header/menu/index.d.ts.map +1 -0
  45. package/dist/header/menu/index.js +170 -0
  46. package/dist/header/menu/index.js.map +1 -0
  47. package/dist/header/mobile-header.d.ts +9 -0
  48. package/dist/header/mobile-header.d.ts.map +1 -0
  49. package/dist/header/mobile-header.js +46 -0
  50. package/dist/header/mobile-header.js.map +1 -0
  51. package/dist/header/post-title-setter.d.ts +6 -0
  52. package/dist/header/post-title-setter.d.ts.map +1 -0
  53. package/dist/header/post-title-setter.js +22 -0
  54. package/dist/header/post-title-setter.js.map +1 -0
  55. package/dist/header/shared-components.d.ts +31 -0
  56. package/dist/header/shared-components.d.ts.map +1 -0
  57. package/dist/header/shared-components.js +256 -0
  58. package/dist/header/shared-components.js.map +1 -0
  59. package/dist/hooks/index.d.ts +4 -0
  60. package/dist/hooks/index.d.ts.map +1 -0
  61. package/dist/hooks/index.js +36 -0
  62. package/dist/hooks/index.js.map +1 -0
  63. package/dist/hooks/use-is-at-top.d.ts +3 -0
  64. package/dist/hooks/use-is-at-top.d.ts.map +1 -0
  65. package/dist/hooks/use-is-at-top.js +26 -0
  66. package/dist/hooks/use-is-at-top.js.map +1 -0
  67. package/dist/hooks/use-media-query.d.ts +7 -0
  68. package/dist/hooks/use-media-query.d.ts.map +1 -0
  69. package/dist/hooks/use-media-query.js +47 -0
  70. package/dist/hooks/use-media-query.js.map +1 -0
  71. package/dist/hooks/use-scroll-level.d.ts +11 -0
  72. package/dist/hooks/use-scroll-level.d.ts.map +1 -0
  73. package/dist/hooks/use-scroll-level.js +53 -0
  74. package/dist/hooks/use-scroll-level.js.map +1 -0
  75. package/dist/icons/index.d.ts +17 -0
  76. package/dist/icons/index.d.ts.map +1 -0
  77. package/dist/icons/index.js +341 -0
  78. package/dist/icons/index.js.map +1 -0
  79. package/dist/index.d.ts +10 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +101 -0
  82. package/dist/index.js.map +1 -0
  83. package/dist/styles.css +475 -0
  84. package/dist/types/index.d.ts +10 -0
  85. package/dist/types/index.d.ts.map +1 -0
  86. package/dist/types/index.js +6 -0
  87. package/dist/types/index.js.map +1 -0
  88. package/dist/utils/cn.d.ts +8 -0
  89. package/dist/utils/cn.d.ts.map +1 -0
  90. package/dist/utils/cn.js +27 -0
  91. package/dist/utils/cn.js.map +1 -0
  92. package/dist/utils/generate-social-media-config.d.ts +9 -0
  93. package/dist/utils/generate-social-media-config.d.ts.map +1 -0
  94. package/dist/utils/generate-social-media-config.js +55 -0
  95. package/dist/utils/generate-social-media-config.js.map +1 -0
  96. package/dist/utils/index.d.ts +3 -0
  97. package/dist/utils/index.d.ts.map +1 -0
  98. package/dist/utils/index.js +28 -0
  99. package/dist/utils/index.js.map +1 -0
  100. package/eslint.config.mjs +55 -0
  101. package/package.json +51 -0
  102. package/prettier.config.mjs +13 -0
  103. package/scripts/build.sh +18 -0
  104. package/src/components/button.tsx +108 -0
  105. package/src/components/index.tsx +2 -0
  106. package/src/components/input.tsx +171 -0
  107. package/src/constants/default-values.tsx +153 -0
  108. package/src/footer.tsx +151 -0
  109. package/src/header/desktop-header.tsx +128 -0
  110. package/src/header/header-context.tsx +56 -0
  111. package/src/header/index.tsx +96 -0
  112. package/src/header/menu/header-menu-item-group.tsx +37 -0
  113. package/src/header/menu/header-menu-item.tsx +132 -0
  114. package/src/header/menu/index.tsx +206 -0
  115. package/src/header/mobile-header.tsx +61 -0
  116. package/src/header/post-title-setter.tsx +22 -0
  117. package/src/header/shared-components.tsx +326 -0
  118. package/src/hooks/index.ts +3 -0
  119. package/src/hooks/use-is-at-top.ts +23 -0
  120. package/src/hooks/use-media-query.ts +57 -0
  121. package/src/hooks/use-scroll-level.ts +52 -0
  122. package/src/icons/index.tsx +358 -0
  123. package/src/index.ts +9 -0
  124. package/src/styles.css +475 -0
  125. package/src/types/index.ts +10 -0
  126. package/src/utils/cn.ts +41 -0
  127. package/src/utils/generate-social-media-config.ts +75 -0
  128. package/src/utils/index.ts +2 -0
  129. package/tsconfig.json +33 -0
@@ -0,0 +1,206 @@
1
+ 'use client'
2
+
3
+ import Image from 'next/image'
4
+ import { useEffect } from 'react'
5
+
6
+ import Button from '../../components/button'
7
+ import { ClearIcon } from '../../icons'
8
+ import type { MenuItem, SocialMediaHrefs } from '../../types'
9
+ import { cn } from '../../utils/cn'
10
+ import { generateSocialMediaConfig } from '../../utils/generate-social-media-config'
11
+ import { SearchInputSection } from '../shared-components'
12
+ import HeaderMenuItem from './header-menu-item'
13
+ import HeaderMenuItemGroup from './header-menu-item-group'
14
+
15
+ type MenuProps = {
16
+ isOpen: boolean
17
+ onClose: () => void
18
+ keywords: string[]
19
+ menuItems: MenuItem[]
20
+ additionalMenuItems: MenuItem[]
21
+ socialMediaHrefs: SocialMediaHrefs
22
+ donateUrl: string
23
+ subscribeUrl: string
24
+ searchPlaceholder: string
25
+ }
26
+
27
+ function Divider() {
28
+ return (
29
+ <div className="px-6 tablet:px-8 py-4 w-full">
30
+ <div className="h-px w-full bg-neutral-300"></div>
31
+ </div>
32
+ )
33
+ }
34
+
35
+ function Menu({
36
+ isOpen,
37
+ onClose,
38
+ keywords,
39
+ menuItems,
40
+ additionalMenuItems,
41
+ socialMediaHrefs,
42
+ donateUrl,
43
+ subscribeUrl,
44
+ searchPlaceholder,
45
+ }: MenuProps) {
46
+ const socialMediaConfig = generateSocialMediaConfig(socialMediaHrefs)
47
+
48
+ useEffect(() => {
49
+ if (isOpen) {
50
+ document.body.classList.add('no-scroll')
51
+ } else {
52
+ document.body.classList.remove('no-scroll')
53
+ }
54
+
55
+ return () => {
56
+ document.body.classList.remove('no-scroll')
57
+ }
58
+ }, [isOpen])
59
+
60
+ useEffect(() => {
61
+ const handleEscape = (e: KeyboardEvent) => {
62
+ if (e.key === 'Escape') {
63
+ onClose()
64
+ }
65
+ }
66
+
67
+ if (isOpen) {
68
+ document.addEventListener('keydown', handleEscape)
69
+ }
70
+
71
+ return () => {
72
+ document.removeEventListener('keydown', handleEscape)
73
+ }
74
+ }, [isOpen, onClose])
75
+
76
+ return (
77
+ <>
78
+ {/* Overlay */}
79
+ <div
80
+ className={cn(
81
+ 'inset-0 fixed z-1001 bg-neutral-500/50 transition-opacity duration-300',
82
+ isOpen ? 'opacity-100' : 'pointer-events-none opacity-0'
83
+ )}
84
+ onClick={onClose}
85
+ />
86
+
87
+ {/* Menu */}
88
+ <div
89
+ className={cn(
90
+ 'top-0 left-0 tablet:w-80 bg-white shadow-2xl ease-in-out scrollbar-thin tablet:pt-0 fixed z-1001 h-full w-full transform pt-(--mobile-header-height) transition-transform duration-300',
91
+ isOpen ? 'translate-x-0' : '-translate-x-full'
92
+ )}
93
+ >
94
+ <div className="flex h-full flex-col overflow-y-auto">
95
+ <div className="px-6 tablet:px-8 py-4 mt-4 hidden items-center justify-between tablet:flex">
96
+ <div className="flex items-center">
97
+ <a href="/">
98
+ <Image
99
+ src="/assets/images/brand-icon.svg"
100
+ alt="少年報導者 The Reporter for Kids"
101
+ className="h-5"
102
+ height={20}
103
+ width={183}
104
+ loading="eager"
105
+ />
106
+ </a>
107
+ </div>
108
+ <button
109
+ onClick={onClose}
110
+ className="w-8 h-8 flex cursor-pointer items-center justify-center rounded-full text-neutral-600 transition-colors duration-200 hover:text-neutral-800"
111
+ aria-label="關閉選單"
112
+ >
113
+ <ClearIcon />
114
+ </button>
115
+ </div>
116
+
117
+ <div className="px-6 tablet:px-8 pt-4 desktop:hidden">
118
+ <SearchInputSection
119
+ mode="inline"
120
+ tags={keywords}
121
+ searchPlaceholder={searchPlaceholder}
122
+ />
123
+ </div>
124
+
125
+ <div className="py-4 flex-1">
126
+ <HeaderMenuItem {...menuItems?.[0]} />
127
+
128
+ <Divider />
129
+
130
+ {/* Categories */}
131
+ <div className="py-2">
132
+ <HeaderMenuItemGroup isMenuOpen={isOpen} menuItems={menuItems} />
133
+ </div>
134
+
135
+ <Divider />
136
+
137
+ {/* Reading Settings */}
138
+ <HeaderMenuItem
139
+ {...additionalMenuItems?.[0]}
140
+ contentClassName="text-neutral-600 [&_span]:[font-family:var(--font-family-noto)] [&_span]:[font-size:var(--font-size-p2)] [&_span]:[font-weight:500] [&_span]:[line-height:var(--line-height-normal)] [&_span]:[letter-spacing:var(--letter-spacing-wide)] hover:text-neutral-900"
141
+ />
142
+
143
+ <Divider />
144
+
145
+ {/* About Us Section */}
146
+ <div className="py-2">
147
+ {additionalMenuItems.slice(1).map((item, index) => (
148
+ <HeaderMenuItem
149
+ key={index}
150
+ label={item.label}
151
+ href={item.href}
152
+ external={item.external}
153
+ contentClassName="text-neutral-600 [&_span]:[font-family:var(--font-family-noto)] [&_span]:[font-size:var(--font-size-p2)] [&_span]:[font-weight:500] [&_span]:[line-height:var(--line-height-normal)] [&_span]:[letter-spacing:var(--letter-spacing-wide)] hover:text-neutral-900"
154
+ />
155
+ ))}
156
+ </div>
157
+
158
+ <Divider />
159
+
160
+ {/* Social Media */}
161
+ <div className="px-6 tablet:px-8">
162
+ <div className="gap-4 tablet:gap-0 px-4 flex items-center justify-center tablet:justify-between">
163
+ {socialMediaConfig.map((item) => (
164
+ <a
165
+ key={item.label}
166
+ href={item.href}
167
+ className="text-neutral-900 transition-colors duration-200 hover:text-red-500"
168
+ target="_blank"
169
+ rel="noopener noreferrer"
170
+ aria-label={item.label}
171
+ >
172
+ <div className="w-6 h-6 flex items-center justify-center">
173
+ {item.icon && <item.icon />}
174
+ </div>
175
+ </a>
176
+ ))}
177
+ </div>
178
+ </div>
179
+ </div>
180
+
181
+ {/* Action Buttons */}
182
+ <div className="px-6 tablet:px-8 py-6 tablet:pt-6 tablet:pb-8">
183
+ <div className="gap-4 flex flex-col">
184
+ <Button variant="secondary" size={44} asChild className="w-full">
185
+ <a
186
+ href={subscribeUrl}
187
+ target="_blank"
188
+ rel="noopener noreferrer"
189
+ >
190
+ 訂閱電子報
191
+ </a>
192
+ </Button>
193
+ <Button variant="primary" size={44} asChild className="w-full">
194
+ <a href={donateUrl} target="_blank" rel="noopener noreferrer">
195
+ 贊助我們
196
+ </a>
197
+ </Button>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </>
203
+ )
204
+ }
205
+
206
+ export default Menu
@@ -0,0 +1,61 @@
1
+ 'use client'
2
+
3
+ import { ClearIcon, LoginIcon } from '../icons'
4
+ import { cn } from '../utils/cn'
5
+ import { HamburgerButton, LogoLink } from './shared-components'
6
+
7
+ type MobileHeaderProps = {
8
+ onHamburgerOverlayOpen: () => void
9
+ onCloseMenu: () => void
10
+ showCloseButtonWhenMenuOpen: boolean
11
+ isMenuOpen: boolean
12
+ }
13
+
14
+ export function MobileHeader({
15
+ onHamburgerOverlayOpen,
16
+ onCloseMenu,
17
+ showCloseButtonWhenMenuOpen,
18
+ isMenuOpen,
19
+ }: MobileHeaderProps) {
20
+ const showCloseButton = showCloseButtonWhenMenuOpen && isMenuOpen
21
+
22
+ return (
23
+ <>
24
+ <div className="h-(--mobile-header-height) desktop:hidden"></div>
25
+ <div
26
+ className={cn(
27
+ 'px-6 tablet:px-8 ease-in-out top-0 translate-y-0 pointer-events-auto fixed z-1002 w-full bg-neutral-white opacity-100 transition-all duration-300 tablet:z-1000 desktop:hidden'
28
+ )}
29
+ >
30
+ <div className="py-4 flex items-center justify-between">
31
+ <LogoLink />
32
+
33
+ <div className="gap-4 flex items-center">
34
+ {!showCloseButton && (
35
+ <a
36
+ href="/login"
37
+ className="w-8 h-8 flex items-center justify-center rounded-full text-red-400 transition-colors duration-200 hover:text-red-500"
38
+ aria-label="登入"
39
+ >
40
+ <LoginIcon />
41
+ </a>
42
+ )}
43
+ {showCloseButton ? (
44
+ <button
45
+ onClick={onCloseMenu}
46
+ className="w-8 h-8 flex cursor-pointer items-center justify-center rounded-full text-neutral-600 transition-colors duration-200 hover:text-neutral-800"
47
+ aria-label="關閉選單"
48
+ >
49
+ <ClearIcon />
50
+ </button>
51
+ ) : (
52
+ <HamburgerButton
53
+ onHamburgerOverlayOpen={onHamburgerOverlayOpen}
54
+ />
55
+ )}
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </>
60
+ )
61
+ }
@@ -0,0 +1,22 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ import { useHeaderContext } from './header-context'
6
+
7
+ type PostTitleSetterProps = {
8
+ postTitle?: string
9
+ }
10
+
11
+ function PostTitleSetter({ postTitle }: PostTitleSetterProps) {
12
+ const context = useHeaderContext()
13
+ const setPostTitle = context?.setPostTitle
14
+ useEffect(() => {
15
+ setPostTitle?.(postTitle)
16
+ return () => setPostTitle?.(undefined)
17
+ }, [postTitle, setPostTitle])
18
+
19
+ return null
20
+ }
21
+
22
+ export default PostTitleSetter
@@ -0,0 +1,326 @@
1
+ 'use client'
2
+ import { cva } from 'class-variance-authority'
3
+ import Image from 'next/image'
4
+ import { useEffect, useRef, useState } from 'react'
5
+
6
+ import { Button, Input } from '../components'
7
+ import {
8
+ ClearIcon,
9
+ HamburgerIcon,
10
+ HamburgerIconSmall,
11
+ SearchIcon,
12
+ SettingsIcon,
13
+ } from '../icons'
14
+ import type { MenuItem } from '../types'
15
+ import { cn } from '../utils/cn'
16
+
17
+ const searchFormVariants = cva(
18
+ 'ease-in-out h-full transition-all duration-300',
19
+ {
20
+ variants: {
21
+ mode: {
22
+ inline: 'h-11 w-full',
23
+ popover: 'top-0 -right-4 w-66 absolute overflow-hidden opacity-0',
24
+ },
25
+ isSearchOpen: {
26
+ true: '',
27
+ false: '',
28
+ },
29
+ },
30
+ compoundVariants: [
31
+ {
32
+ mode: 'popover',
33
+ isSearchOpen: true,
34
+ class: 'w-66 pointer-events-auto opacity-100',
35
+ },
36
+ {
37
+ mode: 'popover',
38
+ isSearchOpen: false,
39
+ class: 'pointer-events-none',
40
+ },
41
+ ],
42
+ }
43
+ )
44
+
45
+ const searchDropdownVariants = cva(
46
+ 'rounded-xl mt-2 w-66 ease-in-out h-0 p-0 z-50 bg-neutral-white opacity-0 transition-all duration-200',
47
+ {
48
+ variants: {
49
+ mode: {
50
+ inline: '',
51
+ popover: 'top-12 -right-4 shadow-custom p-4 absolute',
52
+ },
53
+ isSearchOpen: {
54
+ true: '',
55
+ false: '',
56
+ },
57
+ isFocused: {
58
+ true: '',
59
+ false: '',
60
+ },
61
+ },
62
+ compoundVariants: [
63
+ {
64
+ mode: 'popover',
65
+ isSearchOpen: true,
66
+ isFocused: true,
67
+ class: 'p-4 h-min opacity-100',
68
+ },
69
+ {
70
+ mode: 'popover',
71
+ isSearchOpen: false,
72
+ class: 'pointer-events-none',
73
+ },
74
+ {
75
+ mode: 'inline',
76
+ isFocused: true,
77
+ class:
78
+ 'translate-y-0 pt-6 mt-0 bg-neutral-transparent h-min w-full opacity-100',
79
+ },
80
+ {
81
+ mode: 'inline',
82
+ isFocused: false,
83
+ class: '-translate-y-3 pointer-events-none w-full',
84
+ },
85
+ ],
86
+ }
87
+ )
88
+
89
+ export function LogoLink({ compactMode = false }: { compactMode?: boolean }) {
90
+ return (
91
+ <a href="/" className="flex items-center" rel="home">
92
+ <Image
93
+ src="/assets/images/brand-icon.svg"
94
+ alt="少年報導者 The Reporter for Kids"
95
+ loading="eager"
96
+ className={cn(
97
+ 'h-5 tablet:h-6 desktop:h-8 ease-in-out w-auto transition-all duration-500',
98
+ compactMode && 'desktop:h-[26px]'
99
+ )}
100
+ width={293}
101
+ height={32}
102
+ />
103
+ </a>
104
+ )
105
+ }
106
+
107
+ type SearchInputSectionProps =
108
+ | {
109
+ mode: 'popover'
110
+ isSearchOpen: boolean
111
+ tags: string[]
112
+ searchPlaceholder: string
113
+ }
114
+ | {
115
+ mode: 'inline'
116
+ tags: string[]
117
+ searchPlaceholder: string
118
+ }
119
+
120
+ export function SearchInputSection(props: SearchInputSectionProps) {
121
+ const ref = useRef<HTMLInputElement>(null)
122
+ const [isFocused, setIsFocused] = useState(false)
123
+ const [searchValue, setSearchValue] = useState('')
124
+
125
+ const mode = props.mode
126
+ const isSearchOpen = mode === 'popover' && props.isSearchOpen
127
+ const tags = props.tags
128
+ const searchPlaceholder = props.searchPlaceholder
129
+
130
+ useEffect(() => {
131
+ if (mode === 'inline') {
132
+ return
133
+ }
134
+ if (isSearchOpen) {
135
+ ref.current?.focus()
136
+ setIsFocused(true)
137
+ document.body.classList.add('no-scroll')
138
+ return
139
+ }
140
+ setIsFocused(false)
141
+ document.body.classList.remove('no-scroll')
142
+ }, [mode, isSearchOpen])
143
+
144
+ return (
145
+ <div
146
+ onFocus={() => setIsFocused(true)}
147
+ onBlur={() => setIsFocused(false)}
148
+ className={mode === 'inline' ? 'relative w-full' : 'h-11'}
149
+ >
150
+ <form
151
+ role="search"
152
+ method="get"
153
+ action="/search"
154
+ className={searchFormVariants({
155
+ mode,
156
+ isSearchOpen: mode === 'popover' ? isSearchOpen : undefined,
157
+ })}
158
+ >
159
+ <Input
160
+ placeholder={searchPlaceholder}
161
+ name="q"
162
+ title="Search for..."
163
+ aria-label="Search for..."
164
+ className="w-[99%]"
165
+ inputRef={ref}
166
+ onChange={setSearchValue}
167
+ value={searchValue}
168
+ />
169
+ </form>
170
+ <div
171
+ className={searchDropdownVariants({
172
+ mode,
173
+ isSearchOpen: mode === 'popover' ? isSearchOpen : undefined,
174
+ isFocused,
175
+ })}
176
+ >
177
+ <h3 className="prose-p3 font-bold mb-3 text-neutral-700">熱門搜尋</h3>
178
+ <div className="gap-2.5 flex flex-wrap">
179
+ {tags.map((keyword) => (
180
+ <a
181
+ key={keyword}
182
+ className="px-3 py-1 prose-p2 font-bold cursor-pointer rounded-full bg-neutral-200 text-neutral-900 transition-colors duration-200 hover:bg-red-500 hover:text-neutral-white"
183
+ href={`/search?q=${encodeURIComponent(keyword)}`}
184
+ >
185
+ #{keyword}
186
+ </a>
187
+ ))}
188
+ </div>
189
+ </div>
190
+ </div>
191
+ )
192
+ }
193
+
194
+ export function ActionButtons({
195
+ hideCtaButtons = false,
196
+ tags,
197
+ searchPlaceholder,
198
+ subscribeUrl,
199
+ }: {
200
+ hideCtaButtons?: boolean
201
+ tags: string[]
202
+ searchPlaceholder: string
203
+ subscribeUrl: string
204
+ }) {
205
+ const [isSearchOpen, setIsSearchOpen] = useState(false)
206
+
207
+ const containerRef = useRef<HTMLDivElement>(null)
208
+ const buttonRef = useRef<HTMLButtonElement>(null)
209
+
210
+ useEffect(() => {
211
+ const handleClickOutside = (event: MouseEvent) => {
212
+ const containerElement = containerRef.current
213
+ const buttonElement = buttonRef.current
214
+ if (!containerElement || !buttonElement) return
215
+ if (
216
+ !containerElement.contains(event.target as Node) &&
217
+ !buttonElement.contains(event.target as Node)
218
+ ) {
219
+ setIsSearchOpen(false)
220
+ }
221
+ }
222
+
223
+ document.addEventListener('mousedown', handleClickOutside)
224
+ return () => {
225
+ document.removeEventListener('mousedown', handleClickOutside)
226
+ }
227
+ }, [])
228
+
229
+ return (
230
+ <div className="relative flex items-center">
231
+ <div className="mr-6 relative flex items-center" ref={containerRef}>
232
+ {/* CTA Buttons - Base layer */}
233
+ {!hideCtaButtons && !isSearchOpen && (
234
+ <div className="gap-4 flex items-center">
235
+ <Button variant="secondary" size={32} asChild>
236
+ <a href="/about#post">投稿</a>
237
+ </Button>
238
+ <Button variant="primary" size={32} asChild>
239
+ <a href={subscribeUrl} target="_blank" rel="noopener noreferrer">
240
+ 訂閱
241
+ </a>
242
+ </Button>
243
+ </div>
244
+ )}
245
+
246
+ <SearchInputSection
247
+ isSearchOpen={isSearchOpen}
248
+ mode="popover"
249
+ tags={tags}
250
+ searchPlaceholder={searchPlaceholder}
251
+ />
252
+ </div>
253
+
254
+ <button
255
+ className="w-8 h-8 mr-4 flex cursor-pointer items-center justify-center rounded-full text-neutral-600 transition-all duration-200 hover:text-neutral-800"
256
+ aria-label="搜尋"
257
+ onClick={() => setIsSearchOpen(!isSearchOpen)}
258
+ ref={buttonRef}
259
+ >
260
+ {isSearchOpen ? <ClearIcon /> : <SearchIcon />}
261
+ </button>
262
+ <button
263
+ className="w-8 h-8 flex cursor-pointer items-center justify-center rounded-full text-neutral-600 transition-all duration-200 hover:text-neutral-800"
264
+ aria-label="設定"
265
+ >
266
+ <SettingsIcon />
267
+ </button>
268
+ </div>
269
+ )
270
+ }
271
+
272
+ export function BottomNavigation({
273
+ onHamburgerOverlayOpen,
274
+ menuItems,
275
+ }: {
276
+ onHamburgerOverlayOpen: () => void
277
+ menuItems: MenuItem[]
278
+ }) {
279
+ return (
280
+ <div className="py-2 px-4 flex w-full items-center justify-between border-y border-neutral-border">
281
+ <HamburgerButton onHamburgerOverlayOpen={onHamburgerOverlayOpen} small />
282
+
283
+ {menuItems.reduce((acc, item, index) => {
284
+ return [
285
+ ...acc,
286
+ <div key={item.label} className="flex items-center">
287
+ <a
288
+ href={item.href}
289
+ className="py-1 prose-p1 font-bold! h-6 flex items-center text-neutral-900 transition-colors hover:text-red-400"
290
+ >
291
+ {item.label}
292
+ </a>
293
+ </div>,
294
+ ...(index < menuItems.length - 1
295
+ ? [
296
+ <div
297
+ key={`separator-${index}`}
298
+ className="h-4 mx-2 w-px bg-neutral-border"
299
+ />,
300
+ ]
301
+ : []),
302
+ ]
303
+ }, [] as React.ReactNode[])}
304
+ </div>
305
+ )
306
+ }
307
+
308
+ export function HamburgerButton({
309
+ onHamburgerOverlayOpen,
310
+ small = false,
311
+ }: {
312
+ onHamburgerOverlayOpen: () => void
313
+ small?: boolean
314
+ }) {
315
+ return (
316
+ <button
317
+ className={cn(
318
+ 'rounded-sm ease-in-out flex cursor-pointer items-center justify-center transition-all duration-300 hover:[&>svg>path:nth-child(1)]:fill-blue-500 hover:[&>svg>path:nth-child(2)]:fill-red-500 hover:[&>svg>path:nth-child(3)]:fill-yellow-500 hover:[&>svg>rect:nth-child(1)]:fill-blue-500 hover:[&>svg>rect:nth-child(2)]:fill-red-500 hover:[&>svg>rect:nth-child(3)]:fill-yellow-500',
319
+ small ? 'w-6 h-6' : 'w-8 h-8'
320
+ )}
321
+ onClick={onHamburgerOverlayOpen}
322
+ >
323
+ {small ? <HamburgerIconSmall /> : <HamburgerIcon />}
324
+ </button>
325
+ )
326
+ }
@@ -0,0 +1,3 @@
1
+ export { default as useIsAtTop } from './use-is-at-top'
2
+ export { default as useMediaQuery } from './use-media-query'
3
+ export { ScrollLevel, default as useScrollLevel } from './use-scroll-level'
@@ -0,0 +1,23 @@
1
+ 'use client'
2
+ import { useEffect, useState } from 'react'
3
+
4
+ const useIsAtTop = (threshold = 35) => {
5
+ const [isAtTop, setIsAtTop] = useState(true)
6
+
7
+ useEffect(() => {
8
+ const checkIsAtTop = () => {
9
+ setIsAtTop(window.scrollY <= threshold)
10
+ }
11
+
12
+ checkIsAtTop()
13
+ window.addEventListener('scroll', checkIsAtTop, { passive: true })
14
+
15
+ return () => {
16
+ window.removeEventListener('scroll', checkIsAtTop)
17
+ }
18
+ }, [threshold])
19
+
20
+ return isAtTop
21
+ }
22
+
23
+ export default useIsAtTop
@@ -0,0 +1,57 @@
1
+ 'use client'
2
+ import { useEffect, useLayoutEffect, useState } from 'react'
3
+
4
+ const useIsomorphicLayoutEffect =
5
+ typeof window !== 'undefined' ? useLayoutEffect : useEffect
6
+
7
+ type UseMediaQueryOptions = {
8
+ defaultValue?: boolean
9
+ initializeWithValue?: boolean
10
+ }
11
+
12
+ const IS_SERVER = typeof window === 'undefined'
13
+
14
+ function useMediaQuery(
15
+ query: string,
16
+ {
17
+ defaultValue = false,
18
+ initializeWithValue = true,
19
+ }: UseMediaQueryOptions = {}
20
+ ): boolean {
21
+ const getMatches = (query: string): boolean => {
22
+ if (IS_SERVER) {
23
+ return defaultValue
24
+ }
25
+ return window.matchMedia(query).matches
26
+ }
27
+
28
+ const [matches, setMatches] = useState<boolean>(() => {
29
+ if (initializeWithValue && !IS_SERVER) {
30
+ return getMatches(query)
31
+ }
32
+ return defaultValue
33
+ })
34
+
35
+ // Handles the change event of the media query.
36
+ function handleChange() {
37
+ setMatches(getMatches(query))
38
+ }
39
+
40
+ useIsomorphicLayoutEffect(() => {
41
+ const matchMedia = window.matchMedia(query)
42
+
43
+ // Triggered at the first client-side load and if query changes
44
+ handleChange()
45
+
46
+ // Use modern addEventListener/removeEventListener
47
+ matchMedia.addEventListener('change', handleChange)
48
+
49
+ return () => {
50
+ matchMedia.removeEventListener('change', handleChange)
51
+ }
52
+ }, [query])
53
+
54
+ return matches
55
+ }
56
+
57
+ export default useMediaQuery