@coldsurf/ocean-road 1.13.2

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 (195) hide show
  1. package/dist/css/global.css +30 -0
  2. package/dist/index.d.ts +641 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +733 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/native.cjs +94 -0
  7. package/dist/native.cjs.map +1 -0
  8. package/dist/native.d.cts +304 -0
  9. package/dist/native.d.cts.map +1 -0
  10. package/dist/native.d.ts +304 -0
  11. package/dist/native.d.ts.map +1 -0
  12. package/dist/native.js +94 -0
  13. package/dist/native.js.map +1 -0
  14. package/dist/next.cjs +949 -0
  15. package/dist/next.cjs.map +1 -0
  16. package/dist/next.d.cts +270 -0
  17. package/dist/next.d.cts.map +1 -0
  18. package/dist/next.d.ts +270 -0
  19. package/dist/next.d.ts.map +1 -0
  20. package/dist/next.js +949 -0
  21. package/dist/next.js.map +1 -0
  22. package/native/index.d.ts +7 -0
  23. package/next/index.d.ts +7 -0
  24. package/package.json +126 -0
  25. package/src/GlobalStyle.tsx +111 -0
  26. package/src/base/badge/badge.tsx +50 -0
  27. package/src/base/badge/index.ts +1 -0
  28. package/src/base/button/button.styled.tsx +123 -0
  29. package/src/base/button/button.tsx +60 -0
  30. package/src/base/button/button.types.ts +20 -0
  31. package/src/base/button/button.utils.ts +36 -0
  32. package/src/base/button/index.tsx +2 -0
  33. package/src/base/checkbox/checkbox.styled.ts +52 -0
  34. package/src/base/checkbox/checkbox.tsx +26 -0
  35. package/src/base/checkbox/index.ts +1 -0
  36. package/src/base/icon-button/icon-button.styled.ts +8 -0
  37. package/src/base/icon-button/icon-button.tsx +15 -0
  38. package/src/base/icon-button/icon-button.types.ts +3 -0
  39. package/src/base/icon-button/index.ts +2 -0
  40. package/src/base/index.ts +11 -0
  41. package/src/base/label/index.ts +1 -0
  42. package/src/base/label/label.styled.ts +7 -0
  43. package/src/base/label/label.tsx +27 -0
  44. package/src/base/modal/index.ts +1 -0
  45. package/src/base/modal/modal.tsx +59 -0
  46. package/src/base/spinner/index.ts +2 -0
  47. package/src/base/spinner/spinner.styled.ts +25 -0
  48. package/src/base/spinner/spinner.tsx +36 -0
  49. package/src/base/spinner/spinner.types.ts +1 -0
  50. package/src/base/switch/index.ts +1 -0
  51. package/src/base/switch/switch.styled.tsx +49 -0
  52. package/src/base/switch/switch.tsx +29 -0
  53. package/src/base/text/index.ts +1 -0
  54. package/src/base/text/text.styled.ts +17 -0
  55. package/src/base/text/text.tsx +37 -0
  56. package/src/base/text-area/index.ts +2 -0
  57. package/src/base/text-area/text-area.styled.ts +16 -0
  58. package/src/base/text-area/text-area.tsx +29 -0
  59. package/src/base/text-area/text-area.types.ts +11 -0
  60. package/src/base/text-input/index.ts +2 -0
  61. package/src/base/text-input/text-input.styled.ts +40 -0
  62. package/src/base/text-input/text-input.tsx +59 -0
  63. package/src/base/text-input/text-input.types.ts +15 -0
  64. package/src/base/toast/index.ts +2 -0
  65. package/src/base/toast/toast.tsx +60 -0
  66. package/src/base/toast/toast.types.ts +5 -0
  67. package/src/constants.ts +1 -0
  68. package/src/contexts/ColorSchemeProvider.tsx +154 -0
  69. package/src/css/global.css +30 -0
  70. package/src/extensions/accordion/accordion.hooks.ts +11 -0
  71. package/src/extensions/accordion/accordion.tsx +80 -0
  72. package/src/extensions/accordion/index.ts +1 -0
  73. package/src/extensions/app-header/app-header.hooks.ts +94 -0
  74. package/src/extensions/app-header/app-header.tsx +31 -0
  75. package/src/extensions/app-header/app-header.types.ts +1 -0
  76. package/src/extensions/app-header/index.ts +8 -0
  77. package/src/extensions/app-logo/app-logo.tsx +40 -0
  78. package/src/extensions/app-logo/index.ts +1 -0
  79. package/src/extensions/app-store-button/app-store-button.tsx +64 -0
  80. package/src/extensions/app-store-button/index.ts +1 -0
  81. package/src/extensions/brand-icon/brand-icon.android.tsx +11 -0
  82. package/src/extensions/brand-icon/brand-icon.apple.tsx +11 -0
  83. package/src/extensions/brand-icon/brand-icon.google.tsx +11 -0
  84. package/src/extensions/brand-icon/brand-icon.tsx +22 -0
  85. package/src/extensions/brand-icon/index.ts +1 -0
  86. package/src/extensions/color-scheme-toggle/color-scheme-toggle.tsx +76 -0
  87. package/src/extensions/color-scheme-toggle/index.ts +1 -0
  88. package/src/extensions/dropdown/dropdown.menu-item.tsx +237 -0
  89. package/src/extensions/dropdown/dropdown.result-item.tsx +26 -0
  90. package/src/extensions/dropdown/dropdown.styled.tsx +48 -0
  91. package/src/extensions/dropdown/dropdown.trigger.tsx +72 -0
  92. package/src/extensions/dropdown/dropdown.tsx +222 -0
  93. package/src/extensions/dropdown/dropdown.types.ts +3 -0
  94. package/src/extensions/dropdown/dropdown.utils.ts +40 -0
  95. package/src/extensions/dropdown/index.ts +14 -0
  96. package/src/extensions/error-ui/index.ts +7 -0
  97. package/src/extensions/error-ui/network-error/index.ts +1 -0
  98. package/src/extensions/error-ui/network-error/network-error.styled.ts +16 -0
  99. package/src/extensions/error-ui/network-error/network-error.tsx +14 -0
  100. package/src/extensions/error-ui/unknown-error/index.ts +1 -0
  101. package/src/extensions/error-ui/unknown-error/unknown-error.styled.ts +16 -0
  102. package/src/extensions/error-ui/unknown-error/unknown-error.tsx +14 -0
  103. package/src/extensions/full-screen-modal/full-screen-modal.tsx +52 -0
  104. package/src/extensions/full-screen-modal/index.ts +1 -0
  105. package/src/extensions/grid-card-image/grid-card-image.tsx +11 -0
  106. package/src/extensions/grid-card-image/index.ts +1 -0
  107. package/src/extensions/grid-card-image-empty/grid-card-image-empty.tsx +11 -0
  108. package/src/extensions/grid-card-image-empty/index.ts +1 -0
  109. package/src/extensions/grid-card-item/grid-card-item.masonry.styled.tsx +95 -0
  110. package/src/extensions/grid-card-item/grid-card-item.masonry.tsx +63 -0
  111. package/src/extensions/grid-card-item/grid-card-item.styled.tsx +93 -0
  112. package/src/extensions/grid-card-item/grid-card-item.subscribe-btn-layout.tsx +30 -0
  113. package/src/extensions/grid-card-item/grid-card-item.tsx +81 -0
  114. package/src/extensions/grid-card-item/index.ts +2 -0
  115. package/src/extensions/grid-card-list/grid-card-list.masonry.styled.tsx +45 -0
  116. package/src/extensions/grid-card-list/grid-card-list.masonry.tsx +58 -0
  117. package/src/extensions/grid-card-list/grid-card-list.styled.tsx +40 -0
  118. package/src/extensions/grid-card-list/grid-card-list.tsx +59 -0
  119. package/src/extensions/grid-card-list/index.ts +2 -0
  120. package/src/extensions/grid-card-list-empty/grid-card-list-empty.tsx +38 -0
  121. package/src/extensions/grid-card-list-empty/index.ts +1 -0
  122. package/src/extensions/grid-card-list-load-more/grid-card-list-load-more.styled.tsx +15 -0
  123. package/src/extensions/grid-card-list-load-more/grid-card-list-load-more.tsx +43 -0
  124. package/src/extensions/grid-card-list-load-more/index.ts +1 -0
  125. package/src/extensions/index.ts +38 -0
  126. package/src/extensions/menu-item/index.ts +1 -0
  127. package/src/extensions/menu-item/menu-item.tsx +87 -0
  128. package/src/extensions/sns-icon/index.ts +1 -0
  129. package/src/extensions/sns-icon/sns-icon.facebook.tsx +11 -0
  130. package/src/extensions/sns-icon/sns-icon.instagram.tsx +11 -0
  131. package/src/extensions/sns-icon/sns-icon.tsx +24 -0
  132. package/src/extensions/sns-icon/sns-icon.x.tsx +11 -0
  133. package/src/extensions/sns-icon/sns-icon.youtube.tsx +11 -0
  134. package/src/index.ts +8 -0
  135. package/src/native/button/button.styled.tsx +99 -0
  136. package/src/native/button/button.tsx +42 -0
  137. package/src/native/button/index.ts +1 -0
  138. package/src/native/contexts/color-scheme-context/color-scheme-context.tsx +45 -0
  139. package/src/native/contexts/color-scheme-context/index.ts +1 -0
  140. package/src/native/contexts/index.ts +1 -0
  141. package/src/native/icon-button/icon-button.styled.ts +6 -0
  142. package/src/native/icon-button/icon-button.tsx +33 -0
  143. package/src/native/icon-button/icon-button.types.ts +14 -0
  144. package/src/native/icon-button/icon-button.utils.ts +114 -0
  145. package/src/native/icon-button/index.ts +1 -0
  146. package/src/native/index.ts +9 -0
  147. package/src/native/modal/index.ts +2 -0
  148. package/src/native/modal/modal.styled.ts +17 -0
  149. package/src/native/modal/modal.tsx +21 -0
  150. package/src/native/modal/modal.types.ts +8 -0
  151. package/src/native/profile-thumbnail/index.ts +1 -0
  152. package/src/native/profile-thumbnail/profile-thumbnail.tsx +91 -0
  153. package/src/native/spinner/index.ts +1 -0
  154. package/src/native/spinner/spinner.tsx +75 -0
  155. package/src/native/text/index.ts +2 -0
  156. package/src/native/text/text.tsx +51 -0
  157. package/src/native/text/text.types.ts +5 -0
  158. package/src/native/text-input/index.ts +2 -0
  159. package/src/native/text-input/text-input.tsx +72 -0
  160. package/src/native/text-input/text-input.types.ts +3 -0
  161. package/src/native/toast/index.ts +2 -0
  162. package/src/native/toast/toast.styled.ts +40 -0
  163. package/src/native/toast/toast.tsx +23 -0
  164. package/src/native/toast/toast.types.ts +10 -0
  165. package/src/next/app-footer/app-footer.tsx +250 -0
  166. package/src/next/app-footer/index.ts +1 -0
  167. package/src/next/app-header/app-header.fixed-header.tsx +83 -0
  168. package/src/next/app-header/app-header.full-screen-mobile-accordion-drawer.tsx +131 -0
  169. package/src/next/app-header/app-header.logo.tsx +50 -0
  170. package/src/next/app-header/app-header.modal-mobile-accordion-drawer.tsx +69 -0
  171. package/src/next/app-header/app-header.styled.ts +160 -0
  172. package/src/next/app-header/app-header.tsx +91 -0
  173. package/src/next/app-header/index.ts +13 -0
  174. package/src/next/global-link/global-link.store.ts +41 -0
  175. package/src/next/global-link/global-link.tsx +52 -0
  176. package/src/next/global-link/global-link.utils.ts +9 -0
  177. package/src/next/global-link/index.ts +3 -0
  178. package/src/next/grid-card-item/grid-card-item.masonry.tsx +23 -0
  179. package/src/next/grid-card-item/grid-card-item.tsx +23 -0
  180. package/src/next/grid-card-item/index.ts +2 -0
  181. package/src/next/index.ts +16 -0
  182. package/src/next/new-tab-link/index.ts +1 -0
  183. package/src/next/new-tab-link/new-tab-link.tsx +15 -0
  184. package/src/next/route-loading/index.ts +1 -0
  185. package/src/next/route-loading/route-loading.tsx +21 -0
  186. package/src/tokens/index.ts +2 -0
  187. package/src/tokens/tokens.ts +8 -0
  188. package/src/tokens/tokens.types.ts +7 -0
  189. package/src/utils/breakpoints.ts +9 -0
  190. package/src/utils/common-styles.ts +23 -0
  191. package/src/utils/index.ts +2 -0
  192. package/src/utils/media.ts +23 -0
  193. package/src/utils/use-prevent-scroll-effect.ts +19 -0
  194. package/src/utils/with-id.ts +3 -0
  195. package/src/utils/with-stop-propagation.ts +10 -0
