@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.
- package/.prettierignore +17 -0
- package/babel.config.cjs +31 -0
- package/dist/components/button.d.ts +23 -0
- package/dist/components/button.d.ts.map +1 -0
- package/dist/components/button.js +82 -0
- package/dist/components/button.js.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +21 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/input.d.ts +20 -0
- package/dist/components/input.d.ts.map +1 -0
- package/dist/components/input.js +143 -0
- package/dist/components/input.js.map +1 -0
- package/dist/constants/default-values.d.ts +12 -0
- package/dist/constants/default-values.d.ts.map +1 -0
- package/dist/constants/default-values.js +148 -0
- package/dist/constants/default-values.js.map +1 -0
- package/dist/footer.d.ts +10 -0
- package/dist/footer.d.ts.map +1 -0
- package/dist/footer.js +144 -0
- package/dist/footer.js.map +1 -0
- package/dist/header/desktop-header.d.ts +14 -0
- package/dist/header/desktop-header.d.ts.map +1 -0
- package/dist/header/desktop-header.js +85 -0
- package/dist/header/desktop-header.js.map +1 -0
- package/dist/header/header-context.d.ts +16 -0
- package/dist/header/header-context.d.ts.map +1 -0
- package/dist/header/header-context.js +37 -0
- package/dist/header/header-context.js.map +1 -0
- package/dist/header/index.d.ts +12 -0
- package/dist/header/index.d.ts.map +1 -0
- package/dist/header/index.js +72 -0
- package/dist/header/index.js.map +1 -0
- package/dist/header/menu/header-menu-item-group.d.ts +7 -0
- package/dist/header/menu/header-menu-item-group.d.ts.map +1 -0
- package/dist/header/menu/header-menu-item-group.js +31 -0
- package/dist/header/menu/header-menu-item-group.js.map +1 -0
- package/dist/header/menu/header-menu-item.d.ts +9 -0
- package/dist/header/menu/header-menu-item.d.ts.map +1 -0
- package/dist/header/menu/header-menu-item.js +92 -0
- package/dist/header/menu/header-menu-item.js.map +1 -0
- package/dist/header/menu/index.d.ts +15 -0
- package/dist/header/menu/index.d.ts.map +1 -0
- package/dist/header/menu/index.js +170 -0
- package/dist/header/menu/index.js.map +1 -0
- package/dist/header/mobile-header.d.ts +9 -0
- package/dist/header/mobile-header.d.ts.map +1 -0
- package/dist/header/mobile-header.js +46 -0
- package/dist/header/mobile-header.js.map +1 -0
- package/dist/header/post-title-setter.d.ts +6 -0
- package/dist/header/post-title-setter.d.ts.map +1 -0
- package/dist/header/post-title-setter.js +22 -0
- package/dist/header/post-title-setter.js.map +1 -0
- package/dist/header/shared-components.d.ts +31 -0
- package/dist/header/shared-components.d.ts.map +1 -0
- package/dist/header/shared-components.js +256 -0
- package/dist/header/shared-components.js.map +1 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +36 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/use-is-at-top.d.ts +3 -0
- package/dist/hooks/use-is-at-top.d.ts.map +1 -0
- package/dist/hooks/use-is-at-top.js +26 -0
- package/dist/hooks/use-is-at-top.js.map +1 -0
- package/dist/hooks/use-media-query.d.ts +7 -0
- package/dist/hooks/use-media-query.d.ts.map +1 -0
- package/dist/hooks/use-media-query.js +47 -0
- package/dist/hooks/use-media-query.js.map +1 -0
- package/dist/hooks/use-scroll-level.d.ts +11 -0
- package/dist/hooks/use-scroll-level.d.ts.map +1 -0
- package/dist/hooks/use-scroll-level.js +53 -0
- package/dist/hooks/use-scroll-level.js.map +1 -0
- package/dist/icons/index.d.ts +17 -0
- package/dist/icons/index.d.ts.map +1 -0
- package/dist/icons/index.js +341 -0
- package/dist/icons/index.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +475 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/cn.d.ts +8 -0
- package/dist/utils/cn.d.ts.map +1 -0
- package/dist/utils/cn.js +27 -0
- package/dist/utils/cn.js.map +1 -0
- package/dist/utils/generate-social-media-config.d.ts +9 -0
- package/dist/utils/generate-social-media-config.d.ts.map +1 -0
- package/dist/utils/generate-social-media-config.js +55 -0
- package/dist/utils/generate-social-media-config.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +28 -0
- package/dist/utils/index.js.map +1 -0
- package/eslint.config.mjs +55 -0
- package/package.json +51 -0
- package/prettier.config.mjs +13 -0
- package/scripts/build.sh +18 -0
- package/src/components/button.tsx +108 -0
- package/src/components/index.tsx +2 -0
- package/src/components/input.tsx +171 -0
- package/src/constants/default-values.tsx +153 -0
- package/src/footer.tsx +151 -0
- package/src/header/desktop-header.tsx +128 -0
- package/src/header/header-context.tsx +56 -0
- package/src/header/index.tsx +96 -0
- package/src/header/menu/header-menu-item-group.tsx +37 -0
- package/src/header/menu/header-menu-item.tsx +132 -0
- package/src/header/menu/index.tsx +206 -0
- package/src/header/mobile-header.tsx +61 -0
- package/src/header/post-title-setter.tsx +22 -0
- package/src/header/shared-components.tsx +326 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-is-at-top.ts +23 -0
- package/src/hooks/use-media-query.ts +57 -0
- package/src/hooks/use-scroll-level.ts +52 -0
- package/src/icons/index.tsx +358 -0
- package/src/index.ts +9 -0
- package/src/styles.css +475 -0
- package/src/types/index.ts +10 -0
- package/src/utils/cn.ts +41 -0
- package/src/utils/generate-social-media-config.ts +75 -0
- package/src/utils/index.ts +2 -0
- 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,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
|