@kids-reporter/routing-ui 0.1.0-alpha.4 → 0.1.0-alpha.6

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