@@ -0,0 +1,154 @@
1
+ import darkColorDesignTokens from '@coldsurfers/ocean-road-design-tokens/dist/json/color/variables-dark.json';
2
+ import lightColorDesignTokens from '@coldsurfers/ocean-road-design-tokens/dist/json/color/variables-light.json';
3
+ import {
4
+ type Context,
5
+ type PropsWithChildren,
6
+ createContext,
7
+ useCallback,
8
+ useContext,
9
+ useEffect,
10
+ useState,
11
+ } from 'react';
12
+ import { type DarkColorDesignTokens, type LightColorDesignTokens, colors } from '../tokens';
13
+
14
+ export type ColorScheme = 'light' | 'dark' | 'userPreference';
15
+
16
+ export interface Theme extends DarkColorDesignTokens, LightColorDesignTokens {
17
+ name: 'lightMode' | 'darkMode';
18
+ }
19
+
20
+ export const lightModeTheme: Theme = {
21
+ name: 'lightMode',
22
+ ...lightColorDesignTokens,
23
+ };
24
+ export const darkModeTheme: Theme = {
25
+ name: 'darkMode',
26
+ ...darkColorDesignTokens,
27
+ };
28
+
29
+ export const generateCssVar = (themeName: Theme['name']) => {
30
+ const theme = themeName === 'lightMode' ? lightModeTheme : darkModeTheme;
31
+ let styles = '';
32
+ Object.keys(theme).forEach((key) => {
33
+ styles += ` --${key}: ${theme[key as keyof Theme]};\n`;
34
+ });
35
+ return styles;
36
+ };
37
+
38
+ const cssVar = (name: string) => `var(--${name})`;
39
+
40
+ const darkColorKeys = Object.keys(darkColorDesignTokens);
41
+
42
+ export const themeVariables = darkColorKeys.reduce(
43
+ (prev, curr) => {
44
+ const next = prev;
45
+ next[curr as keyof DarkColorDesignTokens] = cssVar(curr);
46
+ return next;
47
+ },
48
+ {} as Record<keyof DarkColorDesignTokens, string>
49
+ );
50
+
51
+ export const themeToStyles = (theme: Theme) => {
52
+ let styles = '';
53
+ Object.keys(colors).forEach((key) => {
54
+ styles += ` --${key}: ${colors[key as keyof typeof colors]};\n`;
55
+ });
56
+ if (theme.name === 'darkMode') {
57
+ Object.keys(darkColorDesignTokens).forEach((key) => {
58
+ styles += ` --${key}: ${darkColorDesignTokens[key as keyof typeof darkColorDesignTokens]};\n`;
59
+ });
60
+ }
61
+ if (theme.name === 'lightMode') {
62
+ Object.keys(lightColorDesignTokens).forEach((key) => {
63
+ styles += ` --${key}: ${lightColorDesignTokens[key as keyof typeof lightColorDesignTokens]};\n`;
64
+ });
65
+ }
66
+
67
+ return styles;
68
+ };
69
+
70
+ type ThemeContextValue = {
71
+ theme: Theme;
72
+ setTheme: (theme: ColorScheme) => void;
73
+ };
74
+
75
+ const ThemeContext: Context<ThemeContextValue> = createContext<ThemeContextValue>({
76
+ theme: lightModeTheme,
77
+ setTheme: () => {},
78
+ });
79
+
80
+ const getTheme = (colorScheme?: ColorScheme) =>
81
+ colorScheme === 'dark' ||
82
+ (colorScheme === 'userPreference' &&
83
+ typeof window !== 'undefined' &&
84
+ window.matchMedia &&
85
+ window.matchMedia('(prefers-color-scheme: dark)').matches)
86
+ ? darkModeTheme
87
+ : lightModeTheme;
88
+
89
+ const sanitizeThemeScopeId = (id?: string) => {
90
+ if (!id) return undefined;
91
+ const safeId = id.replace(/[^A-Za-z0-9_-]/g, '');
92
+ return safeId || undefined;
93
+ };
94
+
95
+ const ColorSchemeProvider = ({
96
+ children,
97
+ colorScheme,
98
+ id,
99
+ }: PropsWithChildren<{ colorScheme: ColorScheme; id?: string }>) => {
100
+ const [theme, setTheme] = useState(getTheme(colorScheme));
101
+ const safeId = sanitizeThemeScopeId(id);
102
+ const className = safeId ? `__oceanRoadTheme${safeId}` : undefined;
103
+ const selector = className ? `.${className}` : ':root';
104
+
105
+ const handlePrefChange = useCallback((e: MediaQueryListEvent) => {
106
+ setTheme(getTheme(e.matches ? 'dark' : 'light'));
107
+ }, []);
108
+
109
+ useEffect(() => {
110
+ setTheme(getTheme(colorScheme));
111
+ if (colorScheme === 'userPreference' && window.matchMedia) {
112
+ window.matchMedia('(prefers-color-scheme: dark)').addListener(handlePrefChange);
113
+ return () =>
114
+ window.matchMedia('(prefers-color-scheme: dark)').removeListener(handlePrefChange);
115
+ }
116
+ return undefined;
117
+ }, [colorScheme, handlePrefChange]);
118
+
119
+ return (
120
+ <ThemeContext.Provider
121
+ value={{
122
+ theme: theme,
123
+ setTheme: (theme: ColorScheme) => {
124
+ setTheme(getTheme(theme));
125
+ },
126
+ }}
127
+ >
128
+ <style
129
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation>
130
+ dangerouslySetInnerHTML={{
131
+ __html:
132
+ colorScheme === 'userPreference'
133
+ ? `${selector} {
134
+ ${themeToStyles(lightModeTheme)}
135
+ }\n@media(prefers-color-scheme: dark) {
136
+ ${selector} {
137
+ ${themeToStyles(darkModeTheme)}
138
+ }
139
+ }`
140
+ : `${selector} {
141
+ ${themeToStyles(theme)} }`,
142
+ }}
143
+ />
144
+ <div className={className}>{children}</div>
145
+ </ThemeContext.Provider>
146
+ );
147
+ };
148
+
149
+ export default ColorSchemeProvider;
150
+
151
+ export const useColorScheme = () => {
152
+ const theme = useContext(ThemeContext);
153
+ return theme || lightModeTheme;
154
+ };
@@ -0,0 +1,30 @@
1
+ html,
2
+ body {
3
+ padding: 0;
4
+ margin: 0;
5
+ }
6
+
7
+ body {
8
+ background-color: white;
9
+ color: black;
10
+ }
11
+
12
+ @media (prefers-color-scheme: dark) {
13
+ body {
14
+ background-color: rgb(24, 24, 31);
15
+ color: rgb(238, 238, 238);
16
+ }
17
+ }
18
+
19
+ a {
20
+ color: #2563eb;
21
+ text-decoration: none;
22
+ }
23
+
24
+ * {
25
+ box-sizing: border-box;
26
+ }
27
+
28
+ h1 {
29
+ font-weight: 800;
30
+ }
@@ -0,0 +1,11 @@
1
+ import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
2
+
3
+ export const useAccordion = (): [string | null, Dispatch<SetStateAction<string | null>>] => {
4
+ const [accordionKey, setAccordionKey] = useState<string | null>(null);
5
+
6
+ useEffect(() => {
7
+ return () => setAccordionKey(null);
8
+ }, []);
9
+
10
+ return [accordionKey, setAccordionKey];
11
+ };
@@ -0,0 +1,80 @@
1
+ import styled from '@emotion/styled';
2
+ import type { ReactNode } from 'react';
3
+ import { useAccordion } from './accordion.hooks';
4
+
5
+ const StyledLi = styled.li`
6
+ margin: 20px 0;
7
+ `;
8
+
9
+ const StyledDropdownWrapper = styled.div`
10
+ margin-top: 0.5rem;
11
+ `;
12
+
13
+ type AccordionRendererProps<ItemT> = {
14
+ accordionKey: string | null;
15
+ item: ItemT;
16
+ renderTrigger: (item: ItemT) => ReactNode;
17
+ renderExpanded: ({ selectedItem }: { selectedItem: ItemT }) => ReactNode;
18
+ };
19
+
20
+ const AccordionRenderer = <ItemT extends { accordionKey: string }>({
21
+ accordionKey,
22
+ item,
23
+ renderTrigger,
24
+ renderExpanded,
25
+ }: AccordionRendererProps<ItemT>) => {
26
+ return (
27
+ <StyledLi>
28
+ {renderTrigger(item)}
29
+ {accordionKey === item.accordionKey && (
30
+ <StyledDropdownWrapper>
31
+ {renderExpanded({
32
+ selectedItem: item,
33
+ })}
34
+ </StyledDropdownWrapper>
35
+ )}
36
+ </StyledLi>
37
+ );
38
+ };
39
+
40
+ export type AccordionProps<ItemT> = {
41
+ data: ItemT[];
42
+ renderTrigger: (item: ItemT) => ReactNode;
43
+ renderExpanded: ({ selectedItem }: { selectedItem: ItemT }) => ReactNode;
44
+ customized?: ReactNode;
45
+ };
46
+
47
+ export const Accordion = <ItemT extends { accordionKey: string }>({
48
+ data,
49
+ renderTrigger,
50
+ renderExpanded,
51
+ customized,
52
+ }: AccordionProps<ItemT>) => {
53
+ const [accordionKey, setAccordionKey] = useAccordion();
54
+ return (
55
+ <>
56
+ {data.map((item) => {
57
+ return (
58
+ <AccordionRenderer
59
+ key={item.accordionKey}
60
+ accordionKey={accordionKey}
61
+ item={item}
62
+ renderTrigger={(item) => (
63
+ // biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
64
+ <div
65
+ key={item.accordionKey}
66
+ onClick={() =>
67
+ setAccordionKey((prev) => (prev === item.accordionKey ? null : item.accordionKey))
68
+ }
69
+ >
70
+ {renderTrigger(item)}
71
+ </div>
72
+ )}
73
+ renderExpanded={({ selectedItem }) => <>{renderExpanded({ selectedItem })}</>}
74
+ />
75
+ );
76
+ })}
77
+ {customized}
78
+ </>
79
+ );
80
+ };
@@ -0,0 +1 @@
1
+ export * from './accordion';
@@ -0,0 +1,94 @@
1
+ import { breakpoints } from '@/utils';
2
+ import { useEffect, useState, useSyncExternalStore } from 'react';
3
+ import type { AnimatedHeaderAnimation } from './app-header.types';
4
+
5
+ export function useHeaderScrollAnimation() {
6
+ const [animation, setAnimation] = useState<AnimatedHeaderAnimation>('show');
7
+
8
+ useEffect(() => {
9
+ let lastScrollTop = 0;
10
+ const onScroll = () => {
11
+ const currentScroll = window.pageYOffset || document.documentElement.scrollTop;
12
+ const diff = Math.abs(currentScroll - lastScrollTop);
13
+ if (diff >= 12) {
14
+ if (currentScroll > lastScrollTop) {
15
+ setAnimation('hide');
16
+ } else {
17
+ setAnimation('show');
18
+ }
19
+ }
20
+ lastScrollTop = currentScroll <= 0 ? 0 : currentScroll; // For Mobile or negative scrolling
21
+ };
22
+
23
+ window.addEventListener('scroll', onScroll);
24
+
25
+ return () => {
26
+ window.removeEventListener('scroll', onScroll);
27
+ };
28
+ }, []);
29
+
30
+ return { headerAnimation: animation };
31
+ }
32
+
33
+ type Listener = () => void;
34
+
35
+ let isOpen = false;
36
+ const listeners = new Set<Listener>();
37
+
38
+ const notify = () => {
39
+ listeners.forEach((listener) => listener());
40
+ };
41
+
42
+ export const mobileMenuStore = {
43
+ getSnapshot: () => isOpen,
44
+
45
+ subscribe: (listener: Listener) => {
46
+ listeners.add(listener);
47
+ return () => listeners.delete(listener);
48
+ },
49
+
50
+ open() {
51
+ if (!isOpen) {
52
+ isOpen = true;
53
+ notify();
54
+ }
55
+ },
56
+
57
+ close() {
58
+ if (isOpen) {
59
+ isOpen = false;
60
+ notify();
61
+ }
62
+ },
63
+ };
64
+
65
+ let initialized = false;
66
+
67
+ function initMobileMenuEffects() {
68
+ if (initialized || typeof window === 'undefined') return;
69
+ initialized = true;
70
+
71
+ const handleResize = () => {
72
+ if (window.innerWidth > breakpoints.large) {
73
+ mobileMenuStore.close();
74
+ }
75
+ };
76
+
77
+ window.addEventListener('resize', handleResize);
78
+ }
79
+
80
+ export function useIsMobileMenuOpen() {
81
+ // 졜초 1회만 effect μ΄ˆκΈ°ν™”
82
+ initMobileMenuEffects();
83
+ const isMobileMenuOpen = useSyncExternalStore(
84
+ mobileMenuStore.subscribe,
85
+ mobileMenuStore.getSnapshot,
86
+ () => false // SSR fallback
87
+ );
88
+
89
+ return {
90
+ isMobileMenuOpen,
91
+ openMobileMenu: mobileMenuStore.open,
92
+ closeMobileMenu: mobileMenuStore.close,
93
+ };
94
+ }
@@ -0,0 +1,31 @@
1
+ import styled from '@emotion/styled';
2
+ import { type PropsWithChildren, memo } from 'react';
3
+ import type { AnimatedHeaderAnimation } from './app-header.types';
4
+
5
+ const HeaderContainer = styled.header<{ $animation: AnimatedHeaderAnimation; $zIndex?: number }>`
6
+ position: fixed;
7
+ top: 0;
8
+ left: 0;
9
+ right: 0;
10
+ transition: all 0.3s ease-in-out;
11
+ transform: translateY(${({ $animation }) => ($animation === 'show' ? '0' : '-100%')});
12
+ z-index: ${({ $zIndex }) => $zIndex ?? 100};
13
+ `;
14
+
15
+ export type AnimatedHeaderProps = PropsWithChildren<{
16
+ animation: AnimatedHeaderAnimation;
17
+ className?: string;
18
+ zIndex?: number;
19
+ }>;
20
+
21
+ export const AnimatedHeader = memo(
22
+ ({ animation, children, className, zIndex }: AnimatedHeaderProps) => {
23
+ return (
24
+ <HeaderContainer $animation={animation} className={className} $zIndex={zIndex}>
25
+ {children}
26
+ </HeaderContainer>
27
+ );
28
+ }
29
+ );
30
+
31
+ AnimatedHeader.displayName = 'AppHeader.AnimatedHeader';
@@ -0,0 +1 @@
1
+ export type AnimatedHeaderAnimation = 'show' | 'hide';
@@ -0,0 +1,8 @@
1
+ import { AnimatedHeader } from './app-header';
2
+ import { useHeaderScrollAnimation, useIsMobileMenuOpen } from './app-header.hooks';
3
+
4
+ export const AppHeader = {
5
+ useHeaderScrollAnimation,
6
+ useIsMobileMenuOpen,
7
+ AnimatedHeader,
8
+ };
@@ -0,0 +1,40 @@
1
+ import styled from '@emotion/styled';
2
+ import { memo } from 'react';
3
+ import { match } from 'ts-pattern';
4
+ import appLogoChristmas from '../../../assets/app-logo-christmas.webp';
5
+ import appLogoTransparent from '../../../assets/app-logo-transparent.webp';
6
+ import appLogoWhiteBackground from '../../../assets/app-logo-white-background.webp';
7
+
8
+ const createDataUrl = (base64Encoded: string) => {
9
+ return `url(${base64Encoded})`;
10
+ };
11
+
12
+ type LogoTheme = 'christmas' | 'transparent' | 'white-background';
13
+
14
+ const StyledAppLogo = styled.div<{
15
+ $circle?: boolean;
16
+ $logoTheme: LogoTheme;
17
+ }>`
18
+
19
+ background-image: ${({ $logoTheme }) => {
20
+ return match($logoTheme)
21
+ .with('christmas', () => createDataUrl(appLogoChristmas))
22
+ .with('transparent', () => createDataUrl(appLogoTransparent))
23
+ .with('white-background', () => createDataUrl(appLogoWhiteBackground))
24
+ .otherwise(() => createDataUrl(appLogoWhiteBackground));
25
+ }};
26
+ background-size: cover;
27
+ background-position: 50%;;
28
+ border-radius: ${({ $circle }) => ($circle ? '50%' : '12px')};
29
+ `;
30
+
31
+ interface Props {
32
+ type?: 'round' | 'square';
33
+ logoTheme: LogoTheme;
34
+ }
35
+
36
+ export const AppLogo = memo(({ type = 'round', logoTheme, ...otherProps }: Props) => {
37
+ return <StyledAppLogo $circle={type === 'round'} $logoTheme={logoTheme} {...otherProps} />;
38
+ });
39
+
40
+ AppLogo.displayName = 'AppLogo';
@@ -0,0 +1 @@
1
+ export * from './app-logo';
@@ -0,0 +1,64 @@
1
+ import { Button, Text } from '@/base';
2
+ import { semantics } from '@/tokens';
3
+ import styled from '@emotion/styled';
4
+ import { memo } from 'react';
5
+ import { match } from 'ts-pattern';
6
+ import { BrandIcon } from '../brand-icon';
7
+
8
+ const StyledAppStoreButton = styled(Button)`
9
+ border-radius: 32px;
10
+ background-color: ${semantics.color.background[1]} !important;
11
+
12
+ display: flex;
13
+ flex-direction: row;
14
+ align-items: center;
15
+ justify-content: center;
16
+ gap: 0.5rem;
17
+ width: fit-content;
18
+ margin-bottom: 1.5rem;
19
+
20
+ padding: 0.5rem 1rem !important;
21
+ `;
22
+
23
+ const StyledAppStoreLogo = styled(BrandIcon)`
24
+ width: 1.25rem;
25
+ height: 1.25rem;
26
+ color: ${semantics.color.foreground[1]};
27
+ fill: ${semantics.color.foreground[1]};
28
+ `;
29
+
30
+ const StyledAppStoreText = styled(Text)`
31
+ font-size: 0.85rem;
32
+ font-weight: 600;
33
+ color: ${semantics.color.foreground[1]};
34
+ margin: unset;
35
+ `;
36
+
37
+ type Props = {
38
+ store: 'app-store' | 'google-play';
39
+ };
40
+
41
+ export const AppStoreButton = memo(({ store }: Props) => {
42
+ return (
43
+ <>
44
+ {match(store)
45
+ .with('app-store', () => {
46
+ return (
47
+ <StyledAppStoreButton variant="border">
48
+ <StyledAppStoreLogo brand="apple" />
49
+ <StyledAppStoreText as="p">iOS</StyledAppStoreText>
50
+ </StyledAppStoreButton>
51
+ );
52
+ })
53
+ .with('google-play', () => (
54
+ <StyledAppStoreButton variant="border">
55
+ <StyledAppStoreLogo brand="android" />
56
+ <StyledAppStoreText as="p">Android</StyledAppStoreText>
57
+ </StyledAppStoreButton>
58
+ ))
59
+ .exhaustive()}
60
+ </>
61
+ );
62
+ });
63
+
64
+ AppStoreButton.displayName = 'AppStoreButton';
@@ -0,0 +1 @@
1
+ export * from './app-store-button';
@@ -0,0 +1,11 @@
1
+ import * as React from 'react';
2
+ import { type Ref, type SVGProps, forwardRef, memo } from 'react';
3
+ const SvgComponent = (props: SVGProps<SVGSVGElement>, ref: Ref<SVGSVGElement>) => (
4
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ref={ref} {...props}>
5
+ <title>{'Android'}</title>
6
+ <path d="M18.44 5.559c-.676 1.166-1.353 2.331-2.028 3.498-.037-.016-.074-.029-.111-.043a12.098 12.098 0 0 0-8.68.033C7.537 8.897 5.868 6.026 5.6 5.56a1.145 1.145 0 0 0-.141-.19 1.104 1.104 0 0 0-1.768 1.298c1.947 3.37-.096-.216 1.948 3.36.017.03-.495.263-1.393 1.017C2.9 12.176.452 14.772 0 18.99h24a11.728 11.728 0 0 0-.746-3.068 12.1 12.1 0 0 0-2.74-4.184 12.105 12.105 0 0 0-2.131-1.687c.66-1.122 1.312-2.256 1.965-3.385a1.108 1.108 0 0 0-.008-1.12 1.1 1.1 0 0 0-.852-.532c-.522-.054-.939.313-1.049.545zm-.04 8.46c.395.593.324 1.331-.156 1.65-.48.32-1.188.1-1.582-.493-.394-.593-.324-1.33.156-1.65.473-.316 1.182-.11 1.582.494zm-11.193-.492c.48.32.55 1.058.156 1.65-.394.593-1.103.815-1.584.495-.48-.32-.55-1.058-.156-1.65.4-.603 1.109-.811 1.584-.495z" />
7
+ </svg>
8
+ );
9
+ const ForwardRef = forwardRef(SvgComponent);
10
+ const Memo = memo(ForwardRef);
11
+ export default Memo;
@@ -0,0 +1,11 @@
1
+ import * as React from 'react';
2
+ import { type Ref, type SVGProps, forwardRef, memo } from 'react';
3
+ const SvgComponent = (props: SVGProps<SVGSVGElement>, ref: Ref<SVGSVGElement>) => (
4
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ref={ref} {...props}>
5
+ <title>{'Apple'}</title>
6
+ <path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
7
+ </svg>
8
+ );
9
+ const ForwardRef = forwardRef(SvgComponent);
10
+ const Memo = memo(ForwardRef);
11
+ export default Memo;
@@ -0,0 +1,11 @@
1
+ import * as React from 'react';
2
+ import { type Ref, type SVGProps, forwardRef, memo } from 'react';
3
+ const SvgComponent = (props: SVGProps<SVGSVGElement>, ref: Ref<SVGSVGElement>) => (
4
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ref={ref} {...props}>
5
+ <title>{'Google'}</title>
6
+ <path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
7
+ </svg>
8
+ );
9
+ const ForwardRef = forwardRef(SvgComponent);
10
+ const Memo = memo(ForwardRef);
11
+ export default Memo;
@@ -0,0 +1,22 @@
1
+ import { type Ref, type SVGProps, memo } from 'react';
2
+ import { match } from 'ts-pattern';
3
+ import AndroidLogo from './brand-icon.android';
4
+ import AppleLogo from './brand-icon.apple';
5
+ import GoogleLogo from './brand-icon.google';
6
+
7
+ interface Props extends SVGProps<SVGSVGElement> {
8
+ brand: 'apple' | 'google' | 'android';
9
+ ref?: Ref<SVGSVGElement>;
10
+ }
11
+
12
+ export const BrandIcon = memo(({ brand, ...svgProps }: Props) => {
13
+ const Component = match(brand)
14
+ .with('apple', () => <AppleLogo {...svgProps} />)
15
+ .with('google', () => <GoogleLogo {...svgProps} />)
16
+ .with('android', () => <AndroidLogo {...svgProps} />)
17
+ .exhaustive();
18
+
19
+ return Component;
20
+ });
21
+
22
+ BrandIcon.displayName = 'BrandIcon';
@@ -0,0 +1 @@
1
+ export * from './brand-icon';
@@ -0,0 +1,76 @@
1
+ import { Button, Text } from '@/base';
2
+ import { useColorScheme } from '@/contexts/ColorSchemeProvider';
3
+ import styled from '@emotion/styled';
4
+ import { memo, useCallback, useEffect } from 'react';
5
+
6
+ const DarkLabelText = styled(Text)`
7
+ display: block;
8
+ html.dark & {
9
+ display: none;
10
+ }
11
+ `;
12
+
13
+ const DarkLabel = () => {
14
+ return <DarkLabelText>β˜€οΈ</DarkLabelText>;
15
+ };
16
+
17
+ const LightLabelText = styled(Text)`
18
+ display: none;
19
+ html.dark & {
20
+ display: block;
21
+ }
22
+ `;
23
+
24
+ const LightLabel = () => {
25
+ return <LightLabelText>πŸŒ•</LightLabelText>;
26
+ };
27
+
28
+ type Props = {
29
+ onToggle?: (params: { setTheme: ReturnType<typeof useColorScheme>['setTheme'] }) => void;
30
+ };
31
+
32
+ export const ColorSchemeToggle = memo(({ onToggle }: Props) => {
33
+ const { setTheme } = useColorScheme();
34
+
35
+ const handleToggle = useCallback(() => {
36
+ onToggle?.({
37
+ setTheme: (theme) => {
38
+ window.__setPreferredTheme(theme);
39
+ setTheme(theme);
40
+ },
41
+ });
42
+ }, [onToggle, setTheme]);
43
+
44
+ useEffect(() => {
45
+ const darkModeMedia = window.matchMedia('(prefers-color-scheme: dark)');
46
+ function handleThemeChange(e: MediaQueryListEvent) {
47
+ // μ΄ˆκΈ°μ— μœ μ €κ°€ λΈŒλΌμš°μ €λ₯Ό 톡해 μ§„μž… ν›„, 닀크λͺ¨λ“œ/라이트λͺ¨λ“œ 변경이 μžˆμ„μ‹œμ—λ§Œ ocean road theme 변경을 μœ„ν•΄ μ‹€ν–‰
48
+ if (e.matches) {
49
+ // console.log('πŸŒ™ μ‚¬μš©μž 닀크λͺ¨λ“œλ‘œ λ³€κ²½');
50
+ setTheme('dark');
51
+ } else {
52
+ // console.log('β˜€οΈ μ‚¬μš©μž 라이트λͺ¨λ“œλ‘œ λ³€κ²½');
53
+ setTheme('light');
54
+ }
55
+ }
56
+ // λ³€ν™” 감지 λ¦¬μŠ€λ„ˆ 등둝 (졜초 κ°μ§€λŠ” ν•˜μ§€ μ•ŠμŒ)
57
+ darkModeMedia.addEventListener('change', handleThemeChange);
58
+
59
+ return () => {
60
+ darkModeMedia.removeEventListener('change', handleThemeChange);
61
+ };
62
+ }, [setTheme]);
63
+
64
+ return (
65
+ <Button
66
+ onClick={handleToggle}
67
+ variant="transparent"
68
+ style={{ marginLeft: 'auto', fontSize: 20 }}
69
+ >
70
+ <DarkLabel />
71
+ <LightLabel />
72
+ </Button>
73
+ );
74
+ });
75
+
76
+ ColorSchemeToggle.displayName = 'ColorSchemeToggle';
@@ -0,0 +1 @@
1
+ export * from './color-scheme-toggle';