merge: 게시판관리와 hub(디자인시스템·관리자 회원 실API) 통합
충돌 2건 해결: - lib/http/backend-fetch.ts: hub 버전 채택. hub가 이미 PUT/DELETE·form· multipart를 갖췄고 data:null 성공을 canHaveNullData로 다루므로, 같은 목적으로 추가했던 backendCommand를 걷어내고 게시판 Repository를 그 규약에 맞췄다. 파일 업로드용 timeoutMs만 BackendRequestInit에 추가로 남겼다. - admin-sidebar.tsx: 꾸미기아이템관리(hub)와 게시판관리(고객센터)를 함께 둔다. Co-Authored-By: Claude Opus 5
@30d977e09ff598deb72a9804c341beeace5f0cb3
+++ @fox/README.md
... | ... | @@ -0,0 +1,236 @@ |
| 1 | +# @fox — 포터블 디자인 시스템 | |
| 2 | + | |
| 3 | +프로젝트에 종속되지 않는 디자인 시스템. **이 폴더를 통째로 복사하고 설정 몇 줄을 추가하면** | |
| 4 | +다른 프로젝트에서 그대로 동작합니다. | |
| 5 | + | |
| 6 | +``` | |
| 7 | +@fox/ | |
| 8 | + styles/ SCSS — 여기가 디자인의 전부입니다 | |
| 9 | + index.scss 토큰 진입점 (앱이 한 번 @use) | |
| 10 | + components.scss 공용 컴포넌트 스타일 묶음 진입점 | |
| 11 | + _fox-*.scss 컴포넌트별 스타일 (개별 @use 가능) | |
| 12 | + abstracts.scss 저작 진입점 — 함수·믹스인 (CSS 출력 0) | |
| 13 | + tokens/ 토큰 map = 단일 진실 공급원 (자동 생성) | |
| 14 | + _root.scss map → --fox-* 커스텀 프로퍼티 | |
| 15 | + _functions.scss fox.color() 등 토큰 접근 + 이름 검증 | |
| 16 | + _mixins.scss fox.pc / fox.mobile | |
| 17 | + core/ | |
| 18 | + components/ React 래퍼 (선택 — 아래 "React 없이 쓰기" 참고) | |
| 19 | + utils/ cx() 등 의존성 없는 유틸 | |
| 20 | + dev-test/ 개발 전용 테스트·감사 화면 | |
| 21 | + tools/build-tokens.py Figma JSON → SCSS 변환기 | |
| 22 | +``` | |
| 23 | + | |
| 24 | +## 이식하기 | |
| 25 | + | |
| 26 | +1. `@fox/` 폴더를 대상 프로젝트에 복사합니다. | |
| 27 | + | |
| 28 | +2. `sass`를 설치합니다. | |
| 29 | + | |
| 30 | + ```bash | |
| 31 | + npm install --save-dev sass | |
| 32 | + ``` | |
| 33 | + | |
| 34 | +3. **SCSS 로드 경로**를 설정합니다. Next.js면 `next.config.ts`: | |
| 35 | + | |
| 36 | + ```ts | |
| 37 | + sassOptions: { loadPaths: [path.join(process.cwd())] } | |
| 38 | + ``` | |
| 39 | + | |
| 40 | + 다른 번들러면 그 도구의 Sass 옵션(`includePaths` / `loadPaths`)에 프로젝트 루트를 | |
| 41 | + 넣으면 됩니다. 이게 있어야 `@use "@fox/styles/..."` 같은 절대 표기가 해석되고, 없으면 | |
| 42 | + 컴포넌트마다 `../../../` 상대 경로를 써야 해서 폴더 복사가 불가능해집니다. | |
| 43 | + | |
| 44 | +4. 글로벌 스타일에서 진입점을 불러옵니다. | |
| 45 | + | |
| 46 | + ```scss | |
| 47 | + @use "@fox/styles"; // 토큰 | |
| 48 | + @use "@fox/styles/components"; // 공용 컴포넌트 스타일 (전부) | |
| 49 | + ``` | |
| 50 | + | |
| 51 | + 골라 쓰려면 묶음 대신 개별 파티셜을 가져옵니다 — 안 쓰는 CSS가 번들에 실리지 않습니다. | |
| 52 | + 둘을 같이 써도 Sass가 모듈을 한 번만 로드해 중복되지 않습니다. | |
| 53 | + | |
| 54 | + ```scss | |
| 55 | + @use "@fox/styles/fox-button"; | |
| 56 | + ``` | |
| 57 | + | |
| 58 | +5. (TypeScript 프로젝트에서 React 래퍼도 쓸 경우) 경로 별칭을 추가합니다. | |
| 59 | + | |
| 60 | + ```json | |
| 61 | + { "compilerOptions": { "paths": { "@fox/*": ["./@fox/*"] } } } | |
| 62 | + ``` | |
| 63 | + | |
| 64 | +6. (선택) 폰트를 주입합니다 — 아래 "호스트 앱과의 계약" 참고. | |
| 65 | + | |
| 66 | +## React 없이 쓰기 | |
| 67 | + | |
| 68 | +**디자인 레이어는 프레임워크에 의존하지 않습니다.** 토큰도 컴포넌트 스타일도 순수 SCSS이고, | |
| 69 | +클래스명이 `fox-` 접두사 + BEM이라 전역에 풀어도 충돌하지 않습니다. Vue·Svelte·서버 | |
| 70 | +템플릿·순수 HTML 어디서든 위 2~4번만 하고 마크업 계약을 지키면 같은 결과가 나옵니다. | |
| 71 | + | |
| 72 | +각 컴포넌트의 **마크업 계약은 해당 스타일 파일 상단 주석**에 있습니다. 버튼 네 종류는 이렇습니다. | |
| 73 | + | |
| 74 | +```html | |
| 75 | +<button class="fox-button fox-button--primary fox-button--md"> | |
| 76 | + <span class="fox-button__icon"><!-- svg (선택) --></span> | |
| 77 | + <span class="fox-button__label">버튼</span> | |
| 78 | + <span class="fox-button__icon"><!-- svg (선택) --></span> | |
| 79 | +</button> | |
| 80 | + | |
| 81 | +<button class="fox-text-button fox-text-button--primary fox-text-button--md"> | |
| 82 | + <span class="fox-text-button__label">텍스트 버튼</span> | |
| 83 | +</button> | |
| 84 | + | |
| 85 | +<a href="…" class="fox-link-button fox-link-button--primary fox-link-button--md"> | |
| 86 | + <span class="fox-link-button__label">링크</span> | |
| 87 | + <span class="fox-link-button__icon"><!-- svg (선택, 항상 라벨 뒤) --></span> | |
| 88 | +</a> | |
| 89 | + | |
| 90 | +<button class="fox-icon-button fox-icon-button--ghost fox-icon-button--md" aria-label="공유"> | |
| 91 | + <span class="fox-icon-button__icon"><!-- svg --></span> | |
| 92 | +</button> | |
| 93 | + | |
| 94 | +<div class="fox-button-group fox-button-group--horizontal fox-button-group--start"> | |
| 95 | + <button class="fox-button fox-button--default fox-button--md">취소</button> | |
| 96 | + <button class="fox-button fox-button--primary fox-button--md">확인</button> | |
| 97 | +</div> | |
| 98 | + | |
| 99 | +<div class="fox-button-panel fox-button-panel--offset"> | |
| 100 | + <div class="fox-button-group fox-button-group--horizontal fox-button-group--start">…</div> | |
| 101 | + <div class="fox-button-group fox-button-group--horizontal fox-button-group--end">…</div> | |
| 102 | +</div> | |
| 103 | + | |
| 104 | +<div class="fox-input fox-input--md"> | |
| 105 | + <label class="fox-input__label" for="name">레이블</label> | |
| 106 | + <div class="fox-input__box"> | |
| 107 | + <input class="fox-input__field" id="name" placeholder="내용을 입력하세요."> | |
| 108 | + <span class="fox-input__icon"><!-- svg (선택) --></span> | |
| 109 | + </div> | |
| 110 | + <p class="fox-input__message"><!-- 헬퍼 (선택) --></p> | |
| 111 | +</div> | |
| 112 | + | |
| 113 | +<div class="fox-phone-number" role="group" aria-labelledby="phone-label"> | |
| 114 | + <span class="fox-phone-number__label" id="phone-label">전화번호</span> | |
| 115 | + <div class="fox-phone-number__box"> <!-- combine --> | |
| 116 | + <input class="fox-phone-number__segment" aria-label="전화번호 앞자리"> | |
| 117 | + <span class="fox-phone-number__separator">-</span> | |
| 118 | + <input class="fox-phone-number__segment" aria-label="전화번호 가운데자리"> | |
| 119 | + <span class="fox-phone-number__separator">-</span> | |
| 120 | + <input class="fox-phone-number__segment" aria-label="전화번호 뒷자리"> | |
| 121 | + </div> | |
| 122 | +</div> | |
| 123 | + | |
| 124 | +<div class="fox-text-area"> | |
| 125 | + <label class="fox-text-area__label" for="memo">레이블</label> | |
| 126 | + <div class="fox-text-area__box"> | |
| 127 | + <textarea class="fox-text-area__field" id="memo" rows="5" placeholder="내용을 입력하세요."></textarea> | |
| 128 | + </div> | |
| 129 | + <div class="fox-text-area__footer"> | |
| 130 | + <p class="fox-text-area__message"><!-- 헬퍼 (선택) --></p> | |
| 131 | + <p class="fox-text-area__counter"> | |
| 132 | + <span class="fox-text-area__counter-current">0</span>/100 | |
| 133 | + </p> | |
| 134 | + </div> | |
| 135 | +</div> | |
| 136 | +``` | |
| 137 | + | |
| 138 | +- 비활성은 네이티브 `disabled` 속성으로 표현합니다(별도 클래스 없음). 앵커에는 `disabled`가 | |
| 139 | + 없으므로 `aria-disabled="true"`를 쓰고 `href`를 뺍니다. | |
| 140 | +- 로딩은 앞 아이콘 자리에 `fox-button__spinner`를 두고 `disabled`를 함께 겁니다. | |
| 141 | +- 토글은 `fox-icon-button`에 `aria-pressed="true|false"`를 더합니다. | |
| 142 | +- 버튼 묶음은 간격·방향·정렬만 담당합니다. 자식 버튼의 크기 클래스를 그룹 크기로 맞추는 건 | |
| 143 | + 작성자 몫입니다(React 래퍼는 `size` prop으로 자동화합니다). 세로(`--vertical`)는 자식이 | |
| 144 | + 폭을 채웁니다. | |
| 145 | +- 버튼 패널은 그룹이 여럿이면 양끝으로, 하나면 가운데로 둡니다 — 자식 수는 CSS `:only-child`가 | |
| 146 | + 판단하므로 따로 표시할 게 없습니다. 패널 안에서는 그룹 간격이 넓어지고 버튼에 최소 폭이 | |
| 147 | + 붙으며, 모바일에서는 패널·그룹이 모두 세로로 쌓입니다. | |
| 148 | +- 입력과 여러 줄 입력의 상태는 기본적으로 브라우저가 판단합니다 — 포커스는 `:focus-within`, | |
| 149 | + 비활성·읽기전용은 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에 | |
| 150 | + `data-state`를 줍니다 (`default` `focused` `completed` `error` `disabled` `view`). | |
| 151 | +- 여러 줄 입력에는 크기 축이 없습니다. 높이는 `rows`가 정하고 시안은 5줄입니다. | |
| 152 | +- 이메일은 값이 `아이디@도메인`이 합쳐진 문자열 하나입니다. 도메인을 드롭다운에서 고르면 | |
| 153 | + 옆 칸에 그 값이 채워지고 읽기 전용이 되며, `직접입력`을 고르면 풀립니다. 확인 버튼은 | |
| 154 | + `onVerify`를 넘겼을 때만 생깁니다. | |
| 155 | +- 주소는 우편번호와 주소가 검색 결과로만 채워지는 읽기 전용이고 상세주소만 입력합니다. | |
| 156 | + 어떤 우편번호 서비스를 쓸지는 `@fox`가 모릅니다 — 호출부가 `onSearch`에서 결과를 | |
| 157 | + `FoxAddressSearchResult`(`zipCode`·`address`·선택 `detail`)로 돌려주고, 취소는 `null`입니다. | |
| 158 | +- 휴대폰 인증은 보내기·확인을 호출부가 `Promise`로 넘깁니다 — resolve면 성공, reject면 | |
| 159 | + 실패이고 결과는 이벤트로 올라옵니다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고, | |
| 160 | + 인증에 성공하면 전부 잠깁니다. 남은 시간은 `remainingSeconds`를 주면 그 값을, 없으면 | |
| 161 | + `expiresIn`부터 컴포넌트가 셉니다. | |
| 162 | +- 전화번호는 화면만 세 칸이고 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지 | |
| 163 | + 않습니다. `unit`은 앞자리가 드롭다운이라 조각을 select·input으로 조립하고, `combine`은 | |
| 164 | + 상자 하나에 세 칸을 둡니다. | |
| 165 | +- 파일 첨부는 세 모드(`default`·`area`·`image`)를 한 컴포넌트로 둡니다. 업로드는 하지 | |
| 166 | + 않고 고른 원본 `File`만 넘기며, 진행률·서버 오류를 목록에 어떻게 비출지는 `files`로 | |
| 167 | + 정합니다. `area`는 끌어다 놓기가 항상 켜져 있고 `image`에는 목록이 없습니다. | |
| 168 | +- 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용됩니다. 크기는 슬롯이 정합니다. | |
| 169 | + | |
| 170 | +`core/`의 React 컴포넌트는 이 클래스를 조립해주는 **얇은 래퍼**일 뿐입니다 — 쓰지 않아도 | |
| 171 | +디자인은 그대로 얻습니다. | |
| 172 | + | |
| 173 | +토큰만 쓰는 것도 물론 됩니다. | |
| 174 | + | |
| 175 | +```scss | |
| 176 | +@use "@fox/styles/abstracts" as fox; | |
| 177 | + | |
| 178 | +.whatever { | |
| 179 | + padding: fox.padding(6); | |
| 180 | + color: fox.color(font-neutral-default); | |
| 181 | + @include fox.pc { padding: fox.padding(8); } | |
| 182 | +} | |
| 183 | +``` | |
| 184 | + | |
| 185 | +## 핵심 설계 | |
| 186 | + | |
| 187 | +**토큰의 SSOT는 `tokens/`의 SCSS map 하나입니다.** 여기서 두 가지가 함께 파생됩니다 — | |
| 188 | +`_root.scss`가 만드는 `--fox-*` 커스텀 프로퍼티와, `_functions.scss`가 검증에 쓰는 유효 | |
| 189 | +이름 목록. 값과 이름이 한 곳에만 있으므로 둘이 어긋날 수 없습니다. | |
| 190 | + | |
| 191 | +**토큰은 Figma에서 자동 생성됩니다. 손으로 고치지 않습니다.** Figma 변수를 재수출한 뒤 | |
| 192 | +변환기를 다시 돌리면 됩니다. 생성물이 재현 가능해서 재실행 diff가 곧 Figma 변경분입니다. | |
| 193 | + | |
| 194 | +```bash | |
| 195 | +FOX_TOKENS_SRC=<export 폴더> python3 @fox/tools/build-tokens.py | |
| 196 | +``` | |
| 197 | + | |
| 198 | +**규율을 컴파일러가 강제합니다.** 화면 코드는 `color: #256EF4`를 쓸 수 없고 | |
| 199 | +`fox.color(...)`로 토큰을 지목해야 합니다. 없는 이름은 빌드가 실패하며 사용 가능한 목록을 | |
| 200 | +함께 출력합니다. | |
| 201 | + | |
| 202 | +``` | |
| 203 | +Error: [@fox] 알 수 없는 color 토큰: `primry` — 사용 가능한 값: background-default, ... | |
| 204 | +``` | |
| 205 | + | |
| 206 | +**1rem = 10px입니다.** `_root.scss`의 `html { font-size: 62.5% }`가 근거입니다. | |
| 207 | + | |
| 208 | +> ⚠️ **미디어 쿼리 안의 `rem`은 예외입니다.** 미디어 쿼리는 `html`의 `font-size` 영향을 | |
| 209 | +> 받지 않습니다. 그래서 브레이크포인트는 `768px`로 두었고, 미디어 쿼리는 `fox.pc` / | |
| 210 | +> `fox.mobile` 믹스인으로만 씁니다 — 조건부는 `var()`를 해석하지 못합니다. | |
| 211 | + | |
| 212 | +**라이트/다크는 값이 한 곳에만 존재합니다.** `light-dark(라이트, 다크)`로 한 줄에 두 값을 | |
| 213 | +쓰고 전환은 `color-scheme`이 담당합니다. 다크 전용 오버라이드 블록이 없어 테마 불일치가 | |
| 214 | +구조적으로 불가능합니다. `<html data-theme="light"|"dark">`로 수동 선택을 덮어쓸 수 있고, | |
| 215 | +속성이 없으면 OS 설정을 따릅니다. | |
| 216 | + | |
| 217 | +> `light-dark()` 자체는 Chrome 123 / Safari 17.5 / Firefox 120 이상이 필요하지만, | |
| 218 | +> Lightning CSS(Next.js Turbopack)가 커스텀 프로퍼티 조합으로 폴리필해 출력하므로 구형 | |
| 219 | +> 브라우저에서도 동작합니다. browserslist 타깃을 좁히거나 다른 번들러로 옮길 때 재확인하세요. | |
| 220 | + | |
| 221 | +## 호스트 앱과의 계약 | |
| 222 | + | |
| 223 | +`@fox`가 소유하지 않고 앱에서 받는 값입니다. | |
| 224 | + | |
| 225 | +| CSS 변수 | 용도 | 미주입 시 | | |
| 226 | +| --- | --- | --- | | |
| 227 | +| `--app-font-sans` | 기본 서체 | `system-ui`로 폴백 | | |
| 228 | +| `--app-font-mono` | 고정폭 서체 | `ui-monospace`로 폴백 | | |
| 229 | + | |
| 230 | +폰트 파일은 앱이 소유합니다. `@fox`는 변수를 참조만 하므로, 복사해 간 프로젝트는 그 | |
| 231 | +프로젝트의 폰트를 그대로 씁니다. | |
| 232 | + | |
| 233 | +## 새 컴포넌트 추가 | |
| 234 | + | |
| 235 | +`core/components/README.md`의 작성 규약을 따릅니다. 요점은 스타일을 `styles/_<name>.scss`에 | |
| 236 | +두고 `fox-` 접두사 + BEM으로 이름 짓는 것 — 그래야 React 밖에서도 쓸 수 있습니다. |
+++ @fox/core/components/README.md
... | ... | @@ -0,0 +1,91 @@ |
| 1 | +# 컴포넌트 작성 규약 | |
| 2 | + | |
| 3 | +## 파일 배치 | |
| 4 | + | |
| 5 | +컴포넌트 하나당 폴더 하나. **스타일은 여기에 두지 않는다** — `@fox/styles/_<name>.scss`가 소유한다. | |
| 6 | + | |
| 7 | +``` | |
| 8 | +core/components/ | |
| 9 | + fox-button/ | |
| 10 | + fox-button.tsx 컴포넌트 (React) | |
| 11 | + index.ts export { FoxButton } from "./fox-button"; | |
| 12 | + index.ts 배럴 — export * from "./fox-button"; | |
| 13 | + | |
| 14 | +styles/ | |
| 15 | + _fox-button.scss 모양 규칙 (프레임워크 무관) | |
| 16 | + components.scss 전부 묶음 진입점 — 새 컴포넌트를 여기에 @use로 추가 | |
| 17 | +``` | |
| 18 | + | |
| 19 | +**스타일과 컴포넌트를 갈라 둔 이유**는 디자인이 React에 묶이면 안 되기 때문이다. SCSS만 | |
| 20 | +쓰는 프로젝트(Vue·Svelte·서버 템플릿 등)도 `@use "@fox/styles/fox-button"` 하나로 같은 | |
| 21 | +버튼을 얻어야 한다. | |
| 22 | + | |
| 23 | +## 작성 규칙 | |
| 24 | + | |
| 25 | +**1. 클래스명은 `fox-` 접두사 + BEM.** | |
| 26 | + | |
| 27 | +``` | |
| 28 | +.fox-button 블록 | |
| 29 | +.fox-button__label 엘리먼트 | |
| 30 | +.fox-button--primary 모디파이어 | |
| 31 | +``` | |
| 32 | + | |
| 33 | +CSS Modules를 쓰지 않는다. 이름이 전역 유일해서 스코프가 필요 없고, 스코프가 없어야 | |
| 34 | +React 밖에서도 같은 이름으로 쓸 수 있다. **충돌 방지는 접두사 규율이 담당한다.** | |
| 35 | + | |
| 36 | +**2. 모양 값은 전부 `fox.*()` 토큰으로 지목한다.** | |
| 37 | + | |
| 38 | +```scss | |
| 39 | +@use "abstracts" as fox; | |
| 40 | + | |
| 41 | +.fox-button { | |
| 42 | + padding-inline: fox.form(padding-md); | |
| 43 | + background: fox.color(button-primary-surface); | |
| 44 | + border-radius: fox.form(radius-md); | |
| 45 | +} | |
| 46 | +``` | |
| 47 | + | |
| 48 | +원시 값(hex·px·rem 리터럴) 금지. 없는 토큰 이름은 빌드가 실패시킨다. 맞는 토큰이 없으면 | |
| 49 | +쓰지 말고 Figma에 먼저 추가한다 — 토큰 파일은 자동 생성물이다. | |
| 50 | + | |
| 51 | +토큰으로 표현할 수 없는 값(시안에 있으나 변수화되지 않은 것 등)이 나오면 **사용자와 상의한 | |
| 52 | +뒤** 넣고, 파일 상단 주석에 근거를 남긴다. | |
| 53 | + | |
| 54 | +**3. 상태는 네이티브 속성으로.** | |
| 55 | + | |
| 56 | +`disabled`·`aria-busy` 같은 표준 속성을 쓰고 `.is-disabled` 같은 클래스를 만들지 않는다. | |
| 57 | +SCSS만 쓰는 소비자도 별도 규칙 없이 같은 결과를 얻는다. | |
| 58 | + | |
| 59 | +**4. 마크업 계약을 스타일 파일 상단에 적는다.** | |
| 60 | + | |
| 61 | +React 밖 소비자는 그 주석만 보고 DOM을 짜야 한다. 필수 구조와 선택 요소를 예시로 남긴다. | |
| 62 | + | |
| 63 | +**5. 기본은 Server Component.** | |
| 64 | + | |
| 65 | +`'use client'`는 상호작용(상태·이벤트 핸들러)이 실제로 필요할 때만 붙인다. | |
| 66 | + | |
| 67 | +**6. variant는 `Record`로 고정한다.** | |
| 68 | + | |
| 69 | +```tsx | |
| 70 | +const TYPE_CLASS: Record<FoxButtonType, string> = { | |
| 71 | + primary: "fox-button--primary", | |
| 72 | + secondary: "fox-button--secondary", | |
| 73 | +}; | |
| 74 | +``` | |
| 75 | + | |
| 76 | +계열을 추가하면 맵 누락이 타입 에러가 된다. | |
| 77 | + | |
| 78 | +**7. `className` prop은 배치용으로만 연다.** | |
| 79 | + | |
| 80 | +호출부가 넘기는 것은 margin·grid 배치 같은 **위치** 조정이지 디자인 값이 아니다. 모양이 | |
| 81 | +달라져야 하면 모디파이어를 추가한다. | |
| 82 | + | |
| 83 | +**8. 호스트 앱에 의존하지 않는다.** | |
| 84 | + | |
| 85 | +`@fox/` 안에서 `@/lib/...` 같은 앱 경로를 import 하지 않는다. 앱이 주는 값은 prop이나 | |
| 86 | +CSS 변수로 받는다. | |
| 87 | + | |
| 88 | +**9. 만들면 테스트 페이지에 등록한다.** | |
| 89 | + | |
| 90 | +`@fox/dev-test/component-registry.tsx`의 `COMPONENT_EXAMPLES`에 항목 하나를 추가하면 | |
| 91 | +`/dev-test/design`에 나타난다. |
+++ @fox/core/components/fox-address/fox-address.tsx
... | ... | @@ -0,0 +1,198 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useId, useState, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxButton } from "../fox-button"; | |
| 6 | +import { FoxInput } from "../fox-input"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 주소 검색 결과. 호출부가 어떤 우편번호 서비스를 쓰든 이 모양으로 바꿔서 돌려준다 — | |
| 10 | + * 그래야 `@fox`가 특정 API에 묶이지 않는다. | |
| 11 | + */ | |
| 12 | +export interface FoxAddressSearchResult { | |
| 13 | + /** 우편번호. 첫 칸에 그대로 들어간다. */ | |
| 14 | + zipCode: string; | |
| 15 | + /** 도로명 또는 지번 주소. 둘째 칸에 그대로 들어간다. 어느 쪽을 쓸지는 호출부가 정한다. */ | |
| 16 | + address: string; | |
| 17 | + /** | |
| 18 | + * 상세주소 칸에 미리 채울 값(건물명 같은 참고항목). | |
| 19 | + * 넘기지 않으면 사용자가 적어 둔 상세주소를 건드리지 않는다. | |
| 20 | + */ | |
| 21 | + detail?: string; | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** 주소 한 벌. 화면의 세 칸을 그대로 담는다. */ | |
| 25 | +export interface FoxAddressValue { | |
| 26 | + zipCode: string; | |
| 27 | + address: string; | |
| 28 | + detail: string; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export const EMPTY_ADDRESS: FoxAddressValue = { zipCode: "", address: "", detail: "" }; | |
| 32 | + | |
| 33 | +export interface FoxAddressProps { | |
| 34 | + value?: FoxAddressValue; | |
| 35 | + defaultValue?: FoxAddressValue; | |
| 36 | + onChange?: (value: FoxAddressValue) => void; | |
| 37 | + | |
| 38 | + /** | |
| 39 | + * 주소 검색. 호출부가 우편번호 서비스를 띄우고 결과를 `FoxAddressSearchResult`로 | |
| 40 | + * 돌려준다. 사용자가 고르지 않고 닫으면 `null`을 돌려주면 되고, 그때는 값이 그대로 | |
| 41 | + * 남는다. throw·reject하면 `onSearchFail`을 부른다. | |
| 42 | + */ | |
| 43 | + onSearch: () => Promise<FoxAddressSearchResult | null>; | |
| 44 | + onSearchSuccess?: (result: FoxAddressSearchResult) => void; | |
| 45 | + onSearchFail?: (error: unknown) => void; | |
| 46 | + searchLabel?: string; | |
| 47 | + | |
| 48 | + label?: string; | |
| 49 | + /** 헬퍼 메시지. 오류일 때 색이 바뀐다. */ | |
| 50 | + message?: string; | |
| 51 | + messageIcon?: ReactNode; | |
| 52 | + zipCodePlaceholder?: string; | |
| 53 | + addressPlaceholder?: string; | |
| 54 | + detailPlaceholder?: string; | |
| 55 | + | |
| 56 | + invalid?: boolean; | |
| 57 | + disabled?: boolean; | |
| 58 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 59 | + hidden?: boolean; | |
| 60 | + /** 배치 조정용. */ | |
| 61 | + className?: string; | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * @fox 주소 입력. 우편번호와 주소는 검색 결과로만 채워지고, 상세주소만 손으로 적는다. | |
| 66 | + * | |
| 67 | + * 어떤 우편번호 서비스를 쓸지는 `@fox`가 알 필요가 없다 — 호출부가 `onSearch`에서 그 | |
| 68 | + * 서비스를 띄우고 결과를 `FoxAddressSearchResult` 모양으로 바꿔서 돌려주면 된다. | |
| 69 | + * | |
| 70 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 71 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 72 | + */ | |
| 73 | +export function FoxAddress({ | |
| 74 | + value, | |
| 75 | + defaultValue, | |
| 76 | + onChange, | |
| 77 | + onSearch, | |
| 78 | + onSearchSuccess, | |
| 79 | + onSearchFail, | |
| 80 | + searchLabel = "주소 검색", | |
| 81 | + label, | |
| 82 | + message, | |
| 83 | + messageIcon, | |
| 84 | + zipCodePlaceholder = "우편번호", | |
| 85 | + addressPlaceholder = "주소", | |
| 86 | + detailPlaceholder = "상세주소", | |
| 87 | + invalid = false, | |
| 88 | + disabled = false, | |
| 89 | + hidden = false, | |
| 90 | + className, | |
| 91 | +}: FoxAddressProps) { | |
| 92 | + const autoId = useId(); | |
| 93 | + const [inner, setInner] = useState<FoxAddressValue>(defaultValue ?? EMPTY_ADDRESS); | |
| 94 | + const [searching, setSearching] = useState(false); | |
| 95 | + | |
| 96 | + if (hidden) { | |
| 97 | + return null; | |
| 98 | + } | |
| 99 | + | |
| 100 | + const current = value ?? inner; | |
| 101 | + const messageId = `${autoId}-message`; | |
| 102 | + | |
| 103 | + const commit = (next: FoxAddressValue) => { | |
| 104 | + if (value === undefined) { | |
| 105 | + setInner(next); | |
| 106 | + } | |
| 107 | + onChange?.(next); | |
| 108 | + }; | |
| 109 | + | |
| 110 | + const handleSearch = async () => { | |
| 111 | + if (searching) { | |
| 112 | + return; | |
| 113 | + } | |
| 114 | + setSearching(true); | |
| 115 | + try { | |
| 116 | + const result = await onSearch(); | |
| 117 | + // 취소하면 `null`이 온다 — 그때는 값이 그대로 남는다. | |
| 118 | + if (result) { | |
| 119 | + commit({ | |
| 120 | + zipCode: result.zipCode, | |
| 121 | + address: result.address, | |
| 122 | + detail: result.detail ?? current.detail, | |
| 123 | + }); | |
| 124 | + onSearchSuccess?.(result); | |
| 125 | + } | |
| 126 | + } catch (error) { | |
| 127 | + onSearchFail?.(error); | |
| 128 | + } finally { | |
| 129 | + setSearching(false); | |
| 130 | + } | |
| 131 | + }; | |
| 132 | + | |
| 133 | + return ( | |
| 134 | + <div className={cx("fox-address", className)}> | |
| 135 | + {label && ( | |
| 136 | + <span className="fox-address__label" id={`${autoId}-label`}> | |
| 137 | + {label} | |
| 138 | + </span> | |
| 139 | + )} | |
| 140 | + | |
| 141 | + <div className="fox-address__row"> | |
| 142 | + <div className="fox-address__zip"> | |
| 143 | + {/* 검색 결과로만 채워진다 — 손으로 고칠 수 없다. */} | |
| 144 | + <FoxInput | |
| 145 | + size="md" | |
| 146 | + aria-label={zipCodePlaceholder} | |
| 147 | + aria-describedby={message ? messageId : undefined} | |
| 148 | + value={current.zipCode} | |
| 149 | + placeholder={zipCodePlaceholder} | |
| 150 | + readOnly | |
| 151 | + invalid={invalid} | |
| 152 | + disabled={disabled} | |
| 153 | + /> | |
| 154 | + </div> | |
| 155 | + | |
| 156 | + <FoxButton | |
| 157 | + size="md" | |
| 158 | + type="secondary" | |
| 159 | + label={searchLabel} | |
| 160 | + loading={searching} | |
| 161 | + disabled={disabled} | |
| 162 | + onAction={handleSearch} | |
| 163 | + /> | |
| 164 | + </div> | |
| 165 | + | |
| 166 | + <FoxInput | |
| 167 | + size="md" | |
| 168 | + aria-label={addressPlaceholder} | |
| 169 | + value={current.address} | |
| 170 | + placeholder={addressPlaceholder} | |
| 171 | + readOnly | |
| 172 | + invalid={invalid} | |
| 173 | + disabled={disabled} | |
| 174 | + /> | |
| 175 | + | |
| 176 | + <FoxInput | |
| 177 | + size="md" | |
| 178 | + aria-label={detailPlaceholder} | |
| 179 | + value={current.detail} | |
| 180 | + onChange={(next) => commit({ ...current, detail: next })} | |
| 181 | + placeholder={detailPlaceholder} | |
| 182 | + invalid={invalid} | |
| 183 | + disabled={disabled} | |
| 184 | + /> | |
| 185 | + | |
| 186 | + {message && ( | |
| 187 | + <p className="fox-address__message" id={messageId}> | |
| 188 | + {messageIcon && ( | |
| 189 | + <span className="fox-address__message-icon" aria-hidden="true"> | |
| 190 | + {messageIcon} | |
| 191 | + </span> | |
| 192 | + )} | |
| 193 | + {message} | |
| 194 | + </p> | |
| 195 | + )} | |
| 196 | + </div> | |
| 197 | + ); | |
| 198 | +} |
+++ @fox/core/components/fox-address/index.ts
... | ... | @@ -0,0 +1,7 @@ |
| 1 | +export { | |
| 2 | + FoxAddress, | |
| 3 | + EMPTY_ADDRESS, | |
| 4 | + type FoxAddressProps, | |
| 5 | + type FoxAddressValue, | |
| 6 | + type FoxAddressSearchResult, | |
| 7 | +} from "./fox-address"; |
+++ @fox/core/components/fox-certified/fox-certified.tsx
... | ... | @@ -0,0 +1,261 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useEffect, useId, useRef, useState, type ChangeEvent } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxButton } from "../fox-button"; | |
| 6 | +import { FoxPhoneNumber } from "../fox-phone-number"; | |
| 7 | + | |
| 8 | +export interface FoxCertifiedProps { | |
| 9 | + /** 전화번호. 숫자만 담긴 문자열이다 — `FoxPhoneNumber`와 같다. */ | |
| 10 | + phone?: string; | |
| 11 | + defaultPhone?: string; | |
| 12 | + onPhoneChange?: (phone: string) => void; | |
| 13 | + /** 전화번호 앞자리 목록. 밖에서 받은 값을 칸으로 나눌 때도 쓴다. */ | |
| 14 | + prefixOptions?: string[]; | |
| 15 | + | |
| 16 | + /** | |
| 17 | + * 인증번호 받기. resolve하면 성공으로 보고 인증번호 칸과 확인 버튼을 연다. | |
| 18 | + * throw하거나 reject하면 실패로 보고 `onSendFail`을 부른다. | |
| 19 | + */ | |
| 20 | + onSend: (phone: string) => Promise<unknown>; | |
| 21 | + /** 인증번호 확인. resolve하면 성공, throw·reject하면 실패다. */ | |
| 22 | + onVerify: (phone: string, code: string) => Promise<unknown>; | |
| 23 | + | |
| 24 | + onSendSuccess?: () => void; | |
| 25 | + onSendFail?: (error: unknown) => void; | |
| 26 | + onVerifySuccess?: () => void; | |
| 27 | + onVerifyFail?: (error: unknown) => void; | |
| 28 | + /** 남은 시간이 0이 됐을 때 한 번 부른다. */ | |
| 29 | + onExpire?: () => void; | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * 제한 시간(초). 주면 인증번호를 보낸 시점부터 컴포넌트가 직접 센다. | |
| 33 | + * `remainingSeconds`를 함께 주면 그쪽이 이긴다. | |
| 34 | + */ | |
| 35 | + expiresIn?: number; | |
| 36 | + /** 남은 시간(초)을 직접 셀 때. 주면 컴포넌트는 세지 않고 이 값만 그린다. */ | |
| 37 | + remainingSeconds?: number; | |
| 38 | + | |
| 39 | + label?: string; | |
| 40 | + sendLabel?: string; | |
| 41 | + verifyLabel?: string; | |
| 42 | + codePlaceholder?: string; | |
| 43 | + codeMaxLength?: number; | |
| 44 | + /** 전체 잠금. 인증에 성공하면 이 값과 무관하게 잠긴다. */ | |
| 45 | + disabled?: boolean; | |
| 46 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 47 | + hidden?: boolean; | |
| 48 | + /** 배치 조정용. */ | |
| 49 | + className?: string; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** 시안의 `24:16` 표기. 분이 두 자리를 넘어가면 그대로 늘어난다. */ | |
| 53 | +function formatTimer(seconds: number): string { | |
| 54 | + const safe = Math.max(0, Math.floor(seconds)); | |
| 55 | + const minutes = Math.floor(safe / 60); | |
| 56 | + const rest = safe % 60; | |
| 57 | + return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`; | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** 시안의 ico/ClockCountdown 자리. @fox에 아이콘 세트가 없어 같은 모양을 직접 그린다. */ | |
| 61 | +function TimerIcon() { | |
| 62 | + return ( | |
| 63 | + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> | |
| 64 | + <circle cx="8" cy="8" r="6.3" /> | |
| 65 | + <path d="M8 4.4V8l2.4 1.6" strokeLinecap="round" /> | |
| 66 | + </svg> | |
| 67 | + ); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * @fox 휴대폰 인증. 전화번호를 받고, 인증번호를 보내고, 확인한다. | |
| 72 | + * | |
| 73 | + * 보내기·확인은 호출부가 `Promise`로 넘긴다 — resolve면 성공, throw·reject면 실패다. | |
| 74 | + * 성공·실패는 모두 이벤트로 올라오고, 화면은 최소한만 반응한다(보내기 성공이면 인증번호 | |
| 75 | + * 칸과 확인 버튼이 열리고, 인증 성공이면 전부 잠긴다). 실패 문구는 호출부가 보여준다. | |
| 76 | + * | |
| 77 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 78 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 79 | + */ | |
| 80 | +export function FoxCertified({ | |
| 81 | + phone, | |
| 82 | + defaultPhone, | |
| 83 | + onPhoneChange, | |
| 84 | + prefixOptions = [], | |
| 85 | + onSend, | |
| 86 | + onVerify, | |
| 87 | + onSendSuccess, | |
| 88 | + onSendFail, | |
| 89 | + onVerifySuccess, | |
| 90 | + onVerifyFail, | |
| 91 | + onExpire, | |
| 92 | + expiresIn, | |
| 93 | + remainingSeconds, | |
| 94 | + label, | |
| 95 | + sendLabel = "인증번호 받기", | |
| 96 | + verifyLabel = "인증번호 확인", | |
| 97 | + codePlaceholder = "인증번호 입력", | |
| 98 | + codeMaxLength, | |
| 99 | + disabled = false, | |
| 100 | + hidden = false, | |
| 101 | + className, | |
| 102 | +}: FoxCertifiedProps) { | |
| 103 | + const autoId = useId(); | |
| 104 | + const [innerPhone, setInnerPhone] = useState(defaultPhone ?? ""); | |
| 105 | + const [code, setCode] = useState(""); | |
| 106 | + const [sent, setSent] = useState(false); | |
| 107 | + const [verified, setVerified] = useState(false); | |
| 108 | + const [sending, setSending] = useState(false); | |
| 109 | + const [verifying, setVerifying] = useState(false); | |
| 110 | + const [ticking, setTicking] = useState<number | null>(null); | |
| 111 | + const expiredRef = useRef(false); | |
| 112 | + | |
| 113 | + const selfTimed = remainingSeconds === undefined && expiresIn !== undefined; | |
| 114 | + const left = remainingSeconds ?? ticking; | |
| 115 | + | |
| 116 | + // 스스로 셀 때만 1초마다 줄인다. 호출부가 값을 주면 그리기만 한다. | |
| 117 | + useEffect(() => { | |
| 118 | + if (!selfTimed || !sent) { | |
| 119 | + return; | |
| 120 | + } | |
| 121 | + const id = setInterval(() => { | |
| 122 | + setTicking((prev) => (prev === null || prev <= 0 ? prev : prev - 1)); | |
| 123 | + }, 1000); | |
| 124 | + return () => clearInterval(id); | |
| 125 | + }, [selfTimed, sent]); | |
| 126 | + | |
| 127 | + // 0에 닿는 순간 한 번만 알린다. 다시 보내 시간이 살아나면 잠금을 푼다. | |
| 128 | + useEffect(() => { | |
| 129 | + if (left === null || left === undefined) { | |
| 130 | + return; | |
| 131 | + } | |
| 132 | + if (left <= 0 && !expiredRef.current) { | |
| 133 | + expiredRef.current = true; | |
| 134 | + onExpire?.(); | |
| 135 | + } | |
| 136 | + if (left > 0) { | |
| 137 | + expiredRef.current = false; | |
| 138 | + } | |
| 139 | + }, [left, onExpire]); | |
| 140 | + | |
| 141 | + if (hidden) { | |
| 142 | + return null; | |
| 143 | + } | |
| 144 | + | |
| 145 | + const phoneValue = phone ?? innerPhone; | |
| 146 | + const locked = disabled || verified; | |
| 147 | + const codeId = `${autoId}-code`; | |
| 148 | + | |
| 149 | + const handlePhoneChange = (next: string) => { | |
| 150 | + if (phone === undefined) { | |
| 151 | + setInnerPhone(next); | |
| 152 | + } | |
| 153 | + onPhoneChange?.(next); | |
| 154 | + }; | |
| 155 | + | |
| 156 | + const handleSend = async () => { | |
| 157 | + if (sending) { | |
| 158 | + return; | |
| 159 | + } | |
| 160 | + setSending(true); | |
| 161 | + try { | |
| 162 | + await onSend(phoneValue); | |
| 163 | + setSent(true); | |
| 164 | + expiredRef.current = false; | |
| 165 | + if (selfTimed) { | |
| 166 | + setTicking(expiresIn ?? null); | |
| 167 | + } | |
| 168 | + onSendSuccess?.(); | |
| 169 | + } catch (error) { | |
| 170 | + onSendFail?.(error); | |
| 171 | + } finally { | |
| 172 | + setSending(false); | |
| 173 | + } | |
| 174 | + }; | |
| 175 | + | |
| 176 | + const handleVerify = async () => { | |
| 177 | + if (verifying) { | |
| 178 | + return; | |
| 179 | + } | |
| 180 | + setVerifying(true); | |
| 181 | + try { | |
| 182 | + await onVerify(phoneValue, code); | |
| 183 | + setVerified(true); | |
| 184 | + onVerifySuccess?.(); | |
| 185 | + } catch (error) { | |
| 186 | + onVerifyFail?.(error); | |
| 187 | + } finally { | |
| 188 | + setVerifying(false); | |
| 189 | + } | |
| 190 | + }; | |
| 191 | + | |
| 192 | + return ( | |
| 193 | + <div className={cx("fox-certified", className)}> | |
| 194 | + {label && ( | |
| 195 | + <span className="fox-certified__label" id={`${autoId}-label`}> | |
| 196 | + {label} | |
| 197 | + </span> | |
| 198 | + )} | |
| 199 | + | |
| 200 | + <div className="fox-certified__row"> | |
| 201 | + <div className="fox-certified__phone"> | |
| 202 | + <FoxPhoneNumber | |
| 203 | + type="combine" | |
| 204 | + prefixOptions={prefixOptions} | |
| 205 | + value={phoneValue} | |
| 206 | + onChange={handlePhoneChange} | |
| 207 | + disabled={locked} | |
| 208 | + /> | |
| 209 | + </div> | |
| 210 | + | |
| 211 | + <FoxButton | |
| 212 | + size="md" | |
| 213 | + type="secondary" | |
| 214 | + label={sendLabel} | |
| 215 | + loading={sending} | |
| 216 | + disabled={locked} | |
| 217 | + onAction={handleSend} | |
| 218 | + /> | |
| 219 | + </div> | |
| 220 | + | |
| 221 | + {/* 보내기가 성공해야 열린다. */} | |
| 222 | + {sent && ( | |
| 223 | + <> | |
| 224 | + <div className="fox-certified__box"> | |
| 225 | + <input | |
| 226 | + id={codeId} | |
| 227 | + className="fox-certified__field" | |
| 228 | + inputMode="numeric" | |
| 229 | + autoComplete="one-time-code" | |
| 230 | + aria-label={codePlaceholder} | |
| 231 | + placeholder={codePlaceholder} | |
| 232 | + value={code} | |
| 233 | + maxLength={codeMaxLength} | |
| 234 | + disabled={locked} | |
| 235 | + onChange={(event: ChangeEvent<HTMLInputElement>) => setCode(event.target.value)} | |
| 236 | + /> | |
| 237 | + | |
| 238 | + {left !== null && left !== undefined && ( | |
| 239 | + <span className="fox-certified__timer"> | |
| 240 | + <span className="fox-certified__timer-icon" aria-hidden="true"> | |
| 241 | + <TimerIcon /> | |
| 242 | + </span> | |
| 243 | + <span className="fox-certified__timer-text">{formatTimer(left)}</span> | |
| 244 | + </span> | |
| 245 | + )} | |
| 246 | + </div> | |
| 247 | + | |
| 248 | + <FoxButton | |
| 249 | + size="md" | |
| 250 | + type="primary" | |
| 251 | + label={verifyLabel} | |
| 252 | + fullWidth | |
| 253 | + loading={verifying} | |
| 254 | + disabled={locked} | |
| 255 | + onAction={handleVerify} | |
| 256 | + /> | |
| 257 | + </> | |
| 258 | + )} | |
| 259 | + </div> | |
| 260 | + ); | |
| 261 | +} |
+++ @fox/core/components/fox-certified/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxCertified, type FoxCertifiedProps } from "./fox-certified"; |
+++ @fox/core/components/fox-email/fox-email.tsx
... | ... | @@ -0,0 +1,234 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useId, useState, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxButton } from "../fox-button"; | |
| 6 | +import { FoxInput } from "../fox-input"; | |
| 7 | +import { FoxSelect, type FoxSelectItem } from "../fox-select"; | |
| 8 | + | |
| 9 | +/** 드롭다운의 "직접입력" 항목이 쓰는 값. 실제 도메인과 겹치지 않게 잡았다. */ | |
| 10 | +const CUSTOM_DOMAIN = "__fox-email-custom__"; | |
| 11 | + | |
| 12 | +export interface FoxEmailProps { | |
| 13 | + /** `아이디@도메인` 전체 값. 둘 다 비어 있으면 빈 문자열이다. */ | |
| 14 | + value?: string; | |
| 15 | + defaultValue?: string; | |
| 16 | + onChange?: (value: string) => void; | |
| 17 | + /** 도메인 드롭다운 목록. "직접입력" 항목은 컴포넌트가 맨 아래에 더한다. */ | |
| 18 | + domainOptions?: string[]; | |
| 19 | + customDomainLabel?: string; | |
| 20 | + | |
| 21 | + /** | |
| 22 | + * 주면 확인 버튼이 생기고, 넘기지 않으면 버튼 자체가 렌더되지 않는다. | |
| 23 | + * resolve하면 성공, throw·reject하면 실패로 본다. | |
| 24 | + */ | |
| 25 | + onVerify?: (email: string) => Promise<unknown>; | |
| 26 | + onVerifySuccess?: () => void; | |
| 27 | + onVerifyFail?: (error: unknown) => void; | |
| 28 | + verifyLabel?: string; | |
| 29 | + | |
| 30 | + label?: string; | |
| 31 | + /** 헬퍼 메시지. 오류일 때 색이 바뀐다. */ | |
| 32 | + message?: string; | |
| 33 | + messageIcon?: ReactNode; | |
| 34 | + localPlaceholder?: string; | |
| 35 | + domainPlaceholder?: string; | |
| 36 | + customPlaceholder?: string; | |
| 37 | + | |
| 38 | + invalid?: boolean; | |
| 39 | + disabled?: boolean; | |
| 40 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 41 | + hidden?: boolean; | |
| 42 | + /** 배치 조정용. */ | |
| 43 | + className?: string; | |
| 44 | +} | |
| 45 | + | |
| 46 | +type Split = { local: string; domain: string; custom: boolean }; | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * `아이디@도메인`을 칸으로 나눈다. `@`는 마지막 것을 기준으로 자른다. | |
| 50 | + * | |
| 51 | + * 나눈 도메인이 목록에 없으면 직접 적은 것으로 보고 드롭다운을 "직접입력"에 맞춘다. | |
| 52 | + */ | |
| 53 | +function splitEmail(value: string, domainOptions: string[]): Split { | |
| 54 | + const at = (value ?? "").lastIndexOf("@"); | |
| 55 | + if (at < 0) { | |
| 56 | + return { local: value ?? "", domain: "", custom: false }; | |
| 57 | + } | |
| 58 | + const domain = value.slice(at + 1); | |
| 59 | + return { | |
| 60 | + local: value.slice(0, at), | |
| 61 | + domain, | |
| 62 | + custom: domain !== "" && !domainOptions.includes(domain), | |
| 63 | + }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +function joinEmail(local: string, domain: string): string { | |
| 67 | + return local || domain ? `${local}@${domain}` : ""; | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * @fox 이메일 입력. 아이디와 도메인을 나눠 받되 값은 합쳐진 문자열 하나다. | |
| 72 | + * | |
| 73 | + * 도메인은 드롭다운에서 고르면 옆 칸에 그 값이 채워지고 읽기 전용이 된다. "직접입력"을 | |
| 74 | + * 고르면 그 칸이 풀려 손으로 적을 수 있다. | |
| 75 | + * | |
| 76 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 77 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 78 | + */ | |
| 79 | +export function FoxEmail({ | |
| 80 | + value, | |
| 81 | + defaultValue, | |
| 82 | + onChange, | |
| 83 | + domainOptions = [], | |
| 84 | + customDomainLabel = "직접입력", | |
| 85 | + onVerify, | |
| 86 | + onVerifySuccess, | |
| 87 | + onVerifyFail, | |
| 88 | + verifyLabel = "확인", | |
| 89 | + label, | |
| 90 | + message, | |
| 91 | + messageIcon, | |
| 92 | + localPlaceholder = "아이디", | |
| 93 | + domainPlaceholder = "도메인 선택", | |
| 94 | + customPlaceholder = "직접입력", | |
| 95 | + invalid = false, | |
| 96 | + disabled = false, | |
| 97 | + hidden = false, | |
| 98 | + className, | |
| 99 | +}: FoxEmailProps) { | |
| 100 | + const autoId = useId(); | |
| 101 | + const [parts, setParts] = useState<Split>(() => | |
| 102 | + splitEmail(value ?? defaultValue ?? "", domainOptions) | |
| 103 | + ); | |
| 104 | + const [seenValue, setSeenValue] = useState(value); | |
| 105 | + const [verifying, setVerifying] = useState(false); | |
| 106 | + | |
| 107 | + // 밖에서 값을 바꿨을 때만 다시 나눈다. 우리가 방금 올려보낸 값이면 그대로 둔다. | |
| 108 | + if (value !== undefined && value !== seenValue) { | |
| 109 | + setSeenValue(value); | |
| 110 | + if (value !== joinEmail(parts.local, parts.domain)) { | |
| 111 | + setParts(splitEmail(value, domainOptions)); | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 115 | + if (hidden) { | |
| 116 | + return null; | |
| 117 | + } | |
| 118 | + | |
| 119 | + const messageId = `${autoId}-message`; | |
| 120 | + const email = joinEmail(parts.local, parts.domain); | |
| 121 | + | |
| 122 | + const commit = (next: Split) => { | |
| 123 | + setParts(next); | |
| 124 | + const joined = joinEmail(next.local, next.domain); | |
| 125 | + setSeenValue(joined); | |
| 126 | + onChange?.(joined); | |
| 127 | + }; | |
| 128 | + | |
| 129 | + const domainItems: FoxSelectItem[] = [ | |
| 130 | + ...domainOptions.map((option) => ({ value: option, label: option })), | |
| 131 | + { value: CUSTOM_DOMAIN, label: customDomainLabel }, | |
| 132 | + ]; | |
| 133 | + | |
| 134 | + // 목록에서 고르면 옆 칸을 그 값으로 덮고 잠근다. "직접입력"이면 잠금만 푼다. | |
| 135 | + const handleDomainPick = (picked: string) => { | |
| 136 | + if (picked === CUSTOM_DOMAIN) { | |
| 137 | + commit({ ...parts, custom: true }); | |
| 138 | + return; | |
| 139 | + } | |
| 140 | + commit({ ...parts, domain: picked, custom: false }); | |
| 141 | + }; | |
| 142 | + | |
| 143 | + const handleVerify = async () => { | |
| 144 | + if (!onVerify || verifying) { | |
| 145 | + return; | |
| 146 | + } | |
| 147 | + setVerifying(true); | |
| 148 | + try { | |
| 149 | + await onVerify(email); | |
| 150 | + onVerifySuccess?.(); | |
| 151 | + } catch (error) { | |
| 152 | + onVerifyFail?.(error); | |
| 153 | + } finally { | |
| 154 | + setVerifying(false); | |
| 155 | + } | |
| 156 | + }; | |
| 157 | + | |
| 158 | + return ( | |
| 159 | + <div className={cx("fox-email", className)}> | |
| 160 | + {label && ( | |
| 161 | + <span className="fox-email__label" id={`${autoId}-label`}> | |
| 162 | + {label} | |
| 163 | + </span> | |
| 164 | + )} | |
| 165 | + | |
| 166 | + <div className="fox-email__row"> | |
| 167 | + <div className="fox-email__part"> | |
| 168 | + <FoxInput | |
| 169 | + size="md" | |
| 170 | + aria-label="이메일 아이디" | |
| 171 | + aria-describedby={message ? messageId : undefined} | |
| 172 | + value={parts.local} | |
| 173 | + onChange={(next) => commit({ ...parts, local: next })} | |
| 174 | + placeholder={localPlaceholder} | |
| 175 | + invalid={invalid} | |
| 176 | + disabled={disabled} | |
| 177 | + /> | |
| 178 | + </div> | |
| 179 | + | |
| 180 | + <span className="fox-email__at" aria-hidden="true"> | |
| 181 | + @ | |
| 182 | + </span> | |
| 183 | + | |
| 184 | + <div className="fox-email__part"> | |
| 185 | + <FoxSelect | |
| 186 | + size="md" | |
| 187 | + options={domainItems} | |
| 188 | + value={parts.custom ? CUSTOM_DOMAIN : parts.domain} | |
| 189 | + onValueChange={handleDomainPick} | |
| 190 | + placeholder={domainPlaceholder} | |
| 191 | + error={invalid} | |
| 192 | + disabled={disabled} | |
| 193 | + /> | |
| 194 | + </div> | |
| 195 | + | |
| 196 | + {/* 목록에서 고른 값이 여기에 채워지고 잠긴다. "직접입력"일 때만 풀린다. */} | |
| 197 | + <div className="fox-email__part"> | |
| 198 | + <FoxInput | |
| 199 | + size="md" | |
| 200 | + aria-label="이메일 도메인" | |
| 201 | + value={parts.domain} | |
| 202 | + onChange={(next) => commit({ ...parts, domain: next })} | |
| 203 | + placeholder={customPlaceholder} | |
| 204 | + readOnly={!parts.custom} | |
| 205 | + invalid={invalid} | |
| 206 | + disabled={disabled} | |
| 207 | + /> | |
| 208 | + </div> | |
| 209 | + | |
| 210 | + {onVerify && ( | |
| 211 | + <FoxButton | |
| 212 | + size="md" | |
| 213 | + type="primary" | |
| 214 | + label={verifyLabel} | |
| 215 | + loading={verifying} | |
| 216 | + disabled={disabled} | |
| 217 | + onAction={handleVerify} | |
| 218 | + /> | |
| 219 | + )} | |
| 220 | + </div> | |
| 221 | + | |
| 222 | + {message && ( | |
| 223 | + <p className="fox-email__message" id={messageId}> | |
| 224 | + {messageIcon && ( | |
| 225 | + <span className="fox-email__message-icon" aria-hidden="true"> | |
| 226 | + {messageIcon} | |
| 227 | + </span> | |
| 228 | + )} | |
| 229 | + {message} | |
| 230 | + </p> | |
| 231 | + )} | |
| 232 | + </div> | |
| 233 | + ); | |
| 234 | +} |
+++ @fox/core/components/fox-email/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxEmail, type FoxEmailProps } from "./fox-email"; |
+++ @fox/core/components/fox-file-upload/fox-file-upload.tsx
... | ... | @@ -0,0 +1,460 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + useEffect, | |
| 5 | + useId, | |
| 6 | + useRef, | |
| 7 | + useState, | |
| 8 | + type ChangeEvent, | |
| 9 | + type CSSProperties, | |
| 10 | + type DragEvent, | |
| 11 | +} from "react"; | |
| 12 | +import { cx } from "../../utils"; | |
| 13 | +import { FoxButton } from "../fox-button"; | |
| 14 | +import { FoxIconButton } from "../fox-icon-button"; | |
| 15 | +import { FoxInput } from "../fox-input"; | |
| 16 | +import { FoxTextButton } from "../fox-text-button"; | |
| 17 | + | |
| 18 | +export type FoxFileUploadMode = "default" | "area" | "image"; | |
| 19 | + | |
| 20 | +/** 목록에 그릴 파일 한 줄. 업로드 상태는 호출부가 이 모양으로 바꿔서 넘긴다. */ | |
| 21 | +export interface FoxFileItem { | |
| 22 | + /** 목록에서 구분하는 키. */ | |
| 23 | + id: string; | |
| 24 | + /** 확장자를 뺀 이름. 길면 말줄임된다. */ | |
| 25 | + name: string; | |
| 26 | + /** 이름 뒤에 붙는 표기 — 시안은 `.PDF (20KB)`. 줄어들지 않는다. */ | |
| 27 | + meta?: string; | |
| 28 | + /** 참이면 줄이 붉어지고 `message`가 아래에 붙는다. */ | |
| 29 | + error?: boolean; | |
| 30 | + message?: string; | |
| 31 | + /** image 모드에서 썸네일로 쓸 주소. */ | |
| 32 | + previewUrl?: string; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export interface FoxFileUploadProps { | |
| 36 | + mode?: FoxFileUploadMode; | |
| 37 | + /** 주면 제어 컴포넌트가 된다. 없으면 고른 파일을 안에서 들고 있는다. */ | |
| 38 | + files?: FoxFileItem[]; | |
| 39 | + defaultFiles?: FoxFileItem[]; | |
| 40 | + /** 목록이 바뀔 때(고르기·삭제) 부른다. */ | |
| 41 | + onFilesChange?: (files: FoxFileItem[]) => void; | |
| 42 | + /** 파일을 고르면 원본 `File`을 그대로 넘긴다 — 업로드는 호출부가 한다. */ | |
| 43 | + onSelect?: (picked: File[]) => void; | |
| 44 | + onRemove?: (id: string) => void; | |
| 45 | + | |
| 46 | + accept?: string; | |
| 47 | + multiple?: boolean; | |
| 48 | + /** | |
| 49 | + * `default`·`area`에서 목록을 보여준다. `image`에는 목록이 없다. | |
| 50 | + * 넘기지 않으면 `area`이거나 여러 개를 받을 때만 켜진다 — 파일 하나는 칸이 곧 목록이다. | |
| 51 | + */ | |
| 52 | + showList?: boolean; | |
| 53 | + /** 끌어다 놓기. `area`는 안내문이 그렇게 말하므로 이 값과 무관하게 항상 켜져 있다. */ | |
| 54 | + droppable?: boolean; | |
| 55 | + | |
| 56 | + label?: string; | |
| 57 | + description?: string; | |
| 58 | + placeholder?: string; | |
| 59 | + areaText?: string; | |
| 60 | + selectLabel?: string; | |
| 61 | + removeLabel?: string; | |
| 62 | + | |
| 63 | + /** `default` 모드 입력칸 폭. 시안은 320px이고 토큰에 없어 여기서 받는다. */ | |
| 64 | + inputWidth?: string | number; | |
| 65 | + /** `image` 모드 썸네일 크기. 시안은 213×120px(16:9)이고 토큰에 없어 여기서 받는다. */ | |
| 66 | + thumbnailWidth?: string | number; | |
| 67 | + thumbnailHeight?: string | number; | |
| 68 | + | |
| 69 | + disabled?: boolean; | |
| 70 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 71 | + hidden?: boolean; | |
| 72 | + /** 배치 조정용. */ | |
| 73 | + className?: string; | |
| 74 | +} | |
| 75 | + | |
| 76 | +const DEFAULT_AREA_TEXT = "첨부할 파일을 여기에 끌어다 놓거나 파일 선택 버튼을 직접 선택해주세요."; | |
| 77 | + | |
| 78 | +/* ── 아이콘 ──────────────────────────────────────────────────────────────── | |
| 79 | + @fox에 아이콘 세트가 없어 시안의 모양을 직접 그린다. 세트가 들어오면 교체할 자리다. | |
| 80 | + 전부 `currentColor`라 감싼 쪽이 색을 정한다. */ | |
| 81 | + | |
| 82 | +function UploadIcon() { | |
| 83 | + return ( | |
| 84 | + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> | |
| 85 | + <path d="M8 10.5V2.4M4.8 5.4L8 2.2l3.2 3.2" strokeLinecap="round" strokeLinejoin="round" /> | |
| 86 | + <path d="M2.4 10.2v2.6h11.2v-2.6" strokeLinecap="round" /> | |
| 87 | + </svg> | |
| 88 | + ); | |
| 89 | +} | |
| 90 | + | |
| 91 | +function PaperclipIcon() { | |
| 92 | + return ( | |
| 93 | + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> | |
| 94 | + <path | |
| 95 | + d="M12.6 7.6l-4.7 4.7a3 3 0 0 1-4.2-4.2l5.4-5.4a2 2 0 0 1 2.8 2.8l-5.4 5.4a1 1 0 0 1-1.4-1.4l4.7-4.7" | |
| 96 | + strokeLinecap="round" | |
| 97 | + strokeLinejoin="round" | |
| 98 | + /> | |
| 99 | + </svg> | |
| 100 | + ); | |
| 101 | +} | |
| 102 | + | |
| 103 | +function XCircleIcon() { | |
| 104 | + return ( | |
| 105 | + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> | |
| 106 | + <circle cx="8" cy="8" r="6.3" /> | |
| 107 | + <path d="M6 6l4 4M10 6l-4 4" strokeLinecap="round" /> | |
| 108 | + </svg> | |
| 109 | + ); | |
| 110 | +} | |
| 111 | + | |
| 112 | +function ProhibitIcon() { | |
| 113 | + return ( | |
| 114 | + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> | |
| 115 | + <circle cx="8" cy="8" r="6.3" /> | |
| 116 | + <path d="M3.5 3.5l9 9" strokeLinecap="round" /> | |
| 117 | + </svg> | |
| 118 | + ); | |
| 119 | +} | |
| 120 | + | |
| 121 | +function CameraPlusIcon() { | |
| 122 | + return ( | |
| 123 | + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true"> | |
| 124 | + <path d="M1.8 5.4h2.6l1-1.6h3.2l1 1.6h4.6v7.4H1.8z" strokeLinejoin="round" /> | |
| 125 | + <circle cx="8" cy="9.1" r="2.2" /> | |
| 126 | + <path d="M12.6 1.6v2.6M11.3 2.9h2.6" strokeLinecap="round" /> | |
| 127 | + </svg> | |
| 128 | + ); | |
| 129 | +} | |
| 130 | + | |
| 131 | +function TrashIcon() { | |
| 132 | + return ( | |
| 133 | + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> | |
| 134 | + <path d="M2.6 4.2h10.8M6.4 4.2V2.8h3.2v1.4M4 4.2l.6 8.4h6.8l.6-8.4" strokeLinecap="round" strokeLinejoin="round" /> | |
| 135 | + </svg> | |
| 136 | + ); | |
| 137 | +} | |
| 138 | + | |
| 139 | +/* ── 값 다루기 ─────────────────────────────────────────────────────────────── */ | |
| 140 | + | |
| 141 | +function formatBytes(bytes: number): string { | |
| 142 | + if (bytes < 1024) { | |
| 143 | + return `${bytes}B`; | |
| 144 | + } | |
| 145 | + if (bytes < 1024 * 1024) { | |
| 146 | + return `${Math.round(bytes / 1024)}KB`; | |
| 147 | + } | |
| 148 | + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; | |
| 149 | +} | |
| 150 | + | |
| 151 | +/** 비제어로 쓸 때 원본 `File`을 목록 한 줄로 바꾼다. 시안의 `이름` + `.PDF (20KB)` 모양이다. */ | |
| 152 | +function toItem(file: File, previewUrl?: string): FoxFileItem { | |
| 153 | + const dot = file.name.lastIndexOf("."); | |
| 154 | + const base = dot > 0 ? file.name.slice(0, dot) : file.name; | |
| 155 | + const ext = dot > 0 ? file.name.slice(dot + 1).toUpperCase() : ""; | |
| 156 | + return { | |
| 157 | + id: `${file.name}:${file.size}:${file.lastModified}`, | |
| 158 | + name: base, | |
| 159 | + meta: ext ? `.${ext} (${formatBytes(file.size)})` : `(${formatBytes(file.size)})`, | |
| 160 | + previewUrl, | |
| 161 | + }; | |
| 162 | +} | |
| 163 | + | |
| 164 | +function toSize(value: string | number | undefined): string | undefined { | |
| 165 | + if (value === undefined) { | |
| 166 | + return undefined; | |
| 167 | + } | |
| 168 | + return typeof value === "number" ? `${value}px` : value; | |
| 169 | +} | |
| 170 | + | |
| 171 | +/** | |
| 172 | + * @fox 파일 첨부. 세 모드를 한 컴포넌트로 둔다 — 고르는 방식만 다르고 목록은 같기 때문이다. | |
| 173 | + * | |
| 174 | + * 업로드는 하지 않는다. 파일을 고르면 원본 `File`을 넘길 뿐이고, 진행률·서버 오류를 목록에 | |
| 175 | + * 어떻게 비출지는 호출부가 `files`로 정한다. `files`를 넘기지 않으면 고른 파일을 안에서 | |
| 176 | + * 들고 있어 간단한 폼에서는 그대로 쓸 수 있다. | |
| 177 | + * | |
| 178 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 179 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 180 | + */ | |
| 181 | +export function FoxFileUpload({ | |
| 182 | + mode = "default", | |
| 183 | + files, | |
| 184 | + defaultFiles, | |
| 185 | + onFilesChange, | |
| 186 | + onSelect, | |
| 187 | + onRemove, | |
| 188 | + accept, | |
| 189 | + multiple = false, | |
| 190 | + showList, | |
| 191 | + droppable = false, | |
| 192 | + label, | |
| 193 | + description, | |
| 194 | + placeholder = "파일을 선택해 주세요.", | |
| 195 | + areaText = DEFAULT_AREA_TEXT, | |
| 196 | + selectLabel = "파일선택", | |
| 197 | + removeLabel = "삭제", | |
| 198 | + inputWidth = 320, | |
| 199 | + thumbnailWidth = 213, | |
| 200 | + thumbnailHeight = 120, | |
| 201 | + disabled = false, | |
| 202 | + hidden = false, | |
| 203 | + className, | |
| 204 | +}: FoxFileUploadProps) { | |
| 205 | + const autoId = useId(); | |
| 206 | + const pickerRef = useRef<HTMLInputElement>(null); | |
| 207 | + const objectUrls = useRef<string[]>([]); | |
| 208 | + const [inner, setInner] = useState<FoxFileItem[]>(defaultFiles ?? []); | |
| 209 | + const [over, setOver] = useState(false); | |
| 210 | + | |
| 211 | + // 비제어로 만든 미리보기 주소는 사라질 때 돌려준다. | |
| 212 | + useEffect( | |
| 213 | + () => () => { | |
| 214 | + objectUrls.current.forEach((url) => URL.revokeObjectURL(url)); | |
| 215 | + }, | |
| 216 | + [] | |
| 217 | + ); | |
| 218 | + | |
| 219 | + if (hidden) { | |
| 220 | + return null; | |
| 221 | + } | |
| 222 | + | |
| 223 | + const current = files ?? inner; | |
| 224 | + // area는 안내문이 끌어다 놓으라고 말하므로 언제나 받는다. | |
| 225 | + const dropEnabled = !disabled && (mode === "area" || droppable); | |
| 226 | + const listVisible = (showList ?? (mode === "area" || multiple)) && mode !== "image"; | |
| 227 | + const preview = current[0]; | |
| 228 | + // 시안의 upload=true 모양 — 목록 없이 파일 하나를 들고 있으면 칸이 잠기고 버튼이 삭제로 바뀐다. | |
| 229 | + const singleFilled = mode === "default" && !listVisible && current.length === 1; | |
| 230 | + | |
| 231 | + const openPicker = () => { | |
| 232 | + pickerRef.current?.click(); | |
| 233 | + }; | |
| 234 | + | |
| 235 | + const takeFiles = (picked: File[]) => { | |
| 236 | + if (!picked.length) { | |
| 237 | + return; | |
| 238 | + } | |
| 239 | + onSelect?.(picked); | |
| 240 | + | |
| 241 | + if (files !== undefined) { | |
| 242 | + return; | |
| 243 | + } | |
| 244 | + const made = picked.map((file) => { | |
| 245 | + if (!file.type.startsWith("image/")) { | |
| 246 | + return toItem(file); | |
| 247 | + } | |
| 248 | + const url = URL.createObjectURL(file); | |
| 249 | + objectUrls.current.push(url); | |
| 250 | + return toItem(file, url); | |
| 251 | + }); | |
| 252 | + const next = multiple ? [...inner, ...made] : made.slice(0, 1); | |
| 253 | + setInner(next); | |
| 254 | + onFilesChange?.(next); | |
| 255 | + }; | |
| 256 | + | |
| 257 | + const handlePicked = (event: ChangeEvent<HTMLInputElement>) => { | |
| 258 | + takeFiles(Array.from(event.target.files ?? [])); | |
| 259 | + // 같은 파일을 다시 골라도 이벤트가 오게 비운다. | |
| 260 | + event.target.value = ""; | |
| 261 | + }; | |
| 262 | + | |
| 263 | + const handleRemove = (id: string) => { | |
| 264 | + onRemove?.(id); | |
| 265 | + if (files !== undefined) { | |
| 266 | + return; | |
| 267 | + } | |
| 268 | + const next = inner.filter((item) => item.id !== id); | |
| 269 | + setInner(next); | |
| 270 | + onFilesChange?.(next); | |
| 271 | + }; | |
| 272 | + | |
| 273 | + const handleDragOver = (event: DragEvent<HTMLDivElement>) => { | |
| 274 | + if (!dropEnabled) { | |
| 275 | + return; | |
| 276 | + } | |
| 277 | + event.preventDefault(); | |
| 278 | + setOver(true); | |
| 279 | + }; | |
| 280 | + | |
| 281 | + const handleDragLeave = () => { | |
| 282 | + setOver(false); | |
| 283 | + }; | |
| 284 | + | |
| 285 | + const handleDrop = (event: DragEvent<HTMLDivElement>) => { | |
| 286 | + if (!dropEnabled) { | |
| 287 | + return; | |
| 288 | + } | |
| 289 | + event.preventDefault(); | |
| 290 | + setOver(false); | |
| 291 | + takeFiles(Array.from(event.dataTransfer.files ?? [])); | |
| 292 | + }; | |
| 293 | + | |
| 294 | + const thumbStyle: CSSProperties = { | |
| 295 | + inlineSize: toSize(thumbnailWidth), | |
| 296 | + blockSize: toSize(thumbnailHeight), | |
| 297 | + }; | |
| 298 | + | |
| 299 | + const selectButton = (type: "primary" | "secondary") => ( | |
| 300 | + <FoxButton | |
| 301 | + size="md" | |
| 302 | + type={type} | |
| 303 | + label={selectLabel} | |
| 304 | + leadingIcon={<UploadIcon />} | |
| 305 | + disabled={disabled} | |
| 306 | + onAction={openPicker} | |
| 307 | + /> | |
| 308 | + ); | |
| 309 | + | |
| 310 | + return ( | |
| 311 | + <div | |
| 312 | + className={cx("fox-file-upload", mode === "area" && "fox-file-upload--area", className)} | |
| 313 | + onDragOver={handleDragOver} | |
| 314 | + onDragLeave={handleDragLeave} | |
| 315 | + onDrop={handleDrop} | |
| 316 | + > | |
| 317 | + {label && ( | |
| 318 | + <span className="fox-file-upload__label" id={`${autoId}-label`}> | |
| 319 | + {label} | |
| 320 | + </span> | |
| 321 | + )} | |
| 322 | + | |
| 323 | + {/* 파일 고르기는 네이티브 입력이 맡고 화면에는 버튼만 보인다. */} | |
| 324 | + <input | |
| 325 | + ref={pickerRef} | |
| 326 | + type="file" | |
| 327 | + accept={accept} | |
| 328 | + multiple={multiple} | |
| 329 | + disabled={disabled} | |
| 330 | + hidden | |
| 331 | + onChange={handlePicked} | |
| 332 | + /> | |
| 333 | + | |
| 334 | + {mode === "default" && ( | |
| 335 | + <div className="fox-file-upload__row"> | |
| 336 | + <div | |
| 337 | + className={cx( | |
| 338 | + "fox-file-upload__field", | |
| 339 | + !singleFilled && "fox-file-upload__field--empty" | |
| 340 | + )} | |
| 341 | + style={{ inlineSize: toSize(inputWidth) }} | |
| 342 | + > | |
| 343 | + <FoxInput | |
| 344 | + size="md" | |
| 345 | + aria-label={placeholder} | |
| 346 | + value={singleFilled ? `${preview.name}${preview.meta ?? ""}` : ""} | |
| 347 | + placeholder={placeholder} | |
| 348 | + message={description} | |
| 349 | + readOnly | |
| 350 | + disabled={disabled} | |
| 351 | + /> | |
| 352 | + </div> | |
| 353 | + | |
| 354 | + {singleFilled ? ( | |
| 355 | + <FoxButton | |
| 356 | + size="md" | |
| 357 | + type="default" | |
| 358 | + label={removeLabel} | |
| 359 | + leadingIcon={<TrashIcon />} | |
| 360 | + disabled={disabled} | |
| 361 | + onAction={() => handleRemove(preview.id)} | |
| 362 | + /> | |
| 363 | + ) : ( | |
| 364 | + selectButton("secondary") | |
| 365 | + )} | |
| 366 | + </div> | |
| 367 | + )} | |
| 368 | + | |
| 369 | + {mode === "area" && ( | |
| 370 | + <div className={cx("fox-file-upload__area", over && "fox-file-upload__area--over")}> | |
| 371 | + <p className="fox-file-upload__area-text">{areaText}</p> | |
| 372 | + {selectButton("primary")} | |
| 373 | + </div> | |
| 374 | + )} | |
| 375 | + | |
| 376 | + {mode === "image" && ( | |
| 377 | + <div className="fox-file-upload__image"> | |
| 378 | + <div | |
| 379 | + className={cx( | |
| 380 | + "fox-file-upload__thumb", | |
| 381 | + preview?.previewUrl && "fox-file-upload__thumb--filled" | |
| 382 | + )} | |
| 383 | + style={thumbStyle} | |
| 384 | + > | |
| 385 | + {preview?.previewUrl ? ( | |
| 386 | + <> | |
| 387 | + {/* eslint-disable-next-line @next/next/no-img-element -- next/image를 쓰면 이 | |
| 388 | + 폴더가 Next 전용이 된다. */} | |
| 389 | + <img className="fox-file-upload__thumb-image" src={preview.previewUrl} alt="" /> | |
| 390 | + <span className="fox-file-upload__thumb-remove"> | |
| 391 | + <FoxIconButton | |
| 392 | + size="xsm" | |
| 393 | + type="overlay" | |
| 394 | + label={removeLabel} | |
| 395 | + icon={<TrashIcon />} | |
| 396 | + disabled={disabled} | |
| 397 | + onAction={() => handleRemove(preview.id)} | |
| 398 | + /> | |
| 399 | + </span> | |
| 400 | + </> | |
| 401 | + ) : ( | |
| 402 | + <span className="fox-file-upload__thumb-icon" aria-hidden="true"> | |
| 403 | + <CameraPlusIcon /> | |
| 404 | + </span> | |
| 405 | + )} | |
| 406 | + </div> | |
| 407 | + | |
| 408 | + <div className="fox-file-upload__image-side"> | |
| 409 | + {selectButton("secondary")} | |
| 410 | + {description && <p className="fox-file-upload__description">{description}</p>} | |
| 411 | + </div> | |
| 412 | + </div> | |
| 413 | + )} | |
| 414 | + | |
| 415 | + {listVisible && current.length > 0 && ( | |
| 416 | + <ul className="fox-file-upload__list"> | |
| 417 | + {current.map((item) => ( | |
| 418 | + <li | |
| 419 | + key={item.id} | |
| 420 | + className={cx( | |
| 421 | + "fox-file-upload__item", | |
| 422 | + item.error && "fox-file-upload__item--error" | |
| 423 | + )} | |
| 424 | + > | |
| 425 | + <div className="fox-file-upload__item-main"> | |
| 426 | + <span className="fox-file-upload__item-title"> | |
| 427 | + <span className="fox-file-upload__item-icon" aria-hidden="true"> | |
| 428 | + <PaperclipIcon /> | |
| 429 | + </span> | |
| 430 | + <span className="fox-file-upload__item-name">{item.name}</span> | |
| 431 | + {item.meta && <span className="fox-file-upload__item-meta">{item.meta}</span>} | |
| 432 | + </span> | |
| 433 | + | |
| 434 | + <span className="fox-file-upload__item-actions"> | |
| 435 | + <FoxTextButton | |
| 436 | + size="md" | |
| 437 | + type="secondary" | |
| 438 | + label={removeLabel} | |
| 439 | + trailingIcon={<XCircleIcon />} | |
| 440 | + disabled={disabled} | |
| 441 | + onAction={() => handleRemove(item.id)} | |
| 442 | + /> | |
| 443 | + </span> | |
| 444 | + </div> | |
| 445 | + | |
| 446 | + {item.error && item.message && ( | |
| 447 | + <p className="fox-file-upload__item-message"> | |
| 448 | + <span className="fox-file-upload__item-message-icon" aria-hidden="true"> | |
| 449 | + <ProhibitIcon /> | |
| 450 | + </span> | |
| 451 | + {item.message} | |
| 452 | + </p> | |
| 453 | + )} | |
| 454 | + </li> | |
| 455 | + ))} | |
| 456 | + </ul> | |
| 457 | + )} | |
| 458 | + </div> | |
| 459 | + ); | |
| 460 | +} |
+++ @fox/core/components/fox-file-upload/index.ts
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +export { | |
| 2 | + FoxFileUpload, | |
| 3 | + type FoxFileUploadProps, | |
| 4 | + type FoxFileUploadMode, | |
| 5 | + type FoxFileItem, | |
| 6 | +} from "./fox-file-upload"; |
+++ @fox/core/components/fox-input/fox-input.tsx
... | ... | @@ -0,0 +1,193 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + useId, | |
| 5 | + useRef, | |
| 6 | + useState, | |
| 7 | + type ChangeEvent, | |
| 8 | + type ComponentPropsWithRef, | |
| 9 | + type FormEvent, | |
| 10 | + type ReactNode, | |
| 11 | +} from "react"; | |
| 12 | +import { cx } from "../../utils"; | |
| 13 | + | |
| 14 | +export type FoxInputSize = "lg" | "md" | "sm"; | |
| 15 | +export type FoxInputState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * 네이티브 `<input>`이 받는 것을 그대로 넘겨받는다 — type·name·autoComplete·maxLength· | |
| 19 | + * min·max·step·pattern·inputMode·required·readOnly·disabled 등. 이름이 겹치거나 의미가 | |
| 20 | + * 다른 것만 걷어내고 아래에서 다시 정의한다. | |
| 21 | + */ | |
| 22 | +type NativeInputProps = Omit< | |
| 23 | + ComponentPropsWithRef<"input">, | |
| 24 | + "size" | "onChange" | "onInput" | "className" | "children" | "hidden" | |
| 25 | +>; | |
| 26 | + | |
| 27 | +export interface FoxInputProps extends NativeInputProps { | |
| 28 | + size?: FoxInputSize; | |
| 29 | + /** 값이 첫 인자다. 지우기 버튼은 이벤트 없이 빈 문자열로 호출한다. */ | |
| 30 | + onChange?: (value: string, event?: ChangeEvent<HTMLInputElement>) => void; | |
| 31 | + onInput?: (value: string, event: FormEvent<HTMLInputElement>) => void; | |
| 32 | + label?: string; | |
| 33 | + /** 헬퍼 메시지. 색은 상태를 따라간다. */ | |
| 34 | + message?: string; | |
| 35 | + /** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */ | |
| 36 | + messageIcon?: ReactNode; | |
| 37 | + /** 입력 오른쪽 아이콘. `currentColor`로 그린 SVG여야 색이 적용된다. */ | |
| 38 | + icon?: ReactNode; | |
| 39 | + /** 포커스 중이고 값이 있으면 지우기 버튼을 보여준다. */ | |
| 40 | + clearable?: boolean; | |
| 41 | + clearLabel?: string; | |
| 42 | + /** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼 색이 바뀐다. */ | |
| 43 | + invalid?: boolean; | |
| 44 | + /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ | |
| 45 | + state?: FoxInputState; | |
| 46 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 47 | + hidden?: boolean; | |
| 48 | + /** 배치 조정용. */ | |
| 49 | + className?: string; | |
| 50 | +} | |
| 51 | + | |
| 52 | +const SIZE_CLASS: Record<FoxInputSize, string> = { | |
| 53 | + lg: "fox-input--lg", | |
| 54 | + md: "fox-input--md", | |
| 55 | + sm: "fox-input--sm", | |
| 56 | +}; | |
| 57 | + | |
| 58 | +/** | |
| 59 | + * @fox 입력 필드. 네이티브 `<input>`을 감싸므로 date·password·email 등 모든 타입을 받는다. | |
| 60 | + * | |
| 61 | + * date 타입은 브라우저가 그리는 달력 아이콘과 피커를 그대로 쓴다 — 시안의 달력 아이콘으로 | |
| 62 | + * 바꾸지 않는다. | |
| 63 | + * | |
| 64 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 65 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 66 | + */ | |
| 67 | +export function FoxInput({ | |
| 68 | + size = "md", | |
| 69 | + onChange, | |
| 70 | + onInput, | |
| 71 | + label, | |
| 72 | + message, | |
| 73 | + messageIcon, | |
| 74 | + icon, | |
| 75 | + clearable = false, | |
| 76 | + clearLabel = "입력 지우기", | |
| 77 | + invalid = false, | |
| 78 | + state, | |
| 79 | + hidden = false, | |
| 80 | + className, | |
| 81 | + id, | |
| 82 | + value, | |
| 83 | + defaultValue, | |
| 84 | + ref, | |
| 85 | + ...rest | |
| 86 | +}: FoxInputProps) { | |
| 87 | + const autoId = useId(); | |
| 88 | + | |
| 89 | + // 지우기 버튼은 값이 있을 때만 뜬다. 비제어 입력은 DOM만 알고 있으므로 직접 따라간다. | |
| 90 | + const [typedIn, setTypedIn] = useState(() => String(defaultValue ?? "").length > 0); | |
| 91 | + const fieldRef = useRef<HTMLInputElement>(null); | |
| 92 | + | |
| 93 | + if (hidden) { | |
| 94 | + return null; | |
| 95 | + } | |
| 96 | + | |
| 97 | + const fieldId = id ?? `${autoId}-field`; | |
| 98 | + const messageId = `${autoId}-message`; | |
| 99 | + const hasValue = value !== undefined ? String(value).length > 0 : typedIn; | |
| 100 | + const errored = invalid || state === "error"; | |
| 101 | + | |
| 102 | + // 호출부 ref와 내부 ref를 함께 채운다 — 지우기가 비제어 입력의 DOM 값을 비워야 한다. | |
| 103 | + const attachField = (node: HTMLInputElement | null) => { | |
| 104 | + fieldRef.current = node; | |
| 105 | + if (typeof ref === "function") { | |
| 106 | + ref(node); | |
| 107 | + } else if (ref) { | |
| 108 | + (ref as { current: HTMLInputElement | null }).current = node; | |
| 109 | + } | |
| 110 | + }; | |
| 111 | + | |
| 112 | + const handleChange = (event: ChangeEvent<HTMLInputElement>) => { | |
| 113 | + if (value === undefined) { | |
| 114 | + setTypedIn(event.target.value.length > 0); | |
| 115 | + } | |
| 116 | + onChange?.(event.target.value, event); | |
| 117 | + }; | |
| 118 | + | |
| 119 | + const handleInput = (event: FormEvent<HTMLInputElement>) => { | |
| 120 | + onInput?.((event.target as HTMLInputElement).value, event); | |
| 121 | + }; | |
| 122 | + | |
| 123 | + const handleClear = () => { | |
| 124 | + const field = fieldRef.current; | |
| 125 | + if (field) { | |
| 126 | + // 비제어 입력은 DOM을 직접 비운다. 제어 입력이면 onChange를 받은 호출부가 비운다. | |
| 127 | + field.value = ""; | |
| 128 | + field.focus(); | |
| 129 | + } | |
| 130 | + setTypedIn(false); | |
| 131 | + onChange?.(""); | |
| 132 | + }; | |
| 133 | + | |
| 134 | + return ( | |
| 135 | + <div | |
| 136 | + className={cx("fox-input", SIZE_CLASS[size], className)} | |
| 137 | + data-state={state} | |
| 138 | + > | |
| 139 | + {label && ( | |
| 140 | + <label className="fox-input__label" htmlFor={fieldId}> | |
| 141 | + {label} | |
| 142 | + </label> | |
| 143 | + )} | |
| 144 | + | |
| 145 | + <div className="fox-input__box"> | |
| 146 | + <input | |
| 147 | + {...rest} | |
| 148 | + ref={attachField} | |
| 149 | + id={fieldId} | |
| 150 | + className="fox-input__field" | |
| 151 | + value={value} | |
| 152 | + defaultValue={defaultValue} | |
| 153 | + aria-invalid={errored || undefined} | |
| 154 | + aria-describedby={message ? messageId : undefined} | |
| 155 | + onChange={handleChange} | |
| 156 | + onInput={handleInput} | |
| 157 | + /> | |
| 158 | + | |
| 159 | + {clearable && hasValue && ( | |
| 160 | + <button | |
| 161 | + type="button" | |
| 162 | + className="fox-input__clear" | |
| 163 | + aria-label={clearLabel} | |
| 164 | + onClick={handleClear} | |
| 165 | + > | |
| 166 | + {/* 시안의 XCircle. @fox에 아이콘 세트가 없어 같은 모양을 직접 그린다. */} | |
| 167 | + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> | |
| 168 | + <circle cx="8" cy="8" r="6.25" /> | |
| 169 | + <path d="M6 6l4 4M10 6l-4 4" /> | |
| 170 | + </svg> | |
| 171 | + </button> | |
| 172 | + )} | |
| 173 | + | |
| 174 | + {icon && ( | |
| 175 | + <span className="fox-input__icon" aria-hidden="true"> | |
| 176 | + {icon} | |
| 177 | + </span> | |
| 178 | + )} | |
| 179 | + </div> | |
| 180 | + | |
| 181 | + {message && ( | |
| 182 | + <p className="fox-input__message" id={messageId}> | |
| 183 | + {messageIcon && ( | |
| 184 | + <span className="fox-input__message-icon" aria-hidden="true"> | |
| 185 | + {messageIcon} | |
| 186 | + </span> | |
| 187 | + )} | |
| 188 | + {message} | |
| 189 | + </p> | |
| 190 | + )} | |
| 191 | + </div> | |
| 192 | + ); | |
| 193 | +} |
+++ @fox/core/components/fox-input/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxInput, type FoxInputProps, type FoxInputSize, type FoxInputState } from "./fox-input"; |
+++ @fox/core/components/fox-phone-number/fox-phone-number.tsx
... | ... | @@ -0,0 +1,294 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + Fragment, | |
| 5 | + useId, | |
| 6 | + useRef, | |
| 7 | + useState, | |
| 8 | + type ChangeEvent, | |
| 9 | + type KeyboardEvent, | |
| 10 | + type ReactNode, | |
| 11 | +} from "react"; | |
| 12 | +import { cx } from "../../utils"; | |
| 13 | +import { FoxInput, type FoxInputState } from "../fox-input"; | |
| 14 | +import { FoxSelect, type FoxSelectItem } from "../fox-select"; | |
| 15 | + | |
| 16 | +export type FoxPhoneNumberType = "unit" | "combine"; | |
| 17 | +export type FoxPhoneNumberState = FoxInputState; | |
| 18 | + | |
| 19 | +type Parts = [string, string, string]; | |
| 20 | +type Lengths = [number, number, number]; | |
| 21 | + | |
| 22 | +export interface FoxPhoneNumberProps { | |
| 23 | + /** `unit`은 상자 셋을 하이픈으로 잇고, `combine`은 상자 하나에 세 칸을 둔다. */ | |
| 24 | + type?: FoxPhoneNumberType; | |
| 25 | + /** | |
| 26 | + * 숫자만 담긴 값. 하이픈은 화면에만 있고 값에는 들어가지 않는다 — `"01012345678"`. | |
| 27 | + * 꾸며진 값을 넣어도 숫자만 남겨 읽는다. 주면 제어 컴포넌트가 된다. | |
| 28 | + */ | |
| 29 | + value?: string; | |
| 30 | + defaultValue?: string; | |
| 31 | + onChange?: (value: string) => void; | |
| 32 | + /** | |
| 33 | + * 앞자리 목록. `unit`에서는 선택 목록이 되고, 두 모양 모두 **밖에서 받은 값을 나눌 때** | |
| 34 | + * 앞자리 길이를 알아내는 데 쓴다 — 없으면 `maxLengths[0]`만큼을 앞자리로 본다. | |
| 35 | + * 지역번호 `02`처럼 짧은 앞자리를 쓰면 목록에 넣거나 `maxLengths`를 함께 조정한다. | |
| 36 | + */ | |
| 37 | + prefixOptions?: string[]; | |
| 38 | + label?: string; | |
| 39 | + /** 헬퍼 메시지. 색은 상태를 따라간다. */ | |
| 40 | + message?: string; | |
| 41 | + /** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */ | |
| 42 | + messageIcon?: ReactNode; | |
| 43 | + placeholders?: Parts; | |
| 44 | + /** 칸별 최대 자릿수. 자동 이동의 기준이기도 하다. */ | |
| 45 | + maxLengths?: Lengths; | |
| 46 | + /** 칸을 다 채우면 다음 칸으로 옮기고, 빈 칸에서 지우면 앞칸으로 돌아간다. */ | |
| 47 | + autoAdvance?: boolean; | |
| 48 | + /** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼 색이 바뀐다. */ | |
| 49 | + invalid?: boolean; | |
| 50 | + disabled?: boolean; | |
| 51 | + readOnly?: boolean; | |
| 52 | + /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ | |
| 53 | + state?: FoxPhoneNumberState; | |
| 54 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 55 | + hidden?: boolean; | |
| 56 | + /** 배치 조정용. */ | |
| 57 | + className?: string; | |
| 58 | + name?: string; | |
| 59 | + id?: string; | |
| 60 | +} | |
| 61 | + | |
| 62 | +const DEFAULT_PLACEHOLDERS: Parts = ["010", "1234", "5678"]; | |
| 63 | +const DEFAULT_MAX_LENGTHS: Lengths = [3, 4, 4]; | |
| 64 | +const SEGMENT_LABELS: Parts = ["전화번호 앞자리", "전화번호 가운데자리", "전화번호 뒷자리"]; | |
| 65 | + | |
| 66 | +/** 숫자가 아닌 글자는 버린다 — 붙여넣기로 들어온 하이픈·공백도 여기서 걸린다. */ | |
| 67 | +function onlyDigits(text: string): string { | |
| 68 | + return text.replace(/\D/g, ""); | |
| 69 | +} | |
| 70 | + | |
| 71 | +/** | |
| 72 | + * 숫자만 담긴 값을 화면의 세 칸으로 나눈다. | |
| 73 | + * | |
| 74 | + * 앞자리는 선택 목록 중 가장 긴 일치를 쓴다 — `02`와 `021`이 함께 있어도 흔들리지 않는다. | |
| 75 | + * 뒷자리는 항상 마지막 네 자리다(시안 기준). 그래서 `010-123-4567`과 `010-1234-5678`이 | |
| 76 | + * 둘 다 맞게 갈린다. 남은 글자가 그보다 짧으면 아직 입력 중이라 보고 가운데에 둔다. | |
| 77 | + */ | |
| 78 | +function splitValue(value: string, prefixOptions: string[], maxLengths: Lengths): Parts { | |
| 79 | + // 밖에서 `010-1234-5678`처럼 꾸며진 값이 와도 받아 준다 — 내보낼 때는 숫자만 남는다. | |
| 80 | + const digits = onlyDigits(value ?? ""); | |
| 81 | + const matched = prefixOptions | |
| 82 | + .filter((option) => option && digits.startsWith(option)) | |
| 83 | + .sort((a, b) => b.length - a.length)[0]; | |
| 84 | + const head = matched ?? digits.slice(0, maxLengths[0]); | |
| 85 | + const rest = digits.slice(head.length); | |
| 86 | + | |
| 87 | + if (rest.length > maxLengths[2]) { | |
| 88 | + return [head, rest.slice(0, rest.length - maxLengths[2]), rest.slice(-maxLengths[2])]; | |
| 89 | + } | |
| 90 | + return [head, rest, ""]; | |
| 91 | +} | |
| 92 | + | |
| 93 | +/** | |
| 94 | + * @fox 전화번호 입력. 화면은 세 칸이지만 값은 숫자만 담긴 문자열 하나다. | |
| 95 | + * | |
| 96 | + * 화면의 세 칸이 값의 진실 공급원이다 — 입력 도중 `"0101"`처럼 어디까지가 앞자리인지 | |
| 97 | + * 알 수 없는 값이 생겨도 칸이 흔들리지 않는다. 밖에서 `value`를 바꿨을 때만 다시 나눈다. | |
| 98 | + * | |
| 99 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 100 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 101 | + */ | |
| 102 | +export function FoxPhoneNumber({ | |
| 103 | + type = "unit", | |
| 104 | + value, | |
| 105 | + defaultValue, | |
| 106 | + onChange, | |
| 107 | + prefixOptions = [], | |
| 108 | + label, | |
| 109 | + message, | |
| 110 | + messageIcon, | |
| 111 | + placeholders = DEFAULT_PLACEHOLDERS, | |
| 112 | + maxLengths = DEFAULT_MAX_LENGTHS, | |
| 113 | + autoAdvance = true, | |
| 114 | + invalid = false, | |
| 115 | + disabled = false, | |
| 116 | + readOnly = false, | |
| 117 | + state, | |
| 118 | + hidden = false, | |
| 119 | + className, | |
| 120 | + name, | |
| 121 | + id, | |
| 122 | +}: FoxPhoneNumberProps) { | |
| 123 | + const autoId = useId(); | |
| 124 | + const [parts, setParts] = useState<Parts>(() => | |
| 125 | + splitValue(value ?? defaultValue ?? "", prefixOptions, maxLengths) | |
| 126 | + ); | |
| 127 | + const [seenValue, setSeenValue] = useState(value); | |
| 128 | + const tailRefs = useRef<(HTMLInputElement | null)[]>([]); | |
| 129 | + | |
| 130 | + // 밖에서 값을 바꿨을 때만 다시 나눈다. 우리가 방금 올려보낸 값이면 그대로 둔다. | |
| 131 | + if (value !== undefined && value !== seenValue) { | |
| 132 | + setSeenValue(value); | |
| 133 | + if (value !== parts.join("")) { | |
| 134 | + setParts(splitValue(value, prefixOptions, maxLengths)); | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + if (hidden) { | |
| 139 | + return null; | |
| 140 | + } | |
| 141 | + | |
| 142 | + const rootId = id ?? autoId; | |
| 143 | + const labelId = `${rootId}-label`; | |
| 144 | + const messageId = `${rootId}-message`; | |
| 145 | + const errored = invalid || state === "error"; | |
| 146 | + | |
| 147 | + const commit = (index: number, next: string) => { | |
| 148 | + const nextParts = [...parts] as Parts; | |
| 149 | + nextParts[index] = next; | |
| 150 | + setParts(nextParts); | |
| 151 | + const joined = nextParts.join(""); | |
| 152 | + setSeenValue(joined); | |
| 153 | + onChange?.(joined); | |
| 154 | + }; | |
| 155 | + | |
| 156 | + const focusSegment = (index: number) => { | |
| 157 | + tailRefs.current[index]?.focus(); | |
| 158 | + }; | |
| 159 | + | |
| 160 | + // 숫자가 아닌 입력은 아예 들어오지 못한다. 붙여넣기로 하이픈이 섞여 와도 여기서 걸리고, | |
| 161 | + // 그 뒤에 자릿수를 자르므로 `010-1234`를 한 칸에 붙여도 넘치지 않는다. | |
| 162 | + const handleSegmentChange = (index: number) => (next: string) => { | |
| 163 | + const digits = onlyDigits(next).slice(0, maxLengths[index]); | |
| 164 | + commit(index, digits); | |
| 165 | + if (autoAdvance && index < 2 && digits.length >= maxLengths[index]) { | |
| 166 | + focusSegment(index + 1); | |
| 167 | + } | |
| 168 | + }; | |
| 169 | + | |
| 170 | + // 빈 칸에서 백스페이스하면 앞칸으로 돌아간다. | |
| 171 | + const handleSegmentKeyDown = (index: number) => (event: KeyboardEvent<HTMLInputElement>) => { | |
| 172 | + if (autoAdvance && event.key === "Backspace" && index > 0 && parts[index] === "") { | |
| 173 | + event.preventDefault(); | |
| 174 | + focusSegment(index - 1); | |
| 175 | + } | |
| 176 | + }; | |
| 177 | + | |
| 178 | + const prefixItems: FoxSelectItem[] = prefixOptions.map((option) => ({ | |
| 179 | + value: option, | |
| 180 | + label: option, | |
| 181 | + })); | |
| 182 | + | |
| 183 | + const body = | |
| 184 | + type === "unit" ? ( | |
| 185 | + <div className="fox-phone-number__row"> | |
| 186 | + <div className="fox-phone-number__part"> | |
| 187 | + <FoxSelect | |
| 188 | + size="md" | |
| 189 | + options={prefixItems} | |
| 190 | + value={parts[0]} | |
| 191 | + onValueChange={(next) => { | |
| 192 | + commit(0, next); | |
| 193 | + if (autoAdvance) { | |
| 194 | + focusSegment(1); | |
| 195 | + } | |
| 196 | + }} | |
| 197 | + placeholder={placeholders[0]} | |
| 198 | + error={errored} | |
| 199 | + disabled={disabled || state === "disabled"} | |
| 200 | + readOnly={readOnly || state === "view"} | |
| 201 | + /> | |
| 202 | + </div> | |
| 203 | + | |
| 204 | + {[1, 2].map((index) => ( | |
| 205 | + <Fragment key={index}> | |
| 206 | + <span className="fox-phone-number__separator" aria-hidden="true"> | |
| 207 | + - | |
| 208 | + </span> | |
| 209 | + <div className="fox-phone-number__part"> | |
| 210 | + <FoxInput | |
| 211 | + size="md" | |
| 212 | + inputMode="numeric" | |
| 213 | + aria-label={SEGMENT_LABELS[index]} | |
| 214 | + value={parts[index]} | |
| 215 | + onChange={handleSegmentChange(index)} | |
| 216 | + onKeyDown={handleSegmentKeyDown(index)} | |
| 217 | + placeholder={placeholders[index]} | |
| 218 | + maxLength={maxLengths[index]} | |
| 219 | + invalid={invalid} | |
| 220 | + disabled={disabled} | |
| 221 | + readOnly={readOnly} | |
| 222 | + state={state} | |
| 223 | + ref={(node) => { | |
| 224 | + tailRefs.current[index] = node; | |
| 225 | + }} | |
| 226 | + /> | |
| 227 | + </div> | |
| 228 | + </Fragment> | |
| 229 | + ))} | |
| 230 | + </div> | |
| 231 | + ) : ( | |
| 232 | + <div className="fox-phone-number__box"> | |
| 233 | + {[0, 1, 2].map((index) => ( | |
| 234 | + <Fragment key={index}> | |
| 235 | + {index > 0 && ( | |
| 236 | + <span className="fox-phone-number__separator" aria-hidden="true"> | |
| 237 | + - | |
| 238 | + </span> | |
| 239 | + )} | |
| 240 | + <input | |
| 241 | + className="fox-phone-number__segment" | |
| 242 | + inputMode="numeric" | |
| 243 | + aria-label={SEGMENT_LABELS[index]} | |
| 244 | + aria-invalid={errored || undefined} | |
| 245 | + aria-describedby={message ? messageId : undefined} | |
| 246 | + value={parts[index]} | |
| 247 | + placeholder={placeholders[index]} | |
| 248 | + maxLength={maxLengths[index]} | |
| 249 | + disabled={disabled} | |
| 250 | + readOnly={readOnly} | |
| 251 | + onChange={(event: ChangeEvent<HTMLInputElement>) => | |
| 252 | + handleSegmentChange(index)(event.target.value) | |
| 253 | + } | |
| 254 | + onKeyDown={handleSegmentKeyDown(index)} | |
| 255 | + ref={(node) => { | |
| 256 | + tailRefs.current[index] = node; | |
| 257 | + }} | |
| 258 | + /> | |
| 259 | + </Fragment> | |
| 260 | + ))} | |
| 261 | + </div> | |
| 262 | + ); | |
| 263 | + | |
| 264 | + return ( | |
| 265 | + <div | |
| 266 | + className={cx("fox-phone-number", className)} | |
| 267 | + data-state={state} | |
| 268 | + role="group" | |
| 269 | + aria-labelledby={label ? labelId : undefined} | |
| 270 | + > | |
| 271 | + {label && ( | |
| 272 | + <span className="fox-phone-number__label" id={labelId}> | |
| 273 | + {label} | |
| 274 | + </span> | |
| 275 | + )} | |
| 276 | + | |
| 277 | + {body} | |
| 278 | + | |
| 279 | + {/* 폼 전송용. 화면의 세 칸은 이름을 갖지 않는다 — 값은 언제나 합쳐진 하나다. */} | |
| 280 | + {name && <input type="hidden" name={name} value={parts.join("")} />} | |
| 281 | + | |
| 282 | + {message && ( | |
| 283 | + <p className="fox-phone-number__message" id={messageId}> | |
| 284 | + {messageIcon && ( | |
| 285 | + <span className="fox-phone-number__message-icon" aria-hidden="true"> | |
| 286 | + {messageIcon} | |
| 287 | + </span> | |
| 288 | + )} | |
| 289 | + {message} | |
| 290 | + </p> | |
| 291 | + )} | |
| 292 | + </div> | |
| 293 | + ); | |
| 294 | +} |
+++ @fox/core/components/fox-phone-number/index.ts
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +export { | |
| 2 | + FoxPhoneNumber, | |
| 3 | + type FoxPhoneNumberProps, | |
| 4 | + type FoxPhoneNumberType, | |
| 5 | + type FoxPhoneNumberState, | |
| 6 | +} from "./fox-phone-number"; |
+++ @fox/core/components/fox-select-option-item/fox-select-option-item.tsx
... | ... | @@ -0,0 +1,96 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ReactNode, Ref } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | + | |
| 6 | +export interface FoxSelectOptionItemProps { | |
| 7 | + /** 옵션 목록 안에서 유일해야 한다 — 목록을 여는 요소의 `aria-activedescendant`가 가리킨다. */ | |
| 8 | + id?: string; | |
| 9 | + label: ReactNode; | |
| 10 | + /** 라벨 뒤에 놓인다(시안에 앞 슬롯이 없다). 색은 라벨을 따라간다. */ | |
| 11 | + icon?: ReactNode; | |
| 12 | + /** 선택된 항목. `aria-selected`가 붙고 맨 앞에 체크 아이콘이 그려진다. */ | |
| 13 | + selected?: boolean; | |
| 14 | + /** | |
| 15 | + * 키보드로 이동 중인 항목. 마우스 hover와 같은 모양이지만 `:hover`로는 표현할 수 없어 | |
| 16 | + * 클래스로 둔다. 호출부가 소유한다 — 항목이 스스로 켜지지 않는다. | |
| 17 | + */ | |
| 18 | + active?: boolean; | |
| 19 | + /** | |
| 20 | + * 위험한 선택지(시안의 `state=danger`). 글자·아이콘이 danger 색으로 바뀌고, 선택된 뒤에도 | |
| 21 | + * 그 색을 유지한다. 시안이 hover·pressed·selected와 한 `state`로 묶어 놨지만 나머지 셋은 | |
| 22 | + * 브라우저가 스스로 아는 상태라, 밖에서 받아야 하는 건 이것뿐이다. | |
| 23 | + */ | |
| 24 | + danger?: boolean; | |
| 25 | + onSelect?: () => void; | |
| 26 | + /** 포인터가 올라왔을 때. 호출부가 활성 항목을 옮기는 데 쓴다. */ | |
| 27 | + onHover?: () => void; | |
| 28 | + /** 배치 조정용. 모양이 달라야 하면 여기 말고 모디파이어를 추가한다. */ | |
| 29 | + className?: string; | |
| 30 | + ref?: Ref<HTMLLIElement>; | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * @fox 옵션 목록(`FoxSelectOption`)의 항목 하나. 상태를 갖지 않는다. | |
| 35 | + * | |
| 36 | + * 목록을 여는 요소가 포커스를 계속 쥐고 있어야 하므로 이 항목은 포커스를 받지 않는다 — | |
| 37 | + * 활성 표시는 `aria-activedescendant`가, 클릭은 `onSelect`가 담당한다. | |
| 38 | + * | |
| 39 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 40 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 41 | + */ | |
| 42 | +export function FoxSelectOptionItem({ | |
| 43 | + id, | |
| 44 | + label, | |
| 45 | + icon, | |
| 46 | + selected = false, | |
| 47 | + active = false, | |
| 48 | + danger = false, | |
| 49 | + onSelect, | |
| 50 | + onHover, | |
| 51 | + className, | |
| 52 | + ref, | |
| 53 | +}: FoxSelectOptionItemProps) { | |
| 54 | + return ( | |
| 55 | + <li | |
| 56 | + ref={ref} | |
| 57 | + id={id} | |
| 58 | + role="option" | |
| 59 | + aria-selected={selected} | |
| 60 | + className={cx( | |
| 61 | + "fox-select-option-item", | |
| 62 | + active && "fox-select-option-item--active", | |
| 63 | + danger && "fox-select-option-item--danger", | |
| 64 | + className | |
| 65 | + )} | |
| 66 | + onMouseEnter={onHover} | |
| 67 | + onClick={onSelect} | |
| 68 | + > | |
| 69 | + {selected ? ( | |
| 70 | + <span className="fox-select-option-item__check" aria-hidden="true"> | |
| 71 | + <CheckIcon /> | |
| 72 | + </span> | |
| 73 | + ) : null} | |
| 74 | + <span className="fox-select-option-item__text">{label}</span> | |
| 75 | + {icon ? ( | |
| 76 | + <span className="fox-select-option-item__icon" aria-hidden="true"> | |
| 77 | + {icon} | |
| 78 | + </span> | |
| 79 | + ) : null} | |
| 80 | + </li> | |
| 81 | + ); | |
| 82 | +} | |
| 83 | + | |
| 84 | +// 시안 아이콘. 호출부가 넘기는 콘텐츠가 아니라 디자인이 정한 표시라 컴포넌트가 소유한다. | |
| 85 | +// `fill`을 `currentColor`로 두면 선택된 항목의 글자색을 물려받는데, 그 값 | |
| 86 | +// (`font-theme-secondary`)이 시안의 `#1C589C`와 같아 색 규칙을 따로 둘 필요가 없다. | |
| 87 | +function CheckIcon() { | |
| 88 | + return ( | |
| 89 | + <svg viewBox="0 0 16 16" fill="none" aria-hidden="true"> | |
| 90 | + <path | |
| 91 | + d="M14.5306 5.03063L6.5306 13.0306C6.46092 13.1006 6.37813 13.156 6.28696 13.1939C6.1958 13.2317 6.09806 13.2512 5.99935 13.2512C5.90064 13.2512 5.8029 13.2317 5.71173 13.1939C5.62057 13.156 5.53778 13.1006 5.4681 13.0306L1.9681 9.53063C1.89833 9.46087 1.84299 9.37804 1.80524 9.28689C1.76748 9.19574 1.74805 9.09804 1.74805 8.99938C1.74805 8.90072 1.76748 8.80302 1.80524 8.71187C1.84299 8.62072 1.89833 8.53789 1.9681 8.46813C2.03786 8.39837 2.12069 8.34302 2.21184 8.30527C2.30299 8.26751 2.40069 8.24808 2.49935 8.24808C2.59801 8.24808 2.69571 8.26751 2.78686 8.30527C2.87801 8.34302 2.96083 8.39837 3.0306 8.46813L5.99997 11.4375L13.4693 3.96938C13.6102 3.82848 13.8013 3.74933 14.0006 3.74933C14.1999 3.74933 14.391 3.82848 14.5318 3.96938C14.6727 4.11028 14.7519 4.30137 14.7519 4.50063C14.7519 4.69989 14.6727 4.89098 14.5318 5.03188L14.5306 5.03063Z" | |
| 92 | + fill="currentColor" | |
| 93 | + /> | |
| 94 | + </svg> | |
| 95 | + ); | |
| 96 | +} |
+++ @fox/core/components/fox-select-option-item/index.ts
... | ... | @@ -0,0 +1,4 @@ |
| 1 | +export { | |
| 2 | + FoxSelectOptionItem, | |
| 3 | + type FoxSelectOptionItemProps, | |
| 4 | +} from "./fox-select-option-item"; |
+++ @fox/core/components/fox-select-option/fox-select-option.tsx
... | ... | @@ -0,0 +1,51 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ReactNode, Ref } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | + | |
| 6 | +export interface FoxSelectOptionProps { | |
| 7 | + /** 목록을 여는 요소의 `aria-controls`가 가리킬 값. */ | |
| 8 | + id?: string; | |
| 9 | + /** 목록에 이름을 주는 요소의 id — 보통 셀렉트의 라벨이다. */ | |
| 10 | + labelledBy?: string; | |
| 11 | + /** `FoxSelectOptionItem`들. */ | |
| 12 | + children?: ReactNode; | |
| 13 | + /** 배치 조정용. 모양이 달라야 하면 여기 말고 모디파이어를 추가한다. */ | |
| 14 | + className?: string; | |
| 15 | + ref?: Ref<HTMLUListElement>; | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * @fox 셀렉트가 열렸을 때 뜨는 옵션 목록. 배치만 책임지고 상태를 갖지 않는다 — | |
| 20 | + * 열림 여부·활성 항목·선택 값은 전부 목록을 여는 쪽이 소유한다. | |
| 21 | + * | |
| 22 | + * 항목을 눌러도 포커스가 옮겨가지 않게 `mousedown` 기본 동작을 막는다. 콤보박스는 포커스가 | |
| 23 | + * 여는 요소에 남아 있어야 `aria-activedescendant`가 성립하고, 막지 않으면 누르는 순간 | |
| 24 | + * 포커스가 빠져 목록이 닫히면서 클릭이 성사되지 않는다. | |
| 25 | + * | |
| 26 | + * ⚠️ 스스로 위치를 잡지 못한다 — `position: absolute`라 위치 기준이 되는 조상이 필요하다. | |
| 27 | + * `FoxSelect` 안에서는 `fox-select__control`이 그 역할을 한다. | |
| 28 | + * | |
| 29 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 30 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 31 | + */ | |
| 32 | +export function FoxSelectOption({ | |
| 33 | + id, | |
| 34 | + labelledBy, | |
| 35 | + children, | |
| 36 | + className, | |
| 37 | + ref, | |
| 38 | +}: FoxSelectOptionProps) { | |
| 39 | + return ( | |
| 40 | + <ul | |
| 41 | + ref={ref} | |
| 42 | + id={id} | |
| 43 | + role="listbox" | |
| 44 | + aria-labelledby={labelledBy} | |
| 45 | + className={cx("fox-select-option", className)} | |
| 46 | + onMouseDown={(event) => event.preventDefault()} | |
| 47 | + > | |
| 48 | + {children} | |
| 49 | + </ul> | |
| 50 | + ); | |
| 51 | +} |
+++ @fox/core/components/fox-select-option/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxSelectOption, type FoxSelectOptionProps } from "./fox-select-option"; |
+++ @fox/core/components/fox-select/fox-select.tsx
... | ... | @@ -0,0 +1,303 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + useEffect, | |
| 5 | + useId, | |
| 6 | + useRef, | |
| 7 | + useState, | |
| 8 | + type KeyboardEvent, | |
| 9 | + type ReactNode, | |
| 10 | + type Ref, | |
| 11 | +} from "react"; | |
| 12 | +import { cx } from "../../utils"; | |
| 13 | +import { FoxSelectOption } from "../fox-select-option"; | |
| 14 | +import { FoxSelectOptionItem } from "../fox-select-option-item"; | |
| 15 | + | |
| 16 | +export type FoxSelectSize = "sm" | "md" | "lg"; | |
| 17 | + | |
| 18 | +/** 옵션 하나의 데이터. 그리는 일은 `FoxSelectOptionItem`이 한다. */ | |
| 19 | +export interface FoxSelectItem { | |
| 20 | + value: string; | |
| 21 | + label: ReactNode; | |
| 22 | + /** 옵션 라벨 뒤에 붙는 아이콘. 없으면 텍스트만 보여준다. */ | |
| 23 | + icon?: ReactNode; | |
| 24 | + /** 위험한 선택지(시안의 `state=danger`). 글자·아이콘이 danger 색으로 바뀐다. */ | |
| 25 | + danger?: boolean; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface FoxSelectProps { | |
| 29 | + size: FoxSelectSize; | |
| 30 | + options: FoxSelectItem[]; | |
| 31 | + /** 주면 제어 컴포넌트가 된다 — 호출부가 소유하고, select가 스스로 바꾸지 않는다. */ | |
| 32 | + value?: string; | |
| 33 | + /** | |
| 34 | + * 비제어로 쓸 때의 초기 값. `value`를 주면 무시된다. | |
| 35 | + * | |
| 36 | + * 버튼 계열과 달리 값 상태를 안에 둘 수 있게 열어 둔 이유는, 폼 한 칸을 놓기 위해 | |
| 37 | + * 호출부마다 `useState`를 반복하는 비용이 크기 때문이다(사용자 승인). | |
| 38 | + */ | |
| 39 | + defaultValue?: string; | |
| 40 | + /** 값이 바뀔 때 호출된다. 이름이 `onChange`가 아닌 이유는 DOM 이벤트가 아니기 때문이다. */ | |
| 41 | + onValueChange?: (value: string) => void; | |
| 42 | + /** 아무 옵션도 선택되지 않았을 때 트리거에 보여줄 문구. */ | |
| 43 | + placeholder?: ReactNode; | |
| 44 | + /** Figma 시안의 error 상태. */ | |
| 45 | + error?: boolean; | |
| 46 | + disabled?: boolean; | |
| 47 | + /** | |
| 48 | + * Figma 시안의 view(조회 전용) 상태. 값은 보이되 바꿀 수 없다 — 내부적으로는 | |
| 49 | + * `disabled`를 걸되, disabled(입력 불가 안내)와 시각적으로 구분해 보여준다. | |
| 50 | + */ | |
| 51 | + readOnly?: boolean; | |
| 52 | + /** 필드 위 라벨. 넘기지 않으면 라벨을 렌더링하지 않는다. */ | |
| 53 | + label?: ReactNode; | |
| 54 | + /** select 아래 힌트 메시지. 넘기지 않으면 힌트 영역을 렌더링하지 않는다. */ | |
| 55 | + hint?: ReactNode; | |
| 56 | + /** 힌트 메시지 앞에 붙는 아이콘. `hint`가 없으면 의미 없다. */ | |
| 57 | + hintIcon?: ReactNode; | |
| 58 | + id?: string; | |
| 59 | + /** 배치 조정용. 모양이 달라야 하면 여기 말고 모디파이어를 추가한다. */ | |
| 60 | + className?: string; | |
| 61 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 62 | + hidden?: boolean; | |
| 63 | + /** 포인터가 올라오면 `true`, 벗어나면 `false`. */ | |
| 64 | + onHoverChange?: (hovered: boolean) => void; | |
| 65 | + /** 트리거 버튼을 가리킨다 — 호출부가 포커스를 옮길 때 쓴다. */ | |
| 66 | + ref?: Ref<HTMLButtonElement>; | |
| 67 | +} | |
| 68 | + | |
| 69 | +const SIZE_CLASS: Record<FoxSelectSize, string> = { | |
| 70 | + sm: "fox-select--sm", | |
| 71 | + md: "fox-select--md", | |
| 72 | + lg: "fox-select--lg", | |
| 73 | +}; | |
| 74 | + | |
| 75 | +/** | |
| 76 | + * @fox 커스텀 드롭다운. 네이티브 `<select>`는 옵션 목록 자체를 브라우저가 그려서 | |
| 77 | + * 스타일링할 수 없기 때문에, 버튼(트리거) + 리스트박스로 직접 구현한다. | |
| 78 | + * | |
| 79 | + * 버튼 계열과 달리 상태를 갖는다 — 열림 여부와, 비제어로 쓸 때의 값이다. `value`를 주면 | |
| 80 | + * 값은 호출부가 소유하고 열림 여부만 안에 남는다. | |
| 81 | + * | |
| 82 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 83 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 84 | + */ | |
| 85 | +export function FoxSelect({ | |
| 86 | + size, | |
| 87 | + options, | |
| 88 | + value, | |
| 89 | + defaultValue, | |
| 90 | + onValueChange, | |
| 91 | + placeholder = "선택해주세요", | |
| 92 | + error, | |
| 93 | + disabled = false, | |
| 94 | + readOnly = false, | |
| 95 | + label, | |
| 96 | + hint, | |
| 97 | + hintIcon, | |
| 98 | + id, | |
| 99 | + className, | |
| 100 | + hidden = false, | |
| 101 | + onHoverChange, | |
| 102 | + ref, | |
| 103 | +}: FoxSelectProps) { | |
| 104 | + const isDisabled = disabled || readOnly; | |
| 105 | + const [open, setOpen] = useState(false); | |
| 106 | + const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? ""); | |
| 107 | + const [activeIndex, setActiveIndex] = useState(-1); | |
| 108 | + const rootRef = useRef<HTMLDivElement>(null); | |
| 109 | + | |
| 110 | + const currentValue = value ?? uncontrolledValue; | |
| 111 | + const selectedIndex = options.findIndex((option) => option.value === currentValue); | |
| 112 | + const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined; | |
| 113 | + | |
| 114 | + const generatedId = useId(); | |
| 115 | + const triggerId = id ?? generatedId; | |
| 116 | + const listboxId = `${triggerId}-listbox`; | |
| 117 | + const labelId = `${triggerId}-label`; | |
| 118 | + // 값이 아니라 순번으로 만든다 — 옵션 값에 공백·특수문자가 들어와도 id가 깨지지 않는다. | |
| 119 | + const optionId = (index: number) => `${triggerId}-opt-${index}`; | |
| 120 | + | |
| 121 | + useEffect(() => { | |
| 122 | + if (!open) return; | |
| 123 | + function handlePointerDown(event: PointerEvent) { | |
| 124 | + if (!rootRef.current?.contains(event.target as Node)) { | |
| 125 | + setOpen(false); | |
| 126 | + } | |
| 127 | + } | |
| 128 | + // 포인터만 보면 Tab으로 빠져나갈 때 리스트가 열린 채 남는다. | |
| 129 | + // | |
| 130 | + // 포커스가 아무 데도 가지 않은 경우(relatedTarget이 null)는 여기서 닫지 않는다 — | |
| 131 | + // 바깥의 포커스 못 받는 곳을 누른 것이고, 그건 위 pointerdown이 이미 처리한다. | |
| 132 | + // 이걸 닫기로 치면 옵션을 누르는 순간에도 닫혀 클릭이 성사되지 않는다. | |
| 133 | + function handleFocusOut(event: FocusEvent) { | |
| 134 | + const next = event.relatedTarget as Node | null; | |
| 135 | + if (next && !rootRef.current?.contains(next)) { | |
| 136 | + setOpen(false); | |
| 137 | + } | |
| 138 | + } | |
| 139 | + document.addEventListener("pointerdown", handlePointerDown); | |
| 140 | + const root = rootRef.current; | |
| 141 | + root?.addEventListener("focusout", handleFocusOut); | |
| 142 | + return () => { | |
| 143 | + document.removeEventListener("pointerdown", handlePointerDown); | |
| 144 | + root?.removeEventListener("focusout", handleFocusOut); | |
| 145 | + }; | |
| 146 | + }, [open]); | |
| 147 | + | |
| 148 | + function openList() { | |
| 149 | + setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); | |
| 150 | + setOpen(true); | |
| 151 | + } | |
| 152 | + | |
| 153 | + function commit(next: string) { | |
| 154 | + // 제어 컴포넌트일 때는 내부 값을 건드리지 않는다 — 건드리면 호출부가 값을 거부해도 | |
| 155 | + // 내부에만 반영되어 둘이 조용히 갈라진다. | |
| 156 | + if (value === undefined) setUncontrolledValue(next); | |
| 157 | + onValueChange?.(next); | |
| 158 | + setOpen(false); | |
| 159 | + } | |
| 160 | + | |
| 161 | + function moveActive(delta: number) { | |
| 162 | + setActiveIndex((current) => { | |
| 163 | + const count = options.length; | |
| 164 | + if (count === 0) return current; | |
| 165 | + return (current + delta + count) % count; | |
| 166 | + }); | |
| 167 | + } | |
| 168 | + | |
| 169 | + function handleTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>) { | |
| 170 | + if (isDisabled) return; | |
| 171 | + | |
| 172 | + if (!open) { | |
| 173 | + if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") { | |
| 174 | + event.preventDefault(); | |
| 175 | + openList(); | |
| 176 | + } | |
| 177 | + return; | |
| 178 | + } | |
| 179 | + | |
| 180 | + switch (event.key) { | |
| 181 | + case "ArrowDown": | |
| 182 | + event.preventDefault(); | |
| 183 | + moveActive(1); | |
| 184 | + break; | |
| 185 | + case "ArrowUp": | |
| 186 | + event.preventDefault(); | |
| 187 | + moveActive(-1); | |
| 188 | + break; | |
| 189 | + case "Home": | |
| 190 | + event.preventDefault(); | |
| 191 | + setActiveIndex(0); | |
| 192 | + break; | |
| 193 | + case "End": | |
| 194 | + event.preventDefault(); | |
| 195 | + setActiveIndex(options.length - 1); | |
| 196 | + break; | |
| 197 | + case "Enter": | |
| 198 | + case " ": | |
| 199 | + event.preventDefault(); | |
| 200 | + if (activeIndex >= 0) { | |
| 201 | + commit(options[activeIndex].value); | |
| 202 | + } | |
| 203 | + break; | |
| 204 | + case "Escape": | |
| 205 | + event.preventDefault(); | |
| 206 | + setOpen(false); | |
| 207 | + break; | |
| 208 | + default: | |
| 209 | + break; | |
| 210 | + } | |
| 211 | + } | |
| 212 | + | |
| 213 | + // 훅을 전부 부른 뒤에 빠져나간다 — 위쪽에서 반환하면 훅 호출 순서가 깨진다. | |
| 214 | + if (hidden) { | |
| 215 | + return null; | |
| 216 | + } | |
| 217 | + | |
| 218 | + return ( | |
| 219 | + <div | |
| 220 | + ref={rootRef} | |
| 221 | + className={cx("fox-select", SIZE_CLASS[size], className)} | |
| 222 | + onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined} | |
| 223 | + onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined} | |
| 224 | + > | |
| 225 | + {label ? ( | |
| 226 | + <label id={labelId} htmlFor={triggerId} className="fox-select__label"> | |
| 227 | + {label} | |
| 228 | + </label> | |
| 229 | + ) : null} | |
| 230 | + | |
| 231 | + <div className="fox-select__control"> | |
| 232 | + <button | |
| 233 | + ref={ref} | |
| 234 | + type="button" | |
| 235 | + id={triggerId} | |
| 236 | + role="combobox" | |
| 237 | + disabled={isDisabled} | |
| 238 | + aria-haspopup="listbox" | |
| 239 | + aria-expanded={open} | |
| 240 | + // 리스트박스는 열렸을 때만 렌더되므로, 닫힌 동안 없는 id를 가리키지 않게 한다. | |
| 241 | + aria-controls={open ? listboxId : undefined} | |
| 242 | + // 화살표로 옮겨 다니는 옵션을 보조기술에 알린다. 이게 없으면 `--active` 강조가 | |
| 243 | + // 시각 전용이라 스크린리더 사용자는 이동을 알 수 없다. | |
| 244 | + aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined} | |
| 245 | + aria-invalid={error || undefined} | |
| 246 | + aria-readonly={readOnly || undefined} | |
| 247 | + className={cx( | |
| 248 | + "fox-select__trigger", | |
| 249 | + selectedOption && "fox-select__trigger--completed" | |
| 250 | + )} | |
| 251 | + onClick={() => (open ? setOpen(false) : openList())} | |
| 252 | + onKeyDown={handleTriggerKeyDown} | |
| 253 | + > | |
| 254 | + <span className="fox-select__value">{selectedOption?.label ?? placeholder}</span> | |
| 255 | + <ChevronIcon className="fox-select__icon" /> | |
| 256 | + </button> | |
| 257 | + | |
| 258 | + {open ? ( | |
| 259 | + <FoxSelectOption id={listboxId} labelledBy={label ? labelId : undefined}> | |
| 260 | + {options.map((option, index) => ( | |
| 261 | + <FoxSelectOptionItem | |
| 262 | + key={option.value} | |
| 263 | + id={optionId(index)} | |
| 264 | + label={option.label} | |
| 265 | + icon={option.icon} | |
| 266 | + danger={option.danger} | |
| 267 | + selected={option.value === currentValue} | |
| 268 | + active={index === activeIndex} | |
| 269 | + onHover={() => setActiveIndex(index)} | |
| 270 | + onSelect={() => commit(option.value)} | |
| 271 | + /> | |
| 272 | + ))} | |
| 273 | + </FoxSelectOption> | |
| 274 | + ) : null} | |
| 275 | + </div> | |
| 276 | + | |
| 277 | + {hint ? ( | |
| 278 | + <div className="fox-select__hint"> | |
| 279 | + {hintIcon ? ( | |
| 280 | + <span className="fox-select__hint-icon" aria-hidden="true"> | |
| 281 | + {hintIcon} | |
| 282 | + </span> | |
| 283 | + ) : null} | |
| 284 | + <span className="fox-select__hint-message">{hint}</span> | |
| 285 | + </div> | |
| 286 | + ) : null} | |
| 287 | + </div> | |
| 288 | + ); | |
| 289 | +} | |
| 290 | + | |
| 291 | +// 시안 아이콘. viewBox가 24×24 정사각형이고 화살표가 그 안에 여백을 두고 들어 있다 — | |
| 292 | +// 여백이 SVG에 내장돼 있어 슬롯이 커지고 작아질 때 여백도 같이 비례한다(CSS 패딩 불필요). | |
| 293 | +// `fill`은 시안의 리터럴 대신 `currentColor`로 두어 색을 토큰이 정한다. | |
| 294 | +function ChevronIcon({ className }: { className?: string }) { | |
| 295 | + return ( | |
| 296 | + <svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true"> | |
| 297 | + <path | |
| 298 | + d="M20.296 9.79595L12.796 17.2959C12.6914 17.4008 12.5672 17.484 12.4305 17.5408C12.2938 17.5976 12.1471 17.6268 11.9991 17.6268C11.851 17.6268 11.7044 17.5976 11.5677 17.5408C11.4309 17.484 11.3067 17.4008 11.2022 17.2959L3.70221 9.79595C3.49086 9.5846 3.37213 9.29796 3.37213 8.99907C3.37213 8.70019 3.49086 8.41354 3.70221 8.2022C3.91355 7.99085 4.2002 7.87212 4.49908 7.87212C4.79797 7.87212 5.08461 7.99085 5.29596 8.2022L12 14.9063L18.7041 8.20126C18.9154 7.98992 19.2021 7.87119 19.501 7.87119C19.7998 7.87119 20.0865 7.98992 20.2978 8.20126C20.5092 8.41261 20.6279 8.69925 20.6279 8.99814C20.6279 9.29702 20.5092 9.58367 20.2978 9.79501L20.296 9.79595Z" | |
| 299 | + fill="currentColor" | |
| 300 | + /> | |
| 301 | + </svg> | |
| 302 | + ); | |
| 303 | +} |
+++ @fox/core/components/fox-select/index.ts
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +export { | |
| 2 | + FoxSelect, | |
| 3 | + type FoxSelectProps, | |
| 4 | + type FoxSelectSize, | |
| 5 | + type FoxSelectItem, | |
| 6 | +} from "./fox-select"; |
+++ @fox/core/components/fox-text-area/fox-text-area.tsx
... | ... | @@ -0,0 +1,142 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + useId, | |
| 5 | + useState, | |
| 6 | + type ChangeEvent, | |
| 7 | + type ComponentPropsWithRef, | |
| 8 | + type FormEvent, | |
| 9 | + type ReactNode, | |
| 10 | +} from "react"; | |
| 11 | +import { cx } from "../../utils"; | |
| 12 | + | |
| 13 | +export type FoxTextAreaState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 네이티브 `<textarea>`가 받는 것을 그대로 넘겨받는다 — rows·cols·maxLength·name· | |
| 17 | + * required·readOnly·disabled·autoComplete 등. 이름이 겹치거나 의미가 다른 것만 걷어낸다. | |
| 18 | + */ | |
| 19 | +type NativeTextAreaProps = Omit< | |
| 20 | + ComponentPropsWithRef<"textarea">, | |
| 21 | + "onChange" | "onInput" | "className" | "children" | "hidden" | |
| 22 | +>; | |
| 23 | + | |
| 24 | +export interface FoxTextAreaProps extends NativeTextAreaProps { | |
| 25 | + /** 값이 첫 인자다. */ | |
| 26 | + onChange?: (value: string, event: ChangeEvent<HTMLTextAreaElement>) => void; | |
| 27 | + onInput?: (value: string, event: FormEvent<HTMLTextAreaElement>) => void; | |
| 28 | + label?: string; | |
| 29 | + /** 헬퍼 메시지. 색은 상태를 따라간다. */ | |
| 30 | + message?: string; | |
| 31 | + /** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */ | |
| 32 | + messageIcon?: ReactNode; | |
| 33 | + /** 글자 수를 오른쪽 아래에 보여준다. `maxLength`가 있으면 `현재/최대`로 적는다. */ | |
| 34 | + counter?: boolean; | |
| 35 | + /** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼·카운터 색이 바뀐다. */ | |
| 36 | + invalid?: boolean; | |
| 37 | + /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ | |
| 38 | + state?: FoxTextAreaState; | |
| 39 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 40 | + hidden?: boolean; | |
| 41 | + /** 배치 조정용. */ | |
| 42 | + className?: string; | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** | |
| 46 | + * @fox 여러 줄 입력. 시안에 크기 축이 없어 높이는 `rows`가 정한다(기본 5줄). | |
| 47 | + * | |
| 48 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 49 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 50 | + */ | |
| 51 | +export function FoxTextArea({ | |
| 52 | + onChange, | |
| 53 | + onInput, | |
| 54 | + label, | |
| 55 | + message, | |
| 56 | + messageIcon, | |
| 57 | + counter = false, | |
| 58 | + invalid = false, | |
| 59 | + state, | |
| 60 | + hidden = false, | |
| 61 | + className, | |
| 62 | + id, | |
| 63 | + rows = 5, | |
| 64 | + value, | |
| 65 | + defaultValue, | |
| 66 | + maxLength, | |
| 67 | + ...rest | |
| 68 | +}: FoxTextAreaProps) { | |
| 69 | + const autoId = useId(); | |
| 70 | + | |
| 71 | + // 카운터는 현재 길이를 알아야 한다. 비제어 입력은 DOM만 알고 있으므로 직접 따라간다. | |
| 72 | + const [typedLength, setTypedLength] = useState(() => String(defaultValue ?? "").length); | |
| 73 | + | |
| 74 | + if (hidden) { | |
| 75 | + return null; | |
| 76 | + } | |
| 77 | + | |
| 78 | + const fieldId = id ?? `${autoId}-field`; | |
| 79 | + const messageId = `${autoId}-message`; | |
| 80 | + const length = value !== undefined ? String(value).length : typedLength; | |
| 81 | + const errored = invalid || state === "error"; | |
| 82 | + const showFooter = Boolean(message) || counter; | |
| 83 | + | |
| 84 | + const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => { | |
| 85 | + if (value === undefined) { | |
| 86 | + setTypedLength(event.target.value.length); | |
| 87 | + } | |
| 88 | + onChange?.(event.target.value, event); | |
| 89 | + }; | |
| 90 | + | |
| 91 | + const handleInput = (event: FormEvent<HTMLTextAreaElement>) => { | |
| 92 | + onInput?.((event.target as HTMLTextAreaElement).value, event); | |
| 93 | + }; | |
| 94 | + | |
| 95 | + return ( | |
| 96 | + <div className={cx("fox-text-area", className)} data-state={state}> | |
| 97 | + {label && ( | |
| 98 | + <label className="fox-text-area__label" htmlFor={fieldId}> | |
| 99 | + {label} | |
| 100 | + </label> | |
| 101 | + )} | |
| 102 | + | |
| 103 | + <div className="fox-text-area__box"> | |
| 104 | + <textarea | |
| 105 | + {...rest} | |
| 106 | + id={fieldId} | |
| 107 | + className="fox-text-area__field" | |
| 108 | + rows={rows} | |
| 109 | + value={value} | |
| 110 | + defaultValue={defaultValue} | |
| 111 | + maxLength={maxLength} | |
| 112 | + aria-invalid={errored || undefined} | |
| 113 | + aria-describedby={message ? messageId : undefined} | |
| 114 | + onChange={handleChange} | |
| 115 | + onInput={handleInput} | |
| 116 | + /> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + {showFooter && ( | |
| 120 | + <div className="fox-text-area__footer"> | |
| 121 | + {message && ( | |
| 122 | + <p className="fox-text-area__message" id={messageId}> | |
| 123 | + {messageIcon && ( | |
| 124 | + <span className="fox-text-area__message-icon" aria-hidden="true"> | |
| 125 | + {messageIcon} | |
| 126 | + </span> | |
| 127 | + )} | |
| 128 | + {message} | |
| 129 | + </p> | |
| 130 | + )} | |
| 131 | + | |
| 132 | + {counter && ( | |
| 133 | + <p className="fox-text-area__counter"> | |
| 134 | + <span className="fox-text-area__counter-current">{length}</span> | |
| 135 | + {maxLength !== undefined && `/${maxLength}`} | |
| 136 | + </p> | |
| 137 | + )} | |
| 138 | + </div> | |
| 139 | + )} | |
| 140 | + </div> | |
| 141 | + ); | |
| 142 | +} |
+++ @fox/core/components/fox-text-area/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxTextArea, type FoxTextAreaProps, type FoxTextAreaState } from "./fox-text-area"; |
+++ @fox/core/components/index.ts
... | ... | @@ -0,0 +1,16 @@ |
| 1 | +export * from "./fox-address"; | |
| 2 | +export * from "./fox-button"; | |
| 3 | +export * from "./fox-button-group"; | |
| 4 | +export * from "./fox-button-panel"; | |
| 5 | +export * from "./fox-certified"; | |
| 6 | +export * from "./fox-email"; | |
| 7 | +export * from "./fox-file-upload"; | |
| 8 | +export * from "./fox-icon-button"; | |
| 9 | +export * from "./fox-input"; | |
| 10 | +export * from "./fox-link-button"; | |
| 11 | +export * from "./fox-phone-number"; | |
| 12 | +export * from "./fox-select"; | |
| 13 | +export * from "./fox-select-option"; | |
| 14 | +export * from "./fox-select-option-item"; | |
| 15 | +export * from "./fox-text-area"; | |
| 16 | +export * from "./fox-text-button"; |
+++ @fox/core/utils/cx.ts
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +export type ClassValue = string | false | null | undefined; | |
| 2 | + | |
| 3 | + | |
| 4 | +export function cx(...values: ClassValue[]): string { | |
| 5 | + return values.filter(Boolean).join(" "); | |
| 6 | +} |
+++ @fox/core/utils/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { cx, type ClassValue } from "./cx"; |
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -0,0 +1,1395 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { FoxInput, FoxInputState, FoxSelect, FoxSelectItem, FoxSelectOption, FoxSelectOptionItem } from "@/@fox/core/components"; | |
| 4 | +import { useState, type CSSProperties, type ReactNode } from "react"; | |
| 5 | +import { FoxButton } from "../core/components/fox-button"; | |
| 6 | +import { FoxButtonGroup } from "../core/components/fox-button-group"; | |
| 7 | +import { FoxButtonPanel } from "../core/components/fox-button-panel"; | |
| 8 | +import { FoxIconButton } from "../core/components/fox-icon-button"; | |
| 9 | +import { FoxLinkButton } from "../core/components/fox-link-button"; | |
| 10 | +import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address"; | |
| 11 | +import { FoxCertified } from "../core/components/fox-certified"; | |
| 12 | +import { FoxEmail } from "../core/components/fox-email"; | |
| 13 | +import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload"; | |
| 14 | +import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number"; | |
| 15 | +import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area"; | |
| 16 | +import { FoxTextButton } from "../core/components/fox-text-button"; | |
| 17 | +import styles from "./dev-test.module.scss"; | |
| 18 | + | |
| 19 | +export interface ComponentExample { | |
| 20 | + /** 해시 링크에 쓰이는 kebab-case 식별자. */ | |
| 21 | + id: string; | |
| 22 | + name: string; | |
| 23 | + description?: string; | |
| 24 | + /** 눈으로 비교해야 하는 조합을 빠짐없이 넣는다 — 예제가 곧 회귀 확인 수단이다. */ | |
| 25 | + variants: { label: string; node: ReactNode }[]; | |
| 26 | +} | |
| 27 | + | |
| 28 | +// 자리 확인용 도형. `currentColor`로 그려야 계열별 색이 입혀진다. style은 FoxSelect | |
| 29 | +// 힌트 아이콘처럼 크기를 줄여 써야 하는 자리에 쓴다. | |
| 30 | +function DemoIcon({ style }: { style?: CSSProperties } = {}) { | |
| 31 | + return ( | |
| 32 | + <svg | |
| 33 | + viewBox="0 0 16 16" | |
| 34 | + fill="none" | |
| 35 | + stroke="currentColor" | |
| 36 | + strokeWidth="2" | |
| 37 | + aria-hidden="true" | |
| 38 | + style={style} | |
| 39 | + > | |
| 40 | + <path d="M6 3l5 5-5 5" /> | |
| 41 | + </svg> | |
| 42 | + ); | |
| 43 | +} | |
| 44 | + | |
| 45 | +function DemoStar() { | |
| 46 | + return ( | |
| 47 | + <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| 48 | + <path d="M8 1.6l1.9 4 4.4.6-3.2 3 .8 4.3L8 11.5l-3.9 2 .8-4.3-3.2-3 4.4-.6z" /> | |
| 49 | + </svg> | |
| 50 | + ); | |
| 51 | +} | |
| 52 | + | |
| 53 | +// overlay 계열은 어두운 반투명 배경이라 밝은 바탕 위에서는 대비를 볼 수 없다. | |
| 54 | +function OnPhoto({ children }: { children: ReactNode }) { | |
| 55 | + return ( | |
| 56 | + <span | |
| 57 | + style={{ | |
| 58 | + display: "inline-flex", | |
| 59 | + gap: "0.8rem", | |
| 60 | + padding: "0.8rem", | |
| 61 | + borderRadius: "0.8rem", | |
| 62 | + backgroundImage: "url(https://picsum.photos/seed/picsum/200/300)", | |
| 63 | + backgroundSize: "cover", | |
| 64 | + }} | |
| 65 | + > | |
| 66 | + {children} | |
| 67 | + </span> | |
| 68 | + ); | |
| 69 | +} | |
| 70 | + | |
| 71 | +// 입력 예제는 폭이 있어야 시안(360px)처럼 보인다. | |
| 72 | +function InputStage({ children, wide = false }: { children: ReactNode; wide?: boolean }) { | |
| 73 | + return <div className={wide ? styles.demoFieldWide : styles.demoField}>{children}</div>; | |
| 74 | +} | |
| 75 | + | |
| 76 | +// 지우기 버튼과 자동 상태 판정을 실제로 눌러 보려면 예제가 값을 들고 있어야 한다. | |
| 77 | +function InputLiveDemo() { | |
| 78 | + const [text, setText] = useState("지우기를 눌러보세요"); | |
| 79 | + return ( | |
| 80 | + <> | |
| 81 | + <InputStage> | |
| 82 | + <FoxInput | |
| 83 | + size="md" | |
| 84 | + label="자동 상태" | |
| 85 | + placeholder="입력하면 글자가 진해집니다" | |
| 86 | + message="포커스하면 도움말이 파랗게 바뀝니다" | |
| 87 | + value={text} | |
| 88 | + onChange={setText} | |
| 89 | + clearable | |
| 90 | + /> | |
| 91 | + </InputStage> | |
| 92 | + <InputStage> | |
| 93 | + <FoxInput | |
| 94 | + size="md" | |
| 95 | + label="오류 (invalid)" | |
| 96 | + placeholder="내용을 입력하세요." | |
| 97 | + message="필수 항목입니다" | |
| 98 | + invalid | |
| 99 | + /> | |
| 100 | + </InputStage> | |
| 101 | + </> | |
| 102 | + ); | |
| 103 | +} | |
| 104 | + | |
| 105 | +// 카운터가 실제로 올라가는 걸 보려면 예제가 값을 들고 있어야 한다. | |
| 106 | +function TextAreaLiveDemo() { | |
| 107 | + const [text, setText] = useState("입력하면 아래 숫자가 따라 올라갑니다."); | |
| 108 | + return ( | |
| 109 | + <> | |
| 110 | + <InputStage> | |
| 111 | + <FoxTextArea | |
| 112 | + label="자동 상태 · 카운터" | |
| 113 | + placeholder="내용을 입력하세요." | |
| 114 | + message="포커스하면 도움말이 파랗게 바뀝니다" | |
| 115 | + value={text} | |
| 116 | + onChange={setText} | |
| 117 | + maxLength={100} | |
| 118 | + counter | |
| 119 | + /> | |
| 120 | + </InputStage> | |
| 121 | + <InputStage> | |
| 122 | + <FoxTextArea | |
| 123 | + label="오류 (invalid)" | |
| 124 | + placeholder="내용을 입력하세요." | |
| 125 | + message="100자를 넘길 수 없습니다" | |
| 126 | + defaultValue={"x".repeat(100)} | |
| 127 | + maxLength={100} | |
| 128 | + counter | |
| 129 | + invalid | |
| 130 | + /> | |
| 131 | + </InputStage> | |
| 132 | + </> | |
| 133 | + ); | |
| 134 | +} | |
| 135 | + | |
| 136 | +const PHONE_PREFIXES = ["010", "011", "016", "017", "018", "019", "02", "031", "051"]; | |
| 137 | + | |
| 138 | +// 값이 숫자만 담긴 문자열 하나라는 걸 보려면 예제가 그 값을 그대로 보여줘야 한다. | |
| 139 | +function PhoneNumberLiveDemo() { | |
| 140 | + const [unit, setUnit] = useState("01012345678"); | |
| 141 | + const [combine, setCombine] = useState("0212345678"); | |
| 142 | + return ( | |
| 143 | + <> | |
| 144 | + <InputStage> | |
| 145 | + <FoxPhoneNumber | |
| 146 | + label={`unit — value="${unit}"`} | |
| 147 | + prefixOptions={PHONE_PREFIXES} | |
| 148 | + value={unit} | |
| 149 | + onChange={setUnit} | |
| 150 | + message="칸을 다 채우면 다음 칸으로 넘어갑니다" | |
| 151 | + /> | |
| 152 | + </InputStage> | |
| 153 | + <InputStage> | |
| 154 | + <FoxPhoneNumber | |
| 155 | + type="combine" | |
| 156 | + label={`combine — value="${combine}"`} | |
| 157 | + prefixOptions={PHONE_PREFIXES} | |
| 158 | + value={combine} | |
| 159 | + onChange={setCombine} | |
| 160 | + message="앞자리 목록에 02가 있어 지역번호도 제자리에 갈립니다" | |
| 161 | + /> | |
| 162 | + </InputStage> | |
| 163 | + </> | |
| 164 | + ); | |
| 165 | +} | |
| 166 | + | |
| 167 | +const EMAIL_DOMAINS = ["naver.com", "gmail.com", "daum.net", "kakao.com"]; | |
| 168 | + | |
| 169 | +// 값이 합쳐진 문자열 하나라는 걸 보려면 예제가 그 값을 그대로 보여줘야 한다. | |
| 170 | +function EmailLiveDemo() { | |
| 171 | + const [email, setEmail] = useState("hong@naver.com"); | |
| 172 | + return ( | |
| 173 | + <InputStage wide> | |
| 174 | + <FoxEmail | |
| 175 | + label={`value="${email}"`} | |
| 176 | + domainOptions={EMAIL_DOMAINS} | |
| 177 | + value={email} | |
| 178 | + onChange={setEmail} | |
| 179 | + message="도메인을 고르면 옆 칸이 채워지고 잠깁니다. 직접입력을 고르면 풀립니다." | |
| 180 | + /> | |
| 181 | + </InputStage> | |
| 182 | + ); | |
| 183 | +} | |
| 184 | + | |
| 185 | +// 어떤 우편번호 서비스를 쓰든 이 모양으로 돌려주면 된다 — 여기서는 지연만 흉내낸다. | |
| 186 | +const FAKE_SEARCH_RESULT: FoxAddressSearchResult = { | |
| 187 | + zipCode: "06236", | |
| 188 | + address: "서울 강남구 테헤란로 152", | |
| 189 | + detail: "강남파이낸스센터", | |
| 190 | +}; | |
| 191 | + | |
| 192 | +function AddressLiveDemo({ cancel = false }) { | |
| 193 | + const [value, setValue] = useState<FoxAddressValue>({ zipCode: "", address: "", detail: "" }); | |
| 194 | + return ( | |
| 195 | + <> | |
| 196 | + <InputStage> | |
| 197 | + <FoxAddress | |
| 198 | + label={cancel ? "검색을 취소하면 값이 그대로 남습니다" : "주소 검색을 눌러보세요"} | |
| 199 | + value={value} | |
| 200 | + onChange={setValue} | |
| 201 | + onSearch={async () => { | |
| 202 | + await new Promise((resolve) => setTimeout(resolve, 400)); | |
| 203 | + return cancel ? null : FAKE_SEARCH_RESULT; | |
| 204 | + }} | |
| 205 | + /> | |
| 206 | + </InputStage> | |
| 207 | + <pre className={styles.code}>{JSON.stringify(value, null, 2)}</pre> | |
| 208 | + </> | |
| 209 | + ); | |
| 210 | +} | |
| 211 | + | |
| 212 | +// 목록은 업로드 결과에 따라 바뀌므로, 오류 줄까지 보려면 예제가 값을 들고 있어야 한다. | |
| 213 | +const SAMPLE_FILES: FoxFileItem[] = [ | |
| 214 | + { id: "a", name: "사업자등록증", meta: ".PDF (20KB)" }, | |
| 215 | + { id: "b", name: "아주 긴 파일 이름이라 가운데에서 말줄임되어야 하는 첨부물", meta: ".XLSX (1.2MB)" }, | |
| 216 | + { | |
| 217 | + id: "c", | |
| 218 | + name: "용량초과파일", | |
| 219 | + meta: ".ZIP (32MB)", | |
| 220 | + error: true, | |
| 221 | + message: "등록 가능한 파일 용량을 초과하였습니다. 20MB 미만의 파일만 등록할 수 있습니다.", | |
| 222 | + }, | |
| 223 | +]; | |
| 224 | + | |
| 225 | +function FileUploadLiveDemo({ mode }: { mode: "default" | "area" }) { | |
| 226 | + const [files, setFiles] = useState<FoxFileItem[]>(SAMPLE_FILES); | |
| 227 | + return ( | |
| 228 | + <InputStage wide> | |
| 229 | + <FoxFileUpload | |
| 230 | + mode={mode} | |
| 231 | + label={mode === "area" ? "끌어다 놓거나 버튼으로 고릅니다" : "고른 파일이 칸에 적힙니다"} | |
| 232 | + files={files} | |
| 233 | + onSelect={(picked) => | |
| 234 | + setFiles((prev) => [ | |
| 235 | + ...prev, | |
| 236 | + ...picked.map((file) => ({ id: file.name, name: file.name, meta: `(${file.size}B)` })), | |
| 237 | + ]) | |
| 238 | + } | |
| 239 | + onRemove={(id) => setFiles((prev) => prev.filter((item) => item.id !== id))} | |
| 240 | + description={mode === "default" ? "JPG, PNG 파일만 가능 (최대 5MB)" : undefined} | |
| 241 | + multiple | |
| 242 | + /> | |
| 243 | + </InputStage> | |
| 244 | + ); | |
| 245 | +} | |
| 246 | + | |
| 247 | +// 이벤트가 실제로 도는 걸 보려면 예제가 결과를 적어 둬야 한다. 서버 대신 지연만 흉내낸다. | |
| 248 | +function CertifiedLiveDemo({ failSend = false, failVerify = false }) { | |
| 249 | + const [log, setLog] = useState<string[]>([]); | |
| 250 | + const write = (line: string) => setLog((prev) => [line, ...prev].slice(0, 5)); | |
| 251 | + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| 252 | + | |
| 253 | + return ( | |
| 254 | + <> | |
| 255 | + <InputStage> | |
| 256 | + <FoxCertified | |
| 257 | + prefixOptions={PHONE_PREFIXES} | |
| 258 | + defaultPhone="01012345678" | |
| 259 | + expiresIn={180} | |
| 260 | + onSend={async (phone) => { | |
| 261 | + await wait(400); | |
| 262 | + if (failSend) throw new Error("발송 한도를 넘었습니다"); | |
| 263 | + write(`보내기 요청 → ${phone}`); | |
| 264 | + }} | |
| 265 | + onVerify={async (_phone, code) => { | |
| 266 | + await wait(400); | |
| 267 | + if (failVerify || code !== "123456") throw new Error("번호가 맞지 않습니다"); | |
| 268 | + }} | |
| 269 | + onSendSuccess={() => write("onSendSuccess")} | |
| 270 | + onSendFail={(error) => write(`onSendFail — ${(error as Error).message}`)} | |
| 271 | + onVerifySuccess={() => write("onVerifySuccess — 이제 전부 잠깁니다")} | |
| 272 | + onVerifyFail={(error) => write(`onVerifyFail — ${(error as Error).message}`)} | |
| 273 | + onExpire={() => write("onExpire")} | |
| 274 | + /> | |
| 275 | + </InputStage> | |
| 276 | + <pre className={styles.code}>{log.length ? log.join("\n") : "이벤트가 여기에 쌓입니다 (인증번호는 123456)"}</pre> | |
| 277 | + </> | |
| 278 | + ); | |
| 279 | +} | |
| 280 | + | |
| 281 | +// active는 호출부가 소유하므로 켜고 끄는 걸 보려면 예제가 상태를 들고 있어야 한다. | |
| 282 | +function IconToggleDemo() { | |
| 283 | + const [on, setOn] = useState(false); | |
| 284 | + return ( | |
| 285 | + <> | |
| 286 | + <FoxIconButton | |
| 287 | + size="md" | |
| 288 | + type="default" | |
| 289 | + label={on ? "즐겨찾기 해제" : "즐겨찾기"} | |
| 290 | + icon={<DemoStar />} | |
| 291 | + active={on} | |
| 292 | + onAction={() => setOn((prev) => !prev)} | |
| 293 | + /> | |
| 294 | + <FoxIconButton | |
| 295 | + size="md" | |
| 296 | + type="ghost" | |
| 297 | + label={on ? "즐겨찾기 해제" : "즐겨찾기"} | |
| 298 | + icon={<DemoStar />} | |
| 299 | + active={on} | |
| 300 | + onAction={() => setOn((prev) => !prev)} | |
| 301 | + /> | |
| 302 | + <OnPhoto> | |
| 303 | + <FoxIconButton | |
| 304 | + size="md" | |
| 305 | + type="overlay" | |
| 306 | + label={on ? "즐겨찾기 해제" : "즐겨찾기"} | |
| 307 | + icon={<DemoStar />} | |
| 308 | + active={on} | |
| 309 | + onAction={() => setOn((prev) => !prev)} | |
| 310 | + /> | |
| 311 | + </OnPhoto> | |
| 312 | + </> | |
| 313 | + ); | |
| 314 | +} | |
| 315 | + | |
| 316 | +const SELECT_ITEMS: FoxSelectItem[] = [ | |
| 317 | + { value: "a", label: "옵션 A" }, | |
| 318 | + { value: "b", label: "옵션 B" }, | |
| 319 | + { value: "c", label: "옵션 C" }, | |
| 320 | +]; | |
| 321 | + | |
| 322 | +// 옵션 아이콘은 별도 목록으로 둔다 — 위 목록에 섞으면 크기·상태 예제가 전부 같이 바뀌어 | |
| 323 | +// 무엇을 보는 예제인지 흐려진다. 아이콘은 시안 미정이라 자리 확인용 도형이다. | |
| 324 | +const SELECT_ITEMS_RICH: FoxSelectItem[] = [ | |
| 325 | + { value: "a", label: "아이콘 있는 옵션", icon: <DemoIcon /> }, | |
| 326 | + { value: "b", label: "아이콘 없는 옵션" }, | |
| 327 | + { value: "c", label: "아주 긴 옵션 라벨 — 트리거에서는 말줄임으로 잘린다" }, | |
| 328 | +]; | |
| 329 | + | |
| 330 | +/** 컴포넌트를 만들 때마다 항목 하나를 추가한다 — 다른 파일은 고치지 않는다. */ | |
| 331 | +export const COMPONENT_EXAMPLES: ComponentExample[] = [ | |
| 332 | + { | |
| 333 | + id: "fox-button", | |
| 334 | + name: "FoxButton", | |
| 335 | + description: | |
| 336 | + "Figma 시안(btn, 컴포넌트 세트 106:1606)을 토큰만으로 옮겼습니다. 너비는 내용에 따라 가변이고 높이만 고정입니다. hover·pressed는 마우스를 올리거나 눌러 확인하세요. error 계열은 시안 미정이라 비어 있습니다.", | |
| 337 | + variants: [ | |
| 338 | + { | |
| 339 | + label: "계열 (type) — hover·pressed 확인 가능", | |
| 340 | + node: ( | |
| 341 | + <> | |
| 342 | + <FoxButton size="md" label="default" /> | |
| 343 | + <FoxButton size="md" type="primary" label="primary" /> | |
| 344 | + <FoxButton size="md" type="secondary" label="secondary" /> | |
| 345 | + <FoxButton size="md" type="error" label="error (시안 미정)" /> | |
| 346 | + </> | |
| 347 | + ), | |
| 348 | + }, | |
| 349 | + { | |
| 350 | + label: "크기 (size)", | |
| 351 | + node: ( | |
| 352 | + <> | |
| 353 | + <FoxButton size="lg" label="lg" /> | |
| 354 | + <FoxButton size="md" label="md" /> | |
| 355 | + <FoxButton size="sm" label="sm" /> | |
| 356 | + <FoxButton size="xsm" label="xsm" /> | |
| 357 | + </> | |
| 358 | + ), | |
| 359 | + }, | |
| 360 | + { | |
| 361 | + label: "아이콘", | |
| 362 | + node: ( | |
| 363 | + <> | |
| 364 | + <FoxButton size="md" label="leading" leadingIcon={<DemoIcon />} /> | |
| 365 | + <FoxButton size="md" label="trailing" trailingIcon={<DemoIcon />} /> | |
| 366 | + <FoxButton size="md" label="both" leadingIcon={<DemoIcon />} trailingIcon={<DemoIcon />} /> | |
| 367 | + </> | |
| 368 | + ), | |
| 369 | + }, | |
| 370 | + { | |
| 371 | + label: "상태 — disabled / loading (둘 다 클릭이 막힌다)", | |
| 372 | + node: ( | |
| 373 | + <> | |
| 374 | + <FoxButton size="md" label="normal" onAction={() => undefined} /> | |
| 375 | + <FoxButton size="md" label="disabled" disabled /> | |
| 376 | + <FoxButton size="md" label="loading" loading /> | |
| 377 | + <FoxButton size="md" label="loading + icon" loading leadingIcon={<DemoIcon />} /> | |
| 378 | + </> | |
| 379 | + ), | |
| 380 | + }, | |
| 381 | + { | |
| 382 | + label: "fullWidth", | |
| 383 | + node: <FoxButton size="md" label="fullWidth" fullWidth />, | |
| 384 | + }, | |
| 385 | + { | |
| 386 | + label: "htmlType — 폼 안에서의 역할", | |
| 387 | + node: ( | |
| 388 | + <> | |
| 389 | + <FoxButton size="md" label="button (기본)" /> | |
| 390 | + <FoxButton size="md" label="submit" htmlType="submit" /> | |
| 391 | + <FoxButton size="md" label="reset" htmlType="reset" /> | |
| 392 | + </> | |
| 393 | + ), | |
| 394 | + }, | |
| 395 | + { | |
| 396 | + label: "hidden — 렌더되지 않는다(아래는 비어 있는 것이 정상)", | |
| 397 | + node: <FoxButton size="md" label="hidden" hidden />, | |
| 398 | + }, | |
| 399 | + ], | |
| 400 | + }, | |
| 401 | + { | |
| 402 | + id: "fox-text-button", | |
| 403 | + name: "FoxTextButton", | |
| 404 | + description: | |
| 405 | + "Figma 시안(btn-txt, 컴포넌트 세트 106:2468)을 토큰만으로 옮겼습니다. 높이를 고정하지 않고 패딩과 line-height로만 크기를 잡습니다. lg만 상하 패딩과 간격이 한 단계 넓습니다.", | |
| 406 | + variants: [ | |
| 407 | + { | |
| 408 | + label: "계열 (type) — primary는 굵기가 한 단계 더 굵다", | |
| 409 | + node: ( | |
| 410 | + <> | |
| 411 | + <FoxTextButton size="md" type="primary" label="primary" /> | |
| 412 | + <FoxTextButton size="md" type="secondary" label="secondary" /> | |
| 413 | + </> | |
| 414 | + ), | |
| 415 | + }, | |
| 416 | + { | |
| 417 | + label: "크기 (size)", | |
| 418 | + node: ( | |
| 419 | + <> | |
| 420 | + <FoxTextButton size="lg" label="lg" /> | |
| 421 | + <FoxTextButton size="md" label="md" /> | |
| 422 | + <FoxTextButton size="sm" label="sm" /> | |
| 423 | + <FoxTextButton size="xsm" label="xsm" /> | |
| 424 | + </> | |
| 425 | + ), | |
| 426 | + }, | |
| 427 | + { | |
| 428 | + label: "아이콘", | |
| 429 | + node: ( | |
| 430 | + <> | |
| 431 | + <FoxTextButton size="md" label="leading" leadingIcon={<DemoIcon />} /> | |
| 432 | + <FoxTextButton size="md" label="trailing" trailingIcon={<DemoIcon />} /> | |
| 433 | + <FoxTextButton | |
| 434 | + size="md" | |
| 435 | + label="both" | |
| 436 | + leadingIcon={<DemoIcon />} | |
| 437 | + trailingIcon={<DemoIcon />} | |
| 438 | + /> | |
| 439 | + </> | |
| 440 | + ), | |
| 441 | + }, | |
| 442 | + { | |
| 443 | + label: "상태 — disabled", | |
| 444 | + node: ( | |
| 445 | + <> | |
| 446 | + <FoxTextButton size="md" label="normal" onAction={() => undefined} /> | |
| 447 | + <FoxTextButton size="md" label="disabled" disabled /> | |
| 448 | + <FoxTextButton size="md" type="secondary" label="disabled (secondary)" disabled /> | |
| 449 | + </> | |
| 450 | + ), | |
| 451 | + }, | |
| 452 | + ], | |
| 453 | + }, | |
| 454 | + { | |
| 455 | + id: "fox-link-button", | |
| 456 | + name: "FoxLinkButton", | |
| 457 | + description: | |
| 458 | + "Figma 시안(btn-lnk, 컴포넌트 세트 107:207)을 토큰만으로 옮겼습니다. href를 주면 <a>, 없으면 <button>으로 렌더합니다. 방문 색은 브라우저가 실제 방문 기록으로만 켜므로 아래에서 링크를 한 번 눌러 돌아오면 색이 바뀝니다. tertiary만 평소에 밑줄이 없고 hover에서만 생깁니다.", | |
| 459 | + variants: [ | |
| 460 | + { | |
| 461 | + label: "계열 (type) — hover하면 tertiary에 밑줄이 생긴다", | |
| 462 | + node: ( | |
| 463 | + <> | |
| 464 | + <FoxLinkButton size="md" type="primary" label="primary" href="https://example.com" /> | |
| 465 | + <FoxLinkButton | |
| 466 | + size="md" | |
| 467 | + type="secondary" | |
| 468 | + label="secondary" | |
| 469 | + href="https://example.com" | |
| 470 | + /> | |
| 471 | + <FoxLinkButton size="md" type="tertiary" label="tertiary" href="https://example.com" /> | |
| 472 | + </> | |
| 473 | + ), | |
| 474 | + }, | |
| 475 | + { | |
| 476 | + label: "크기 (size)", | |
| 477 | + node: ( | |
| 478 | + <> | |
| 479 | + <FoxLinkButton size="lg" label="lg" href="https://example.com" /> | |
| 480 | + <FoxLinkButton size="md" label="md" href="https://example.com" /> | |
| 481 | + <FoxLinkButton size="sm" label="sm" href="https://example.com" /> | |
| 482 | + </> | |
| 483 | + ), | |
| 484 | + }, | |
| 485 | + { | |
| 486 | + label: "아이콘 — 라벨 뒤에만 놓인다", | |
| 487 | + node: ( | |
| 488 | + <> | |
| 489 | + <FoxLinkButton size="lg" label="lg" href="https://example.com" icon={<DemoIcon />} /> | |
| 490 | + <FoxLinkButton size="md" label="md" href="https://example.com" icon={<DemoIcon />} /> | |
| 491 | + <FoxLinkButton size="sm" label="sm" href="https://example.com" icon={<DemoIcon />} /> | |
| 492 | + </> | |
| 493 | + ), | |
| 494 | + }, | |
| 495 | + { | |
| 496 | + label: "href 없음 — <button>으로 렌더된다(방문 색이 켜지지 않는다)", | |
| 497 | + node: ( | |
| 498 | + <> | |
| 499 | + <FoxLinkButton size="md" label="onAction만" onAction={() => undefined} /> | |
| 500 | + <FoxLinkButton size="md" type="tertiary" label="tertiary" onAction={() => undefined} /> | |
| 501 | + </> | |
| 502 | + ), | |
| 503 | + }, | |
| 504 | + { | |
| 505 | + label: "상태 — disabled (앵커는 href가 빠져 포커스·클릭이 함께 사라진다)", | |
| 506 | + node: ( | |
| 507 | + <> | |
| 508 | + <FoxLinkButton size="md" label="disabled (a)" href="https://example.com" disabled /> | |
| 509 | + <FoxLinkButton size="md" label="disabled (button)" disabled /> | |
| 510 | + </> | |
| 511 | + ), | |
| 512 | + }, | |
| 513 | + ], | |
| 514 | + }, | |
| 515 | + { | |
| 516 | + id: "fox-icon-button", | |
| 517 | + name: "FoxIconButton", | |
| 518 | + description: | |
| 519 | + "Figma 시안(btn-ico 400:6312 + btn-ico-toggle 404:5247)을 토큰만으로 옮겼습니다. 두 시안이 계열·크기·모양이 같고 켜짐 상태만 달라 한 컴포넌트로 합쳤습니다 — active를 주면 토글(aria-pressed)이 됩니다. ghost는 테두리가 없는 대신 아이콘이 한 단계 큽니다.", | |
| 520 | + variants: [ | |
| 521 | + { | |
| 522 | + label: "계열 (type) — overlay는 사진 위에서 봐야 한다", | |
| 523 | + node: ( | |
| 524 | + <> | |
| 525 | + <FoxIconButton size="md" type="default" label="default" icon={<DemoIcon />} /> | |
| 526 | + <FoxIconButton size="md" type="ghost" label="ghost" icon={<DemoIcon />} /> | |
| 527 | + <OnPhoto> | |
| 528 | + <FoxIconButton size="md" type="overlay" label="overlay" icon={<DemoIcon />} /> | |
| 529 | + </OnPhoto> | |
| 530 | + </> | |
| 531 | + ), | |
| 532 | + }, | |
| 533 | + { | |
| 534 | + label: "크기 (size) — default 계열", | |
| 535 | + node: ( | |
| 536 | + <> | |
| 537 | + <FoxIconButton size="lg" label="lg" icon={<DemoIcon />} /> | |
| 538 | + <FoxIconButton size="md" label="md" icon={<DemoIcon />} /> | |
| 539 | + <FoxIconButton size="sm" label="sm" icon={<DemoIcon />} /> | |
| 540 | + <FoxIconButton size="xsm" label="xsm" icon={<DemoIcon />} /> | |
| 541 | + </> | |
| 542 | + ), | |
| 543 | + }, | |
| 544 | + { | |
| 545 | + label: "크기 (size) — ghost는 같은 상자에 아이콘만 한 단계 크다", | |
| 546 | + node: ( | |
| 547 | + <> | |
| 548 | + <FoxIconButton size="lg" type="ghost" label="lg" icon={<DemoIcon />} /> | |
| 549 | + <FoxIconButton size="md" type="ghost" label="md" icon={<DemoIcon />} /> | |
| 550 | + <FoxIconButton size="sm" type="ghost" label="sm" icon={<DemoIcon />} /> | |
| 551 | + <FoxIconButton size="xsm" type="ghost" label="xsm" icon={<DemoIcon />} /> | |
| 552 | + </> | |
| 553 | + ), | |
| 554 | + }, | |
| 555 | + { | |
| 556 | + label: "토글 — 눌러서 켜고 끈다 (active)", | |
| 557 | + node: <IconToggleDemo />, | |
| 558 | + }, | |
| 559 | + { | |
| 560 | + label: "토글 켜짐 고정 — 계열별 색 비교", | |
| 561 | + node: ( | |
| 562 | + <> | |
| 563 | + <FoxIconButton size="md" type="default" label="on" icon={<DemoStar />} active /> | |
| 564 | + <FoxIconButton size="md" type="ghost" label="on" icon={<DemoStar />} active /> | |
| 565 | + <OnPhoto> | |
| 566 | + <FoxIconButton size="md" type="overlay" label="on" icon={<DemoStar />} active /> | |
| 567 | + </OnPhoto> | |
| 568 | + </> | |
| 569 | + ), | |
| 570 | + }, | |
| 571 | + { | |
| 572 | + label: "상태 — disabled (켜짐보다 우선한다)", | |
| 573 | + node: ( | |
| 574 | + <> | |
| 575 | + <FoxIconButton size="md" label="disabled" icon={<DemoIcon />} disabled /> | |
| 576 | + <FoxIconButton size="md" type="ghost" label="disabled" icon={<DemoIcon />} disabled /> | |
| 577 | + <FoxIconButton | |
| 578 | + size="md" | |
| 579 | + type="overlay" | |
| 580 | + label="disabled" | |
| 581 | + icon={<DemoIcon />} | |
| 582 | + disabled | |
| 583 | + /> | |
| 584 | + <FoxIconButton | |
| 585 | + size="md" | |
| 586 | + label="active + disabled" | |
| 587 | + icon={<DemoStar />} | |
| 588 | + active | |
| 589 | + disabled | |
| 590 | + /> | |
| 591 | + </> | |
| 592 | + ), | |
| 593 | + }, | |
| 594 | + ], | |
| 595 | + }, | |
| 596 | + { | |
| 597 | + id: "fox-button-group", | |
| 598 | + name: "FoxButtonGroup", | |
| 599 | + description: | |
| 600 | + "간격만 Figma 시안(btn-group, 컴포넌트 세트 398:4854)에서 가져왔습니다 — 크기·방향과 무관하게 gap-2(4px) 하나뿐이라 그룹에는 크기별 스타일이 없습니다. 크기는 그룹이 단일 진실 공급원이라 자식이 저마다 다른 size를 줘도 그룹 값으로 통일됩니다. 세로일 때는 자식이 항상 폭을 채웁니다. 블록 레벨 flex라 부모 폭을 채웁니다.", | |
| 601 | + variants: [ | |
| 602 | + { | |
| 603 | + label: "가로 · 정렬 (align) — 회색 테두리가 그룹 영역", | |
| 604 | + node: ( | |
| 605 | + <div className={styles.demoStack}> | |
| 606 | + {(["start", "center", "end"] as const).map((align) => ( | |
| 607 | + <div key={align} className={styles.demoOutline}> | |
| 608 | + <FoxButtonGroup size="md" align={align}> | |
| 609 | + <FoxButton size="md" label={align} /> | |
| 610 | + <FoxButton size="md" type="primary" label="확인" /> | |
| 611 | + </FoxButtonGroup> | |
| 612 | + </div> | |
| 613 | + ))} | |
| 614 | + </div> | |
| 615 | + ), | |
| 616 | + }, | |
| 617 | + { | |
| 618 | + label: "크기 (size) — 자식이 준 size는 무시된다", | |
| 619 | + node: ( | |
| 620 | + <div className={styles.demoStack}> | |
| 621 | + {(["lg", "md", "sm", "xsm"] as const).map((size) => ( | |
| 622 | + <FoxButtonGroup key={size} size={size}> | |
| 623 | + {/* 일부러 어긋난 size를 준다 — 전부 그룹 값으로 통일되어야 정상 */} | |
| 624 | + <FoxButton size="lg" label={size} /> | |
| 625 | + <FoxButton size="xsm" type="primary" label="확인" /> | |
| 626 | + <FoxIconButton size="lg" label="공유" icon={<DemoIcon />} /> | |
| 627 | + </FoxButtonGroup> | |
| 628 | + ))} | |
| 629 | + </div> | |
| 630 | + ), | |
| 631 | + }, | |
| 632 | + { | |
| 633 | + label: "세로 — 자식이 항상 폭을 채운다", | |
| 634 | + node: ( | |
| 635 | + <div className={styles.demoOutline}> | |
| 636 | + <FoxButtonGroup size="md" orientation="vertical"> | |
| 637 | + <FoxButton size="md" label="취소" /> | |
| 638 | + <FoxButton size="md" type="primary" label="확인" /> | |
| 639 | + </FoxButtonGroup> | |
| 640 | + </div> | |
| 641 | + ), | |
| 642 | + }, | |
| 643 | + { | |
| 644 | + label: "wrap — 폭이 모자라면 다음 줄로 넘어간다", | |
| 645 | + node: ( | |
| 646 | + <div className={styles.demoStack}> | |
| 647 | + <div className={styles.demoNarrow}> | |
| 648 | + <FoxButtonGroup size="md" wrap> | |
| 649 | + {["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label) => ( | |
| 650 | + <FoxButton key={label} size="md" label={label} /> | |
| 651 | + ))} | |
| 652 | + </FoxButtonGroup> | |
| 653 | + </div> | |
| 654 | + <div className={styles.demoNarrow}> | |
| 655 | + <FoxButtonGroup size="md"> | |
| 656 | + {["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label) => ( | |
| 657 | + <FoxButton key={label} size="md" label={label} /> | |
| 658 | + ))} | |
| 659 | + </FoxButtonGroup> | |
| 660 | + </div> | |
| 661 | + </div> | |
| 662 | + ), | |
| 663 | + }, | |
| 664 | + { | |
| 665 | + label: "버튼이 아닌 자식 — 호스트 요소는 건드리지 않는다", | |
| 666 | + node: ( | |
| 667 | + <FoxButtonGroup size="sm" align="center"> | |
| 668 | + <FoxButton size="sm" label="이전" /> | |
| 669 | + <span className={styles.demoText}>1 / 10</span> | |
| 670 | + <FoxButton size="sm" label="다음" /> | |
| 671 | + </FoxButtonGroup> | |
| 672 | + ), | |
| 673 | + }, | |
| 674 | + { | |
| 675 | + label: "프래그먼트로 감싼 자식도 덮어쓴다", | |
| 676 | + node: ( | |
| 677 | + <FoxButtonGroup size="xsm"> | |
| 678 | + <> | |
| 679 | + <FoxButton size="lg" label="프래그먼트 안" /> | |
| 680 | + <FoxButton size="lg" type="primary" label="확인" /> | |
| 681 | + </> | |
| 682 | + <FoxButton size="lg" label="프래그먼트 밖" /> | |
| 683 | + </FoxButtonGroup> | |
| 684 | + ), | |
| 685 | + }, | |
| 686 | + ], | |
| 687 | + }, | |
| 688 | + { | |
| 689 | + id: "fox-button-panel", | |
| 690 | + name: "FoxButtonPanel", | |
| 691 | + description: | |
| 692 | + "Figma 시안(btn-page, 컴포넌트 세트 404:5121)을 옮겼습니다. 자식은 버튼 그룹이고, 여럿이면 양끝으로 벌어지고 하나면 가운데로 갑니다 — 자식 수는 CSS :only-child가 판단합니다. 패널 안에서는 그룹 간격이 gap-3(8px)으로 덮어써지고(단독으로 쓰면 gap-2), 버튼에 최소 폭 160px이 붙습니다. 창을 768px 아래로 줄이면 패널도 그룹도 세로로 쌓이고 버튼이 폭을 채웁니다.", | |
| 693 | + variants: [ | |
| 694 | + { | |
| 695 | + label: "그룹 둘 — 양끝 (시안 type=flow)", | |
| 696 | + node: ( | |
| 697 | + <div className={styles.demoOutline}> | |
| 698 | + <FoxButtonPanel offset={false}> | |
| 699 | + <FoxButtonGroup size="lg"> | |
| 700 | + <FoxButton size="lg" label="취소" /> | |
| 701 | + </FoxButtonGroup> | |
| 702 | + <FoxButtonGroup size="lg"> | |
| 703 | + <FoxButton size="lg" type="secondary" label="임시 저장" /> | |
| 704 | + <FoxButton size="lg" type="primary" label="다음" /> | |
| 705 | + </FoxButtonGroup> | |
| 706 | + </FoxButtonPanel> | |
| 707 | + </div> | |
| 708 | + ), | |
| 709 | + }, | |
| 710 | + { | |
| 711 | + label: "그룹 하나 — 가운데 (시안 type=default)", | |
| 712 | + node: ( | |
| 713 | + <div className={styles.demoOutline}> | |
| 714 | + <FoxButtonPanel offset={false}> | |
| 715 | + <FoxButtonGroup size="lg"> | |
| 716 | + <FoxButton size="lg" label="취소" /> | |
| 717 | + <FoxButton size="lg" type="primary" label="확인" /> | |
| 718 | + </FoxButtonGroup> | |
| 719 | + </FoxButtonPanel> | |
| 720 | + </div> | |
| 721 | + ), | |
| 722 | + }, | |
| 723 | + { | |
| 724 | + label: "상단 여백 (offset) — 기본 켜짐, 80px", | |
| 725 | + node: ( | |
| 726 | + <div className={styles.demoOutline}> | |
| 727 | + <FoxButtonPanel> | |
| 728 | + <FoxButtonGroup size="lg"> | |
| 729 | + <FoxButton size="lg" type="primary" label="확인" /> | |
| 730 | + </FoxButtonGroup> | |
| 731 | + </FoxButtonPanel> | |
| 732 | + </div> | |
| 733 | + ), | |
| 734 | + }, | |
| 735 | + { | |
| 736 | + label: "그룹 셋 이상도 양끝 기준으로 벌어진다", | |
| 737 | + node: ( | |
| 738 | + <div className={styles.demoOutline}> | |
| 739 | + <FoxButtonPanel offset={false}> | |
| 740 | + <FoxButtonGroup size="lg"> | |
| 741 | + <FoxButton size="lg" label="삭제" /> | |
| 742 | + </FoxButtonGroup> | |
| 743 | + <FoxButtonGroup size="lg"> | |
| 744 | + <FoxButton size="lg" label="미리보기" /> | |
| 745 | + </FoxButtonGroup> | |
| 746 | + <FoxButtonGroup size="lg"> | |
| 747 | + <FoxButton size="lg" type="primary" label="저장" /> | |
| 748 | + </FoxButtonGroup> | |
| 749 | + </FoxButtonPanel> | |
| 750 | + </div> | |
| 751 | + ), | |
| 752 | + }, | |
| 753 | + ], | |
| 754 | + }, | |
| 755 | + { | |
| 756 | + id: "fox-input", | |
| 757 | + name: "FoxInput", | |
| 758 | + description: | |
| 759 | + "Figma 시안(input, 컴포넌트 세트 135:621)을 옮겼습니다. 네이티브 <input>을 감싸므로 시안에 없는 password·email·number 등 모든 타입을 그대로 받습니다. 상태는 기본적으로 브라우저가 판단하고(포커스·값·비활성·aria-invalid), state를 주면 그 모양으로 고정됩니다. 라벨은 입력 크기와 무관하게 항상 같은 크기입니다.", | |
| 760 | + variants: [ | |
| 761 | + { | |
| 762 | + label: "직접 조작 — 입력·포커스·지우기", | |
| 763 | + node: <InputLiveDemo />, | |
| 764 | + }, | |
| 765 | + { | |
| 766 | + label: "크기 (size) — 상자·글자·아이콘이 함께 줄고 라벨만 고정", | |
| 767 | + node: ( | |
| 768 | + <> | |
| 769 | + {(["lg", "md", "sm"] as const).map((size) => ( | |
| 770 | + <InputStage key={size}> | |
| 771 | + <FoxInput size={size} label={`레이블 (${size})`} placeholder="내용을 입력하세요." /> | |
| 772 | + </InputStage> | |
| 773 | + ))} | |
| 774 | + </> | |
| 775 | + ), | |
| 776 | + }, | |
| 777 | + { | |
| 778 | + label: "상태 고정 (state) — 시안 6종을 그대로 비교", | |
| 779 | + node: ( | |
| 780 | + <> | |
| 781 | + {( | |
| 782 | + ["default", "focused", "completed", "error", "disabled", "view"] as FoxInputState[] | |
| 783 | + ).map((state) => ( | |
| 784 | + <InputStage key={state}> | |
| 785 | + <FoxInput | |
| 786 | + size="md" | |
| 787 | + state={state} | |
| 788 | + label={state} | |
| 789 | + placeholder="내용을 입력하세요." | |
| 790 | + message="힌트 메시지" | |
| 791 | + /> | |
| 792 | + </InputStage> | |
| 793 | + ))} | |
| 794 | + </> | |
| 795 | + ), | |
| 796 | + }, | |
| 797 | + { | |
| 798 | + label: "타입 — 시안에 없는 것도 네이티브 그대로 동작한다", | |
| 799 | + node: ( | |
| 800 | + <> | |
| 801 | + {(["text", "date", "password", "email", "number", "time"] as const).map((type) => ( | |
| 802 | + <InputStage key={type}> | |
| 803 | + <FoxInput size="md" type={type} label={type} placeholder="내용을 입력하세요." /> | |
| 804 | + </InputStage> | |
| 805 | + ))} | |
| 806 | + </> | |
| 807 | + ), | |
| 808 | + }, | |
| 809 | + { | |
| 810 | + label: "아이콘 · 헬퍼 아이콘 슬롯", | |
| 811 | + node: ( | |
| 812 | + <> | |
| 813 | + <InputStage> | |
| 814 | + <FoxInput | |
| 815 | + size="md" | |
| 816 | + label="오른쪽 아이콘" | |
| 817 | + placeholder="내용을 입력하세요." | |
| 818 | + icon={<DemoIcon />} | |
| 819 | + /> | |
| 820 | + </InputStage> | |
| 821 | + <InputStage> | |
| 822 | + <FoxInput | |
| 823 | + size="md" | |
| 824 | + label="헬퍼 아이콘" | |
| 825 | + placeholder="내용을 입력하세요." | |
| 826 | + message="아이콘은 호출부가 넣습니다" | |
| 827 | + messageIcon={<DemoIcon />} | |
| 828 | + /> | |
| 829 | + </InputStage> | |
| 830 | + </> | |
| 831 | + ), | |
| 832 | + }, | |
| 833 | + { | |
| 834 | + label: "비활성 · 읽기 전용 — 네이티브 속성으로 자동 판정", | |
| 835 | + node: ( | |
| 836 | + <> | |
| 837 | + <InputStage> | |
| 838 | + <FoxInput size="md" label="disabled" defaultValue="수정할 수 없음" disabled /> | |
| 839 | + </InputStage> | |
| 840 | + <InputStage> | |
| 841 | + <FoxInput size="md" label="readOnly" defaultValue="읽기 전용" readOnly /> | |
| 842 | + </InputStage> | |
| 843 | + </> | |
| 844 | + ), | |
| 845 | + }, | |
| 846 | + { | |
| 847 | + label: "라벨·헬퍼 없이도 쓸 수 있다", | |
| 848 | + node: ( | |
| 849 | + <InputStage> | |
| 850 | + <FoxInput size="md" placeholder="내용을 입력하세요." /> | |
| 851 | + </InputStage> | |
| 852 | + ), | |
| 853 | + }, | |
| 854 | + | |
| 855 | + ], | |
| 856 | + | |
| 857 | + }, | |
| 858 | + { | |
| 859 | + id: "fox-select", | |
| 860 | + name: "FoxSelect", | |
| 861 | + description: | |
| 862 | + "Figma 시안(select, 컴포넌트 세트 111:272). 네이티브 <select>가 아니라 트리거 버튼 + 리스트박스라 옵션 목록까지 시안대로 그립니다. 상태는 전부 ARIA 속성이 스타일 훅을 겸합니다 — 열림·오류·조회 전용·옵션 선택에 별도 클래스가 없습니다.", | |
| 863 | + variants: [ | |
| 864 | + { | |
| 865 | + label: "크기 (size)", | |
| 866 | + node: ( | |
| 867 | + <div className={styles.demoFields}> | |
| 868 | + <FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 869 | + <FoxSelect size="md" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 870 | + <FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 871 | + </div> | |
| 872 | + ), | |
| 873 | + }, | |
| 874 | + { | |
| 875 | + label: "완료(값 선택됨)", | |
| 876 | + node: ( | |
| 877 | + <div className={styles.demoFields}> | |
| 878 | + <FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 879 | + <FoxSelect size="md" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 880 | + <FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 881 | + </div> | |
| 882 | + ), | |
| 883 | + }, | |
| 884 | + { | |
| 885 | + label: "error", | |
| 886 | + node: ( | |
| 887 | + <div className={styles.demoFields}> | |
| 888 | + <FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 889 | + <FoxSelect size="md" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 890 | + <FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 891 | + </div> | |
| 892 | + ), | |
| 893 | + }, | |
| 894 | + { | |
| 895 | + label: "disabled", | |
| 896 | + node: ( | |
| 897 | + <div className={styles.demoFields}> | |
| 898 | + <FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 899 | + <FoxSelect size="md" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 900 | + <FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 901 | + </div> | |
| 902 | + ), | |
| 903 | + }, | |
| 904 | + { | |
| 905 | + label: "view (조회 전용)", | |
| 906 | + node: ( | |
| 907 | + <div className={styles.demoFields}> | |
| 908 | + <FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 909 | + <FoxSelect size="md" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 910 | + <FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} /> | |
| 911 | + </div> | |
| 912 | + ), | |
| 913 | + }, | |
| 914 | + { | |
| 915 | + label: "옵션 아이콘(맨 뒤) · 긴 라벨 — 열어서 확인한다", | |
| 916 | + node: ( | |
| 917 | + <div className={styles.demoFields}> | |
| 918 | + <FoxSelect size="md" label="레이블" options={SELECT_ITEMS_RICH} /> | |
| 919 | + </div> | |
| 920 | + ), | |
| 921 | + }, | |
| 922 | + { | |
| 923 | + label: "라벨 없음 · 힌트 없음 — 해당 영역이 렌더되지 않는다", | |
| 924 | + node: ( | |
| 925 | + <div className={styles.demoFields}> | |
| 926 | + <FoxSelect size="md" options={SELECT_ITEMS} /> | |
| 927 | + <FoxSelect size="md" label="라벨만" options={SELECT_ITEMS} /> | |
| 928 | + </div> | |
| 929 | + ), | |
| 930 | + }, | |
| 931 | + { | |
| 932 | + label: "placeholder — 기본값은 “선택해주세요”", | |
| 933 | + node: ( | |
| 934 | + <div className={styles.demoFields}> | |
| 935 | + <FoxSelect size="md" label="기본" options={SELECT_ITEMS} /> | |
| 936 | + <FoxSelect size="md" label="직접 지정" options={SELECT_ITEMS} placeholder="등급을 고르세요" /> | |
| 937 | + </div> | |
| 938 | + ), | |
| 939 | + }, | |
| 940 | + ], | |
| 941 | + }, | |
| 942 | + { | |
| 943 | + id: "fox-select-option", | |
| 944 | + name: "FoxSelectOption", | |
| 945 | + description: | |
| 946 | + "Figma 시안(select, 컴포넌트 세트 732:5879) FoxSelect가 열렸을 때 뜨는 옵션 목록입니다. 셀렉트에서 떼어내 단독으로 올려두었습니다. 목록은 position: absolute라 위치 기준이 되는 조상이 필요합니다", | |
| 947 | + variants: [ | |
| 948 | + { | |
| 949 | + label: "목록", | |
| 950 | + node: ( | |
| 951 | + <div className={styles.demoListboxStage}> | |
| 952 | + <FoxSelectOption> | |
| 953 | + <FoxSelectOptionItem label="셀렉트 옵션" /> | |
| 954 | + <FoxSelectOptionItem label="셀렉트 옵션" /> | |
| 955 | + <FoxSelectOptionItem label="셀렉트 옵션" /> | |
| 956 | + <FoxSelectOptionItem label="셀렉트 옵션" /> | |
| 957 | + </FoxSelectOption> | |
| 958 | + </div> | |
| 959 | + ), | |
| 960 | + }, | |
| 961 | + { | |
| 962 | + label: "스크롤 — 항목이 많으면 240px에서 스크롤된다 (⚠️ 높이 미확정)", | |
| 963 | + node: ( | |
| 964 | + <div className={styles.demoListboxStage}> | |
| 965 | + <FoxSelectOption> | |
| 966 | + {Array.from({ length: 12 }, (_, i) => ( | |
| 967 | + <FoxSelectOptionItem key={i} label={`항목 ${i + 1}`} selected={i === 2} /> | |
| 968 | + ))} | |
| 969 | + </FoxSelectOption> | |
| 970 | + </div> | |
| 971 | + ), | |
| 972 | + }, | |
| 973 | + ], | |
| 974 | + }, | |
| 975 | + { | |
| 976 | + id: "fox-select-option-item", | |
| 977 | + name: "FoxSelectOptionItem", | |
| 978 | + description: | |
| 979 | + "Figma 시안(select, 컴포넌트 세트 251:396) 옵션 목록의 항목 하나입니다. 선택 여부는 aria-selected가 스타일 훅을 겸하고, 키보드로 이동 중인 항목만 네이티브 대응이 없어 --active 클래스를 씁니다. 항목은 포커스를 받지 않습니다 — 콤보박스는 포커스가 트리거에 남아야 aria-activedescendant가 성립하기 때문입니다. 눌림(pressed)은 마우스로 누르고 있는 동안 보입니다.", | |
| 980 | + variants: [ | |
| 981 | + { | |
| 982 | + label: "상태 — 기본 / 선택됨 / hover / pressed 클릭", | |
| 983 | + node: ( | |
| 984 | + <div className={styles.demoListboxStage}> | |
| 985 | + <FoxSelectOption> | |
| 986 | + <FoxSelectOptionItem label="셀렉트 옵션" /> | |
| 987 | + <FoxSelectOptionItem label="셀렉트 옵션-selected" selected /> | |
| 988 | + <FoxSelectOptionItem label="셀렉트 옵션-hover" active /> | |
| 989 | + <FoxSelectOptionItem label="셀렉트 옵션-pressed (클릭으로 확인)" /> | |
| 990 | + <FoxSelectOptionItem label="셀렉트 옵션-danger" danger /> | |
| 991 | + </FoxSelectOption> | |
| 992 | + </div> | |
| 993 | + ), | |
| 994 | + }, | |
| 995 | + { | |
| 996 | + label: "아이콘 — 체크는 맨 앞(선택됐을 때만), 아이콘은 맨 뒤", | |
| 997 | + node: ( | |
| 998 | + <div className={styles.demoListboxStage}> | |
| 999 | + <FoxSelectOption> | |
| 1000 | + <FoxSelectOptionItem label="셀렉트 옵션" icon={<DemoIcon />} /> | |
| 1001 | + <FoxSelectOptionItem label="셀렉트 옵션-selected" selected icon={<DemoIcon />} /> | |
| 1002 | + </FoxSelectOption> | |
| 1003 | + </div> | |
| 1004 | + ), | |
| 1005 | + }, | |
| 1006 | + ], | |
| 1007 | + }, | |
| 1008 | + { | |
| 1009 | + id: "fox-text-area", | |
| 1010 | + name: "FoxTextArea", | |
| 1011 | + description: | |
| 1012 | + "Figma 시안(textarea 135:1655)을 옮겼습니다. 시안에 크기 축이 없어 상자는 하나뿐이고 높이는 rows가 정합니다(기본 5줄, 시안과 같음). 상태 판정은 FoxInput과 같은 방식입니다 — 기본은 브라우저가 판단하고 state를 주면 고정됩니다. 하단은 왼쪽 헬퍼와 오른쪽 글자수 카운터로 나뉘고, 오류일 때 현재 글자수가 빨개집니다.", | |
| 1013 | + variants: [ | |
| 1014 | + { | |
| 1015 | + label: "직접 조작 — 입력·포커스·카운터", | |
| 1016 | + node: <TextAreaLiveDemo />, | |
| 1017 | + }, | |
| 1018 | + { | |
| 1019 | + label: "상태 고정 (state) — 시안 6종을 그대로 비교", | |
| 1020 | + node: ( | |
| 1021 | + <> | |
| 1022 | + {( | |
| 1023 | + ["default", "focused", "completed", "error", "disabled", "view"] as FoxTextAreaState[] | |
| 1024 | + ).map((state) => ( | |
| 1025 | + <InputStage key={state}> | |
| 1026 | + <FoxTextArea | |
| 1027 | + state={state} | |
| 1028 | + label={state} | |
| 1029 | + placeholder="내용을 입력하세요." | |
| 1030 | + message="힌트 메시지" | |
| 1031 | + defaultValue={""} | |
| 1032 | + maxLength={100} | |
| 1033 | + counter | |
| 1034 | + /> | |
| 1035 | + </InputStage> | |
| 1036 | + ))} | |
| 1037 | + </> | |
| 1038 | + ), | |
| 1039 | + }, | |
| 1040 | + { | |
| 1041 | + label: "줄 수 (rows)", | |
| 1042 | + node: ( | |
| 1043 | + <> | |
| 1044 | + {[3, 5, 8].map((rows) => ( | |
| 1045 | + <InputStage key={rows}> | |
| 1046 | + <FoxTextArea rows={rows} label={`rows=${rows}`} placeholder="내용을 입력하세요." /> | |
| 1047 | + </InputStage> | |
| 1048 | + ))} | |
| 1049 | + </> | |
| 1050 | + ), | |
| 1051 | + }, | |
| 1052 | + { | |
| 1053 | + label: "헬퍼·카운터는 따로 켠다", | |
| 1054 | + node: ( | |
| 1055 | + <> | |
| 1056 | + <InputStage> | |
| 1057 | + <FoxTextArea label="헬퍼만" placeholder="내용을 입력하세요." message="힌트 메시지" /> | |
| 1058 | + </InputStage> | |
| 1059 | + <InputStage> | |
| 1060 | + <FoxTextArea label="카운터만" placeholder="내용을 입력하세요." maxLength={100} counter /> | |
| 1061 | + </InputStage> | |
| 1062 | + <InputStage> | |
| 1063 | + <FoxTextArea label="최대 없이 개수만" placeholder="내용을 입력하세요." counter /> | |
| 1064 | + </InputStage> | |
| 1065 | + <InputStage> | |
| 1066 | + <FoxTextArea label="둘 다 없음" placeholder="내용을 입력하세요." /> | |
| 1067 | + </InputStage> | |
| 1068 | + </> | |
| 1069 | + ), | |
| 1070 | + }, | |
| 1071 | + { | |
| 1072 | + label: "비활성 · 읽기 전용 — 네이티브 속성으로 자동 판정", | |
| 1073 | + node: ( | |
| 1074 | + <> | |
| 1075 | + <InputStage> | |
| 1076 | + <FoxTextArea label="disabled" defaultValue="수정할 수 없음" disabled /> | |
| 1077 | + </InputStage> | |
| 1078 | + <InputStage> | |
| 1079 | + <FoxTextArea label="readOnly" defaultValue="읽기 전용" readOnly /> | |
| 1080 | + </InputStage> | |
| 1081 | + </> | |
| 1082 | + ), | |
| 1083 | + }, | |
| 1084 | + ], | |
| 1085 | + }, | |
| 1086 | + { | |
| 1087 | + id: "fox-phone-number", | |
| 1088 | + name: "FoxPhoneNumber", | |
| 1089 | + description: | |
| 1090 | + "Figma 시안(form-phonenumber 391:3156)을 옮겼습니다. unit은 상자 셋을 하이픈으로 잇고 앞자리가 선택이며, combine은 상자 하나에 세 칸을 둡니다. 화면은 세 칸이지만 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지 않습니다. 칸을 다 채우면 다음 칸으로 넘어가고, 빈 칸에서 지우면 앞칸으로 돌아갑니다.", | |
| 1091 | + variants: [ | |
| 1092 | + { | |
| 1093 | + label: "직접 조작 — 값이 그대로 라벨에 보입니다", | |
| 1094 | + node: <PhoneNumberLiveDemo />, | |
| 1095 | + }, | |
| 1096 | + { | |
| 1097 | + label: "모양 (type)", | |
| 1098 | + node: ( | |
| 1099 | + <> | |
| 1100 | + <InputStage> | |
| 1101 | + <FoxPhoneNumber label="unit" prefixOptions={PHONE_PREFIXES} /> | |
| 1102 | + </InputStage> | |
| 1103 | + <InputStage> | |
| 1104 | + <FoxPhoneNumber type="combine" label="combine" prefixOptions={PHONE_PREFIXES} /> | |
| 1105 | + </InputStage> | |
| 1106 | + </> | |
| 1107 | + ), | |
| 1108 | + }, | |
| 1109 | + { | |
| 1110 | + label: "상태 고정 (state) — combine 기준", | |
| 1111 | + node: ( | |
| 1112 | + <> | |
| 1113 | + {( | |
| 1114 | + ["default", "focused", "completed", "error", "disabled", "view"] as FoxPhoneNumberState[] | |
| 1115 | + ).map((state) => ( | |
| 1116 | + <InputStage key={state}> | |
| 1117 | + <FoxPhoneNumber | |
| 1118 | + type="combine" | |
| 1119 | + state={state} | |
| 1120 | + label={state} | |
| 1121 | + message="힌트 메시지" | |
| 1122 | + /> | |
| 1123 | + </InputStage> | |
| 1124 | + ))} | |
| 1125 | + </> | |
| 1126 | + ), | |
| 1127 | + }, | |
| 1128 | + { | |
| 1129 | + label: "자동 이동 끄기 (autoAdvance)", | |
| 1130 | + node: ( | |
| 1131 | + <InputStage> | |
| 1132 | + <FoxPhoneNumber | |
| 1133 | + label="autoAdvance={false}" | |
| 1134 | + prefixOptions={PHONE_PREFIXES} | |
| 1135 | + autoAdvance={false} | |
| 1136 | + message="칸을 다 채워도 커서가 그대로 있습니다" | |
| 1137 | + /> | |
| 1138 | + </InputStage> | |
| 1139 | + ), | |
| 1140 | + }, | |
| 1141 | + { | |
| 1142 | + label: "비활성 · 읽기 전용", | |
| 1143 | + node: ( | |
| 1144 | + <> | |
| 1145 | + <InputStage> | |
| 1146 | + <FoxPhoneNumber | |
| 1147 | + label="disabled" | |
| 1148 | + prefixOptions={PHONE_PREFIXES} | |
| 1149 | + defaultValue="01012345678" | |
| 1150 | + disabled | |
| 1151 | + /> | |
| 1152 | + </InputStage> | |
| 1153 | + <InputStage> | |
| 1154 | + <FoxPhoneNumber | |
| 1155 | + type="combine" | |
| 1156 | + label="readOnly" | |
| 1157 | + prefixOptions={PHONE_PREFIXES} | |
| 1158 | + defaultValue="01012345678" | |
| 1159 | + readOnly | |
| 1160 | + /> | |
| 1161 | + </InputStage> | |
| 1162 | + </> | |
| 1163 | + ), | |
| 1164 | + }, | |
| 1165 | + ], | |
| 1166 | + }, | |
| 1167 | + { | |
| 1168 | + id: "fox-certified", | |
| 1169 | + name: "FoxCertified", | |
| 1170 | + description: | |
| 1171 | + "Figma 시안(form-certified 190:2017)을 옮겼습니다. 전화번호는 FoxPhoneNumber(combine), 두 버튼은 FoxButton(secondary·primary)을 그대로 씁니다. 보내기·확인은 호출부가 Promise로 넘기고, resolve면 성공 reject면 실패입니다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고, 인증에 성공하면 전부 잠깁니다.", | |
| 1172 | + variants: [ | |
| 1173 | + { | |
| 1174 | + label: "직접 조작 — 인증번호 123456이면 성공, 아니면 실패", | |
| 1175 | + node: <CertifiedLiveDemo />, | |
| 1176 | + }, | |
| 1177 | + { | |
| 1178 | + label: "보내기가 실패하는 경우 — 칸이 열리지 않고 이벤트만 옵니다", | |
| 1179 | + node: <CertifiedLiveDemo failSend />, | |
| 1180 | + }, | |
| 1181 | + { | |
| 1182 | + label: "타이머를 호출부가 넘길 때 (remainingSeconds)", | |
| 1183 | + node: ( | |
| 1184 | + <InputStage> | |
| 1185 | + <FoxCertified | |
| 1186 | + prefixOptions={PHONE_PREFIXES} | |
| 1187 | + defaultPhone="01012345678" | |
| 1188 | + remainingSeconds={1456} | |
| 1189 | + onSend={async () => undefined} | |
| 1190 | + onVerify={async () => undefined} | |
| 1191 | + /> | |
| 1192 | + </InputStage> | |
| 1193 | + ), | |
| 1194 | + }, | |
| 1195 | + { | |
| 1196 | + label: "전체 잠금 (disabled)", | |
| 1197 | + node: ( | |
| 1198 | + <InputStage> | |
| 1199 | + <FoxCertified | |
| 1200 | + prefixOptions={PHONE_PREFIXES} | |
| 1201 | + defaultPhone="01012345678" | |
| 1202 | + disabled | |
| 1203 | + onSend={async () => undefined} | |
| 1204 | + onVerify={async () => undefined} | |
| 1205 | + /> | |
| 1206 | + </InputStage> | |
| 1207 | + ), | |
| 1208 | + }, | |
| 1209 | + ], | |
| 1210 | + }, | |
| 1211 | + { | |
| 1212 | + id: "fox-email", | |
| 1213 | + name: "FoxEmail", | |
| 1214 | + description: | |
| 1215 | + "Figma 시안(form-email 136:3092)을 옮겼습니다. 세 칸이 모두 input·select 인스턴스라 FoxInput·FoxSelect를 그대로 조합했습니다. 값은 아이디@도메인이 합쳐진 문자열 하나입니다. 도메인을 고르면 옆 칸에 그 값이 채워지고 읽기 전용이 되며, 직접입력을 고르면 풀립니다. 칸은 최소 240px을 지키며 남는 폭을 나눠 갖고, 좁아지면 줄이 넘어갑니다.", | |
| 1216 | + variants: [ | |
| 1217 | + { | |
| 1218 | + label: "직접 조작 — 도메인을 바꿔보세요", | |
| 1219 | + node: <EmailLiveDemo />, | |
| 1220 | + }, | |
| 1221 | + { | |
| 1222 | + label: "확인 버튼 — onVerify를 넘겼을 때만 생깁니다", | |
| 1223 | + node: ( | |
| 1224 | + <> | |
| 1225 | + <InputStage wide> | |
| 1226 | + <FoxEmail | |
| 1227 | + label="onVerify 있음" | |
| 1228 | + domainOptions={EMAIL_DOMAINS} | |
| 1229 | + defaultValue="hong@naver.com" | |
| 1230 | + onVerify={async () => new Promise((resolve) => setTimeout(resolve, 400))} | |
| 1231 | + /> | |
| 1232 | + </InputStage> | |
| 1233 | + <InputStage wide> | |
| 1234 | + <FoxEmail label="onVerify 없음" domainOptions={EMAIL_DOMAINS} /> | |
| 1235 | + </InputStage> | |
| 1236 | + </> | |
| 1237 | + ), | |
| 1238 | + }, | |
| 1239 | + { | |
| 1240 | + label: "좁은 곳에서는 줄이 넘어갑니다", | |
| 1241 | + node: ( | |
| 1242 | + <InputStage> | |
| 1243 | + <FoxEmail domainOptions={EMAIL_DOMAINS} defaultValue="hong@naver.com" /> | |
| 1244 | + </InputStage> | |
| 1245 | + ), | |
| 1246 | + }, | |
| 1247 | + { | |
| 1248 | + label: "오류 · 비활성", | |
| 1249 | + node: ( | |
| 1250 | + <> | |
| 1251 | + <InputStage wide> | |
| 1252 | + <FoxEmail | |
| 1253 | + label="invalid" | |
| 1254 | + domainOptions={EMAIL_DOMAINS} | |
| 1255 | + defaultValue="hong@naver.com" | |
| 1256 | + message="이미 쓰고 있는 주소입니다" | |
| 1257 | + invalid | |
| 1258 | + /> | |
| 1259 | + </InputStage> | |
| 1260 | + <InputStage wide> | |
| 1261 | + <FoxEmail label="disabled" domainOptions={EMAIL_DOMAINS} defaultValue="hong@naver.com" disabled /> | |
| 1262 | + </InputStage> | |
| 1263 | + </> | |
| 1264 | + ), | |
| 1265 | + }, | |
| 1266 | + ], | |
| 1267 | + }, | |
| 1268 | + { | |
| 1269 | + id: "fox-address", | |
| 1270 | + name: "FoxAddress", | |
| 1271 | + description: | |
| 1272 | + "Figma 시안(form-address 5346:5823)을 옮겼습니다. 세 상자가 모두 input 인스턴스라 FoxInput을 그대로 조합했습니다. 우편번호와 주소는 검색 결과로만 채워지는 읽기 전용이고 상세주소만 손으로 적습니다. 어떤 우편번호 서비스를 쓸지는 호출부가 정합니다 — onSearch에서 결과를 FoxAddressSearchResult 모양으로 돌려주면 되고, 취소는 null입니다.", | |
| 1273 | + variants: [ | |
| 1274 | + { | |
| 1275 | + label: "직접 조작 — 검색하면 두 칸이 채워집니다", | |
| 1276 | + node: <AddressLiveDemo />, | |
| 1277 | + }, | |
| 1278 | + { | |
| 1279 | + label: "검색을 취소하면(null) 값이 그대로 남습니다", | |
| 1280 | + node: <AddressLiveDemo cancel />, | |
| 1281 | + }, | |
| 1282 | + { | |
| 1283 | + label: "오류 · 비활성", | |
| 1284 | + node: ( | |
| 1285 | + <> | |
| 1286 | + <InputStage> | |
| 1287 | + <FoxAddress | |
| 1288 | + label="invalid" | |
| 1289 | + message="주소를 입력해 주세요" | |
| 1290 | + invalid | |
| 1291 | + onSearch={async () => null} | |
| 1292 | + /> | |
| 1293 | + </InputStage> | |
| 1294 | + <InputStage> | |
| 1295 | + <FoxAddress | |
| 1296 | + label="disabled" | |
| 1297 | + defaultValue={{ zipCode: "06236", address: "서울 강남구 테헤란로 152", detail: "10층" }} | |
| 1298 | + disabled | |
| 1299 | + onSearch={async () => null} | |
| 1300 | + /> | |
| 1301 | + </InputStage> | |
| 1302 | + </> | |
| 1303 | + ), | |
| 1304 | + }, | |
| 1305 | + ], | |
| 1306 | + }, | |
| 1307 | + { | |
| 1308 | + id: "fox-file-upload", | |
| 1309 | + name: "FoxFileUpload", | |
| 1310 | + description: | |
| 1311 | + "Figma 시안 다섯(file-upload 391:3677 · file-upload-area 392:5968 · file-upload-img 391:3732 · file-list 392:5938 · file-list-item 391:3509)을 한 컴포넌트로 옮겼습니다. 버튼·입력이 모두 기존 컴포넌트의 인스턴스라 FoxButton·FoxTextButton·FoxIconButton·FoxInput을 그대로 조합했습니다. 업로드는 하지 않고 고른 원본 File만 넘깁니다 — 목록은 files로 주면 제어, 안 주면 안에서 들고 있습니다. default·area는 목록을 붙일 수 있고 image는 목록이 없습니다.", | |
| 1312 | + variants: [ | |
| 1313 | + { | |
| 1314 | + label: "default 모드 — 읽기 전용 칸 + 파일선택, 목록 지원", | |
| 1315 | + node: <FileUploadLiveDemo mode="default" />, | |
| 1316 | + }, | |
| 1317 | + { | |
| 1318 | + label: "area 모드 — 끌어다 놓기가 항상 켜져 있고 목록을 지원합니다", | |
| 1319 | + node: <FileUploadLiveDemo mode="area" />, | |
| 1320 | + }, | |
| 1321 | + { | |
| 1322 | + label: "image 모드 — 목록이 없고 썸네일 하나입니다", | |
| 1323 | + node: ( | |
| 1324 | + <> | |
| 1325 | + <InputStage wide> | |
| 1326 | + <FoxFileUpload | |
| 1327 | + mode="image" | |
| 1328 | + label="비었을 때" | |
| 1329 | + accept="image/*" | |
| 1330 | + description="JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)" | |
| 1331 | + /> | |
| 1332 | + </InputStage> | |
| 1333 | + <InputStage wide> | |
| 1334 | + <FoxFileUpload | |
| 1335 | + mode="image" | |
| 1336 | + label="채워졌을 때 — 가운데 버튼으로 지웁니다" | |
| 1337 | + accept="image/*" | |
| 1338 | + defaultFiles={[ | |
| 1339 | + { id: "p", name: "미리보기", previewUrl: "https://picsum.photos/seed/picsum/426/240" }, | |
| 1340 | + ]} | |
| 1341 | + description="JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)" | |
| 1342 | + /> | |
| 1343 | + </InputStage> | |
| 1344 | + </> | |
| 1345 | + ), | |
| 1346 | + }, | |
| 1347 | + { | |
| 1348 | + label: "default 모드 · 파일 하나 (시안 upload=true) — 칸이 잠기고 버튼이 삭제로 바뀝니다", | |
| 1349 | + node: ( | |
| 1350 | + <> | |
| 1351 | + <InputStage wide> | |
| 1352 | + <FoxFileUpload label="비었을 때" description="JPG, PNG 파일만 가능 (최대 5MB)" /> | |
| 1353 | + </InputStage> | |
| 1354 | + <InputStage wide> | |
| 1355 | + <FoxFileUpload | |
| 1356 | + label="파일이 있을 때" | |
| 1357 | + defaultFiles={[{ id: "one", name: "파일명", meta: ".PDF (20KB)" }]} | |
| 1358 | + description="JPG, PNG 파일만 가능 (최대 5MB)" | |
| 1359 | + /> | |
| 1360 | + </InputStage> | |
| 1361 | + </> | |
| 1362 | + ), | |
| 1363 | + }, | |
| 1364 | + { | |
| 1365 | + label: "목록 없이 · 크기 바꾸기", | |
| 1366 | + node: ( | |
| 1367 | + <> | |
| 1368 | + <InputStage wide> | |
| 1369 | + <FoxFileUpload label="showList={false}" defaultFiles={SAMPLE_FILES} showList={false} multiple /> | |
| 1370 | + </InputStage> | |
| 1371 | + <InputStage wide> | |
| 1372 | + <FoxFileUpload label="inputWidth=440" inputWidth={440} /> | |
| 1373 | + </InputStage> | |
| 1374 | + <InputStage wide> | |
| 1375 | + <FoxFileUpload | |
| 1376 | + mode="image" | |
| 1377 | + label="thumbnail 160×90" | |
| 1378 | + thumbnailWidth={160} | |
| 1379 | + thumbnailHeight={90} | |
| 1380 | + /> | |
| 1381 | + </InputStage> | |
| 1382 | + </> | |
| 1383 | + ), | |
| 1384 | + }, | |
| 1385 | + { | |
| 1386 | + label: "비활성", | |
| 1387 | + node: ( | |
| 1388 | + <InputStage wide> | |
| 1389 | + <FoxFileUpload label="disabled" defaultFiles={SAMPLE_FILES} multiple disabled /> | |
| 1390 | + </InputStage> | |
| 1391 | + ), | |
| 1392 | + }, | |
| 1393 | + ], | |
| 1394 | + }, | |
| 1395 | +]; |
+++ @fox/dev-test/component-view.tsx
... | ... | @@ -0,0 +1,46 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ComponentExample } from "./component-registry"; | |
| 4 | +import styles from "./dev-test.module.scss"; | |
| 5 | + | |
| 6 | +export function ComponentView({ example }: { example: ComponentExample }) { | |
| 7 | + return ( | |
| 8 | + <section className={styles.section}> | |
| 9 | + <h2 className={styles.sectionTitle}>{example.name}</h2> | |
| 10 | + {example.description ? ( | |
| 11 | + <p className={styles.sectionNote}>{example.description}</p> | |
| 12 | + ) : null} | |
| 13 | + | |
| 14 | + <div className={styles.variantList}> | |
| 15 | + {example.variants.map((variant) => ( | |
| 16 | + <div key={variant.label} className={styles.variant}> | |
| 17 | + <span className={styles.variantLabel}>{variant.label}</span> | |
| 18 | + <div className={styles.variantStage}>{variant.node}</div> | |
| 19 | + </div> | |
| 20 | + ))} | |
| 21 | + </div> | |
| 22 | + </section> | |
| 23 | + ); | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function ComponentEmpty() { | |
| 27 | + return ( | |
| 28 | + <section className={styles.section}> | |
| 29 | + <h2 className={styles.sectionTitle}>컴포넌트가 아직 없습니다</h2> | |
| 30 | + <p className={styles.sectionNote}> | |
| 31 | + 컴포넌트를 만들면 <code>@fox/dev-test/component-registry.tsx</code>의{" "} | |
| 32 | + <code>COMPONENT_EXAMPLES</code> 배열에 항목을 하나 추가하세요. 사이드바와 본문은 그 | |
| 33 | + 배열만 보고 그리므로 다른 파일은 고칠 필요가 없습니다. | |
| 34 | + </p> | |
| 35 | + <pre className={styles.code}>{`{ | |
| 36 | + id: "button", | |
| 37 | + name: "Button", | |
| 38 | + description: "기본 액션 버튼", | |
| 39 | + variants: [ | |
| 40 | + { label: "primary", node: <Button variant="primary">확인</Button> }, | |
| 41 | + { label: "disabled", node: <Button disabled>확인</Button> }, | |
| 42 | + ], | |
| 43 | +}`}</pre> | |
| 44 | + </section> | |
| 45 | + ); | |
| 46 | +} |
+++ @fox/dev-test/dev-test-page.tsx
... | ... | @@ -0,0 +1,171 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useSyncExternalStore } from "react"; | |
| 4 | +import { cx } from "../core/utils"; | |
| 5 | +import { COMPONENT_EXAMPLES } from "./component-registry"; | |
| 6 | +import { ComponentEmpty, ComponentView } from "./component-view"; | |
| 7 | +import type { CssSection } from "./read-css-tokens"; | |
| 8 | +import { ThemeSwitch } from "./theme-switch"; | |
| 9 | +import { TokenView } from "./token-view"; | |
| 10 | +import { useCssTokens } from "./use-css-tokens"; | |
| 11 | +import styles from "./dev-test.module.scss"; | |
| 12 | + | |
| 13 | +// 선택 상태는 URL 해시가 SSOT다. 형식: `#token:light` / `#component:fox-button` | |
| 14 | +function subscribeHash(onChange: () => void): () => void { | |
| 15 | + window.addEventListener("hashchange", onChange); | |
| 16 | + return () => window.removeEventListener("hashchange", onChange); | |
| 17 | +} | |
| 18 | + | |
| 19 | +function getHash(): string { | |
| 20 | + return window.location.hash.slice(1); | |
| 21 | +} | |
| 22 | + | |
| 23 | +function getServerHash(): string { | |
| 24 | + return ""; | |
| 25 | +} | |
| 26 | + | |
| 27 | +function useSelection(): { kind: string; id: string } | null { | |
| 28 | + const hash = useSyncExternalStore(subscribeHash, getHash, getServerHash); | |
| 29 | + const [kind, id] = hash.split(":"); | |
| 30 | + return kind && id ? { kind, id } : null; | |
| 31 | +} | |
| 32 | + | |
| 33 | +function select(kind: string, id: string): void { | |
| 34 | + window.location.hash = `${kind}:${id}`; | |
| 35 | +} | |
| 36 | + | |
| 37 | +const GUIDE_ID = "guide"; | |
| 38 | + | |
| 39 | +function isRegistered(id: string): boolean { | |
| 40 | + return COMPONENT_EXAMPLES.some((example) => example.id === id); | |
| 41 | +} | |
| 42 | + | |
| 43 | +/** Figma 변수 페이지와 대조하는 화면이라 우리 CSS 그룹이 아닌 원본 구획을 따른다. */ | |
| 44 | +function groupedSections(sections: CssSection[]) { | |
| 45 | + const groups: { category: string | null; sections: CssSection[] }[] = []; | |
| 46 | + | |
| 47 | + for (const section of sections) { | |
| 48 | + const last = groups[groups.length - 1]; | |
| 49 | + if (last && last.category === section.category && section.category !== null) { | |
| 50 | + last.sections.push(section); | |
| 51 | + } else { | |
| 52 | + groups.push({ category: section.category, sections: [section] }); | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + return groups; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export function DevTestPage() { | |
| 60 | + const selection = useSelection(); | |
| 61 | + const sections = useCssTokens(); | |
| 62 | + | |
| 63 | + if (!sections) { | |
| 64 | + return ( | |
| 65 | + <div className={styles.shell}> | |
| 66 | + <p className={styles.loading}>스타일시트를 읽는 중…</p> | |
| 67 | + </div> | |
| 68 | + ); | |
| 69 | + } | |
| 70 | + | |
| 71 | + const totalTokens = sections.reduce((sum, section) => sum + section.tokens.length, 0); | |
| 72 | + const current = selection ?? { kind: "token", id: sections[0]?.id ?? "" }; | |
| 73 | + const activeSection = sections.find((section) => section.id === current.id); | |
| 74 | + | |
| 75 | + return ( | |
| 76 | + <div className={styles.shell}> | |
| 77 | + <aside className={styles.sidebar}> | |
| 78 | + <div className={styles.brand}> | |
| 79 | + <span className={styles.brandTitle}>@fox 디자인 시스템</span> | |
| 80 | + <span className={styles.brandNote}>개발 전용 테스트 페이지</span> | |
| 81 | + </div> | |
| 82 | + | |
| 83 | + <ThemeSwitch /> | |
| 84 | + | |
| 85 | + <nav className={styles.nav} aria-label="섹션"> | |
| 86 | + <div className={styles.navGroup}> | |
| 87 | + <h2 className={styles.navGroupTitle}>토큰 · {totalTokens}</h2> | |
| 88 | + | |
| 89 | + {groupedSections(sections).map((group) => ( | |
| 90 | + <div key={group.category ?? group.sections[0].id} className={styles.navSubGroup}> | |
| 91 | + {group.category ? ( | |
| 92 | + <h3 className={styles.navSubTitle}>{group.category}</h3> | |
| 93 | + ) : null} | |
| 94 | + <ul className={styles.navList}> | |
| 95 | + {group.sections.map((section) => ( | |
| 96 | + <li key={section.id}> | |
| 97 | + <button | |
| 98 | + type="button" | |
| 99 | + className={styles.navItem} | |
| 100 | + aria-current={ | |
| 101 | + current.kind === "token" && current.id === section.id ? "page" : undefined | |
| 102 | + } | |
| 103 | + onClick={() => select("token", section.id)} | |
| 104 | + > | |
| 105 | + <span>{section.label}</span> | |
| 106 | + <span className={styles.navCount}>{section.tokens.length}</span> | |
| 107 | + </button> | |
| 108 | + </li> | |
| 109 | + ))} | |
| 110 | + </ul> | |
| 111 | + </div> | |
| 112 | + ))} | |
| 113 | + </div> | |
| 114 | + | |
| 115 | + <div className={styles.navGroup}> | |
| 116 | + <h2 className={styles.navGroupTitle}>컴포넌트 · {COMPONENT_EXAMPLES.length}</h2> | |
| 117 | + <ul className={styles.navList}> | |
| 118 | + {COMPONENT_EXAMPLES.map((example) => ( | |
| 119 | + <li key={example.id}> | |
| 120 | + <button | |
| 121 | + type="button" | |
| 122 | + className={styles.navItem} | |
| 123 | + aria-current={ | |
| 124 | + current.kind === "component" && current.id === example.id | |
| 125 | + ? "page" | |
| 126 | + : undefined | |
| 127 | + } | |
| 128 | + onClick={() => select("component", example.id)} | |
| 129 | + > | |
| 130 | + <span>{example.name}</span> | |
| 131 | + <span className={styles.navCount}>{example.variants.length}</span> | |
| 132 | + </button> | |
| 133 | + </li> | |
| 134 | + ))} | |
| 135 | + | |
| 136 | + {/* 컴포넌트가 아니라 안내라 항상 맨 아래에 두고 생김새를 달리한다. */} | |
| 137 | + <li> | |
| 138 | + <button | |
| 139 | + type="button" | |
| 140 | + className={cx(styles.navItem, styles.navItemGuide)} | |
| 141 | + aria-current={ | |
| 142 | + current.kind === "component" && !isRegistered(current.id) ? "page" : undefined | |
| 143 | + } | |
| 144 | + onClick={() => select("component", GUIDE_ID)} | |
| 145 | + > | |
| 146 | + <span>추가 방법</span> | |
| 147 | + <span aria-hidden="true" className={styles.navGuideMark}> | |
| 148 | + + | |
| 149 | + </span> | |
| 150 | + </button> | |
| 151 | + </li> | |
| 152 | + </ul> | |
| 153 | + </div> | |
| 154 | + </nav> | |
| 155 | + </aside> | |
| 156 | + | |
| 157 | + <main className={styles.content}> | |
| 158 | + {current.kind === "component" ? ( | |
| 159 | + (() => { | |
| 160 | + const example = COMPONENT_EXAMPLES.find((entry) => entry.id === current.id); | |
| 161 | + return example ? <ComponentView example={example} /> : <ComponentEmpty />; | |
| 162 | + })() | |
| 163 | + ) : activeSection ? ( | |
| 164 | + <TokenView section={activeSection} /> | |
| 165 | + ) : ( | |
| 166 | + <p className={styles.loading}>선택한 토큰 섹션이 없습니다.</p> | |
| 167 | + )} | |
| 168 | + </main> | |
| 169 | + </div> | |
| 170 | + ); | |
| 171 | +} |
+++ @fox/dev-test/dev-test.module.scss
... | ... | @@ -0,0 +1,479 @@ |
| 1 | +// 개발 전용 테스트 페이지 스타일. | |
| 2 | +// | |
| 3 | +// 이 파일 자체가 토큰 규율의 첫 소비처다 — 여기 있는 모든 값은 `fox.*()` 함수를 거치므로, | |
| 4 | +// 없는 토큰을 쓰면 빌드가 실패한다. 페이지가 그려진다는 것은 사용된 토큰이 전부 실재한다는 | |
| 5 | +// 뜻이기도 하다. | |
| 6 | + | |
| 7 | +@use "@fox/styles/abstracts" as fox; | |
| 8 | + | |
| 9 | +.shell { | |
| 10 | + display: grid; | |
| 11 | + grid-template-columns: 1fr; | |
| 12 | + min-block-size: 100vh; | |
| 13 | + background: fox.color(background-default); | |
| 14 | + color: fox.color(font-neutral-default); | |
| 15 | + | |
| 16 | + @include fox.pc { | |
| 17 | + grid-template-columns: 26rem 1fr; | |
| 18 | + } | |
| 19 | +} | |
| 20 | + | |
| 21 | +.loading { | |
| 22 | + padding: fox.padding(8); | |
| 23 | + color: fox.color(font-neutral-subtle); | |
| 24 | + font-size: fox.font-size(body-md); | |
| 25 | +} | |
| 26 | + | |
| 27 | +// ── 사이드바 ──────────────────────────────────────────────────────────────── | |
| 28 | +.sidebar { | |
| 29 | + display: flex; | |
| 30 | + flex-direction: column; | |
| 31 | + gap: fox.gap(5); | |
| 32 | + padding: fox.padding(7); | |
| 33 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 34 | + background: fox.color(surface-neutral-gray); | |
| 35 | + | |
| 36 | + @include fox.pc { | |
| 37 | + position: sticky; | |
| 38 | + inset-block-start: 0; | |
| 39 | + block-size: 100vh; | |
| 40 | + overflow-y: auto; | |
| 41 | + border-block-end: 0; | |
| 42 | + border-inline-end: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +.brand { | |
| 47 | + display: flex; | |
| 48 | + flex-direction: column; | |
| 49 | + gap: fox.gap(1); | |
| 50 | +} | |
| 51 | + | |
| 52 | +.brandTitle { | |
| 53 | + font-size: fox.font-size(heading-sm); | |
| 54 | + font-weight: fox.font-weight(bold); | |
| 55 | +} | |
| 56 | + | |
| 57 | +.brandNote { | |
| 58 | + color: fox.color(font-neutral-subtler); | |
| 59 | + font-size: fox.font-size(label-xsm); | |
| 60 | +} | |
| 61 | + | |
| 62 | +.themeSwitch { | |
| 63 | + display: flex; | |
| 64 | + gap: fox.gap(1); | |
| 65 | + padding: fox.padding(1); | |
| 66 | + border: fox.border(1) solid fox.color(border-neutral-default); | |
| 67 | + border-radius: fox.radius(max); | |
| 68 | + background: fox.color(surface-neutral-default); | |
| 69 | +} | |
| 70 | + | |
| 71 | +.themeButton { | |
| 72 | + flex: 1; | |
| 73 | + padding: fox.padding(3) fox.padding(4); | |
| 74 | + border-radius: fox.radius(max); | |
| 75 | + color: fox.color(font-neutral-subtle); | |
| 76 | + font-size: fox.font-size(label-xsm); | |
| 77 | + font-weight: fox.font-weight(medium); | |
| 78 | + | |
| 79 | + &[aria-pressed="true"] { | |
| 80 | + background: fox.color(button-primary-surface); | |
| 81 | + color: fox.color(button-primary-font); | |
| 82 | + } | |
| 83 | + | |
| 84 | + &:focus-visible { | |
| 85 | + outline: fox.border(2) solid fox.color(border-theme-primary); | |
| 86 | + outline-offset: fox.border(2); | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +.nav { | |
| 91 | + display: flex; | |
| 92 | + flex-direction: column; | |
| 93 | + gap: fox.gap(6); | |
| 94 | +} | |
| 95 | + | |
| 96 | +.navGroup { | |
| 97 | + display: flex; | |
| 98 | + flex-direction: column; | |
| 99 | + gap: fox.gap(2); | |
| 100 | +} | |
| 101 | + | |
| 102 | +.navGroupTitle { | |
| 103 | + padding-inline: fox.padding(3); | |
| 104 | + color: fox.color(font-neutral-subtler); | |
| 105 | + font-size: fox.font-size(label-xsm); | |
| 106 | + font-weight: fox.font-weight(bold); | |
| 107 | + letter-spacing: 0.04em; | |
| 108 | +} | |
| 109 | + | |
| 110 | +// Mode / Responsive 처럼 하위 항목을 묶는 분류. | |
| 111 | +.navSubGroup { | |
| 112 | + display: flex; | |
| 113 | + flex-direction: column; | |
| 114 | + gap: fox.gap(1); | |
| 115 | + margin-block-start: fox.gap(2); | |
| 116 | +} | |
| 117 | + | |
| 118 | +.navSubTitle { | |
| 119 | + padding-inline: fox.padding(3); | |
| 120 | + color: fox.color(font-neutral-subtler); | |
| 121 | + font-size: fox.font-size(label-xsm); | |
| 122 | + font-weight: fox.font-weight(medium); | |
| 123 | + opacity: 0.8; | |
| 124 | +} | |
| 125 | + | |
| 126 | +.navList { | |
| 127 | + display: flex; | |
| 128 | + flex-direction: column; | |
| 129 | + gap: fox.gap(1); | |
| 130 | + margin: 0; | |
| 131 | + padding: 0; | |
| 132 | + list-style: none; | |
| 133 | +} | |
| 134 | + | |
| 135 | +.navItem { | |
| 136 | + display: flex; | |
| 137 | + inline-size: 100%; | |
| 138 | + align-items: center; | |
| 139 | + justify-content: space-between; | |
| 140 | + gap: fox.gap(3); | |
| 141 | + padding: fox.padding(3) fox.padding(4); | |
| 142 | + border-radius: fox.radius(3); | |
| 143 | + color: fox.color(font-neutral-subtle); | |
| 144 | + font-size: fox.font-size(label-sm); | |
| 145 | + text-align: start; | |
| 146 | + | |
| 147 | + &:hover { | |
| 148 | + background: fox.color(button-default-surface-hover); | |
| 149 | + color: fox.color(font-neutral-default); | |
| 150 | + } | |
| 151 | + | |
| 152 | + &[aria-current="page"] { | |
| 153 | + background: fox.color(button-primary-surface); | |
| 154 | + color: fox.color(button-primary-font); | |
| 155 | + } | |
| 156 | + | |
| 157 | + &:focus-visible { | |
| 158 | + outline: fox.border(2) solid fox.color(border-theme-primary); | |
| 159 | + outline-offset: fox.border(1); | |
| 160 | + } | |
| 161 | +} | |
| 162 | + | |
| 163 | +.navCount { | |
| 164 | + color: currentcolor; | |
| 165 | + font-size: fox.font-size(label-xsm); | |
| 166 | + opacity: 0.7; | |
| 167 | +} | |
| 168 | + | |
| 169 | +// "추가 방법"은 컴포넌트가 아니라 안내다. 목록 맨 아래에 두고 점선 테두리로 구분해, | |
| 170 | +// 등록된 컴포넌트 탭과 같은 층위로 읽히지 않게 한다. | |
| 171 | +// `.navItem` 뒤에 와야 선택 상태(aria-current)를 덮어쓴다 — 특이도가 같아 순서가 정한다. | |
| 172 | +.navItemGuide { | |
| 173 | + margin-block-start: fox.gap(2); | |
| 174 | + border: fox.border(1) dashed fox.color(border-neutral-strong); | |
| 175 | + background: none; | |
| 176 | + color: fox.color(font-neutral-subtler); | |
| 177 | + | |
| 178 | + &:hover { | |
| 179 | + border-color: fox.color(border-theme-primary); | |
| 180 | + background: none; | |
| 181 | + color: fox.color(font-theme-primary); | |
| 182 | + } | |
| 183 | + | |
| 184 | + &[aria-current="page"] { | |
| 185 | + border-style: solid; | |
| 186 | + border-color: fox.color(border-theme-primary); | |
| 187 | + background: none; | |
| 188 | + color: fox.color(font-theme-primary); | |
| 189 | + } | |
| 190 | +} | |
| 191 | + | |
| 192 | +.navGuideMark { | |
| 193 | + font-size: fox.font-size(label-lg); | |
| 194 | + line-height: 1; | |
| 195 | +} | |
| 196 | + | |
| 197 | +// ── 본문 ──────────────────────────────────────────────────────────────────── | |
| 198 | +.content { | |
| 199 | + min-inline-size: 0; | |
| 200 | + padding: fox.spacing(top-md) fox.padding(7) fox.spacing(bottom-xlg); | |
| 201 | + | |
| 202 | + @include fox.pc { | |
| 203 | + padding: fox.spacing(top-lg) fox.padding(11) fox.spacing(bottom-xlg); | |
| 204 | + } | |
| 205 | +} | |
| 206 | + | |
| 207 | +.section { | |
| 208 | + display: flex; | |
| 209 | + flex-direction: column; | |
| 210 | +} | |
| 211 | + | |
| 212 | +.sectionTitle { | |
| 213 | + font-size: fox.font-size(heading-md); | |
| 214 | + font-weight: fox.font-weight(bold); | |
| 215 | +} | |
| 216 | + | |
| 217 | +.sectionNote { | |
| 218 | + margin-block: fox.gap(2) fox.gap(6); | |
| 219 | + color: fox.color(font-neutral-subtle); | |
| 220 | + font-size: fox.font-size(body-sm); | |
| 221 | +} | |
| 222 | + | |
| 223 | +.code { | |
| 224 | + overflow-x: auto; | |
| 225 | + margin-block-start: fox.gap(4); | |
| 226 | + padding: fox.padding(6); | |
| 227 | + border: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 228 | + border-radius: fox.radius(3); | |
| 229 | + background: fox.color(surface-neutral-gray); | |
| 230 | + color: fox.color(font-neutral-default); | |
| 231 | + font-size: fox.font-size(body-sm); | |
| 232 | +} | |
| 233 | + | |
| 234 | +// ── 색상 칩 ───────────────────────────────────────────────────────────────── | |
| 235 | +// 값 앞에 붙는 16px 정사각형. 16px는 `icon-3`(1.6rem)과 같은 값이라 리터럴 대신 | |
| 236 | +// 토큰을 쓴다. | |
| 237 | +// 테두리는 box-shadow로 그린다 — border는 칩 안쪽을 먹어 색이 보이는 면적을 줄인다. | |
| 238 | +.colorChip { | |
| 239 | + display: inline-block; | |
| 240 | + inline-size: fox.icon(3); | |
| 241 | + block-size: fox.icon(3); | |
| 242 | + margin-inline-end: fox.gap(3); | |
| 243 | + box-shadow: 0 0 0 fox.border(1) #{fox.color(border-neutral-overlay)}; | |
| 244 | + vertical-align: middle; | |
| 245 | +} | |
| 246 | + | |
| 247 | +.tokenName { | |
| 248 | + display: block; | |
| 249 | + color: fox.color(font-neutral-default); | |
| 250 | + font-size: fox.font-size(label-sm); | |
| 251 | + font-weight: fox.font-weight(medium); | |
| 252 | + word-break: break-all; | |
| 253 | +} | |
| 254 | + | |
| 255 | + | |
| 256 | +// 다른 토큰을 가리키는 값. 실제 값과 구분되어야 "이게 참조구나"가 한눈에 보인다. | |
| 257 | +.aliasRef { | |
| 258 | + color: fox.color(font-theme-primary); | |
| 259 | +} | |
| 260 | + | |
| 261 | + | |
| 262 | +// 서체 토큰 미리보기 문장. | |
| 263 | +.demoType { | |
| 264 | + margin: 0; | |
| 265 | + color: fox.color(font-neutral-default); | |
| 266 | + white-space: nowrap; | |
| 267 | +} | |
| 268 | + | |
| 269 | +// ── 값 테이블 ─────────────────────────────────────────────────────────────── | |
| 270 | +.table { | |
| 271 | + inline-size: 100%; | |
| 272 | + border-collapse: collapse; | |
| 273 | + text-align: start; | |
| 274 | +} | |
| 275 | + | |
| 276 | +.table th, | |
| 277 | +.table td { | |
| 278 | + padding: fox.padding(3) fox.padding(5); | |
| 279 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 280 | +} | |
| 281 | + | |
| 282 | +.table th { | |
| 283 | + color: fox.color(font-neutral-subtler); | |
| 284 | + font-size: fox.font-size(label-xsm); | |
| 285 | + font-weight: fox.font-weight(medium); | |
| 286 | +} | |
| 287 | + | |
| 288 | +.table td { | |
| 289 | + color: fox.color(font-neutral-default); | |
| 290 | + font-size: fox.font-size(body-sm); | |
| 291 | +} | |
| 292 | + | |
| 293 | +.tdValue { | |
| 294 | + color: fox.color(font-neutral-subtle); | |
| 295 | +} | |
| 296 | + | |
| 297 | +// 미리보기 칸은 내용만큼만 차지하게 두어 토큰/값 열이 밀리지 않게 한다. | |
| 298 | +.tdDemo { | |
| 299 | + inline-size: 1%; | |
| 300 | + white-space: nowrap; | |
| 301 | +} | |
| 302 | + | |
| 303 | +// ── 패딩 · 간격 미리보기 ──────────────────────────────────────────────────── | |
| 304 | +// 점선 테두리와 옅은 바탕이 "토큰이 만든 공간"이고, 흰 칩이 문단이다. | |
| 305 | +.demoPadBox, | |
| 306 | +.demoGapBox { | |
| 307 | + border: fox.border(1) dashed fox.color(border-theme-primary); | |
| 308 | + background: fox.color(surface-theme-primary); | |
| 309 | +} | |
| 310 | + | |
| 311 | +.demoPadBox { | |
| 312 | + display: inline-block; | |
| 313 | +} | |
| 314 | + | |
| 315 | +.demoGapBox { | |
| 316 | + display: inline-flex; | |
| 317 | + align-items: center; | |
| 318 | +} | |
| 319 | + | |
| 320 | +.demoText { | |
| 321 | + margin: 0; | |
| 322 | + padding: fox.padding(1) fox.padding(3); | |
| 323 | + background: fox.color(surface-neutral-default); | |
| 324 | + color: fox.color(font-neutral-default); | |
| 325 | + font-size: fox.font-size(label-xsm); | |
| 326 | + white-space: nowrap; | |
| 327 | +} | |
| 328 | + | |
| 329 | +// ── 모서리 반경 · 테두리 두께 미리보기 ────────────────────────────────────── | |
| 330 | +.demoRadius, | |
| 331 | +.demoBorder { | |
| 332 | + inline-size: fox.icon(7); | |
| 333 | + block-size: fox.icon(7); | |
| 334 | + border-color: fox.color(border-theme-primary); | |
| 335 | + border-style: solid; | |
| 336 | + background: fox.color(surface-neutral-gray); | |
| 337 | +} | |
| 338 | + | |
| 339 | +.demoRadius { | |
| 340 | + border-width: fox.border(2); | |
| 341 | +} | |
| 342 | + | |
| 343 | +// ── 아이콘 크기 미리보기 ──────────────────────────────────────────────────── | |
| 344 | +// 브라우저 기본 체크박스를 토큰 크기로 키운다 — 별도 에셋 없이 크기만 비교한다. | |
| 345 | +.demoIcon { | |
| 346 | + display: block; | |
| 347 | + margin: 0; | |
| 348 | + accent-color: fox.color(surface-theme-primary); | |
| 349 | +} | |
| 350 | + | |
| 351 | +// ── 그림자 미리보기 ───────────────────────────────────────────────────────── | |
| 352 | +// 그림자 색은 Figma에 토큰이 없어 중립 알파 토큰을 데모용으로 쓴다. | |
| 353 | +// 인라인 style이 참조할 수 있도록 여기서 커스텀 프로퍼티로 내려둔다. | |
| 354 | +.demoShadow { | |
| 355 | + --demo-shadow-color: #{fox.color(alpha-default-inverse25)}; | |
| 356 | + | |
| 357 | + inline-size: fox.icon(7); | |
| 358 | + block-size: fox.icon(6); | |
| 359 | + border-radius: fox.radius(2); | |
| 360 | + background: fox.color(surface-neutral-default); | |
| 361 | +} | |
| 362 | + | |
| 363 | +// ── 배경 흐림 미리보기 ────────────────────────────────────────────────────── | |
| 364 | +// 캔버스 크기는 디자인 값이 아니라 데모용 지면이라 토큰을 두지 않는다. | |
| 365 | +.demoBackdrop { | |
| 366 | + position: relative; | |
| 367 | + overflow: hidden; | |
| 368 | + inline-size: 20rem; | |
| 369 | + block-size: 12rem; | |
| 370 | + border-radius: fox.radius(2); | |
| 371 | +} | |
| 372 | + | |
| 373 | +.demoBackdropImage { | |
| 374 | + display: block; | |
| 375 | + inline-size: 100%; | |
| 376 | + block-size: 100%; | |
| 377 | + object-fit: cover; | |
| 378 | +} | |
| 379 | + | |
| 380 | +.demoBackdropOverlay { | |
| 381 | + position: absolute; | |
| 382 | + inset-block: 0; | |
| 383 | + inset-inline-end: 0; | |
| 384 | + inline-size: 50%; | |
| 385 | + border-inline-start: fox.border(1) solid fox.color(border-neutral-overlay); | |
| 386 | +} | |
| 387 | + | |
| 388 | +// ── 컴포넌트 예제 ─────────────────────────────────────────────────────────── | |
| 389 | +.variantList { | |
| 390 | + display: flex; | |
| 391 | + flex-direction: column; | |
| 392 | + gap: fox.gap(5); | |
| 393 | +} | |
| 394 | + | |
| 395 | +.variant { | |
| 396 | + display: flex; | |
| 397 | + flex-direction: column; | |
| 398 | + gap: fox.gap(2); | |
| 399 | +} | |
| 400 | + | |
| 401 | +.variantLabel { | |
| 402 | + color: fox.color(font-neutral-subtler); | |
| 403 | + font-size: fox.font-size(label-xsm); | |
| 404 | + font-weight: fox.font-weight(medium); | |
| 405 | +} | |
| 406 | + | |
| 407 | +// 예제를 얹는 무대. 컴포넌트 자체의 배경·여백과 섞이지 않도록 중립 표면 위에 올린다. | |
| 408 | +.variantStage { | |
| 409 | + display: flex; | |
| 410 | + flex-wrap: wrap; | |
| 411 | + gap: fox.gap(4); | |
| 412 | + align-items: center; | |
| 413 | + padding: fox.padding(7); | |
| 414 | + border: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 415 | + border-radius: fox.radius(3); | |
| 416 | + background: fox.color(surface-neutral-default); | |
| 417 | +} | |
| 418 | + | |
| 419 | +// 그룹 예제용. 그룹이 블록 레벨이라 정렬·줄바꿈을 보려면 폭이 정해진 무대가 필요하다. | |
| 420 | +.demoStack { | |
| 421 | + display: flex; | |
| 422 | + flex-direction: column; | |
| 423 | + gap: fox.gap(4); | |
| 424 | + inline-size: 100%; | |
| 425 | +} | |
| 426 | + | |
| 427 | +.demoOutline { | |
| 428 | + padding: fox.padding(3); | |
| 429 | + border: fox.border(1) dashed fox.color(border-neutral-subtle); | |
| 430 | + border-radius: fox.radius(2); | |
| 431 | + inline-size: 100%; | |
| 432 | +} | |
| 433 | + | |
| 434 | +// 폼 컨트롤 예제용. FoxSelect는 폭을 부모가 정하므로 무대가 폭을 줘야 크기 비교가 된다. | |
| 435 | +// 칸 너비는 디자인 값이 아니라 데모용 지면이라 토큰을 두지 않는다. | |
| 436 | +.demoFields { | |
| 437 | + display: flex; | |
| 438 | + flex-wrap: wrap; | |
| 439 | + gap: fox.gap(5); | |
| 440 | + align-items: start; | |
| 441 | + inline-size: 100%; | |
| 442 | + | |
| 443 | + > * { | |
| 444 | + inline-size: 28rem; | |
| 445 | + max-inline-size: 100%; | |
| 446 | + } | |
| 447 | +} | |
| 448 | + | |
| 449 | +// 옵션 목록 단독 예제용. 목록이 `position: absolute`라 위치 기준이 필요하고, 자기 높이를 | |
| 450 | +// 차지하지 않으므로 아래쪽 여백으로 펼쳐질 자리를 만들어 준다. 높이 0이면 목록이 무대 | |
| 451 | +// 맨 위에서 시작한다. 치수는 데모용 지면이라 토큰을 두지 않는다. | |
| 452 | +.demoListboxStage { | |
| 453 | + position: relative; | |
| 454 | + inline-size: 28rem; | |
| 455 | + max-inline-size: 100%; | |
| 456 | + block-size: 0; | |
| 457 | + margin-block-end: 22rem; | |
| 458 | +} | |
| 459 | + | |
| 460 | +// wrap 예제는 자식이 확실히 넘치도록 좁아야 한다. | |
| 461 | +.demoNarrow { | |
| 462 | + padding: fox.padding(3); | |
| 463 | + border: fox.border(1) dashed fox.color(border-neutral-subtle); | |
| 464 | + border-radius: fox.radius(2); | |
| 465 | + inline-size: 20rem; | |
| 466 | + max-inline-size: 100%; | |
| 467 | +} | |
| 468 | + | |
| 469 | +// 입력 예제용. 시안 폭(360px)에 맞춰야 실제 비율로 보인다. | |
| 470 | +.demoField { | |
| 471 | + inline-size: 36rem; | |
| 472 | + max-inline-size: 100%; | |
| 473 | +} | |
| 474 | + | |
| 475 | +// 이메일은 세 칸이라 360px에서는 계속 줄이 넘어간다. 넓게 볼 무대를 따로 둔다. | |
| 476 | +.demoFieldWide { | |
| 477 | + inline-size: 80rem; | |
| 478 | + max-inline-size: 100%; | |
| 479 | +} |
+++ @fox/dev-test/index.ts
... | ... | @@ -0,0 +1,2 @@ |
| 1 | +export { DevTestPage } from "./dev-test-page"; | |
| 2 | +export { COMPONENT_EXAMPLES, type ComponentExample } from "./component-registry"; |
+++ @fox/dev-test/read-css-tokens.ts
... | ... | @@ -0,0 +1,216 @@ |
| 1 | +export interface CssToken { | |
| 2 | + /** `--fox-` 를 뗀 이름 */ | |
| 3 | + name: string; | |
| 4 | + property: string; | |
| 5 | + /** 그 모드에서 선언된 원문. 참조면 `var(--fox-…)`, 아니면 값. */ | |
| 6 | + declaration: string; | |
| 7 | + /** 참조면 가리키는 토큰 이름, 아니면 null */ | |
| 8 | + alias: string | null; | |
| 9 | +} | |
| 10 | + | |
| 11 | +export interface CssSection { | |
| 12 | + id: string; | |
| 13 | + label: string; | |
| 14 | + category: string | null; | |
| 15 | + /** 이 섹션의 값이 어디서 왔는지 */ | |
| 16 | + source: string; | |
| 17 | + tokens: CssToken[]; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const PREFIX = "--fox-"; | |
| 21 | + | |
| 22 | +/** `var(--fox-x)` 형태면 가리키는 토큰 이름을 돌려준다. */ | |
| 23 | +function aliasOf(value: string): string | null { | |
| 24 | + const match = /^var\(\s*--fox-([a-z0-9-]+)\s*\)$/.exec(value.trim()); | |
| 25 | + return match ? match[1] : null; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** 내용에 `var(--fox-…)`가 중첩돼 정규식으로는 못 자르므로 괄호 짝을 센다. */ | |
| 29 | +function extractFallback(text: string, marker: string): string | null { | |
| 30 | + const start = text.indexOf(marker); | |
| 31 | + if (start < 0) { | |
| 32 | + return null; | |
| 33 | + } | |
| 34 | + | |
| 35 | + let depth = 1; | |
| 36 | + let i = start + marker.length; | |
| 37 | + for (; i < text.length && depth > 0; i += 1) { | |
| 38 | + if (text[i] === "(") depth += 1; | |
| 39 | + else if (text[i] === ")") depth -= 1; | |
| 40 | + } | |
| 41 | + return text.slice(start + marker.length, i - 1).trim(); | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** 괄호 안의 콤마는 무시하고 최상위 콤마로 나눈다. */ | |
| 45 | +function splitTopLevel(text: string): [string, string] | null { | |
| 46 | + let depth = 0; | |
| 47 | + for (let i = 0; i < text.length; i += 1) { | |
| 48 | + const ch = text[i]; | |
| 49 | + if (ch === "(") depth += 1; | |
| 50 | + else if (ch === ")") depth -= 1; | |
| 51 | + else if (ch === "," && depth === 0) { | |
| 52 | + return [text.slice(0, i).trim(), text.slice(i + 1).trim()]; | |
| 53 | + } | |
| 54 | + } | |
| 55 | + return null; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** | |
| 59 | + * Lightning CSS가 `light-dark()`를 폴리필한 형태를 먼저 보고, 폴리필이 없는 설정을 | |
| 60 | + * 대비해 원형도 처리한다. | |
| 61 | + */ | |
| 62 | +function splitLightDark(declaration: string): { light: string; dark: string } | null { | |
| 63 | + const light = extractFallback(declaration, "var(--lightningcss-light,"); | |
| 64 | + const dark = extractFallback(declaration, "var(--lightningcss-dark,"); | |
| 65 | + if (light !== null && dark !== null) { | |
| 66 | + return { light, dark }; | |
| 67 | + } | |
| 68 | + | |
| 69 | + if (declaration.startsWith("light-dark(")) { | |
| 70 | + const inner = extractFallback(declaration, "light-dark("); | |
| 71 | + const pair = inner === null ? null : splitTopLevel(inner); | |
| 72 | + if (pair) { | |
| 73 | + return { light: pair[0], dark: pair[1] }; | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + return null; | |
| 78 | +} | |
| 79 | + | |
| 80 | +function toToken(name: string, declaration: string): CssToken { | |
| 81 | + return { | |
| 82 | + name, | |
| 83 | + property: `${PREFIX}${name}`, | |
| 84 | + declaration, | |
| 85 | + alias: aliasOf(declaration), | |
| 86 | + }; | |
| 87 | +} | |
| 88 | + | |
| 89 | +interface RawTokens { | |
| 90 | + /** `:root` 기본 선언 — 색상은 light-dark 형태, 반응형은 모바일 값 */ | |
| 91 | + base: Map<string, string>; | |
| 92 | + /** `@media (min-width: …)` 안의 오버라이드 — PC 값 */ | |
| 93 | + pc: Map<string, string>; | |
| 94 | +} | |
| 95 | + | |
| 96 | +function readRaw(): RawTokens { | |
| 97 | + const base = new Map<string, string>(); | |
| 98 | + const pc = new Map<string, string>(); | |
| 99 | + | |
| 100 | + const collect = (rule: CSSStyleRule, into: Map<string, string>) => { | |
| 101 | + if (rule.selectorText !== ":root") { | |
| 102 | + return; | |
| 103 | + } | |
| 104 | + for (const property of Array.from(rule.style)) { | |
| 105 | + if (property.startsWith(PREFIX) && !into.has(property)) { | |
| 106 | + into.set(property.slice(PREFIX.length), rule.style.getPropertyValue(property).trim()); | |
| 107 | + } | |
| 108 | + } | |
| 109 | + }; | |
| 110 | + | |
| 111 | + for (const sheet of Array.from(document.styleSheets)) { | |
| 112 | + let rules: CSSRuleList; | |
| 113 | + try { | |
| 114 | + rules = sheet.cssRules; | |
| 115 | + } catch { | |
| 116 | + // 교차 출처 스타일시트 — 우리 토큰은 동일 출처이므로 건너뛴다. | |
| 117 | + continue; | |
| 118 | + } | |
| 119 | + | |
| 120 | + for (const rule of Array.from(rules)) { | |
| 121 | + if (rule instanceof CSSStyleRule) { | |
| 122 | + collect(rule, base); | |
| 123 | + } else if (rule instanceof CSSMediaRule && rule.conditionText.includes("min-width")) { | |
| 124 | + // 반응형 오버라이드. `prefers-color-scheme` 블록은 조건에 min-width가 없어 걸러진다. | |
| 125 | + for (const inner of Array.from(rule.cssRules)) { | |
| 126 | + if (inner instanceof CSSStyleRule) { | |
| 127 | + collect(inner, pc); | |
| 128 | + } | |
| 129 | + } | |
| 130 | + } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + return { base, pc }; | |
| 135 | +} | |
| 136 | + | |
| 137 | +const SIZE_PREFIX = /^(border|padding|gap|radius|icon|shadow|backdrop)-/; | |
| 138 | +const PRIMITIVE_PREFIX = /^(primitive|font-family|font-weight|number)-/; | |
| 139 | + | |
| 140 | +/** | |
| 141 | + * 컴파일된 스타일시트에서 섹션을 구성한다. 계산값이 아니라 **선언 원문**을 읽는 것이 | |
| 142 | + * 요점 — 계산값은 참조가 이미 풀려 있고 현재 테마·뷰포트 한쪽만 보인다. | |
| 143 | + */ | |
| 144 | +export function readCssSections(): CssSection[] { | |
| 145 | + const { base, pc } = readRaw(); | |
| 146 | + | |
| 147 | + const colors: { name: string; light: string; dark: string }[] = []; | |
| 148 | + const responsive: string[] = []; | |
| 149 | + const size: string[] = []; | |
| 150 | + const theme: string[] = []; | |
| 151 | + const primitive: string[] = []; | |
| 152 | + | |
| 153 | + for (const [name, declaration] of base) { | |
| 154 | + if (name.startsWith("color-")) { | |
| 155 | + const pair = splitLightDark(declaration); | |
| 156 | + colors.push({ | |
| 157 | + name, | |
| 158 | + light: pair?.light ?? declaration, | |
| 159 | + dark: pair?.dark ?? declaration, | |
| 160 | + }); | |
| 161 | + continue; | |
| 162 | + } | |
| 163 | + if (pc.has(name)) { | |
| 164 | + responsive.push(name); | |
| 165 | + continue; | |
| 166 | + } | |
| 167 | + if (SIZE_PREFIX.test(name)) { | |
| 168 | + size.push(name); | |
| 169 | + continue; | |
| 170 | + } | |
| 171 | + if (name.startsWith("theme-")) { | |
| 172 | + theme.push(name); | |
| 173 | + continue; | |
| 174 | + } | |
| 175 | + if (PRIMITIVE_PREFIX.test(name)) { | |
| 176 | + primitive.push(name); | |
| 177 | + continue; | |
| 178 | + } | |
| 179 | + // 분류에 없는 새 토큰도 버리지 않는다. | |
| 180 | + size.push(name); | |
| 181 | + } | |
| 182 | + | |
| 183 | + return [ | |
| 184 | + { | |
| 185 | + id: "light", | |
| 186 | + label: "Light", | |
| 187 | + category: "Mode", | |
| 188 | + source: "컴파일된 CSS — light-dark()의 라이트 쪽", | |
| 189 | + tokens: colors.map((entry) => toToken(entry.name, entry.light)), | |
| 190 | + }, | |
| 191 | + { | |
| 192 | + id: "dark", | |
| 193 | + label: "Dark", | |
| 194 | + category: "Mode", | |
| 195 | + source: "컴파일된 CSS — light-dark()의 다크 쪽", | |
| 196 | + tokens: colors.map((entry) => toToken(entry.name, entry.dark)), | |
| 197 | + }, | |
| 198 | + { | |
| 199 | + id: "pc", | |
| 200 | + label: "PC", | |
| 201 | + category: "Responsive", | |
| 202 | + source: "컴파일된 CSS — @media (min-width: 768px) 블록", | |
| 203 | + tokens: responsive.map((name) => toToken(name, pc.get(name) ?? "")), | |
| 204 | + }, | |
| 205 | + { | |
| 206 | + id: "mobile", | |
| 207 | + label: "Mobile", | |
| 208 | + category: "Responsive", | |
| 209 | + source: "컴파일된 CSS — :root 기본값(모바일 우선)", | |
| 210 | + tokens: responsive.map((name) => toToken(name, base.get(name) ?? "")), | |
| 211 | + }, | |
| 212 | + { id: "size", label: "Size", category: null, source: "컴파일된 CSS — :root", tokens: size.map((n) => toToken(n, base.get(n) ?? "")) }, | |
| 213 | + { id: "theme", label: "Theme", category: null, source: "컴파일된 CSS — :root", tokens: theme.map((n) => toToken(n, base.get(n) ?? "")) }, | |
| 214 | + { id: "primitive", label: "Primitive", category: null, source: "컴파일된 CSS — :root", tokens: primitive.map((n) => toToken(n, base.get(n) ?? "")) }, | |
| 215 | + ]; | |
| 216 | +} |
+++ @fox/dev-test/theme-switch.tsx
... | ... | @@ -0,0 +1,66 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useSyncExternalStore } from "react"; | |
| 4 | +import styles from "./dev-test.module.scss"; | |
| 5 | + | |
| 6 | +/** `@fox/styles/_root.scss`가 읽는 수동 테마 선택 속성. */ | |
| 7 | +const THEME_ATTRIBUTE = "data-theme"; | |
| 8 | + | |
| 9 | +type ThemeChoice = "system" | "light" | "dark"; | |
| 10 | + | |
| 11 | +const CHOICES: { value: ThemeChoice; label: string }[] = [ | |
| 12 | + { value: "system", label: "시스템" }, | |
| 13 | + { value: "light", label: "라이트" }, | |
| 14 | + { value: "dark", label: "다크" }, | |
| 15 | +]; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * `<html data-theme>`가 SSOT라 React state를 두지 않는다. 저장 키는 호스트 앱이 | |
| 19 | + * 소유하는 값이라 `@fox`가 알면 포터빌리티가 깨지므로 선택을 저장하지 않는다. | |
| 20 | + */ | |
| 21 | +function subscribe(onChange: () => void): () => void { | |
| 22 | + const observer = new MutationObserver(onChange); | |
| 23 | + observer.observe(document.documentElement, { | |
| 24 | + attributes: true, | |
| 25 | + attributeFilter: [THEME_ATTRIBUTE], | |
| 26 | + }); | |
| 27 | + return () => observer.disconnect(); | |
| 28 | +} | |
| 29 | + | |
| 30 | +function getSnapshot(): ThemeChoice { | |
| 31 | + const value = document.documentElement.getAttribute(THEME_ATTRIBUTE); | |
| 32 | + return value === "light" || value === "dark" ? value : "system"; | |
| 33 | +} | |
| 34 | + | |
| 35 | +function getServerSnapshot(): ThemeChoice { | |
| 36 | + return "system"; | |
| 37 | +} | |
| 38 | + | |
| 39 | +function apply(next: ThemeChoice): void { | |
| 40 | + const root = document.documentElement; | |
| 41 | + if (next === "system") { | |
| 42 | + root.removeAttribute(THEME_ATTRIBUTE); | |
| 43 | + } else { | |
| 44 | + root.setAttribute(THEME_ATTRIBUTE, next); | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +export function ThemeSwitch() { | |
| 49 | + const current = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | |
| 50 | + | |
| 51 | + return ( | |
| 52 | + <div className={styles.themeSwitch} role="group" aria-label="테마 전환"> | |
| 53 | + {CHOICES.map(({ value, label }) => ( | |
| 54 | + <button | |
| 55 | + key={value} | |
| 56 | + type="button" | |
| 57 | + className={styles.themeButton} | |
| 58 | + aria-pressed={current === value} | |
| 59 | + onClick={() => apply(value)} | |
| 60 | + > | |
| 61 | + {label} | |
| 62 | + </button> | |
| 63 | + ))} | |
| 64 | + </div> | |
| 65 | + ); | |
| 66 | +} |
+++ @fox/dev-test/token-view.tsx
... | ... | @@ -0,0 +1,170 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { CSSProperties, ReactNode } from "react"; | |
| 4 | +import type { CssSection, CssToken } from "./read-css-tokens"; | |
| 5 | +import styles from "./dev-test.module.scss"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * ⚠️ 값으로 그 토큰의 CSS 변수가 아니라 **섹션의 선언 원문**을 넣는다. 변수를 쓰면 PC | |
| 9 | + * 섹션이 좁은 창에서 모바일 값으로, Light 섹션이 다크 테마에서 다크 색으로 그려진다. | |
| 10 | + * 렌더할 토큰을 미리 알 수 없어 인라인 `style`을 쓰는 예외 지점이기도 하다. | |
| 11 | + */ | |
| 12 | +const PREVIEWS: { match: RegExp; render: (value: string) => ReactNode }[] = [ | |
| 13 | + { | |
| 14 | + match: /(^|-)padding(-|$)/, | |
| 15 | + render: (value) => ( | |
| 16 | + <div className={styles.demoPadBox} style={{ padding: value }}> | |
| 17 | + <p className={styles.demoText}>첫 번째 문단</p> | |
| 18 | + <p className={styles.demoText}>두 번째 문단</p> | |
| 19 | + </div> | |
| 20 | + ), | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + match: /(^|-)(gap|gutter)(-|$)|^spacing-/, | |
| 24 | + render: (value) => ( | |
| 25 | + <div className={styles.demoGapBox} style={{ gap: value }}> | |
| 26 | + <p className={styles.demoText}>첫 번째 문단</p> | |
| 27 | + <p className={styles.demoText}>두 번째 문단</p> | |
| 28 | + </div> | |
| 29 | + ), | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + match: /(^|-)radius(-|$)/, | |
| 33 | + render: (value) => <div className={styles.demoRadius} style={{ borderRadius: value }} />, | |
| 34 | + }, | |
| 35 | + { | |
| 36 | + // 브라우저가 그리는 컨트롤이라 별도 에셋 없이 크기만 확인한다. | |
| 37 | + match: /(^|-)icon(-|$)/, | |
| 38 | + render: (value) => ( | |
| 39 | + <input | |
| 40 | + type="checkbox" | |
| 41 | + defaultChecked | |
| 42 | + aria-label="크기 미리보기" | |
| 43 | + className={styles.demoIcon} | |
| 44 | + style={{ inlineSize: value, blockSize: value }} | |
| 45 | + /> | |
| 46 | + ), | |
| 47 | + }, | |
| 48 | + { | |
| 49 | + match: /^border-/, | |
| 50 | + render: (value) => <div className={styles.demoBorder} style={{ borderWidth: value }} />, | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + match: /^shadow-/, | |
| 54 | + render: (value) => ( | |
| 55 | + <div | |
| 56 | + className={styles.demoShadow} | |
| 57 | + style={{ boxShadow: `0 0 ${value} var(--demo-shadow-color)` }} | |
| 58 | + /> | |
| 59 | + ), | |
| 60 | + }, | |
| 61 | + { | |
| 62 | + match: /^backdrop-/, | |
| 63 | + render: (value) => ( | |
| 64 | + <div className={styles.demoBackdrop}> | |
| 65 | + {/* eslint-disable-next-line @next/next/no-img-element -- next/image를 쓰면 이 | |
| 66 | + 폴더가 Next 전용이 된다. */} | |
| 67 | + <img | |
| 68 | + src="https://picsum.photos/seed/picsum/200/300" | |
| 69 | + alt="" | |
| 70 | + className={styles.demoBackdropImage} | |
| 71 | + /> | |
| 72 | + <div | |
| 73 | + className={styles.demoBackdropOverlay} | |
| 74 | + style={{ backdropFilter: `blur(${value})`, WebkitBackdropFilter: `blur(${value})` }} | |
| 75 | + /> | |
| 76 | + </div> | |
| 77 | + ), | |
| 78 | + }, | |
| 79 | + { | |
| 80 | + match: /^font-size-/, | |
| 81 | + render: (value) => ( | |
| 82 | + <p className={styles.demoType} style={{ fontSize: value }}> | |
| 83 | + 다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123 | |
| 84 | + </p> | |
| 85 | + ), | |
| 86 | + }, | |
| 87 | + { | |
| 88 | + match: /^font-family-/, | |
| 89 | + render: (value) => ( | |
| 90 | + <p className={styles.demoType} style={{ fontFamily: value }}> | |
| 91 | + 다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123 | |
| 92 | + </p> | |
| 93 | + ), | |
| 94 | + }, | |
| 95 | + { | |
| 96 | + match: /^font-weight-/, | |
| 97 | + render: (value) => ( | |
| 98 | + <p className={styles.demoType} style={{ fontWeight: value }}> | |
| 99 | + 다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123 | |
| 100 | + </p> | |
| 101 | + ), | |
| 102 | + }, | |
| 103 | +]; | |
| 104 | + | |
| 105 | +function previewFor(token: CssToken): ReactNode { | |
| 106 | + if (isColor(token)) { | |
| 107 | + // 색은 값 칸의 칩이 이미 보여준다. | |
| 108 | + return null; | |
| 109 | + } | |
| 110 | + const hit = PREVIEWS.find((entry) => entry.match.test(token.name)); | |
| 111 | + return hit ? hit.render(token.declaration) : null; | |
| 112 | +} | |
| 113 | + | |
| 114 | +/** 이 세 접두사만 색이다. */ | |
| 115 | +function isColor(token: CssToken): boolean { | |
| 116 | + return /^(color|primitive|theme)-/.test(token.name); | |
| 117 | +} | |
| 118 | + | |
| 119 | +/** 참조든 리터럴이든 원문을 그대로 넣는다 — 참조 대상은 모드 무관 값이라 안전하다. */ | |
| 120 | +function chipStyle(token: CssToken): CSSProperties { | |
| 121 | + return { background: token.declaration }; | |
| 122 | +} | |
| 123 | + | |
| 124 | +export function TokenView({ section }: { section: CssSection }) { | |
| 125 | + const hasPreview = section.tokens.some((token) => previewFor(token) !== null); | |
| 126 | + | |
| 127 | + return ( | |
| 128 | + <section className={styles.section}> | |
| 129 | + <h2 className={styles.sectionTitle}> | |
| 130 | + {section.label} · {section.tokens.length} | |
| 131 | + </h2> | |
| 132 | + <p className={styles.sectionNote}> | |
| 133 | + {section.source} — 값은 컴파일된 스타일시트에서 직접 읽습니다. 참조면 가리키는 토큰을, | |
| 134 | + 아니면 값을 표시합니다. | |
| 135 | + </p> | |
| 136 | + | |
| 137 | + <table className={styles.table}> | |
| 138 | + <thead> | |
| 139 | + <tr> | |
| 140 | + <th scope="col">토큰</th> | |
| 141 | + <th scope="col">값 / 참조</th> | |
| 142 | + {hasPreview ? <th scope="col">미리보기</th> : null} | |
| 143 | + </tr> | |
| 144 | + </thead> | |
| 145 | + <tbody> | |
| 146 | + {section.tokens.map((token) => ( | |
| 147 | + <tr key={token.name}> | |
| 148 | + <td> | |
| 149 | + <span className={styles.tokenName}>{token.name}</span> | |
| 150 | + </td> | |
| 151 | + <td className={styles.tdValue}> | |
| 152 | + {isColor(token) ? ( | |
| 153 | + <span className={styles.colorChip} style={chipStyle(token)} /> | |
| 154 | + ) : null} | |
| 155 | + {token.alias !== null ? ( | |
| 156 | + <span className={styles.aliasRef} title="참조 토큰"> | |
| 157 | + → {token.alias} | |
| 158 | + </span> | |
| 159 | + ) : ( | |
| 160 | + token.declaration | |
| 161 | + )} | |
| 162 | + </td> | |
| 163 | + {hasPreview ? <td className={styles.tdDemo}>{previewFor(token)}</td> : null} | |
| 164 | + </tr> | |
| 165 | + ))} | |
| 166 | + </tbody> | |
| 167 | + </table> | |
| 168 | + </section> | |
| 169 | + ); | |
| 170 | +} |
+++ @fox/dev-test/use-css-tokens.ts
... | ... | @@ -0,0 +1,24 @@ |
| 1 | +import { useSyncExternalStore } from "react"; | |
| 2 | +import { readCssSections, type CssSection } from "./read-css-tokens"; | |
| 3 | + | |
| 4 | +// 선언 원문을 읽어 결과가 테마·뷰포트에 무관하므로 한 번만 읽어 캐시한다. | |
| 5 | +// getSnapshot이 매 렌더 호출되므로 매번 새 배열을 돌려주면 무한 루프가 된다. | |
| 6 | +let cached: CssSection[] | null = null; | |
| 7 | + | |
| 8 | +function subscribe(): () => void { | |
| 9 | + return () => {}; | |
| 10 | +} | |
| 11 | + | |
| 12 | +function getSnapshot(): CssSection[] | null { | |
| 13 | + cached ??= readCssSections(); | |
| 14 | + return cached; | |
| 15 | +} | |
| 16 | + | |
| 17 | +function getServerSnapshot(): CssSection[] | null { | |
| 18 | + return null; | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** 서버 렌더·하이드레이션 첫 렌더에서는 `null`. */ | |
| 22 | +export function useCssTokens(): CssSection[] | null { | |
| 23 | + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | |
| 24 | +} |
+++ @fox/styles/_form-field.scss
... | ... | @@ -0,0 +1,232 @@ |
| 1 | +// 입력 계열 공용 조각. | |
| 2 | +// | |
| 3 | +// 시안에서 form-label · helper-message · 입력 상자가 모두 같은 공용 컴포넌트라 | |
| 4 | +// FoxInput · FoxTextArea · FoxPhoneNumber · FoxCertified · FoxEmail · FoxAddress · | |
| 5 | +// FoxFileUpload가 같은 선언을 되풀이했다. 그 되풀이를 여기로 모은다. | |
| 6 | +// | |
| 7 | +// 믹스인만 있고 규칙이 없어 @use 해도 CSS가 늘지 않는다 — 부르는 자리에서만 나온다. | |
| 8 | +// 그래서 파티셜을 따로 쓰든 묶음(components.scss)으로 쓰든 출력은 그대로다. | |
| 9 | + | |
| 10 | +@use "abstracts" as fox; | |
| 11 | + | |
| 12 | +/// 비활성·읽기전용 상자. 두 상태의 모양이 같아 한곳에 둔다. | |
| 13 | +@mixin _disabled-box { | |
| 14 | + border-width: fox.border(1); | |
| 15 | + border-color: fox.color(input-border-disabled); | |
| 16 | + background: fox.color(input-surface-disabled); | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// 필드 한 벌의 바깥 틀. 라벨·상자·헬퍼가 세로로 쌓인다. | |
| 20 | +@mixin root($gap: 3) { | |
| 21 | + display: flex; | |
| 22 | + flex-direction: column; | |
| 23 | + gap: fox.gap($gap); | |
| 24 | + inline-size: 100%; | |
| 25 | +} | |
| 26 | + | |
| 27 | +/// 시안 form-label. 입력 크기와 무관하게 항상 label/md다(공용 컴포넌트라 크기를 따라가지 | |
| 28 | +/// 않는다). | |
| 29 | +@mixin label { | |
| 30 | + color: fox.color(font-neutral-default); | |
| 31 | + font-family: fox.font-family(body); | |
| 32 | + font-size: fox.font-size(label-md); | |
| 33 | + font-weight: fox.font-weight(medium); | |
| 34 | + line-height: 1; | |
| 35 | + letter-spacing: -0.025em; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/// 시안 helper-message. 오류·포커스 색은 상태 규칙이 덮는다. | |
| 39 | +@mixin message { | |
| 40 | + display: flex; | |
| 41 | + gap: fox.number(3); | |
| 42 | + align-items: flex-start; | |
| 43 | + margin: 0; | |
| 44 | + color: fox.color(font-neutral-subtle); | |
| 45 | + font-family: fox.font-family(body); | |
| 46 | + font-size: fox.font-size(body-sm); | |
| 47 | + font-weight: fox.font-weight(regular); | |
| 48 | + line-height: 1.5; | |
| 49 | + letter-spacing: -0.025em; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/// helper-message 왼쪽 아이콘. 글줄 첫 줄에 맞춰 살짝 내린다. | |
| 53 | +@mixin message-icon { | |
| 54 | + display: inline-flex; | |
| 55 | + flex-shrink: 0; | |
| 56 | + align-items: flex-start; | |
| 57 | + padding-block-start: fox.padding(1); | |
| 58 | + | |
| 59 | + > * { | |
| 60 | + inline-size: fox.icon(3); | |
| 61 | + block-size: fox.icon(3); | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +/// 입력 상자 껍데기. 높이·좌우 여백·모서리는 부르는 쪽이 정한다 — FoxInput은 크기 | |
| 66 | +/// 수식어가, 나머지는 md 하나가 정한다. textarea는 세로 가운데 정렬을 쓰지 않으므로 | |
| 67 | +/// `$align: null`로 끈다. | |
| 68 | +@mixin box($gap: 3, $align: center) { | |
| 69 | + box-sizing: border-box; | |
| 70 | + display: flex; | |
| 71 | + align-items: $align; | |
| 72 | + gap: fox.gap($gap); | |
| 73 | + inline-size: 100%; | |
| 74 | + border: fox.border(1) solid fox.color(input-border); | |
| 75 | + background: fox.color(input-surface); | |
| 76 | +} | |
| 77 | + | |
| 78 | +/// 상자 하나짜리 크기(시안에 크기 축이 없는 컴포넌트용). | |
| 79 | +@mixin box-size($size: md) { | |
| 80 | + block-size: fox.form(height-#{$size}); | |
| 81 | + padding-inline: fox.form(padding-#{$size}); | |
| 82 | + border-radius: fox.form(radius-#{$size}); | |
| 83 | +} | |
| 84 | + | |
| 85 | +/// 상자 속 알맹이. 테두리·배경은 상자가 그리므로 여기선 지운다. | |
| 86 | +/// 글자 크기는 FoxInput만 크기 수식어가 정하므로 기본값이 없다. | |
| 87 | +/// | |
| 88 | +/// 뒤에 중첩 규칙이 붙으므로 부르는 쪽이 선언을 더할 때는 `@include`보다 앞에 적는다 — | |
| 89 | +/// 뒤에 적으면 Sass가 같은 선택자를 한 번 더 낸다. | |
| 90 | +@mixin field($font-size: null, $line-height: 1) { | |
| 91 | + flex: 1 0 0; | |
| 92 | + min-inline-size: 0; | |
| 93 | + margin: 0; | |
| 94 | + padding: 0; | |
| 95 | + border: none; | |
| 96 | + background: none; | |
| 97 | + color: fox.color(font-neutral-default); | |
| 98 | + font-family: fox.font-family(body); | |
| 99 | + | |
| 100 | + @if $font-size { | |
| 101 | + font-size: fox.font-size($font-size); | |
| 102 | + } | |
| 103 | + | |
| 104 | + font-weight: fox.font-weight(regular); | |
| 105 | + line-height: $line-height; | |
| 106 | + letter-spacing: -0.025em; | |
| 107 | + | |
| 108 | + &::placeholder { | |
| 109 | + color: fox.color(font-neutral-subtler); | |
| 110 | + } | |
| 111 | + | |
| 112 | + // 포커스는 상자 테두리가 표시하므로 기본 외곽선을 지운다. | |
| 113 | + &:focus { | |
| 114 | + outline: none; | |
| 115 | + } | |
| 116 | + | |
| 117 | + &:disabled { | |
| 118 | + color: fox.color(font-neutral-disabled-strong); | |
| 119 | + cursor: default; | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +/// 상태 · 자동. 브라우저가 판단한다 — 포커스는 `:focus-within`, 비활성·읽기전용은 | |
| 124 | +/// 네이티브 속성, 오류는 `aria-invalid="true"`. 뒤에 오는 규칙이 이긴다: | |
| 125 | +/// 포커스 < 오류 < 읽기전용 < 비활성. | |
| 126 | +/// | |
| 127 | +/// $field 알맹이의 element 이름(`__field` · `__segment`). | |
| 128 | +/// $invalid-within `:has()`로 필드 전체가 오류인지 볼 때 찾을 것. 조각을 다른 컴포넌트에 | |
| 129 | +/// 맡기는 경우(FoxPhoneNumber unit)는 알맹이가 없으므로 넓게 본다. | |
| 130 | +/// @content 컴포넌트 고유 규칙. `:not([data-state])` 안에 붙는다. | |
| 131 | +@mixin auto-states($block, $field: "field", $invalid-within: null) { | |
| 132 | + $box: ".#{$block}__box"; | |
| 133 | + $input: ".#{$block}__#{$field}"; | |
| 134 | + $message: ".#{$block}__message"; | |
| 135 | + $within: "#{$input}[aria-invalid=\"true\"]"; | |
| 136 | + | |
| 137 | + @if $invalid-within { | |
| 138 | + $within: $invalid-within; | |
| 139 | + } | |
| 140 | + | |
| 141 | + .#{$block}:not([data-state]) { | |
| 142 | + #{$box}:focus-within { | |
| 143 | + border-width: fox.border(2); | |
| 144 | + border-color: fox.color(input-border-active); | |
| 145 | + } | |
| 146 | + | |
| 147 | + #{$box}:has(#{$input}[aria-invalid="true"]) { | |
| 148 | + border-width: fox.border(2); | |
| 149 | + border-color: fox.color(input-border-error); | |
| 150 | + } | |
| 151 | + | |
| 152 | + // 비활성도 `:read-only`에 걸리므로 떼어낸다. | |
| 153 | + #{$box}:has(#{$input}:read-only:not(:disabled)) { | |
| 154 | + @include _disabled-box; | |
| 155 | + } | |
| 156 | + | |
| 157 | + #{$box}:has(#{$input}:disabled) { | |
| 158 | + @include _disabled-box; | |
| 159 | + } | |
| 160 | + | |
| 161 | + &:focus-within #{$message} { | |
| 162 | + color: fox.color(font-system-information); | |
| 163 | + } | |
| 164 | + | |
| 165 | + &:has(#{$within}) #{$message} { | |
| 166 | + color: fox.color(font-system-danger); | |
| 167 | + } | |
| 168 | + | |
| 169 | + @content; | |
| 170 | + } | |
| 171 | +} | |
| 172 | + | |
| 173 | +/// 상태 · 고정. 루트에 `data-state`를 주면 그 모양으로 못박히고 자동 규칙은 꺼진다. | |
| 174 | +/// 카탈로그·스냅샷처럼 상태를 눈으로 비교해야 할 때 쓴다. | |
| 175 | +/// | |
| 176 | +/// 시안의 focused·completed·error·view는 값이 들어찬 모습이라 플레이스홀더도 진하게 둔다. | |
| 177 | +@mixin fixed-states($block, $field: "field") { | |
| 178 | + $root: ".#{$block}"; | |
| 179 | + $box: ".#{$block}__box"; | |
| 180 | + $input: ".#{$block}__#{$field}"; | |
| 181 | + $message: ".#{$block}__message"; | |
| 182 | + | |
| 183 | + #{$root}[data-state="default"] #{$box}, | |
| 184 | + #{$root}[data-state="completed"] #{$box} { | |
| 185 | + border-width: fox.border(1); | |
| 186 | + border-color: fox.color(input-border); | |
| 187 | + background: fox.color(input-surface); | |
| 188 | + } | |
| 189 | + | |
| 190 | + #{$root}[data-state="focused"] #{$box} { | |
| 191 | + border-width: fox.border(2); | |
| 192 | + border-color: fox.color(input-border-active); | |
| 193 | + background: fox.color(input-surface); | |
| 194 | + } | |
| 195 | + | |
| 196 | + #{$root}[data-state="error"] #{$box} { | |
| 197 | + border-width: fox.border(2); | |
| 198 | + border-color: fox.color(input-border-error); | |
| 199 | + background: fox.color(input-surface); | |
| 200 | + } | |
| 201 | + | |
| 202 | + #{$root}[data-state="view"] #{$box}, | |
| 203 | + #{$root}[data-state="disabled"] #{$box} { | |
| 204 | + @include _disabled-box; | |
| 205 | + } | |
| 206 | + | |
| 207 | + #{$root}[data-state="completed"] #{$input}::placeholder, | |
| 208 | + #{$root}[data-state="focused"] #{$input}::placeholder, | |
| 209 | + #{$root}[data-state="error"] #{$input}::placeholder, | |
| 210 | + #{$root}[data-state="view"] #{$input}::placeholder { | |
| 211 | + color: fox.color(font-neutral-default); | |
| 212 | + } | |
| 213 | + | |
| 214 | + #{$root}[data-state="disabled"] #{$input}, | |
| 215 | + #{$root}[data-state="disabled"] #{$input}::placeholder { | |
| 216 | + color: fox.color(font-neutral-disabled-strong); | |
| 217 | + } | |
| 218 | + | |
| 219 | + #{$root}[data-state="focused"] #{$message} { | |
| 220 | + color: fox.color(font-system-information); | |
| 221 | + } | |
| 222 | + | |
| 223 | + #{$root}[data-state="completed"] #{$message} { | |
| 224 | + color: fox.color(font-system-success); | |
| 225 | + } | |
| 226 | + | |
| 227 | + #{$root}[data-state="error"] #{$message} { | |
| 228 | + color: fox.color(font-system-danger); | |
| 229 | + } | |
| 230 | + | |
| 231 | + @content; | |
| 232 | +} |
+++ @fox/styles/_fox-address.scss
... | ... | @@ -0,0 +1,58 @@ |
| 1 | +// FoxAddress — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma form-address (5346:5823 · 섹션 5383:8863) | |
| 3 | +// | |
| 4 | +// [우편번호 + 주소 검색] / [주소] / [상세주소]. 시안에서 세 상자가 모두 input 인스턴스이고 | |
| 5 | +// 버튼도 btn 인스턴스라, 이 파일은 줄 배치만 갖는다 — 상자 모양과 상태는 FoxInput이 가진 | |
| 6 | +// 것을 쓴다. | |
| 7 | +// | |
| 8 | +// 마크업 계약 (React 밖 소비자용): | |
| 9 | +// <div class="fox-address"> | |
| 10 | +// <div class="fox-address__row"> | |
| 11 | +// <div class="fox-address__zip">…fox-input (읽기 전용)…</div> | |
| 12 | +// <button class="fox-button fox-button--secondary fox-button--md">주소 검색</button> | |
| 13 | +// </div> | |
| 14 | +// …fox-input (주소, 읽기 전용)… | |
| 15 | +// …fox-input (상세주소)… | |
| 16 | +// </div> | |
| 17 | +// | |
| 18 | +// 우편번호와 주소는 검색 결과로만 채워진다 — 손으로 고칠 수 없다. 상세주소만 입력한다. | |
| 19 | +// | |
| 20 | +// 토큰이 아닌 값: | |
| 21 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 22 | +// export하지 않는다. | |
| 23 | + | |
| 24 | +@use "abstracts" as fox; | |
| 25 | +@use "form-field" as field; | |
| 26 | + | |
| 27 | +.fox-address { | |
| 28 | + @include field.root; | |
| 29 | + | |
| 30 | + &__label { | |
| 31 | + @include field.label; | |
| 32 | + } | |
| 33 | + | |
| 34 | + // 우편번호는 남는 폭을 먹고 버튼은 글자만큼만 차지한다. | |
| 35 | + &__row { | |
| 36 | + display: flex; | |
| 37 | + align-items: flex-start; | |
| 38 | + gap: fox.gap(3); | |
| 39 | + inline-size: 100%; | |
| 40 | + } | |
| 41 | + | |
| 42 | + &__zip { | |
| 43 | + flex: 1 0 0; | |
| 44 | + min-inline-size: 0; | |
| 45 | + } | |
| 46 | + | |
| 47 | + &__message { | |
| 48 | + @include field.message; | |
| 49 | + } | |
| 50 | + | |
| 51 | + &__message-icon { | |
| 52 | + @include field.message-icon; | |
| 53 | + } | |
| 54 | + | |
| 55 | + &:has([aria-invalid="true"]) .fox-address__message { | |
| 56 | + color: fox.color(font-system-danger); | |
| 57 | + } | |
| 58 | +} |
+++ @fox/styles/_fox-certified.scss
... | ... | @@ -0,0 +1,110 @@ |
| 1 | +// FoxCertified — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma form-certified (190:2017 · 섹션 392:5850) | |
| 3 | +// | |
| 4 | +// 세 줄이다. [전화번호 + 인증번호 받기] / [인증번호 입력 + 남은 시간] / [인증번호 확인]. | |
| 5 | +// 전화번호는 combine 모양 그대로이고 두 버튼도 시안에서 btn 인스턴스라, 이 파일은 줄 배치와 | |
| 6 | +// 인증번호 상자만 갖는다. 인증번호 상자를 FoxInput으로 쓰지 못한 이유는 오른쪽 남은 시간이 | |
| 7 | +// 아이콘 슬롯(정사각 20px)보다 넓기 때문이다. | |
| 8 | +// | |
| 9 | +// 마크업 계약 (React 밖 소비자용): | |
| 10 | +// <div class="fox-certified"> | |
| 11 | +// <div class="fox-certified__row"> | |
| 12 | +// <div class="fox-certified__phone">…fox-phone-number…</div> | |
| 13 | +// <button class="fox-button fox-button--secondary fox-button--md">인증번호 받기</button> | |
| 14 | +// </div> | |
| 15 | +// <div class="fox-certified__box"> | |
| 16 | +// <input class="fox-certified__field" placeholder="인증번호 입력"> | |
| 17 | +// <span class="fox-certified__timer"> | |
| 18 | +// <span class="fox-certified__timer-icon">…svg…</span> | |
| 19 | +// <span class="fox-certified__timer-text">24:16</span> | |
| 20 | +// </span> | |
| 21 | +// </div> | |
| 22 | +// <button class="fox-button fox-button--primary fox-button--md fox-button--full-width"> | |
| 23 | +// 인증번호 확인 | |
| 24 | +// </button> | |
| 25 | +// </div> | |
| 26 | +// | |
| 27 | +// 인증번호 상자의 상태는 FoxInput과 같은 방식으로 브라우저가 판단한다 — 포커스는 | |
| 28 | +// `:focus-within`, 비활성은 네이티브 속성. 시안에 상태 변형이 없어 고정 표기는 두지 않는다. | |
| 29 | +// | |
| 30 | +// 라벨·상자·알맹이는 `form-field` 믹스인이 갖는다. 상태만 여기서 직접 적는다 — | |
| 31 | +// `data-state` 고정 표기가 없어 자동 규칙 두 줄이면 끝난다. | |
| 32 | +// | |
| 33 | +// 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목): | |
| 34 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 35 | +// export하지 않는다. | |
| 36 | +// - flex: 구조 규칙. | |
| 37 | + | |
| 38 | +@use "abstracts" as fox; | |
| 39 | +@use "form-field" as field; | |
| 40 | + | |
| 41 | +.fox-certified { | |
| 42 | + @include field.root; | |
| 43 | + | |
| 44 | + &__label { | |
| 45 | + @include field.label; | |
| 46 | + } | |
| 47 | + | |
| 48 | + // 전화번호는 남는 폭을 먹고 버튼은 글자만큼만 차지한다. | |
| 49 | + &__row { | |
| 50 | + display: flex; | |
| 51 | + align-items: flex-start; | |
| 52 | + gap: fox.gap(3); | |
| 53 | + inline-size: 100%; | |
| 54 | + } | |
| 55 | + | |
| 56 | + &__phone { | |
| 57 | + flex: 1 0 0; | |
| 58 | + min-inline-size: 0; | |
| 59 | + } | |
| 60 | + | |
| 61 | + &__box { | |
| 62 | + @include field.box; | |
| 63 | + @include field.box-size(md); | |
| 64 | + } | |
| 65 | + | |
| 66 | + &__field { | |
| 67 | + @include field.field($font-size: label-md); | |
| 68 | + } | |
| 69 | + | |
| 70 | + &__timer { | |
| 71 | + display: inline-flex; | |
| 72 | + flex-shrink: 0; | |
| 73 | + align-items: center; | |
| 74 | + gap: fox.gap(2); | |
| 75 | + color: fox.color(icon-system-warning-strong); | |
| 76 | + } | |
| 77 | + | |
| 78 | + &__timer-icon { | |
| 79 | + display: inline-flex; | |
| 80 | + flex-shrink: 0; | |
| 81 | + | |
| 82 | + > * { | |
| 83 | + inline-size: fox.icon(3); | |
| 84 | + block-size: fox.icon(3); | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + &__timer-text { | |
| 89 | + color: fox.color(font-system-warning); | |
| 90 | + font-family: fox.font-family(body); | |
| 91 | + font-size: fox.font-size(label-sm); | |
| 92 | + font-weight: fox.font-weight(regular); | |
| 93 | + line-height: 1; | |
| 94 | + letter-spacing: -0.025em; | |
| 95 | + text-align: end; | |
| 96 | + } | |
| 97 | +} | |
| 98 | + | |
| 99 | +// ── 인증번호 상자 상태 ────────────────────────────────────────────────────── | |
| 100 | +// 뒤에 오는 규칙이 이긴다: 포커스 < 비활성. | |
| 101 | +.fox-certified__box:focus-within { | |
| 102 | + border-width: fox.border(2); | |
| 103 | + border-color: fox.color(input-border-active); | |
| 104 | +} | |
| 105 | + | |
| 106 | +.fox-certified__box:has(.fox-certified__field:disabled) { | |
| 107 | + border-width: fox.border(1); | |
| 108 | + border-color: fox.color(input-border-disabled); | |
| 109 | + background: fox.color(input-surface-disabled); | |
| 110 | +} |
+++ @fox/styles/_fox-email.scss
... | ... | @@ -0,0 +1,71 @@ |
| 1 | +// FoxEmail — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma form-email (136:3092 · 섹션 392:5820) | |
| 3 | +// | |
| 4 | +// [아이디] @ [도메인 드롭다운] [도메인 직접입력] + 선택적 확인 버튼. | |
| 5 | +// 시안에서 세 칸이 모두 input·select 인스턴스이고 버튼도 btn 인스턴스라, 이 파일은 줄 | |
| 6 | +// 배치와 `@` 구분자만 갖는다 — 상자 모양과 상태는 FoxInput·FoxSelect가 가진 것을 쓴다. | |
| 7 | +// | |
| 8 | +// 마크업 계약 (React 밖 소비자용): | |
| 9 | +// <div class="fox-email"> | |
| 10 | +// <div class="fox-email__row"> | |
| 11 | +// <div class="fox-email__part">…fox-input…</div> | |
| 12 | +// <span class="fox-email__at">@</span> | |
| 13 | +// <div class="fox-email__part">…fox-select…</div> | |
| 14 | +// <div class="fox-email__part">…fox-input…</div> | |
| 15 | +// <button class="fox-button fox-button--primary fox-button--md">확인</button> | |
| 16 | +// </div> | |
| 17 | +// </div> | |
| 18 | +// | |
| 19 | +// 토큰이 아닌 값(사용자 승인): | |
| 20 | +// - 칸의 `min-inline-size: 24rem`(240px): 시안이 세 칸을 240px로 고정했는데 토큰 | |
| 21 | +// 스케일에 240이 없다. 고정 대신 최소폭으로 두어 넓은 곳에서는 나눠 갖고 좁아지면 | |
| 22 | +// 줄이 넘어가게 했다. | |
| 23 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 24 | +// export하지 않는다. | |
| 25 | + | |
| 26 | +@use "abstracts" as fox; | |
| 27 | +@use "form-field" as field; | |
| 28 | + | |
| 29 | +.fox-email { | |
| 30 | + @include field.root; | |
| 31 | + | |
| 32 | + &__label { | |
| 33 | + @include field.label; | |
| 34 | + } | |
| 35 | + | |
| 36 | + // 좁아지면 칸이 찌그러지는 대신 줄이 넘어간다. | |
| 37 | + &__row { | |
| 38 | + display: flex; | |
| 39 | + flex-wrap: wrap; | |
| 40 | + align-items: center; | |
| 41 | + gap: fox.gap(2); | |
| 42 | + inline-size: 100%; | |
| 43 | + } | |
| 44 | + | |
| 45 | + &__part { | |
| 46 | + flex: 1 1 0; | |
| 47 | + min-inline-size: 24rem; | |
| 48 | + } | |
| 49 | + | |
| 50 | + &__at { | |
| 51 | + flex-shrink: 0; | |
| 52 | + color: fox.color(font-neutral-default); | |
| 53 | + font-family: fox.font-family(body); | |
| 54 | + font-size: fox.font-size(body-md); | |
| 55 | + font-weight: fox.font-weight(regular); | |
| 56 | + line-height: 1.5; | |
| 57 | + letter-spacing: -0.025em; | |
| 58 | + } | |
| 59 | + | |
| 60 | + &__message { | |
| 61 | + @include field.message; | |
| 62 | + } | |
| 63 | + | |
| 64 | + &__message-icon { | |
| 65 | + @include field.message-icon; | |
| 66 | + } | |
| 67 | + | |
| 68 | + &:has([aria-invalid="true"]) .fox-email__message { | |
| 69 | + color: fox.color(font-system-danger); | |
| 70 | + } | |
| 71 | +} |
+++ @fox/styles/_fox-file-upload.scss
... | ... | @@ -0,0 +1,292 @@ |
| 1 | +// FoxFileUpload — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// file-upload (391:3677) · file-upload-area (392:5968) · file-upload-img (391:3732) | |
| 3 | +// file-list (392:5938) · file-list-item (391:3509) | |
| 4 | +// | |
| 5 | +// 세 모드가 한 컴포넌트다. | |
| 6 | +// - default: [읽기 전용 입력 + 파일선택(secondary)] — 목록을 붙일 수 있다. | |
| 7 | +// - area: 점선 상자에 안내문과 파일선택(primary) — 목록을 붙일 수 있고 끌어다 놓기가 | |
| 8 | +// 항상 켜져 있다(안내문이 그렇게 말한다). | |
| 9 | +// - image: 썸네일 + 파일선택(secondary) + 설명 — 목록이 없다. | |
| 10 | +// | |
| 11 | +// 시안에서 버튼·입력이 모두 기존 컴포넌트의 인스턴스라 그대로 조합한다. | |
| 12 | +// 파일선택 = FoxButton(secondary/primary md) · 삭제 = FoxTextButton(md secondary) | |
| 13 | +// 썸네일 삭제 = FoxIconButton(overlay xsm — 24px·radius 4·배경흐림·흰 아이콘이 시안과 같다) | |
| 14 | +// default 모드 입력칸 = FoxInput(md, 읽기 전용) | |
| 15 | +// 그래서 이 파일은 배치와 목록·썸네일·점선 상자만 갖는다. | |
| 16 | +// | |
| 17 | +// 아이콘 색은 에셋에서 확인해 토큰과 맞췄다 — 클립 #8A949E(icon-neutral-subtler), | |
| 18 | +// 오류 #BD2C0F(icon-system-danger-strong), 빈 썸네일은 검정 25%(icon-neutral-fill). | |
| 19 | +// 버튼 안 아이콘은 FoxButton·FoxTextButton이 `currentColor`로 칠하므로 여기서 정하지 않는다. | |
| 20 | +// | |
| 21 | +// 토큰이 아닌 값: | |
| 22 | +// - 입력칸 폭과 썸네일 크기는 인라인 스타일로 들어온다 — 시안 값(320px · 213×120px)이 | |
| 23 | +// 토큰에 없어 prop 기본값으로 두었다(사용자 승인). | |
| 24 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 25 | +// export하지 않는다. | |
| 26 | +// - object-fit / overflow / position: 구조 규칙. | |
| 27 | +// - 목록의 margin/padding/list-style 0: `<ul>`의 브라우저 기본값 지우기. | |
| 28 | + | |
| 29 | +@use "abstracts" as fox; | |
| 30 | +@use "form-field" as field; | |
| 31 | + | |
| 32 | +.fox-file-upload { | |
| 33 | + @include field.root; | |
| 34 | + | |
| 35 | + // area 모드는 상자와 목록 사이가 한 단계 넓다. | |
| 36 | + &--area { | |
| 37 | + gap: fox.gap(7); | |
| 38 | + } | |
| 39 | + | |
| 40 | + &__label { | |
| 41 | + @include field.label; | |
| 42 | + } | |
| 43 | + | |
| 44 | + &__description { | |
| 45 | + margin: 0; | |
| 46 | + color: fox.color(font-neutral-subtle); | |
| 47 | + font-family: fox.font-family(body); | |
| 48 | + font-size: fox.font-size(body-sm); | |
| 49 | + font-weight: fox.font-weight(regular); | |
| 50 | + line-height: 1.5; | |
| 51 | + letter-spacing: -0.025em; | |
| 52 | + } | |
| 53 | + | |
| 54 | + // ── default 모드 ────────────────────────────────────────────────────────── | |
| 55 | + &__row { | |
| 56 | + display: flex; | |
| 57 | + align-items: flex-start; | |
| 58 | + gap: fox.gap(3); | |
| 59 | + inline-size: 100%; | |
| 60 | + } | |
| 61 | + | |
| 62 | + // 폭은 prop이 인라인 스타일로 정한다. | |
| 63 | + &__field { | |
| 64 | + flex-shrink: 0; | |
| 65 | + } | |
| 66 | + | |
| 67 | + | |
| 68 | + // ── area 모드 ───────────────────────────────────────────────────────────── | |
| 69 | + &__area { | |
| 70 | + box-sizing: border-box; | |
| 71 | + display: flex; | |
| 72 | + flex-direction: column; | |
| 73 | + align-items: center; | |
| 74 | + gap: fox.gap(7); | |
| 75 | + inline-size: 100%; | |
| 76 | + padding: fox.padding(10); | |
| 77 | + border: fox.border(2) dashed fox.color(border-neutral-subtle); | |
| 78 | + border-radius: fox.radius(6); | |
| 79 | + background: fox.color(surface-neutral-gray); | |
| 80 | + } | |
| 81 | + | |
| 82 | + // 끌어오는 동안. 시안에 없는 상태라 기존 토큰으로 최소한만 표시한다. | |
| 83 | + &__area--over { | |
| 84 | + border-color: fox.color(border-theme-primary); | |
| 85 | + background: fox.color(surface-theme-primary); | |
| 86 | + } | |
| 87 | + | |
| 88 | + &__area-text { | |
| 89 | + margin: 0; | |
| 90 | + inline-size: 100%; | |
| 91 | + color: fox.color(font-neutral-default); | |
| 92 | + font-family: fox.font-family(body); | |
| 93 | + font-size: fox.font-size(body-lg); | |
| 94 | + font-weight: fox.font-weight(regular); | |
| 95 | + line-height: 1.5; | |
| 96 | + letter-spacing: -0.025em; | |
| 97 | + text-align: center; | |
| 98 | + } | |
| 99 | + | |
| 100 | + // ── image 모드 ──────────────────────────────────────────────────────────── | |
| 101 | + &__image { | |
| 102 | + display: flex; | |
| 103 | + align-items: center; | |
| 104 | + gap: fox.gap(6); | |
| 105 | + inline-size: 100%; | |
| 106 | + } | |
| 107 | + | |
| 108 | + &__thumb { | |
| 109 | + position: relative; | |
| 110 | + box-sizing: border-box; | |
| 111 | + display: flex; | |
| 112 | + flex-shrink: 0; | |
| 113 | + align-items: center; | |
| 114 | + justify-content: center; | |
| 115 | + overflow: hidden; | |
| 116 | + border: fox.border(1) dashed fox.color(border-neutral-subtle); | |
| 117 | + border-radius: fox.radius(3); | |
| 118 | + background: fox.color(surface-neutral-gray); | |
| 119 | + color: fox.color(icon-neutral-fill); | |
| 120 | + } | |
| 121 | + | |
| 122 | + // 그림이 들어오면 점선이 실선이 되고 바탕은 그림이 덮는다. | |
| 123 | + &__thumb--filled { | |
| 124 | + border-style: solid; | |
| 125 | + background: none; | |
| 126 | + } | |
| 127 | + | |
| 128 | + &__thumb-icon > * { | |
| 129 | + inline-size: fox.icon(6); | |
| 130 | + block-size: fox.icon(6); | |
| 131 | + } | |
| 132 | + | |
| 133 | + &__thumb-image { | |
| 134 | + inline-size: 100%; | |
| 135 | + block-size: 100%; | |
| 136 | + object-fit: cover; | |
| 137 | + } | |
| 138 | + | |
| 139 | + &__thumb-remove { | |
| 140 | + position: absolute; | |
| 141 | + inset-block-start: 50%; | |
| 142 | + inset-inline-start: 50%; | |
| 143 | + transform: translate(-50%, -50%); | |
| 144 | + } | |
| 145 | + | |
| 146 | + // 버튼은 글자만큼만 차지한다(시안 items-start). 세로 정렬을 stretch로 두면 버튼이 | |
| 147 | + // 칸 끝까지 늘어난다. | |
| 148 | + &__image-side { | |
| 149 | + display: flex; | |
| 150 | + flex: 1 0 0; | |
| 151 | + flex-direction: column; | |
| 152 | + align-items: flex-start; | |
| 153 | + gap: fox.gap(4); | |
| 154 | + min-inline-size: 0; | |
| 155 | + } | |
| 156 | + | |
| 157 | + // 설명만 폭을 채운다(시안 min-w-full) — 위 정렬 때문에 그냥 두면 글자만큼만 남는다. | |
| 158 | + &__image-side &__description { | |
| 159 | + inline-size: 100%; | |
| 160 | + } | |
| 161 | + | |
| 162 | + // ── 목록 ────────────────────────────────────────────────────────────────── | |
| 163 | + // 줄이 많아지면 상자 안에서만 스크롤한다. | |
| 164 | + // `<ul>`이라 브라우저 기본 여백·글머리표를 먼저 지운다 — 안 지우면 왼쪽에 40px이 붙는다. | |
| 165 | + &__list { | |
| 166 | + display: flex; | |
| 167 | + flex-direction: column; | |
| 168 | + inline-size: 100%; | |
| 169 | + max-block-size: 32rem; | |
| 170 | + margin: 0; | |
| 171 | + padding: 0; | |
| 172 | + overflow-x: clip; | |
| 173 | + overflow-y: auto; | |
| 174 | + border: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 175 | + border-radius: fox.radius(3); | |
| 176 | + list-style: none; | |
| 177 | + } | |
| 178 | + | |
| 179 | + &__item { | |
| 180 | + box-sizing: border-box; | |
| 181 | + display: flex; | |
| 182 | + flex-direction: column; | |
| 183 | + gap: fox.gap(4); | |
| 184 | + justify-content: center; | |
| 185 | + inline-size: 100%; | |
| 186 | + padding-inline: fox.padding(6); | |
| 187 | + padding-block: fox.padding(4); | |
| 188 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 189 | + background: fox.color(surface-neutral-default); | |
| 190 | + } | |
| 191 | + | |
| 192 | + // 오류 줄은 네 변 모두 굵은 테두리를 갖는다. | |
| 193 | + &__item--error { | |
| 194 | + border: fox.border(2) solid fox.color(border-system-danger); | |
| 195 | + background: fox.color(surface-system-danger); | |
| 196 | + } | |
| 197 | + | |
| 198 | + &__item-main { | |
| 199 | + display: flex; | |
| 200 | + align-items: center; | |
| 201 | + gap: fox.gap(3); | |
| 202 | + inline-size: 100%; | |
| 203 | + } | |
| 204 | + | |
| 205 | + &__item-title { | |
| 206 | + display: flex; | |
| 207 | + flex: 1 0 0; | |
| 208 | + align-items: center; | |
| 209 | + gap: fox.gap(2); | |
| 210 | + min-inline-size: 0; | |
| 211 | + color: fox.color(font-neutral-default); | |
| 212 | + font-family: fox.font-family(body); | |
| 213 | + font-size: fox.font-size(body-md); | |
| 214 | + font-weight: fox.font-weight(regular); | |
| 215 | + line-height: 1.5; | |
| 216 | + letter-spacing: -0.025em; | |
| 217 | + white-space: nowrap; | |
| 218 | + } | |
| 219 | + | |
| 220 | + &__item-icon { | |
| 221 | + display: inline-flex; | |
| 222 | + flex-shrink: 0; | |
| 223 | + color: fox.color(icon-neutral-subtler); | |
| 224 | + | |
| 225 | + > * { | |
| 226 | + inline-size: fox.icon(4); | |
| 227 | + block-size: fox.icon(4); | |
| 228 | + } | |
| 229 | + } | |
| 230 | + | |
| 231 | + // 이름만 줄어들고 확장자·용량은 그대로 남는다. | |
| 232 | + &__item-name { | |
| 233 | + overflow: hidden; | |
| 234 | + text-overflow: ellipsis; | |
| 235 | + } | |
| 236 | + | |
| 237 | + &__item-meta { | |
| 238 | + flex-shrink: 0; | |
| 239 | + } | |
| 240 | + | |
| 241 | + &__item-actions { | |
| 242 | + display: flex; | |
| 243 | + flex-shrink: 0; | |
| 244 | + align-items: center; | |
| 245 | + gap: fox.gap(4); | |
| 246 | + } | |
| 247 | + | |
| 248 | + // 오류 문구는 줄 안에서 옅은 선으로 한 번 끊고 붙는다. | |
| 249 | + &__item-message { | |
| 250 | + display: flex; | |
| 251 | + gap: fox.number(3); | |
| 252 | + align-items: flex-start; | |
| 253 | + inline-size: 100%; | |
| 254 | + margin: 0; | |
| 255 | + padding-block-start: fox.padding(5); | |
| 256 | + border-block-start: fox.border(1) solid fox.color(border-neutral-overlay); | |
| 257 | + color: fox.color(font-system-danger); | |
| 258 | + font-family: fox.font-family(body); | |
| 259 | + font-size: fox.font-size(body-sm); | |
| 260 | + font-weight: fox.font-weight(regular); | |
| 261 | + line-height: 1.5; | |
| 262 | + letter-spacing: -0.025em; | |
| 263 | + } | |
| 264 | + | |
| 265 | + &__item-message-icon { | |
| 266 | + display: inline-flex; | |
| 267 | + flex-shrink: 0; | |
| 268 | + align-items: flex-start; | |
| 269 | + padding-block-start: fox.padding(1); | |
| 270 | + color: fox.color(icon-system-danger-strong); | |
| 271 | + | |
| 272 | + > * { | |
| 273 | + inline-size: fox.icon(3); | |
| 274 | + block-size: fox.icon(3); | |
| 275 | + } | |
| 276 | + } | |
| 277 | +} | |
| 278 | + | |
| 279 | +// 칸은 손으로 고칠 수 없으므로 언제나 읽기 전용이지만, 시안은 비었을 때 흰 상자를 보여준다 | |
| 280 | +// (파일이 들어오면 읽기 전용 본래의 회색 상자가 그대로 나온다 — 시안 upload=true). | |
| 281 | +// FoxInput의 읽기 전용 규칙이 특이도 0,6,0이라 그보다 높게 적어 로드 순서와 무관하게 이긴다. | |
| 282 | +.fox-file-upload | |
| 283 | + .fox-file-upload__field--empty | |
| 284 | + .fox-input:not([data-state]) | |
| 285 | + .fox-input__box:has(.fox-file-upload__field--empty .fox-input__field:read-only:not(:disabled)), | |
| 286 | +.fox-file-upload | |
| 287 | + .fox-file-upload__field--empty | |
| 288 | + .fox-input:not([data-state]) | |
| 289 | + .fox-input__box:has(.fox-input__field:read-only:not(:disabled)) { | |
| 290 | + border-color: fox.color(input-border); | |
| 291 | + background: fox.color(input-surface); | |
| 292 | +} |
+++ @fox/styles/_fox-input.scss
... | ... | @@ -0,0 +1,149 @@ |
| 1 | +// FoxInput — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma input (컴포넌트 세트 135:621) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <div class="fox-input fox-input--md"> | |
| 6 | +// <label class="fox-input__label" for="…">레이블</label> | |
| 7 | +// <div class="fox-input__box"> | |
| 8 | +// <input class="fox-input__field" id="…" placeholder="…"> | |
| 9 | +// <button class="fox-input__clear" type="button">…svg…</button> <!-- 선택 --> | |
| 10 | +// <span class="fox-input__icon">…svg…</span> <!-- 선택 --> | |
| 11 | +// </div> | |
| 12 | +// <p class="fox-input__message">…</p> <!-- 선택 --> | |
| 13 | +// </div> | |
| 14 | +// | |
| 15 | +// 상태는 두 갈래다. | |
| 16 | +// - 기본(자동): 브라우저가 판단한다. 포커스는 `:focus-within`, 비활성·읽기전용은 네이티브 | |
| 17 | +// 속성, 오류는 `aria-invalid="true"`. 값이 들어찬 모습(시안의 completed)은 따로 규칙이 | |
| 18 | +// 필요 없다 — 시안에서 default와 completed의 차이가 글자색뿐인데 그건 플레이스홀더와 | |
| 19 | +// 입력값의 네이티브 차이다. | |
| 20 | +// - 고정: 루트에 `data-state`를 주면 그 모양으로 못박힌다. 자동 규칙은 전부 꺼진다. | |
| 21 | +// 카탈로그·스냅샷처럼 상태를 눈으로 비교해야 할 때 쓴다. | |
| 22 | +// | |
| 23 | +// 라벨·헬퍼·상자·알맹이와 두 갈래 상태는 `form-field` 믹스인이 갖는다 — 입력 계열 여섯 | |
| 24 | +// 컴포넌트가 같은 값을 쓴다. 이 파일에는 FoxInput만의 것(크기 축, 아이콘, 지우기 버튼)이 | |
| 25 | +// 남는다. | |
| 26 | +// | |
| 27 | +// 아이콘 크기 24/20/16은 시안이 변수를 걸지 않았지만 상자의 나머지 치수가 전부 `form/*` | |
| 28 | +// 반응형 토큰이라 아이콘도 `form-icon-*`(PC 24/20/16 · 모바일 20/16/12)에 맞춘다. | |
| 29 | +// | |
| 30 | +// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목): | |
| 31 | +// - cursor / flex / appearance 관련: 구조·상호작용 규칙. | |
| 32 | + | |
| 33 | +@use "abstracts" as fox; | |
| 34 | +@use "form-field" as field; | |
| 35 | + | |
| 36 | +.fox-input { | |
| 37 | + @include field.root; | |
| 38 | + | |
| 39 | + &__label { | |
| 40 | + @include field.label; | |
| 41 | + } | |
| 42 | + | |
| 43 | + // 크기(높이·좌우 여백·모서리)는 아래 크기 수식어가 정한다. | |
| 44 | + &__box { | |
| 45 | + @include field.box; | |
| 46 | + } | |
| 47 | + | |
| 48 | + // 글자 크기도 크기 수식어가 정한다. | |
| 49 | + &__field { | |
| 50 | + @include field.field; | |
| 51 | + } | |
| 52 | + | |
| 53 | + &__icon, | |
| 54 | + &__clear { | |
| 55 | + display: inline-flex; | |
| 56 | + flex-shrink: 0; | |
| 57 | + align-items: center; | |
| 58 | + justify-content: center; | |
| 59 | + color: fox.color(icon-neutral-default); | |
| 60 | + } | |
| 61 | + | |
| 62 | + &__icon > *, | |
| 63 | + &__clear > * { | |
| 64 | + inline-size: 100%; | |
| 65 | + block-size: 100%; | |
| 66 | + } | |
| 67 | + | |
| 68 | + &__clear { | |
| 69 | + margin: 0; | |
| 70 | + padding: 0; | |
| 71 | + border: none; | |
| 72 | + background: none; | |
| 73 | + cursor: pointer; | |
| 74 | + } | |
| 75 | + | |
| 76 | + &__message { | |
| 77 | + @include field.message; | |
| 78 | + } | |
| 79 | + | |
| 80 | + &__message-icon { | |
| 81 | + @include field.message-icon; | |
| 82 | + } | |
| 83 | + | |
| 84 | + // ── 크기 ────────────────────────────────────────────────────────────────── | |
| 85 | + &--lg &__box { | |
| 86 | + block-size: fox.form(height-lg); | |
| 87 | + padding-inline: fox.form(padding-lg); | |
| 88 | + border-radius: fox.form(radius-lg); | |
| 89 | + } | |
| 90 | + | |
| 91 | + &--lg &__field { | |
| 92 | + font-size: fox.font-size(label-lg); | |
| 93 | + } | |
| 94 | + | |
| 95 | + &--lg &__icon, | |
| 96 | + &--lg &__clear { | |
| 97 | + inline-size: fox.form(icon-lg); | |
| 98 | + block-size: fox.form(icon-lg); | |
| 99 | + } | |
| 100 | + | |
| 101 | + &--md &__box { | |
| 102 | + block-size: fox.form(height-md); | |
| 103 | + padding-inline: fox.form(padding-md); | |
| 104 | + border-radius: fox.form(radius-md); | |
| 105 | + } | |
| 106 | + | |
| 107 | + &--md &__field { | |
| 108 | + font-size: fox.font-size(label-md); | |
| 109 | + } | |
| 110 | + | |
| 111 | + &--md &__icon, | |
| 112 | + &--md &__clear { | |
| 113 | + inline-size: fox.form(icon-md); | |
| 114 | + block-size: fox.form(icon-md); | |
| 115 | + } | |
| 116 | + | |
| 117 | + &--sm &__box { | |
| 118 | + block-size: fox.form(height-sm); | |
| 119 | + padding-inline: fox.form(padding-sm); | |
| 120 | + border-radius: fox.form(radius-sm); | |
| 121 | + } | |
| 122 | + | |
| 123 | + &--sm &__field { | |
| 124 | + font-size: fox.font-size(label-sm); | |
| 125 | + } | |
| 126 | + | |
| 127 | + &--sm &__icon, | |
| 128 | + &--sm &__clear { | |
| 129 | + inline-size: fox.form(icon-sm); | |
| 130 | + block-size: fox.form(icon-sm); | |
| 131 | + } | |
| 132 | + | |
| 133 | + // 지우기 버튼은 포커스 중에만 보인다(시안에서 focused 변형에만 있다). | |
| 134 | + &__clear { | |
| 135 | + display: none; | |
| 136 | + } | |
| 137 | + | |
| 138 | + &__box:focus-within &__clear { | |
| 139 | + display: inline-flex; | |
| 140 | + } | |
| 141 | +} | |
| 142 | + | |
| 143 | +@include field.auto-states("fox-input"); | |
| 144 | + | |
| 145 | +@include field.fixed-states("fox-input") { | |
| 146 | + .fox-input[data-state="focused"] .fox-input__clear { | |
| 147 | + display: inline-flex; | |
| 148 | + } | |
| 149 | +} |
+++ @fox/styles/_fox-phone-number.scss
... | ... | @@ -0,0 +1,105 @@ |
| 1 | +// FoxPhoneNumber — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma form-phonenumber (섹션 391:3156) | |
| 3 | +// | |
| 4 | +// 두 모양이 있다. | |
| 5 | +// - unit(136:2926): 상자 셋을 하이픈으로 잇는다. 앞자리는 선택, 뒤 둘은 입력이며 | |
| 6 | +// 시안에서도 select·input 컴포넌트의 인스턴스라 그대로 조합한다. 그래서 이 파일은 | |
| 7 | +// 줄 배치와 하이픈만 갖고, 상자 모양·상태는 FoxSelect·FoxInput이 가진 것을 쓴다. | |
| 8 | +// - combine(190:1772): 상자 하나 안에 세 칸을 두고 각 칸을 가운데 정렬한다. 이건 | |
| 9 | +// 시안이 별도 상자라 여기서 직접 그린다. | |
| 10 | +// | |
| 11 | +// 마크업 계약 (React 밖 소비자용): | |
| 12 | +// <div class="fox-phone-number" role="group" aria-labelledby="…"> | |
| 13 | +// <span class="fox-phone-number__label" id="…">레이블</span> | |
| 14 | +// <div class="fox-phone-number__row"> <!-- unit --> | |
| 15 | +// <div class="fox-phone-number__part">…select…</div> | |
| 16 | +// <span class="fox-phone-number__separator">-</span> | |
| 17 | +// <div class="fox-phone-number__part">…input…</div> | |
| 18 | +// <span class="fox-phone-number__separator">-</span> | |
| 19 | +// <div class="fox-phone-number__part">…input…</div> | |
| 20 | +// </div> | |
| 21 | +// <div class="fox-phone-number__box"> <!-- combine --> | |
| 22 | +// <input class="fox-phone-number__segment"> | |
| 23 | +// <span class="fox-phone-number__separator">-</span> | |
| 24 | +// … | |
| 25 | +// </div> | |
| 26 | +// <p class="fox-phone-number__message">…</p> | |
| 27 | +// </div> | |
| 28 | +// | |
| 29 | +// 상태는 FoxInput과 같은 두 갈래다(자동 / `data-state` 고정). combine 상자에만 규칙이 | |
| 30 | +// 필요하다 — unit은 조각이 알아서 따라온다. | |
| 31 | +// | |
| 32 | +// 라벨·헬퍼·상자·알맹이와 두 갈래 상태는 `form-field` 믹스인이 갖는다. | |
| 33 | +// | |
| 34 | +// 시안에 크기 축이 없다. 상자는 md 하나뿐이다. | |
| 35 | +// | |
| 36 | +// 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목): | |
| 37 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 38 | +// export하지 않는다. | |
| 39 | +// - flex / text-align: 구조 규칙. | |
| 40 | + | |
| 41 | +@use "abstracts" as fox; | |
| 42 | +@use "form-field" as field; | |
| 43 | + | |
| 44 | +.fox-phone-number { | |
| 45 | + @include field.root; | |
| 46 | + | |
| 47 | + &__label { | |
| 48 | + @include field.label; | |
| 49 | + } | |
| 50 | + | |
| 51 | + // ── unit ────────────────────────────────────────────────────────────────── | |
| 52 | + &__row { | |
| 53 | + display: flex; | |
| 54 | + align-items: center; | |
| 55 | + gap: fox.gap(2); | |
| 56 | + inline-size: 100%; | |
| 57 | + } | |
| 58 | + | |
| 59 | + // 세 조각이 남는 폭을 똑같이 나눈다. | |
| 60 | + &__part { | |
| 61 | + flex: 1 0 0; | |
| 62 | + min-inline-size: 0; | |
| 63 | + } | |
| 64 | + | |
| 65 | + &__separator { | |
| 66 | + flex-shrink: 0; | |
| 67 | + color: fox.color(font-neutral-default); | |
| 68 | + font-family: fox.font-family(body); | |
| 69 | + font-size: fox.font-size(body-md); | |
| 70 | + font-weight: fox.font-weight(regular); | |
| 71 | + line-height: 1.5; | |
| 72 | + letter-spacing: -0.025em; | |
| 73 | + } | |
| 74 | + | |
| 75 | + // ── combine ─────────────────────────────────────────────────────────────── | |
| 76 | + &__box { | |
| 77 | + @include field.box($gap: 2); | |
| 78 | + @include field.box-size(md); | |
| 79 | + } | |
| 80 | + | |
| 81 | + &__segment { | |
| 82 | + text-align: center; | |
| 83 | + | |
| 84 | + @include field.field($font-size: label-md); | |
| 85 | + } | |
| 86 | + | |
| 87 | + &__message { | |
| 88 | + @include field.message; | |
| 89 | + } | |
| 90 | + | |
| 91 | + &__message-icon { | |
| 92 | + @include field.message-icon; | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +// unit 모양은 조각(FoxSelect·FoxInput)이 제 상태를 갖고 있어 combine 상자에만 규칙이 | |
| 97 | +// 필요하다. 다만 헬퍼 색은 두 모양 모두 물들어야 하므로 오류 표시를 넓게 본다 — unit에는 | |
| 98 | +// `__segment`가 없다. | |
| 99 | +@include field.auto-states( | |
| 100 | + "fox-phone-number", | |
| 101 | + $field: "segment", | |
| 102 | + $invalid-within: "[aria-invalid=\"true\"]" | |
| 103 | +); | |
| 104 | + | |
| 105 | +@include field.fixed-states("fox-phone-number", $field: "segment"); |
+++ @fox/styles/_fox-select-option-item.scss
... | ... | @@ -0,0 +1,107 @@ |
| 1 | +// FoxSelectOptionItem — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) Figma select (컴포넌트 세트 111:272) | |
| 2 | +// | |
| 3 | +// 옵션 목록(`fox-select-option`)의 항목 하나다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <li class="fox-select-option-item" id="sel-opt-0" role="option" aria-selected="true"> | |
| 7 | +// <span class="fox-select-option-item__check">…svg…</span> <!-- 선택됐을 때만, 맨 앞 --> | |
| 8 | +// <span class="fox-select-option-item__text">…</span> | |
| 9 | +// <span class="fox-select-option-item__icon">…svg…</span> <!-- 선택, 맨 뒤 --> | |
| 10 | +// </li> | |
| 11 | +// | |
| 12 | +// 위험한 선택지에는 `fox-select-option-item--danger`를 더한다. | |
| 13 | +// | |
| 14 | +// 선택 여부는 `aria-selected="true"`가 스타일 훅을 겸한다 — 상태용 클래스를 만들지 않는다. | |
| 15 | +// 체크 아이콘은 `currentColor`라 선택된 항목의 글자색을 물려받는다. 그 색이 시안의 아이콘 | |
| 16 | +// 색(`#1C589C`)과 같아 별도 색 규칙이 없다. | |
| 17 | +// | |
| 18 | +// 시안은 hover·pressed·selected·danger를 모두 `state` 하나로 묶어 놨지만, 앞의 셋은 | |
| 19 | +// 브라우저가 스스로 아는 상태다(`--active`·`:active`·`aria-selected`). 밖에서 지정해야 하는 | |
| 20 | +// 건 danger뿐이라 그것만 클래스로 받는다. | |
| 21 | +// | |
| 22 | +// 네이티브 대응이 없어 클래스로 두는 둘: | |
| 23 | +// - `--active`: 키보드로 이동 중인 항목. 마우스 hover와 같은 모양이어야 하는데 `:hover`로는 | |
| 24 | +// 표현할 수 없다. 리스트박스를 여는 요소의 `aria-activedescendant`가 이 항목을 가리킨다. | |
| 25 | +// - `--danger`: 위험한 선택지. 값의 성격이라 브라우저도 ARIA도 알 방법이 없다. | |
| 26 | +// | |
| 27 | +// `__text`가 남는 폭을 먹어서 체크는 왼쪽 끝, 아이콘은 오른쪽 끝에 붙는다. | |
| 28 | +// | |
| 29 | +// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목): | |
| 30 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 않는다. | |
| 31 | +// - cursor / flex-shrink: 구조·상호작용 규칙. | |
| 32 | + | |
| 33 | +@use "@fox/styles/abstracts" as fox; | |
| 34 | + | |
| 35 | +.fox-select-option-item { | |
| 36 | + display: flex; | |
| 37 | + align-items: center; | |
| 38 | + gap: fox.gap(3); | |
| 39 | + padding: fox.padding(4) fox.form(padding-sm); | |
| 40 | + border-radius: fox.form(radius-sm); | |
| 41 | + background: fox.color(action-secondary); | |
| 42 | + color: fox.color(font-neutral-default); | |
| 43 | + font-family: fox.font-family(body); | |
| 44 | + font-weight: fox.font-weight(regular); | |
| 45 | + font-size: fox.font-size(body-sm); | |
| 46 | + // line-height 150% / letter-spacing -0.375px — Figma 텍스트 스타일 값이며 변수로 | |
| 47 | + // export되지 않아 토큰이 없다(fox-button의 label/*-strong 사례와 같은 예외, 사용자 승인). | |
| 48 | + line-height: 1.5; | |
| 49 | + letter-spacing: -0.375px; | |
| 50 | + cursor: pointer; | |
| 51 | + | |
| 52 | + // 키보드 화살표로 이동한 항목도 마우스 호버와 같은 모양이어야 하므로, 실제 `:hover` | |
| 53 | + // 대신 JS로 추적하는 active 모디파이어를 쓴다. | |
| 54 | + &--active { | |
| 55 | + background: fox.color(action-secondary-hover); | |
| 56 | + } | |
| 57 | + | |
| 58 | + &:active { | |
| 59 | + background: fox.color(action-secondary-pressed); | |
| 60 | + } | |
| 61 | + | |
| 62 | + &[aria-selected="true"] { | |
| 63 | + background: fox.color(action-secondary-selected); | |
| 64 | + color: fox.color(font-theme-secondary); | |
| 65 | + } | |
| 66 | + | |
| 67 | + // danger(위험한 선택지). 배경·눌림은 다른 항목과 같고 글자·아이콘 색만 다르다. | |
| 68 | + // | |
| 69 | + // 선택돼도 색을 유지한다 — 위험한 걸 고른 직후가 그 사실을 가장 알려야 할 때다. 다만 | |
| 70 | + // `aria-selected` 규칙과 특이도가 같아 그것만으로는 지지 않으므로, 선택자를 겹쳐 쓴 | |
| 71 | + // 두 번째 줄이 선택된 danger 항목까지 잡는다. | |
| 72 | + &--danger, | |
| 73 | + &--danger[aria-selected="true"] { | |
| 74 | + color: fox.color(font-system-danger); | |
| 75 | + | |
| 76 | + // 아이콘 계열은 토큰이 따로다 — 라이트 모드에서 글자(danger-60)보다 한 단계 | |
| 77 | + // 밝다(danger-50). 힌트 메시지에서 쓰는 짝과 같다. | |
| 78 | + .fox-select-option-item__icon, | |
| 79 | + .fox-select-option-item__check { | |
| 80 | + color: fox.color(icon-system-danger); | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + &__text { | |
| 85 | + flex: 1; | |
| 86 | + } | |
| 87 | + | |
| 88 | + // 시안이 체크 아이콘에 `icon-3`(16px)을 건다. 아이콘 viewBox도 16×16 정사각형이라 | |
| 89 | + // 슬롯을 그대로 채운다. | |
| 90 | + &__icon, | |
| 91 | + &__check { | |
| 92 | + display: inline-flex; | |
| 93 | + flex-shrink: 0; | |
| 94 | + align-items: center; | |
| 95 | + justify-content: center; | |
| 96 | + inline-size: fox.icon(3); | |
| 97 | + block-size: fox.icon(3); | |
| 98 | + } | |
| 99 | + | |
| 100 | + // 슬롯이 크기를 정하고 안의 SVG는 그 상자를 채운다. 비율이 다른 아이콘이 들어와도 | |
| 101 | + // `preserveAspectRatio` 기본값이 비율을 지키며 축소해 가운데 놓는다. | |
| 102 | + &__icon > *, | |
| 103 | + &__check > * { | |
| 104 | + inline-size: 100%; | |
| 105 | + block-size: 100%; | |
| 106 | + } | |
| 107 | +} |
+++ @fox/styles/_fox-select-option.scss
... | ... | @@ -0,0 +1,51 @@ |
| 1 | +// FoxSelectOption — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) Figma select (컴포넌트 세트 111:272) | |
| 2 | +// | |
| 3 | +// 셀렉트가 열렸을 때 뜨는 옵션 목록(리스트박스)이다. 항목 하나는 `fox-select-option-item`이 | |
| 4 | +// 담당한다 — 이 파일은 떠 있는 판때기(위치·테두리·그림자·스크롤)만 책임진다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <ul class="fox-select-option" id="sel-listbox" role="listbox"> | |
| 8 | +// <li class="fox-select-option-item" …>…</li> | |
| 9 | +// </ul> | |
| 10 | +// | |
| 11 | +// ⚠️ 스스로 위치를 잡지 못한다 — `position: absolute`라서 **위치 기준이 되는 조상** | |
| 12 | +// (`position: relative` 등)이 있어야 한다. `fox-select` 안에서는 `fox-select__control`이 | |
| 13 | +// 그 역할을 한다. 단독으로 쓸 때는 감싸는 쪽이 기준을 만들어야 한다. | |
| 14 | +// | |
| 15 | +// ⚠️ 그 대가로 `overflow: hidden`인 조상 안에서는 잘린다(테이블 셀·모달·스크롤 영역). | |
| 16 | +// 그런 자리에 놓아야 하면 `popover` 속성으로 top layer에 올려야 한다 — DOM 위치는 그대로라 | |
| 17 | +// ARIA 연결과 이벤트 버블링은 유지되고, 대신 기준 요소와의 위치를 anchor positioning이나 | |
| 18 | +// 좌표 계산으로 다시 잡아야 한다. React 포털은 이 패키지에선 쓸 수 없다(프레임워크 무관). | |
| 19 | +// | |
| 20 | +// 크기(`size`)에 반응하지 않는다 — 어느 크기에서 열려도 목록은 같은 모양이어야 한다. | |
| 21 | +// | |
| 22 | +// ⚠️ TODO: 스크롤 높이(`max-block-size: 24rem` = 240px)는 아직 확정이 아니다. 옵션이 많을 | |
| 23 | +// 때의 상한이 필요해 임시로 잡은 값이고, 시안이 변수로 바인딩하지 않았으며 토큰 스케일에도 | |
| 24 | +// 240이 없다. 확정되면 값을 갈고 — 토큰이 생기면 `fox.*()`로 바꾸고 — 아래 승인 목록에서 | |
| 25 | +// 이 항목을 뺀다. | |
| 26 | +// | |
| 27 | +// 토큰이 아닌 값(사용자 승인): | |
| 28 | +// - `max-block-size: 24rem`(240px): 위 TODO 참고 — 확정 전까지만 유효하다. | |
| 29 | +// - overflow / list-style / z-index: 구조 규칙. | |
| 30 | + | |
| 31 | +@use "@fox/styles/abstracts" as fox; | |
| 32 | + | |
| 33 | +.fox-select-option { | |
| 34 | + position: absolute; | |
| 35 | + inset-inline: 0; | |
| 36 | + inset-block-start: calc(100% + #{fox.gap(2)}); | |
| 37 | + z-index: 10; | |
| 38 | + margin: 0; | |
| 39 | + padding: fox.padding(3); | |
| 40 | + // ⚠️ 확정 전 임시값 — 파일 상단 TODO 참고. | |
| 41 | + max-block-size: 24rem; | |
| 42 | + overflow-y: auto; | |
| 43 | + list-style: none; | |
| 44 | + border: fox.border(1) solid fox.color(border-neutral-default); | |
| 45 | + border-radius: fox.form(radius-md); | |
| 46 | + background: fox.color(surface-neutral-default); | |
| 47 | + // 시안이 그림자 색을 테마 토큰이 아니라 `alpha/black10` 원시값에 직접 물려 놓았다. | |
| 48 | + // 그래서 다크 모드에서도 검은 그림자다 — 반전시키려면 `alpha-default-inverse10`이다. | |
| 49 | + box-shadow: fox.shadow(location1) fox.shadow(location1) fox.shadow(blur1) 0 | |
| 50 | + fox.primitive(alpha-black10); | |
| 51 | +} |
+++ @fox/styles/_fox-select.scss
... | ... | @@ -0,0 +1,220 @@ |
| 1 | +// FoxSelect — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) Figma select (컴포넌트 세트 111:272) | |
| 2 | +// | |
| 3 | +// 네이티브 `<select>`가 아니라 버튼(트리거) + 리스트박스 커스텀 드롭다운이다. 옵션 목록 | |
| 4 | +// 자체를 스타일링하려면 브라우저가 그리는 네이티브 팝업으로는 불가능하다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <div class="fox-select fox-select--md"> | |
| 8 | +// <label class="fox-select__label" for="sel">라벨</label> <!-- 선택 --> | |
| 9 | +// <div class="fox-select__control"> <!-- 리스트박스 위치 기준 --> | |
| 10 | +// <button class="fox-select__trigger fox-select__trigger--completed" id="sel" | |
| 11 | +// type="button" role="combobox" aria-haspopup="listbox" | |
| 12 | +// aria-expanded="true" aria-controls="sel-listbox" | |
| 13 | +// aria-activedescendant="sel-opt-0"> | |
| 14 | +// <span class="fox-select__value">선택된 값 또는 플레이스홀더</span> | |
| 15 | +// <svg class="fox-select__icon">…svg…</svg> | |
| 16 | +// </button> | |
| 17 | +// <ul class="fox-select-option" id="sel-listbox" role="listbox"> <!-- 열렸을 때만 --> | |
| 18 | +// <li class="fox-select-option-item" id="sel-opt-0" role="option">…</li> | |
| 19 | +// </ul> | |
| 20 | +// </div> | |
| 21 | +// <div class="fox-select__hint">…</div> <!-- 선택 --> | |
| 22 | +// </div> | |
| 23 | +// | |
| 24 | +// 옵션 목록은 이 파일이 아니라 `fox-select-option`(판때기)과 `fox-select-option-item` | |
| 25 | +// (항목)이 소유한다. 각자의 마크업 계약은 그 파일 상단에 있다. | |
| 26 | +// | |
| 27 | +// 상태는 네이티브·ARIA 속성이 스타일 훅을 겸한다 — 상태용 클래스를 만들지 않는다. | |
| 28 | +// - 트리거: 비활성 `disabled` · 조회 전용 `disabled aria-readonly="true"` | |
| 29 | +// · 오류 `aria-invalid="true"` · 열림 `aria-expanded="true"` | |
| 30 | +// | |
| 31 | +// 네이티브 대응이 없어 클래스로 두는 하나: | |
| 32 | +// - `fox-select__trigger--completed`: 값이 선택된 상태. 미선택(플레이스홀더) 글자색과 구분한다. | |
| 33 | +// | |
| 34 | +// `fox-select__control`이 옵션 목록의 위치 기준이다(`position: relative`). `fox-select` | |
| 35 | +// 전체를 기준으로 삼으면 힌트 메시지가 있을 때 목록이 힌트 아래로 밀려 그걸 가리게 된다. | |
| 36 | +// | |
| 37 | +// 폭은 정하지 않는다 — 부모가 정한다. 높이만 `size`가 정한다. | |
| 38 | +// | |
| 39 | +// 라벨·옵션·힌트는 `size`와 무관하게 공통 스타일이다. 크기 모디파이어는 트리거와 트리거 | |
| 40 | +// 아이콘만 바꾼다 — 드롭다운 목록은 어느 크기에서 열려도 같은 서체로 읽혀야 한다. | |
| 41 | +// | |
| 42 | +// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목): | |
| 43 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 않는다. | |
| 44 | +// - box-sizing / cursor / overflow: 구조·상호작용 규칙. | |
| 45 | + | |
| 46 | +@use "@fox/styles/abstracts" as fox; | |
| 47 | + | |
| 48 | +.fox-select { | |
| 49 | + display: flex; | |
| 50 | + flex-direction: column; | |
| 51 | + gap: fox.gap(4); | |
| 52 | + | |
| 53 | + &__label { | |
| 54 | + color: fox.color(font-neutral-default); | |
| 55 | + font-family: fox.font-family(body); | |
| 56 | + font-size: fox.font-size(label-md); | |
| 57 | + font-weight: fox.font-weight(medium); | |
| 58 | + } | |
| 59 | + | |
| 60 | + &__control { | |
| 61 | + position: relative; | |
| 62 | + } | |
| 63 | + | |
| 64 | + &__trigger { | |
| 65 | + // 포커스 시 테두리가 1px → 2px로 두꺼워져도 높이가 흔들리지 않게 한다 | |
| 66 | + // (fox-button의 같은 이유 — box-sizing/_fox-button.scss 주석 참고). | |
| 67 | + box-sizing: border-box; | |
| 68 | + display: flex; | |
| 69 | + align-items: center; | |
| 70 | + gap: fox.gap(3); | |
| 71 | + inline-size: 100%; | |
| 72 | + border: fox.border(1) solid fox.color(input-border); | |
| 73 | + background: fox.color(input-surface); | |
| 74 | + // 미선택(플레이스홀더) 상태의 기본 글자색. `&--completed`가 덮어쓴다. | |
| 75 | + color: fox.color(font-neutral-subtler); | |
| 76 | + font-family: fox.font-family(body); | |
| 77 | + cursor: pointer; | |
| 78 | + | |
| 79 | + // `:focus-visible`는 마우스 클릭으론 안 붙는다(브라우저 기본 동작). 열려 있는 동안은 | |
| 80 | + // 클릭으로 열었어도 같은 포커스 모양이어야 하므로 `aria-expanded`도 같이 건다. | |
| 81 | + &:focus-visible, | |
| 82 | + &[aria-expanded="true"] { | |
| 83 | + border: fox.border(2) solid fox.color(input-border-active); | |
| 84 | + color: fox.color(font-neutral-strong); | |
| 85 | + } | |
| 86 | + | |
| 87 | + // 포커스 표시는 위 테두리가 맡으므로 브라우저 기본 링을 걷어낸다 — 그대로 두면 시안에 | |
| 88 | + // 없는 검은 링이 테두리 위에 겹쳐 그려진다. | |
| 89 | + // | |
| 90 | + // `none`이 아니라 투명 아웃라인인 이유: 강제 색상 모드(Windows 고대비)는 테두리 색을 | |
| 91 | + // 시스템 색으로 덮어써 파란 테두리가 사라진다. 투명 아웃라인은 그 모드에서만 실제 링으로 | |
| 92 | + // 그려져 포커스 표시가 남는다. | |
| 93 | + &:focus-visible { | |
| 94 | + outline: fox.border(2) solid transparent; | |
| 95 | + outline-offset: fox.border(1); | |
| 96 | + } | |
| 97 | + | |
| 98 | + &:disabled { | |
| 99 | + cursor: not-allowed; | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + &__trigger--completed { | |
| 104 | + color: fox.color(font-neutral-strong); | |
| 105 | + } | |
| 106 | + | |
| 107 | + &__trigger[aria-invalid="true"] { | |
| 108 | + border: fox.border(2) solid fox.color(input-border-error); | |
| 109 | + } | |
| 110 | + | |
| 111 | + &__value { | |
| 112 | + flex: 1; | |
| 113 | + overflow: hidden; | |
| 114 | + text-align: start; | |
| 115 | + text-overflow: ellipsis; | |
| 116 | + white-space: nowrap; | |
| 117 | + } | |
| 118 | + | |
| 119 | + // 슬롯은 `form-icon-*` 정사각형이고 아이콘 viewBox도 24×24 정사각형이라 그대로 채운다. | |
| 120 | + // 화살표 주변 여백은 SVG 안에 들어 있으므로 CSS 패딩을 따로 주지 않는다 — 그래야 크기가 | |
| 121 | + // 바뀔 때 여백도 함께 비례하고, 토큰에 없는 패딩 값을 지어내지 않아도 된다. | |
| 122 | + &__icon { | |
| 123 | + flex-shrink: 0; | |
| 124 | + color: fox.color(icon-neutral-default); | |
| 125 | + } | |
| 126 | + | |
| 127 | + &__hint { | |
| 128 | + display: flex; | |
| 129 | + align-items: center; | |
| 130 | + gap: fox.gap(3); | |
| 131 | + color: fox.color(font-neutral-subtle); | |
| 132 | + font-family: fox.font-family(body); | |
| 133 | + font-weight: fox.font-weight(regular); | |
| 134 | + font-size: fox.font-size(body-sm); | |
| 135 | + // line-height 150% / letter-spacing -0.325px — Figma 텍스트 스타일 값이며 변수로 | |
| 136 | + // export되지 않아 토큰이 없다(fox-button의 label/*-strong 사례와 같은 예외, 사용자 승인). | |
| 137 | + line-height: 1.5; | |
| 138 | + letter-spacing: -0.325px; | |
| 139 | + } | |
| 140 | + | |
| 141 | + // 힌트 색은 트리거의 `aria-invalid`를 따라간다 — 오류 표시를 트리거 한 곳에만 걸면 | |
| 142 | + // React를 안 쓰는 소비자가 힌트에 따로 클래스를 달지 않아도 같은 결과를 얻는다. | |
| 143 | + &:has(.fox-select__trigger[aria-invalid="true"]) { | |
| 144 | + .fox-select__hint-message { | |
| 145 | + color: fox.color(font-system-danger); | |
| 146 | + } | |
| 147 | + | |
| 148 | + .fox-select__hint-icon { | |
| 149 | + color: fox.color(icon-system-danger); | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 153 | + &__hint-icon { | |
| 154 | + display: inline-flex; | |
| 155 | + flex-shrink: 0; | |
| 156 | + } | |
| 157 | + | |
| 158 | + // ── 크기 ────────────────────────────────────────────────────────────────── | |
| 159 | + &--lg { | |
| 160 | + .fox-select__trigger { | |
| 161 | + block-size: fox.form(height-lg); | |
| 162 | + padding-inline: fox.form(padding-lg); | |
| 163 | + border-radius: fox.form(radius-lg); | |
| 164 | + font-size: fox.font-size(body-lg); | |
| 165 | + } | |
| 166 | + | |
| 167 | + .fox-select__icon { | |
| 168 | + inline-size: fox.form(icon-lg); | |
| 169 | + block-size: fox.form(icon-lg); | |
| 170 | + } | |
| 171 | + } | |
| 172 | + | |
| 173 | + &--md { | |
| 174 | + .fox-select__trigger { | |
| 175 | + block-size: fox.form(height-md); | |
| 176 | + padding-inline: fox.form(padding-md); | |
| 177 | + border-radius: fox.form(radius-md); | |
| 178 | + font-size: fox.font-size(body-md); | |
| 179 | + } | |
| 180 | + | |
| 181 | + .fox-select__icon { | |
| 182 | + inline-size: fox.form(icon-md); | |
| 183 | + block-size: fox.form(icon-md); | |
| 184 | + } | |
| 185 | + } | |
| 186 | + | |
| 187 | + &--sm { | |
| 188 | + .fox-select__trigger { | |
| 189 | + block-size: fox.form(height-sm); | |
| 190 | + padding-inline: fox.form(padding-sm); | |
| 191 | + border-radius: fox.form(radius-sm); | |
| 192 | + font-size: fox.font-size(body-sm); | |
| 193 | + } | |
| 194 | + | |
| 195 | + .fox-select__icon { | |
| 196 | + inline-size: fox.form(icon-sm); | |
| 197 | + block-size: fox.form(icon-sm); | |
| 198 | + } | |
| 199 | + } | |
| 200 | +} | |
| 201 | + | |
| 202 | +// 비활성 외형. `aria-invalid`(오류)와 특이도가 같아 블록 뒤에 두어야 이긴다 — | |
| 203 | +// 오류 상태에서 비활성이 되면 오류 테두리가 아니라 비활성 테두리가 보여야 한다. | |
| 204 | +.fox-select__trigger:disabled { | |
| 205 | + border: fox.border(1) solid fox.color(input-border-disabled); | |
| 206 | + background: fox.color(input-surface-disabled); | |
| 207 | + color: fox.color(font-neutral-disabled-strong); | |
| 208 | +} | |
| 209 | + | |
| 210 | +// 아이콘은 비활성·조회 전용 둘 다 같은 색이다. 두 상태 모두 트리거에 `disabled`가 붙으므로 | |
| 211 | +// 규칙 하나로 끝난다 — 아래 view 규칙이 글자색만 되돌리고 아이콘은 건드리지 않는다. | |
| 212 | +.fox-select__trigger:disabled .fox-select__icon { | |
| 213 | + color: fox.color(icon-neutral-disabled); | |
| 214 | +} | |
| 215 | + | |
| 216 | +// view(조회 전용)는 `disabled`를 빌려 쓰지만(버튼엔 readonly 개념이 없다) 글자색만 다르다. | |
| 217 | +// 위 비활성 규칙을 이기려면 속성 하나를 더 얹어 특이도를 올려야 한다. | |
| 218 | +.fox-select__trigger:disabled[aria-readonly="true"] { | |
| 219 | + color: fox.color(font-neutral-default); | |
| 220 | +} |
+++ @fox/styles/_fox-text-area.scss
... | ... | @@ -0,0 +1,111 @@ |
| 1 | +// FoxTextArea — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma textarea (135:1655 · 섹션 391:3107) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <div class="fox-text-area"> | |
| 6 | +// <label class="fox-text-area__label" for="…">레이블</label> | |
| 7 | +// <div class="fox-text-area__box"> | |
| 8 | +// <textarea class="fox-text-area__field" id="…" rows="5" placeholder="…"></textarea> | |
| 9 | +// </div> | |
| 10 | +// <div class="fox-text-area__footer"> | |
| 11 | +// <p class="fox-text-area__message">…</p> <!-- 선택 --> | |
| 12 | +// <p class="fox-text-area__counter"> | |
| 13 | +// <span class="fox-text-area__counter-current">80</span>/100 | |
| 14 | +// </p> <!-- 선택 --> | |
| 15 | +// </div> | |
| 16 | +// </div> | |
| 17 | +// | |
| 18 | +// 상태는 FoxInput과 같은 두 갈래다. | |
| 19 | +// - 기본(자동): 포커스는 `:focus-within`, 비활성·읽기전용은 네이티브 속성, 오류는 | |
| 20 | +// `aria-invalid="true"`. 시안의 completed는 default와 글자색만 다른데 그건 | |
| 21 | +// 플레이스홀더와 입력값의 네이티브 차이라 규칙이 필요 없다. | |
| 22 | +// - 고정: 루트에 `data-state`를 주면 그 모양으로 못박힌다. | |
| 23 | +// | |
| 24 | +// 라벨·헬퍼·상자·알맹이와 두 갈래 상태는 `form-field` 믹스인이 갖는다. 이 파일에는 | |
| 25 | +// FoxTextArea만의 것(여러 줄 여백, 카운터)이 남는다. | |
| 26 | +// | |
| 27 | +// 시안에 크기 축이 없다 — 상자 하나뿐이고 높이는 줄 수가 정한다(시안은 5줄). | |
| 28 | +// | |
| 29 | +// 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목): | |
| 30 | +// - line-height(카운터 1) / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 31 | +// export하지 않는다. | |
| 32 | +// - resize / flex 관련: 구조·상호작용 규칙. 시안에 리사이즈 핸들이 없어 막아 둔다 — | |
| 33 | +// 높이가 필요하면 `rows`로 정한다. | |
| 34 | + | |
| 35 | +@use "abstracts" as fox; | |
| 36 | +@use "form-field" as field; | |
| 37 | + | |
| 38 | +.fox-text-area { | |
| 39 | + @include field.root; | |
| 40 | + | |
| 41 | + &__label { | |
| 42 | + @include field.label; | |
| 43 | + } | |
| 44 | + | |
| 45 | + // 여러 줄이라 세로 가운데 정렬을 끄고, 크기 축이 없어 md 여백을 직접 준다. | |
| 46 | + &__box { | |
| 47 | + @include field.box($align: null); | |
| 48 | + | |
| 49 | + padding-inline: fox.form(padding-md); | |
| 50 | + padding-block: fox.padding(5); | |
| 51 | + border-radius: fox.form(radius-lg); | |
| 52 | + } | |
| 53 | + | |
| 54 | + &__field { | |
| 55 | + resize: none; | |
| 56 | + | |
| 57 | + @include field.field($font-size: body-md, $line-height: 1.5); | |
| 58 | + } | |
| 59 | + | |
| 60 | + // 헬퍼는 왼쪽에서 남는 폭을 먹고 카운터는 오른쪽에 붙는다. | |
| 61 | + &__footer { | |
| 62 | + display: flex; | |
| 63 | + gap: fox.gap(5); | |
| 64 | + align-items: center; | |
| 65 | + justify-content: flex-end; | |
| 66 | + inline-size: 100%; | |
| 67 | + } | |
| 68 | + | |
| 69 | + // 카운터와 한 줄에 서므로 남는 폭을 먹는다. | |
| 70 | + &__message { | |
| 71 | + @include field.message; | |
| 72 | + | |
| 73 | + flex: 1 0 0; | |
| 74 | + min-inline-size: 0; | |
| 75 | + } | |
| 76 | + | |
| 77 | + &__message-icon { | |
| 78 | + @include field.message-icon; | |
| 79 | + } | |
| 80 | + | |
| 81 | + &__counter { | |
| 82 | + flex-shrink: 0; | |
| 83 | + margin: 0; | |
| 84 | + color: fox.color(font-neutral-default); | |
| 85 | + font-family: fox.font-family(body); | |
| 86 | + font-size: fox.font-size(label-sm); | |
| 87 | + font-weight: fox.font-weight(regular); | |
| 88 | + line-height: 1; | |
| 89 | + letter-spacing: -0.025em; | |
| 90 | + text-align: end; | |
| 91 | + } | |
| 92 | + | |
| 93 | + // 현재 글자 수만 굵고 색이 다르다. | |
| 94 | + &__counter-current { | |
| 95 | + color: fox.color(font-theme-primary); | |
| 96 | + font-weight: fox.font-weight(bold); | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +@include field.auto-states("fox-text-area") { | |
| 101 | + // 시안의 오류 변형에서만 카운터 현재값이 빨갛다. | |
| 102 | + &:has(.fox-text-area__field[aria-invalid="true"]) .fox-text-area__counter-current { | |
| 103 | + color: fox.color(font-system-danger); | |
| 104 | + } | |
| 105 | +} | |
| 106 | + | |
| 107 | +@include field.fixed-states("fox-text-area") { | |
| 108 | + .fox-text-area[data-state="error"] .fox-text-area__counter-current { | |
| 109 | + color: fox.color(font-system-danger); | |
| 110 | + } | |
| 111 | +} |
+++ @fox/styles/_functions.scss
... | ... | @@ -0,0 +1,95 @@ |
| 1 | +// 토큰 접근 함수. 없는 이름은 `@error`로 빌드를 실패시키고 사용 가능한 목록을 출력한다. | |
| 2 | +// 값이 아니라 `var(--fox-*)` 참조를 돌려주므로 라이트/다크·PC/모바일이 런타임에 전환된다. | |
| 3 | + | |
| 4 | +@use "sass:map"; | |
| 5 | +@use "tokens"; | |
| 6 | + | |
| 7 | +/// 토큰 이름을 검증하고 커스텀 프로퍼티 참조를 반환한다. | |
| 8 | +@function _token($group, $key, $registry, $var-name) { | |
| 9 | + @if not map.has-key($registry, $key) { | |
| 10 | + @error "[@fox] 알 수 없는 #{$group} 토큰: `#{$key}` — 사용 가능한 값: #{map.keys($registry)}"; | |
| 11 | + } | |
| 12 | + | |
| 13 | + @return var(--fox-#{$var-name}); | |
| 14 | +} | |
| 15 | + | |
| 16 | + | |
| 17 | +@function color($name) { | |
| 18 | + @return _token("color", $name, tokens.$color, "color-#{$name}"); | |
| 19 | +} | |
| 20 | + | |
| 21 | +@function primitive($name) { | |
| 22 | + @return _token("primitive", $name, tokens.$primitive, "primitive-#{$name}"); | |
| 23 | +} | |
| 24 | + | |
| 25 | +@function theme($name) { | |
| 26 | + @return _token("theme", $name, tokens.$theme, "theme-#{$name}"); | |
| 27 | +} | |
| 28 | + | |
| 29 | +@function number($name) { | |
| 30 | + @return _token("number", $name, tokens.$number, "number-#{$name}"); | |
| 31 | +} | |
| 32 | + | |
| 33 | +@function font-family($name) { | |
| 34 | + @return _token("font-family", "family-#{$name}", tokens.$font, "font-family-#{$name}"); | |
| 35 | +} | |
| 36 | + | |
| 37 | +@function font-weight($name) { | |
| 38 | + @return _token("font-weight", "weight-#{$name}", tokens.$font, "font-weight-#{$name}"); | |
| 39 | +} | |
| 40 | + | |
| 41 | +@function border($name) { | |
| 42 | + @return _token("border", "border-#{$name}", tokens.$size, "border-#{$name}"); | |
| 43 | +} | |
| 44 | + | |
| 45 | +@function padding($name) { | |
| 46 | + @return _token("padding", "padding-#{$name}", tokens.$size, "padding-#{$name}"); | |
| 47 | +} | |
| 48 | + | |
| 49 | +@function gap($name) { | |
| 50 | + @return _token("gap", "gap-#{$name}", tokens.$size, "gap-#{$name}"); | |
| 51 | +} | |
| 52 | + | |
| 53 | +@function radius($name) { | |
| 54 | + @return _token("radius", "radius-#{$name}", tokens.$size, "radius-#{$name}"); | |
| 55 | +} | |
| 56 | + | |
| 57 | +@function icon($name) { | |
| 58 | + @return _token("icon", "icon-#{$name}", tokens.$size, "icon-#{$name}"); | |
| 59 | +} | |
| 60 | + | |
| 61 | +@function shadow($name) { | |
| 62 | + @return _token("shadow", "shadow-#{$name}", tokens.$size, "shadow-#{$name}"); | |
| 63 | +} | |
| 64 | + | |
| 65 | +@function backdrop($name) { | |
| 66 | + @return _token("backdrop", "backdrop-#{$name}", tokens.$size, "backdrop-#{$name}"); | |
| 67 | +} | |
| 68 | + | |
| 69 | +@function font-size($name) { | |
| 70 | + @return _token("font-size", "font-size-#{$name}", tokens.$responsive, "font-size-#{$name}"); | |
| 71 | +} | |
| 72 | + | |
| 73 | +@function form($name) { | |
| 74 | + @return _token("form", "form-#{$name}", tokens.$responsive, "form-#{$name}"); | |
| 75 | +} | |
| 76 | + | |
| 77 | +@function grid($name) { | |
| 78 | + @return _token("grid", "grid-#{$name}", tokens.$responsive, "grid-#{$name}"); | |
| 79 | +} | |
| 80 | + | |
| 81 | +@function card($name) { | |
| 82 | + @return _token("card", "card-#{$name}", tokens.$responsive, "card-#{$name}"); | |
| 83 | +} | |
| 84 | + | |
| 85 | +@function modal($name) { | |
| 86 | + @return _token("modal", "modal-#{$name}", tokens.$responsive, "modal-#{$name}"); | |
| 87 | +} | |
| 88 | + | |
| 89 | +@function spacing($name) { | |
| 90 | + @return _token("spacing", "spacing-#{$name}", tokens.$responsive, "spacing-#{$name}"); | |
| 91 | +} | |
| 92 | + | |
| 93 | +@function section($name) { | |
| 94 | + @return _token("section", "section-#{$name}", tokens.$responsive, "section-#{$name}"); | |
| 95 | +} |
+++ @fox/styles/_mixins.scss
... | ... | @@ -0,0 +1,16 @@ |
| 1 | +@use "tokens"; | |
| 2 | + | |
| 3 | +/// PC 이상(>= 768px). 미디어 쿼리 조건부는 `var()`를 해석하지 못해 브레이크포인트만 | |
| 4 | +/// 컴파일타임 값이고, 그래서 이 믹스인 경유가 강제된다. | |
| 5 | +@mixin pc { | |
| 6 | + @media (min-width: tokens.$breakpoint-pc) { | |
| 7 | + @content; | |
| 8 | + } | |
| 9 | +} | |
| 10 | + | |
| 11 | +/// PC 미만. `pc`와 경계가 겹치지 않도록 0.02px을 뺀다. | |
| 12 | +@mixin mobile { | |
| 13 | + @media (max-width: tokens.$breakpoint-pc - 0.02px) { | |
| 14 | + @content; | |
| 15 | + } | |
| 16 | +} |
+++ @fox/styles/_root.scss
... | ... | @@ -0,0 +1,65 @@ |
| 1 | +// 토큰 map을 `--fox-*` 커스텀 프로퍼티로 출력한다. **앱 전체에서 한 번만 로드한다.** | |
| 2 | +// map을 순회하므로 토큰을 추가할 때 이 파일은 손대지 않는다. | |
| 3 | + | |
| 4 | +@use "sass:map"; | |
| 5 | +@use "tokens"; | |
| 6 | + | |
| 7 | +:root { | |
| 8 | + // 아래 `[data-theme]`가 이 값만 덮어써 수동 선택을 처리한다 — 색상 값은 한 곳에만 둔다. | |
| 9 | + color-scheme: light dark; | |
| 10 | + | |
| 11 | + @each $name, $value in tokens.$primitive { | |
| 12 | + --fox-primitive-#{$name}: #{$value}; | |
| 13 | + } | |
| 14 | + | |
| 15 | + @each $name, $value in tokens.$font { | |
| 16 | + --fox-font-#{$name}: #{$value}; | |
| 17 | + } | |
| 18 | + | |
| 19 | + @each $name, $value in tokens.$number { | |
| 20 | + --fox-number-#{$name}: #{$value}; | |
| 21 | + } | |
| 22 | + | |
| 23 | + @each $name, $value in tokens.$theme { | |
| 24 | + --fox-theme-#{$name}: #{$value}; | |
| 25 | + } | |
| 26 | + | |
| 27 | + // size / color / responsive 는 이름에 그룹이 이미 들어 있어 접두사가 `--fox-` 뿐이다. | |
| 28 | + @each $name, $value in tokens.$size { | |
| 29 | + --fox-#{$name}: #{$value}; | |
| 30 | + } | |
| 31 | + | |
| 32 | + @each $name, $value in tokens.$color { | |
| 33 | + --fox-color-#{$name}: #{$value}; | |
| 34 | + } | |
| 35 | + | |
| 36 | + // 모바일 우선 기본값. | |
| 37 | + @each $name, $value in tokens.$responsive { | |
| 38 | + --fox-#{$name}: #{$value}; | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +// PC 모드 — 반응형 토큰만 덮어쓴다. 색상은 뷰포트와 무관하므로 여기 오지 않는다. | |
| 43 | +@media (min-width: tokens.$breakpoint-pc) { | |
| 44 | + :root { | |
| 45 | + @each $name, $value in tokens.$responsive-pc { | |
| 46 | + --fox-#{$name}: #{$value}; | |
| 47 | + } | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +// 수동 테마 선택 — `color-scheme`만 고정하면 `light-dark()`가 해당 분기로 확정된다. | |
| 52 | +// 속성이 없거나 `"system"`이면 위 `:root`의 `light dark`가 유지되어 OS를 따른다. | |
| 53 | +:root[data-theme="light"] { | |
| 54 | + color-scheme: light; | |
| 55 | +} | |
| 56 | + | |
| 57 | +:root[data-theme="dark"] { | |
| 58 | + color-scheme: dark; | |
| 59 | +} | |
| 60 | + | |
| 61 | +// ⚠️ 1rem = 10px 규약의 근거. 모든 토큰의 rem 값이 이 선언을 전제로 한다. | |
| 62 | +// 미디어 쿼리 안의 rem은 영향을 받지 않는다(항상 브라우저 기본 크기 기준). | |
| 63 | +html { | |
| 64 | + font-size: 62.5%; | |
| 65 | +} |
+++ @fox/styles/abstracts.scss
... | ... | @@ -0,0 +1,3 @@ |
| 1 | +@forward "tokens"; | |
| 2 | +@forward "functions"; | |
| 3 | +@forward "mixins"; |
+++ @fox/styles/components.scss
... | ... | @@ -0,0 +1,19 @@ |
| 1 | +// 공용 컴포넌트 스타일 묶음. 필요한 것만 쓰려면 개별 파티셜(`@fox/styles/fox-button`)을 | |
| 2 | +// 직접 @use 한다. 둘을 같이 써도 Sass가 모듈을 한 번만 로드해 중복되지 않는다. | |
| 3 | + | |
| 4 | +@use "fox-button"; | |
| 5 | +@use "fox-text-button"; | |
| 6 | +@use "fox-link-button"; | |
| 7 | +@use "fox-icon-button"; | |
| 8 | +@use "fox-button-group"; | |
| 9 | +@use "fox-button-panel"; | |
| 10 | +@use "fox-address"; | |
| 11 | +@use "fox-certified"; | |
| 12 | +@use "fox-email"; | |
| 13 | +@use "fox-file-upload"; | |
| 14 | +@use "fox-input"; | |
| 15 | +@use "fox-phone-number"; | |
| 16 | +@use "fox-select"; | |
| 17 | +@use "fox-select-option"; | |
| 18 | +@use "fox-select-option-item"; | |
| 19 | +@use "fox-text-area"; |
+++ @fox/styles/index.scss
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +@use "root"; |
+++ @fox/styles/tokens/_color.scss
... | ... | @@ -0,0 +1,283 @@ |
| 1 | +// ⚠️ 이 파일은 Figma Design Tokens export에서 **자동 생성**되었습니다. | |
| 2 | +// 원본: light.tokens.json / dark.tokens.json | |
| 3 | +// 손으로 고치지 말고 원본 JSON을 다시 export한 뒤 변환기를 재실행하세요. | |
| 4 | + | |
| 5 | +// 시맨틱 색상 — 라이트/다크 값을 `light-dark()` 한 줄에 함께 보유한다. | |
| 6 | +// 전환은 `_root.scss`의 `color-scheme`이 담당하므로 다크 전용 블록이 없고, | |
| 7 | +// 따라서 한쪽만 고쳐서 생기는 테마 불일치가 구조적으로 불가능하다. | |
| 8 | +// | |
| 9 | +// 값이 `var(--fox-primitive-*)` / `var(--fox-theme-*)`인 항목은 Figma의 alias를 | |
| 10 | +// 그대로 옮긴 것이다 — 바닥층을 바꾸면 여기까지 전파된다. | |
| 11 | +$color: ( | |
| 12 | + background-default: light-dark(var(--fox-primitive-neutral-0), var(--fox-primitive-neutral-100)), | |
| 13 | + background-neutral: light-dark(var(--fox-primitive-neutral-5), var(--fox-primitive-neutral-95)), | |
| 14 | + background-neutral-strong: light-dark(var(--fox-primitive-neutral-10), var(--fox-primitive-neutral-90)), | |
| 15 | + background-primary: light-dark(var(--fox-theme-primary-5), var(--fox-theme-primary-95)), | |
| 16 | + background-secondary: light-dark(var(--fox-theme-secondary-5), var(--fox-theme-secondary-95)), | |
| 17 | + background-accent: light-dark(var(--fox-theme-accent-5), var(--fox-theme-accent-95)), | |
| 18 | + background-dim: light-dark(var(--fox-primitive-alpha-black50), var(--fox-primitive-alpha-black75)), | |
| 19 | + background-dim-inverse: light-dark(var(--fox-primitive-alpha-white50), var(--fox-primitive-alpha-black75)), | |
| 20 | + surface-neutral-default: light-dark(var(--fox-primitive-neutral-0), var(--fox-primitive-neutral-95)), | |
| 21 | + surface-neutral-default-strong: light-dark(var(--fox-primitive-neutral-0), var(--fox-primitive-neutral-90)), | |
| 22 | + surface-neutral-default-static: light-dark(var(--fox-primitive-neutral-0), var(--fox-primitive-neutral-0)), | |
| 23 | + surface-neutral-default-inverse: light-dark(var(--fox-primitive-neutral-90), var(--fox-primitive-neutral-10)), | |
| 24 | + surface-neutral-default-inverse-static: light-dark(var(--fox-primitive-neutral-90), var(--fox-primitive-neutral-90)), | |
| 25 | + surface-neutral-gray: light-dark(var(--fox-primitive-neutral-5), var(--fox-primitive-neutral-90)), | |
| 26 | + surface-neutral-gray-strong: light-dark(var(--fox-primitive-neutral-10), var(--fox-primitive-neutral-80)), | |
| 27 | + surface-neutral-gray-stronger: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-70)), | |
| 28 | + surface-neutral-gray-inverse: light-dark(var(--fox-primitive-neutral-80), var(--fox-primitive-neutral-20)), | |
| 29 | + surface-neutral-gray-strong-inverse: light-dark(var(--fox-primitive-neutral-70), var(--fox-primitive-neutral-30)), | |
| 30 | + surface-neutral-gray-stronger-inverse: light-dark(var(--fox-primitive-neutral-60), var(--fox-primitive-neutral-40)), | |
| 31 | + surface-neutral-disabled: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-70)), | |
| 32 | + surface-theme-primary: light-dark(var(--fox-theme-primary-5), var(--fox-theme-primary-95)), | |
| 33 | + surface-theme-primary-inverse: light-dark(var(--fox-theme-primary-60), var(--fox-theme-primary-40)), | |
| 34 | + surface-theme-secondary: light-dark(var(--fox-theme-secondary-5), var(--fox-theme-secondary-95)), | |
| 35 | + surface-theme-secondary-inverse: light-dark(var(--fox-theme-secondary-60), var(--fox-theme-secondary-40)), | |
| 36 | + surface-theme-accent: light-dark(var(--fox-theme-accent-5), var(--fox-theme-accent-95)), | |
| 37 | + surface-theme-accent-inverse: light-dark(var(--fox-theme-accent-60), var(--fox-theme-accent-40)), | |
| 38 | + surface-system-danger: light-dark(var(--fox-primitive-danger-5), var(--fox-primitive-danger-95)), | |
| 39 | + surface-system-danger-inverse: light-dark(var(--fox-primitive-danger-60), var(--fox-primitive-danger-40)), | |
| 40 | + surface-system-warning: light-dark(var(--fox-primitive-warning-5), var(--fox-primitive-warning-95)), | |
| 41 | + surface-system-warning-inverse: light-dark(var(--fox-primitive-warning-60), var(--fox-primitive-warning-40)), | |
| 42 | + surface-system-success: light-dark(var(--fox-primitive-success-5), var(--fox-primitive-success-95)), | |
| 43 | + surface-system-success-inverse: light-dark(var(--fox-primitive-success-60), var(--fox-primitive-success-40)), | |
| 44 | + surface-system-information: light-dark(var(--fox-primitive-information-5), var(--fox-primitive-information-95)), | |
| 45 | + surface-system-information-inverse: light-dark(var(--fox-primitive-information-60), var(--fox-primitive-information-40)), | |
| 46 | + border-neutral-default: light-dark(var(--fox-primitive-neutral-30), var(--fox-primitive-neutral-70)), | |
| 47 | + border-neutral-subtle: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-80)), | |
| 48 | + border-neutral-subtler: light-dark(var(--fox-primitive-neutral-10), var(--fox-primitive-neutral-90)), | |
| 49 | + border-neutral-strong: light-dark(var(--fox-primitive-neutral-90), var(--fox-primitive-neutral-0)), | |
| 50 | + border-neutral-disabled: light-dark(var(--fox-primitive-neutral-30), var(--fox-primitive-neutral-70)), | |
| 51 | + border-neutral-overlay: light-dark(var(--fox-primitive-alpha-black10), var(--fox-primitive-alpha-white25)), | |
| 52 | + border-theme-primary: light-dark(var(--fox-theme-primary-50), var(--fox-theme-primary-50)), | |
| 53 | + border-theme-primary-inverse: light-dark(var(--fox-theme-primary-10), var(--fox-theme-primary-90)), | |
| 54 | + border-theme-secondary: light-dark(var(--fox-theme-secondary-60), var(--fox-theme-secondary-40)), | |
| 55 | + border-theme-secondary-inverse: light-dark(var(--fox-theme-secondary-10), var(--fox-theme-secondary-90)), | |
| 56 | + border-theme-accent: light-dark(var(--fox-theme-accent-50), var(--fox-theme-accent-50)), | |
| 57 | + border-theme-accent-inverse: light-dark(var(--fox-theme-accent-10), var(--fox-theme-accent-90)), | |
| 58 | + border-system-danger: light-dark(var(--fox-primitive-danger-50), var(--fox-primitive-danger-50)), | |
| 59 | + border-system-danger-inverse: light-dark(var(--fox-primitive-danger-10), var(--fox-primitive-danger-90)), | |
| 60 | + border-system-warning: light-dark(var(--fox-primitive-warning-50), var(--fox-primitive-warning-50)), | |
| 61 | + border-system-warning-inverse: light-dark(var(--fox-primitive-warning-10), var(--fox-primitive-warning-90)), | |
| 62 | + border-system-success: light-dark(var(--fox-primitive-success-50), var(--fox-primitive-success-50)), | |
| 63 | + border-system-success-inverse: light-dark(var(--fox-primitive-success-10), var(--fox-primitive-success-90)), | |
| 64 | + border-system-information: light-dark(var(--fox-primitive-information-50), var(--fox-primitive-information-50)), | |
| 65 | + border-system-information-inverse: light-dark(var(--fox-primitive-information-10), var(--fox-primitive-information-90)), | |
| 66 | + font-neutral-strong: light-dark(var(--fox-primitive-neutral-90), var(--fox-primitive-neutral-5)), | |
| 67 | + font-neutral-default: light-dark(var(--fox-primitive-neutral-70), var(--fox-primitive-neutral-20)), | |
| 68 | + font-neutral-subtle: light-dark(var(--fox-primitive-neutral-50), var(--fox-primitive-neutral-30)), | |
| 69 | + font-neutral-subtler: light-dark(var(--fox-primitive-neutral-40), var(--fox-primitive-neutral-40)), | |
| 70 | + font-neutral-strong-inverse: light-dark(var(--fox-primitive-alpha-white100), var(--fox-primitive-neutral-95)), | |
| 71 | + font-neutral-default-inverse: light-dark(var(--fox-primitive-alpha-white75), var(--fox-primitive-neutral-90)), | |
| 72 | + font-neutral-subtle-inverse: light-dark(var(--fox-primitive-alpha-white50), var(--fox-primitive-neutral-80)), | |
| 73 | + font-neutral-subtler-inverse: light-dark(var(--fox-primitive-alpha-white25), var(--fox-primitive-neutral-70)), | |
| 74 | + font-neutral-static: light-dark(var(--fox-primitive-neutral-90), var(--fox-primitive-neutral-90)), | |
| 75 | + font-neutral-static-inverse: light-dark(var(--fox-primitive-neutral-0), var(--fox-primitive-neutral-0)), | |
| 76 | + font-neutral-disabled: light-dark(var(--fox-primitive-neutral-40), var(--fox-primitive-neutral-60)), | |
| 77 | + font-neutral-disabled-strong: light-dark(var(--fox-primitive-neutral-50), var(--fox-primitive-neutral-50)), | |
| 78 | + font-theme-primary: light-dark(var(--fox-theme-primary-60), var(--fox-theme-primary-20)), | |
| 79 | + font-theme-primary-inverse: light-dark(var(--fox-theme-primary-10), var(--fox-theme-primary-70)), | |
| 80 | + font-theme-secondary: light-dark(var(--fox-theme-secondary-60), var(--fox-theme-secondary-20)), | |
| 81 | + font-theme-secondary-inverse: light-dark(var(--fox-theme-secondary-10), var(--fox-theme-secondary-70)), | |
| 82 | + font-theme-accent: light-dark(var(--fox-theme-accent-60), var(--fox-theme-accent-20)), | |
| 83 | + font-theme-accent-inverse: light-dark(var(--fox-theme-accent-10), var(--fox-theme-accent-70)), | |
| 84 | + font-system-danger: light-dark(var(--fox-primitive-danger-60), var(--fox-primitive-danger-20)), | |
| 85 | + font-system-danger-inverse: light-dark(var(--fox-primitive-danger-10), var(--fox-primitive-danger-70)), | |
| 86 | + font-system-warning: light-dark(var(--fox-primitive-warning-60), var(--fox-primitive-warning-20)), | |
| 87 | + font-system-warning-inverse: light-dark(var(--fox-primitive-warning-10), var(--fox-primitive-warning-70)), | |
| 88 | + font-system-success: light-dark(var(--fox-primitive-success-60), var(--fox-primitive-success-20)), | |
| 89 | + font-system-success-inverse: light-dark(var(--fox-primitive-success-10), var(--fox-primitive-success-70)), | |
| 90 | + font-system-information: light-dark(var(--fox-primitive-information-60), var(--fox-primitive-information-20)), | |
| 91 | + font-system-information-inverse: light-dark(var(--fox-primitive-information-10), var(--fox-primitive-information-70)), | |
| 92 | + icon-neutral-strong: light-dark(var(--fox-primitive-neutral-90), var(--fox-primitive-neutral-5)), | |
| 93 | + icon-neutral-default: light-dark(var(--fox-primitive-neutral-70), var(--fox-primitive-neutral-20)), | |
| 94 | + icon-neutral-subtle: light-dark(var(--fox-primitive-neutral-50), var(--fox-primitive-neutral-30)), | |
| 95 | + icon-neutral-subtler: light-dark(var(--fox-primitive-neutral-40), var(--fox-primitive-neutral-40)), | |
| 96 | + icon-neutral-fill: light-dark(var(--fox-primitive-alpha-black25), var(--fox-primitive-alpha-white25)), | |
| 97 | + icon-neutral-strong-inverse: light-dark(var(--fox-primitive-alpha-white100), var(--fox-primitive-neutral-95)), | |
| 98 | + icon-neutral-default-inverse: light-dark(var(--fox-primitive-alpha-white75), var(--fox-primitive-neutral-90)), | |
| 99 | + icon-neutral-subtle-inverse: light-dark(var(--fox-primitive-alpha-white50), var(--fox-primitive-neutral-80)), | |
| 100 | + icon-neutral-subtler-inverse: light-dark(var(--fox-primitive-alpha-white25), var(--fox-primitive-neutral-70)), | |
| 101 | + icon-neutral-fill-inverse: light-dark(var(--fox-primitive-alpha-white25), var(--fox-primitive-neutral-70)), | |
| 102 | + icon-neutral-static: light-dark(var(--fox-primitive-neutral-90), var(--fox-primitive-neutral-90)), | |
| 103 | + icon-neutral-static-inverse: light-dark(var(--fox-primitive-neutral-0), var(--fox-primitive-neutral-0)), | |
| 104 | + icon-neutral-disabled: light-dark(var(--fox-primitive-neutral-40), var(--fox-primitive-neutral-60)), | |
| 105 | + icon-neutral-disabled-strong: light-dark(var(--fox-primitive-neutral-50), var(--fox-primitive-neutral-50)), | |
| 106 | + icon-theme-primary: light-dark(var(--fox-theme-primary-50), var(--fox-theme-primary-20)), | |
| 107 | + icon-theme-primary-strong: light-dark(var(--fox-theme-primary-60), var(--fox-theme-primary-30)), | |
| 108 | + icon-theme-primary-static: light-dark(var(--fox-theme-primary-50), var(--fox-theme-primary-50)), | |
| 109 | + icon-theme-primary-inverse: light-dark(var(--fox-theme-primary-10), var(--fox-theme-primary-70)), | |
| 110 | + icon-theme-secondary: light-dark(var(--fox-theme-secondary-60), var(--fox-theme-secondary-30)), | |
| 111 | + icon-theme-secondary-strong: light-dark(var(--fox-theme-secondary-70), var(--fox-theme-secondary-40)), | |
| 112 | + icon-theme-secondary-static: light-dark(var(--fox-theme-secondary-50), var(--fox-theme-secondary-50)), | |
| 113 | + icon-theme-secondary-inverse: light-dark(var(--fox-theme-secondary-10), var(--fox-theme-secondary-70)), | |
| 114 | + icon-theme-accent: light-dark(var(--fox-theme-accent-50), var(--fox-theme-accent-20)), | |
| 115 | + icon-theme-accent-strong: light-dark(var(--fox-theme-accent-60), var(--fox-theme-accent-30)), | |
| 116 | + icon-theme-accent-static: light-dark(var(--fox-theme-accent-50), var(--fox-theme-accent-50)), | |
| 117 | + icon-theme-accent-inverse: light-dark(var(--fox-theme-accent-10), var(--fox-theme-accent-70)), | |
| 118 | + icon-system-danger: light-dark(var(--fox-primitive-danger-50), var(--fox-primitive-danger-20)), | |
| 119 | + icon-system-danger-strong: light-dark(var(--fox-primitive-danger-60), var(--fox-primitive-danger-30)), | |
| 120 | + icon-system-danger-inverse: light-dark(var(--fox-primitive-danger-10), var(--fox-primitive-danger-70)), | |
| 121 | + icon-system-warning: light-dark(var(--fox-primitive-warning-50), var(--fox-primitive-warning-20)), | |
| 122 | + icon-system-warning-strong: light-dark(var(--fox-primitive-warning-60), var(--fox-primitive-warning-30)), | |
| 123 | + icon-system-warning-inverse: light-dark(var(--fox-primitive-warning-10), var(--fox-primitive-warning-70)), | |
| 124 | + icon-system-success: light-dark(var(--fox-primitive-success-50), var(--fox-primitive-success-20)), | |
| 125 | + icon-system-success-strong: light-dark(var(--fox-primitive-success-60), var(--fox-primitive-success-30)), | |
| 126 | + icon-system-success-inverse: light-dark(var(--fox-primitive-success-10), var(--fox-primitive-success-70)), | |
| 127 | + icon-system-information: light-dark(var(--fox-primitive-information-50), var(--fox-primitive-information-20)), | |
| 128 | + icon-system-information-strong: light-dark(var(--fox-primitive-information-60), var(--fox-primitive-information-30)), | |
| 129 | + icon-system-information-inverse: light-dark(var(--fox-primitive-information-10), var(--fox-primitive-information-70)), | |
| 130 | + element-neutral-strong: light-dark(var(--fox-primitive-neutral-60), var(--fox-primitive-neutral-40)), | |
| 131 | + element-neutral-default: light-dark(var(--fox-primitive-neutral-50), var(--fox-primitive-neutral-50)), | |
| 132 | + element-neutral-subtle: light-dark(var(--fox-primitive-neutral-10), var(--fox-primitive-neutral-90)), | |
| 133 | + element-neutral-subtler: light-dark(var(--fox-primitive-neutral-5), var(--fox-primitive-neutral-95)), | |
| 134 | + element-neutral-inverse: light-dark(var(--fox-primitive-neutral-0), var(--fox-primitive-neutral-95)), | |
| 135 | + element-neutral-inverse-static: light-dark(var(--fox-primitive-neutral-0), var(--fox-primitive-neutral-0)), | |
| 136 | + element-neutral-alpha: light-dark(rgb(0 0 0 / 0.25), rgb(0 0 0 / 0.5)), | |
| 137 | + element-neutral-alpha-inverse: light-dark(rgb(255 255 255 / 0.5), rgb(255 255 255 / 0.25)), | |
| 138 | + element-neutral-disabled: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-80)), | |
| 139 | + element-neutral-disabled-strong: light-dark(var(--fox-primitive-neutral-40), var(--fox-primitive-neutral-60)), | |
| 140 | + element-theme-primary: light-dark(var(--fox-theme-primary-50), var(--fox-theme-primary-50)), | |
| 141 | + element-theme-primary-strong: light-dark(var(--fox-theme-primary-60), var(--fox-theme-primary-40)), | |
| 142 | + element-theme-primary-subtle: light-dark(var(--fox-theme-primary-10), var(--fox-theme-primary-90)), | |
| 143 | + element-theme-primary-subtler: light-dark(var(--fox-theme-primary-5), var(--fox-theme-primary-95)), | |
| 144 | + element-theme-secondary: light-dark(var(--fox-theme-secondary-70), var(--fox-theme-secondary-60)), | |
| 145 | + element-theme-secondary-strong: light-dark(var(--fox-theme-secondary-70), var(--fox-theme-secondary-60)), | |
| 146 | + element-theme-secondary-subtle: light-dark(var(--fox-theme-secondary-10), var(--fox-theme-secondary-90)), | |
| 147 | + element-theme-secondary-subtler: light-dark(var(--fox-theme-secondary-5), var(--fox-theme-secondary-95)), | |
| 148 | + element-theme-accent: light-dark(var(--fox-theme-accent-50), var(--fox-theme-accent-50)), | |
| 149 | + element-theme-accent-strong: light-dark(var(--fox-theme-accent-60), var(--fox-theme-accent-40)), | |
| 150 | + element-theme-accent-subtle: light-dark(var(--fox-theme-accent-10), var(--fox-theme-accent-90)), | |
| 151 | + element-theme-accent-subtler: light-dark(var(--fox-theme-accent-5), var(--fox-theme-accent-95)), | |
| 152 | + element-system-danger: light-dark(var(--fox-primitive-danger-50), var(--fox-primitive-danger-50)), | |
| 153 | + element-system-danger-strong: light-dark(var(--fox-primitive-danger-60), var(--fox-primitive-danger-40)), | |
| 154 | + element-system-danger-inverse: light-dark(var(--fox-primitive-danger-5), var(--fox-primitive-danger-95)), | |
| 155 | + element-system-warning: light-dark(var(--fox-primitive-warning-30), var(--fox-primitive-warning-30)), | |
| 156 | + element-system-warning-strong: light-dark(var(--fox-primitive-warning-50), var(--fox-primitive-warning-20)), | |
| 157 | + element-system-warning-inverse: light-dark(var(--fox-primitive-warning-5), var(--fox-primitive-warning-95)), | |
| 158 | + element-system-success: light-dark(var(--fox-primitive-success-50), var(--fox-primitive-success-50)), | |
| 159 | + element-system-success-strong: light-dark(var(--fox-primitive-success-60), var(--fox-primitive-success-40)), | |
| 160 | + element-system-success-inverse: light-dark(var(--fox-primitive-success-5), var(--fox-primitive-success-95)), | |
| 161 | + element-system-information: light-dark(var(--fox-primitive-information-50), var(--fox-primitive-information-50)), | |
| 162 | + element-system-information-strong: light-dark(var(--fox-primitive-information-60), var(--fox-primitive-information-40)), | |
| 163 | + element-system-information-inverse: light-dark(var(--fox-primitive-information-5), var(--fox-primitive-information-95)), | |
| 164 | + alpha-default100: light-dark(var(--fox-primitive-alpha-white100), var(--fox-primitive-alpha-black100)), | |
| 165 | + alpha-default75: light-dark(var(--fox-primitive-alpha-white75), var(--fox-primitive-alpha-black75)), | |
| 166 | + alpha-default50: light-dark(var(--fox-primitive-alpha-white50), var(--fox-primitive-alpha-black50)), | |
| 167 | + alpha-default25: light-dark(var(--fox-primitive-alpha-white25), var(--fox-primitive-alpha-black25)), | |
| 168 | + alpha-default10: light-dark(var(--fox-primitive-alpha-white10), var(--fox-primitive-alpha-black10)), | |
| 169 | + alpha-default0: light-dark(var(--fox-primitive-alpha-white0), var(--fox-primitive-alpha-black0)), | |
| 170 | + alpha-default-inverse100: light-dark(var(--fox-primitive-alpha-black100), var(--fox-primitive-alpha-white100)), | |
| 171 | + alpha-default-inverse75: light-dark(var(--fox-primitive-alpha-black75), var(--fox-primitive-alpha-white75)), | |
| 172 | + alpha-default-inverse50: light-dark(var(--fox-primitive-alpha-black50), var(--fox-primitive-alpha-white50)), | |
| 173 | + alpha-default-inverse25: light-dark(var(--fox-primitive-alpha-black25), var(--fox-primitive-alpha-white25)), | |
| 174 | + alpha-default-inverse10: light-dark(var(--fox-primitive-alpha-black10), var(--fox-primitive-alpha-white10)), | |
| 175 | + alpha-default-inverse0: light-dark(var(--fox-primitive-alpha-black0), var(--fox-primitive-alpha-white0)), | |
| 176 | + action-default: light-dark(var(--fox-primitive-neutral-0), var(--fox-primitive-neutral-100)), | |
| 177 | + action-default-hover: light-dark(var(--fox-primitive-neutral-5), var(--fox-primitive-neutral-95)), | |
| 178 | + action-default-pressed: light-dark(var(--fox-primitive-neutral-10), var(--fox-primitive-neutral-90)), | |
| 179 | + action-default-selected: light-dark(var(--fox-primitive-neutral-10), var(--fox-primitive-neutral-90)), | |
| 180 | + action-default-active: light-dark(var(--fox-primitive-neutral-70), var(--fox-primitive-neutral-60)), | |
| 181 | + action-neutral: light-dark(var(--fox-primitive-neutral-5), var(--fox-primitive-neutral-95)), | |
| 182 | + action-neutral-hover: light-dark(var(--fox-primitive-neutral-10), var(--fox-primitive-neutral-90)), | |
| 183 | + action-neutral-pressed: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-80)), | |
| 184 | + action-neutral-selected: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-80)), | |
| 185 | + action-neutral-active: light-dark(var(--fox-primitive-neutral-70), var(--fox-primitive-neutral-60)), | |
| 186 | + action-primary: light-dark(var(--fox-primitive-alpha-white0), var(--fox-primitive-alpha-black0)), | |
| 187 | + action-primary-hover: light-dark(var(--fox-theme-primary-5), var(--fox-theme-primary-95)), | |
| 188 | + action-primary-pressed: light-dark(var(--fox-theme-primary-10), var(--fox-theme-primary-90)), | |
| 189 | + action-primary-selected: light-dark(var(--fox-theme-primary-5), var(--fox-theme-primary-95)), | |
| 190 | + action-primary-active: light-dark(var(--fox-theme-primary-50), var(--fox-theme-primary-50)), | |
| 191 | + action-secondary: light-dark(var(--fox-primitive-alpha-white0), var(--fox-primitive-alpha-black0)), | |
| 192 | + action-secondary-hover: light-dark(var(--fox-theme-secondary-5), var(--fox-theme-secondary-95)), | |
| 193 | + action-secondary-pressed: light-dark(var(--fox-theme-secondary-10), var(--fox-theme-secondary-90)), | |
| 194 | + action-secondary-selected: light-dark(var(--fox-theme-secondary-5), var(--fox-theme-secondary-90)), | |
| 195 | + action-secondary-active: light-dark(var(--fox-theme-secondary-70), var(--fox-theme-secondary-60)), | |
| 196 | + button-primary-surface: light-dark(var(--fox-theme-primary-50), var(--fox-theme-primary-50)), | |
| 197 | + button-primary-surface-hover: light-dark(var(--fox-theme-primary-60), var(--fox-theme-primary-60)), | |
| 198 | + button-primary-surface-pressed: light-dark(var(--fox-theme-primary-70), var(--fox-theme-primary-70)), | |
| 199 | + button-primary-border: light-dark(var(--fox-primitive-alpha-black0), var(--fox-primitive-alpha-black0)), | |
| 200 | + button-primary-font: light-dark(var(--fox-primitive-alpha-white100), var(--fox-primitive-alpha-white100)), | |
| 201 | + button-secondary-surface: light-dark(var(--fox-theme-primary-5), var(--fox-theme-primary-95)), | |
| 202 | + button-secondary-surface-hover: light-dark(var(--fox-theme-primary-10), var(--fox-theme-primary-90)), | |
| 203 | + button-secondary-surface-pressed: light-dark(var(--fox-theme-primary-20), var(--fox-theme-primary-80)), | |
| 204 | + button-secondary-border: light-dark(var(--fox-theme-primary-30), var(--fox-theme-primary-70)), | |
| 205 | + button-secondary-font: light-dark(var(--fox-theme-primary-60), var(--fox-theme-primary-30)), | |
| 206 | + button-default-surface: light-dark(var(--fox-primitive-alpha-white0), var(--fox-primitive-alpha-black0)), | |
| 207 | + button-default-surface-hover: light-dark(var(--fox-primitive-neutral-5), var(--fox-primitive-neutral-95)), | |
| 208 | + button-default-surface-pressed: light-dark(var(--fox-primitive-neutral-10), var(--fox-primitive-neutral-80)), | |
| 209 | + button-default-border: light-dark(var(--fox-primitive-neutral-30), var(--fox-primitive-neutral-70)), | |
| 210 | + button-default-font: light-dark(var(--fox-primitive-neutral-70), var(--fox-primitive-neutral-20)), | |
| 211 | + button-text-surface: light-dark(var(--fox-primitive-alpha-white0), var(--fox-primitive-alpha-black0)), | |
| 212 | + button-text-surface-hover: light-dark(var(--fox-theme-secondary-5), var(--fox-theme-secondary-95)), | |
| 213 | + button-text-surface-pressed: light-dark(var(--fox-theme-secondary-10), var(--fox-theme-secondary-90)), | |
| 214 | + button-text-visited: light-dark(#5917B8, #C49FF9), | |
| 215 | + button-disabled-surface: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-80)), | |
| 216 | + button-disabled-border: light-dark(var(--fox-primitive-neutral-30), var(--fox-primitive-neutral-70)), | |
| 217 | + button-disabled-font: light-dark(var(--fox-primitive-neutral-50), var(--fox-primitive-neutral-50)), | |
| 218 | + input-surface: light-dark(var(--fox-primitive-neutral-0), var(--fox-primitive-neutral-90)), | |
| 219 | + input-surface-disabled: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-95)), | |
| 220 | + input-border: light-dark(var(--fox-primitive-neutral-60), var(--fox-primitive-neutral-40)), | |
| 221 | + input-border-active: light-dark(var(--fox-theme-primary-50), var(--fox-theme-primary-50)), | |
| 222 | + input-border-error: light-dark(var(--fox-primitive-danger-50), var(--fox-primitive-danger-50)), | |
| 223 | + input-border-success: light-dark(var(--fox-primitive-success-50), var(--fox-primitive-success-50)), | |
| 224 | + input-border-disabled: light-dark(var(--fox-primitive-neutral-30), var(--fox-primitive-neutral-70)), | |
| 225 | + chart-default-bar: light-dark(var(--fox-theme-primary-20), var(--fox-theme-primary-70)), | |
| 226 | + chart-default-surface: light-dark(var(--fox-theme-primary-10), var(--fox-theme-primary-70)), | |
| 227 | + chart-default-line: light-dark(var(--fox-theme-primary-50), var(--fox-theme-primary-70)), | |
| 228 | + chart-default-disabled: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-70)), | |
| 229 | + chart-level-1: light-dark(var(--fox-theme-primary-90), var(--fox-theme-primary-10)), | |
| 230 | + chart-level-2: light-dark(var(--fox-theme-primary-70), var(--fox-theme-primary-20)), | |
| 231 | + chart-level-3: light-dark(var(--fox-theme-primary-50), var(--fox-theme-primary-40)), | |
| 232 | + chart-level-4: light-dark(var(--fox-theme-primary-40), var(--fox-theme-primary-50)), | |
| 233 | + chart-level-5: light-dark(var(--fox-theme-primary-20), var(--fox-theme-primary-70)), | |
| 234 | + chart-level-6: light-dark(var(--fox-theme-primary-10), var(--fox-theme-primary-90)), | |
| 235 | + chart-level-7: light-dark(var(--fox-primitive-neutral-60), var(--fox-primitive-neutral-10)), | |
| 236 | + chart-level-8: light-dark(var(--fox-primitive-neutral-40), var(--fox-primitive-neutral-20)), | |
| 237 | + chart-level-9: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-40)), | |
| 238 | + chart-level-10: light-dark(var(--fox-primitive-neutral-10), var(--fox-primitive-neutral-60)), | |
| 239 | + chart-type-1-bar: light-dark(var(--fox-theme-primary-20), var(--fox-theme-primary-50)), | |
| 240 | + chart-type-1-line: light-dark(var(--fox-theme-primary-40), var(--fox-theme-primary-30)), | |
| 241 | + chart-type-1-surface: light-dark(var(--fox-theme-primary-5), var(--fox-theme-primary-95)), | |
| 242 | + chart-type-2-bar: light-dark(var(--fox-theme-accent-20), var(--fox-theme-accent-50)), | |
| 243 | + chart-type-2-line: light-dark(var(--fox-theme-accent-30), var(--fox-theme-accent-30)), | |
| 244 | + chart-type-2-surface: light-dark(var(--fox-theme-accent-5), var(--fox-theme-accent-95)), | |
| 245 | + chart-type-3-bar: light-dark(var(--fox-theme-secondary-20), var(--fox-theme-secondary-50)), | |
| 246 | + chart-type-3-line: light-dark(var(--fox-theme-secondary-30), var(--fox-theme-secondary-30)), | |
| 247 | + chart-type-3-surface: light-dark(var(--fox-theme-secondary-5), var(--fox-theme-secondary-95)), | |
| 248 | + chart-type-4-bar: light-dark(#FFDF94, #DEB451), | |
| 249 | + chart-type-4-line: light-dark(#FBC35A, #FBC35A), | |
| 250 | + chart-type-4-surface: light-dark(#FFF7E9, #2F2D28), | |
| 251 | + chart-type-5-bar: light-dark(#FF9F85, #D96645), | |
| 252 | + chart-type-5-line: light-dark(#FF7C57, #FF7C57), | |
| 253 | + chart-type-5-surface: light-dark(#FFECE7, #3F3330), | |
| 254 | + chart-alt-level-1: light-dark(#002D31, #D4F4F7), | |
| 255 | + chart-alt-level-2: light-dark(#005A62, #9EE7EE), | |
| 256 | + chart-alt-level-3: light-dark(#00828E, #1EBCCB), | |
| 257 | + chart-alt-level-4: light-dark(#1EBCCB, #00828E), | |
| 258 | + chart-alt-level-5: light-dark(#9EE7EE, #005A62), | |
| 259 | + chart-alt-level-6: light-dark(#D4F4F7, #002D31), | |
| 260 | + chart-alt-level-7: light-dark(var(--fox-primitive-neutral-60), var(--fox-primitive-neutral-10)), | |
| 261 | + chart-alt-level-8: light-dark(var(--fox-primitive-neutral-40), var(--fox-primitive-neutral-20)), | |
| 262 | + chart-alt-level-9: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-40)), | |
| 263 | + chart-alt-level-10: light-dark(var(--fox-primitive-neutral-10), var(--fox-primitive-neutral-60)), | |
| 264 | + chart-alt-default-bar: light-dark(#9EE7EE, #005A62), | |
| 265 | + chart-alt-default-surface: light-dark(#D4F4F7, #005A62), | |
| 266 | + chart-alt-default-line: light-dark(#00828E, #005A62), | |
| 267 | + chart-alt-default-disabled: light-dark(var(--fox-primitive-neutral-20), var(--fox-primitive-neutral-70)), | |
| 268 | + chart-alt-type-1-bar: light-dark(#9EE7EE, #00828E), | |
| 269 | + chart-alt-type-1-line: light-dark(#1EBCCB, #57D2DE), | |
| 270 | + chart-alt-type-1-surface: light-dark(#E9FAFC, #001A1C), | |
| 271 | + chart-alt-type-2-bar: light-dark(#CDE566, #5E8100), | |
| 272 | + chart-alt-type-2-line: light-dark(#A7CC00, #A7CC00), | |
| 273 | + chart-alt-type-2-surface: light-dark(#F2F9D9, #121900), | |
| 274 | + chart-alt-type-3-bar: light-dark(#97CBED, #00577D), | |
| 275 | + chart-alt-type-3-line: light-dark(#52A6D3, #52A6D3), | |
| 276 | + chart-alt-type-3-surface: light-dark(#E8F4FC, #00080C), | |
| 277 | + chart-alt-type-4-bar: light-dark(#FFDF94, #DEB451), | |
| 278 | + chart-alt-type-4-line: light-dark(#FBC35A, #FBC35A), | |
| 279 | + chart-alt-type-4-surface: light-dark(#FFF7E9, #2F2D28), | |
| 280 | + chart-alt-type-5-bar: light-dark(#FF9F85, #D96645), | |
| 281 | + chart-alt-type-5-line: light-dark(#FF7C57, #FF7C57), | |
| 282 | + chart-alt-type-5-surface: light-dark(#FFECE7, #3F3330) | |
| 283 | +); |
+++ @fox/styles/tokens/_index.scss
... | ... | @@ -0,0 +1,10 @@ |
| 1 | +// 토큰 map 모음 — **CSS를 한 줄도 출력하지 않는다.** | |
| 2 | +// 값 선언(이 폴더)과 CSS 출력(`../_root.scss`)을 분리해 두었기 때문에, 컴포넌트의 | |
| 3 | +// `.module.scss`가 몇 개든 `abstracts`를 `@use`해도 `:root` 블록이 중복 출력되지 않는다 | |
| 4 | +// (각 CSS Module은 별도 컴파일 단위라 CSS를 내보내는 모듈을 공유하면 파일마다 복제된다). | |
| 5 | + | |
| 6 | +@forward "primitive"; | |
| 7 | +@forward "theme"; | |
| 8 | +@forward "size"; | |
| 9 | +@forward "color"; | |
| 10 | +@forward "responsive"; |
+++ @fox/styles/tokens/_primitive.scss
... | ... | @@ -0,0 +1,112 @@ |
| 1 | +// ⚠️ 이 파일은 Figma Design Tokens export에서 **자동 생성**되었습니다. | |
| 2 | +// 원본: primitive.tokens.json | |
| 3 | +// 손으로 고치지 말고 원본 JSON을 다시 export한 뒤 변환기를 재실행하세요. | |
| 4 | + | |
| 5 | +// 원시 팔레트 — 화면에서 직접 쓰지 않는다. 시맨틱 토큰(`$color`)이 참조하는 바닥층이다. | |
| 6 | +$primitive: ( | |
| 7 | + neutral-0: #FFFFFF, | |
| 8 | + neutral-5: #F4F5F6, | |
| 9 | + neutral-10: #E6E8EA, | |
| 10 | + neutral-20: #CDD1D5, | |
| 11 | + neutral-30: #B1B8BE, | |
| 12 | + neutral-40: #8A949E, | |
| 13 | + neutral-50: #6D7882, | |
| 14 | + neutral-60: #58616A, | |
| 15 | + neutral-70: #464C53, | |
| 16 | + neutral-80: #33363D, | |
| 17 | + neutral-90: #1E2124, | |
| 18 | + neutral-95: #131416, | |
| 19 | + neutral-100: #000000, | |
| 20 | + danger-5: #FDEFEC, | |
| 21 | + danger-10: #FCDFD9, | |
| 22 | + danger-20: #F7AFA1, | |
| 23 | + danger-30: #F48771, | |
| 24 | + danger-40: #F05F42, | |
| 25 | + danger-50: #DE3412, | |
| 26 | + danger-60: #BD2C0F, | |
| 27 | + danger-70: #8A240F, | |
| 28 | + danger-80: #5C180A, | |
| 29 | + danger-90: #390D05, | |
| 30 | + danger-95: #260903, | |
| 31 | + warning-5: #FFF3DB, | |
| 32 | + warning-10: #FFE0A3, | |
| 33 | + warning-20: #FFC95C, | |
| 34 | + warning-30: #FFB114, | |
| 35 | + warning-40: #C78500, | |
| 36 | + warning-50: #9E6A00, | |
| 37 | + warning-60: #8A5C00, | |
| 38 | + warning-70: #614100, | |
| 39 | + warning-80: #422C00, | |
| 40 | + warning-90: #2E1F00, | |
| 41 | + warning-95: #241800, | |
| 42 | + success-5: #EAF6EC, | |
| 43 | + success-10: #D8EEDD, | |
| 44 | + success-20: #A9DAB4, | |
| 45 | + success-30: #7EC88E, | |
| 46 | + success-40: #3FA654, | |
| 47 | + success-50: #228738, | |
| 48 | + success-60: #267337, | |
| 49 | + success-70: #285D33, | |
| 50 | + success-80: #1F4727, | |
| 51 | + success-90: #122B18, | |
| 52 | + success-95: #0E2012, | |
| 53 | + information-5: #E7F4FE, | |
| 54 | + information-10: #D3EBFD, | |
| 55 | + information-20: #9ED2FA, | |
| 56 | + information-30: #5FB5F7, | |
| 57 | + information-40: #2098F3, | |
| 58 | + information-50: #0B78CB, | |
| 59 | + information-60: #096AB3, | |
| 60 | + information-70: #085691, | |
| 61 | + information-80: #053961, | |
| 62 | + information-90: #03253F, | |
| 63 | + information-95: #021A2C, | |
| 64 | + alpha-black100: #000000, | |
| 65 | + alpha-black75: rgb(0 0 0 / 0.75), | |
| 66 | + alpha-black50: rgb(0 0 0 / 0.5), | |
| 67 | + alpha-black25: rgb(0 0 0 / 0.25), | |
| 68 | + alpha-black10: rgb(0 0 0 / 0.1), | |
| 69 | + alpha-black0: rgb(0 0 0 / 0), | |
| 70 | + alpha-white100: #FFFFFF, | |
| 71 | + alpha-white75: rgb(255 255 255 / 0.75), | |
| 72 | + alpha-white50: rgb(255 255 255 / 0.5), | |
| 73 | + alpha-white25: rgb(255 255 255 / 0.25), | |
| 74 | + alpha-white10: rgb(255 255 255 / 0.1), | |
| 75 | + alpha-white0: rgb(255 255 255 / 0) | |
| 76 | +); | |
| 77 | + | |
| 78 | +// 서체 — family는 문자열, weight는 무단위 수치(길이가 아니므로 rem 변환 대상이 아니다). | |
| 79 | +$font: ( | |
| 80 | + family-title: Pretendard, | |
| 81 | + family-body: Pretendard, | |
| 82 | + weight-regular: 400, | |
| 83 | + weight-medium: 600, | |
| 84 | + weight-bold: 800 | |
| 85 | +); | |
| 86 | + | |
| 87 | +// 원시 수치 스케일 — size 컬렉션이 참조하는 값들. 1rem = 10px 기준으로 변환됨. | |
| 88 | +$number: ( | |
| 89 | + 0: 0, | |
| 90 | + 1: 0.1rem, | |
| 91 | + 2: 0.2rem, | |
| 92 | + 3: 0.4rem, | |
| 93 | + 4: 0.6rem, | |
| 94 | + 5: 0.8rem, | |
| 95 | + 6: 1rem, | |
| 96 | + 7: 1.2rem, | |
| 97 | + 8: 1.6rem, | |
| 98 | + 9: 2rem, | |
| 99 | + 10: 2.4rem, | |
| 100 | + 11: 2.8rem, | |
| 101 | + 12: 3.2rem, | |
| 102 | + 13: 3.6rem, | |
| 103 | + 14: 4rem, | |
| 104 | + 15: 4.4rem, | |
| 105 | + 16: 4.8rem, | |
| 106 | + 17: 5.6rem, | |
| 107 | + 18: 6.4rem, | |
| 108 | + 19: 7.2rem, | |
| 109 | + 20: 8rem, | |
| 110 | + 21: 9.6rem, | |
| 111 | + max: 100rem | |
| 112 | +); |
+++ @fox/styles/tokens/_responsive.scss
... | ... | @@ -0,0 +1,163 @@ |
| 1 | +// ⚠️ 이 파일은 Figma Design Tokens export에서 **자동 생성**되었습니다. | |
| 2 | +// 원본: mobile.tokens.json / pc.tokens.json | |
| 3 | +// 손으로 고치지 말고 원본 JSON을 다시 export한 뒤 변환기를 재실행하세요. | |
| 4 | + | |
| 5 | +// 뷰포트에 따라 달라지는 크기 토큰. 키는 두 모드가 완전히 동일하다. | |
| 6 | +// 모바일 우선 — `$responsive`가 기본값이고 `$responsive-pc`가 브레이크포인트 이상에서 덮는다. | |
| 7 | +$breakpoint-pc: 768px; | |
| 8 | + | |
| 9 | +$responsive: ( | |
| 10 | + font-size-display-lg: 4.4rem, | |
| 11 | + font-size-display-md: 3.2rem, | |
| 12 | + font-size-display-sm: 2.8rem, | |
| 13 | + font-size-heading-xlg: 2.8rem, | |
| 14 | + font-size-heading-lg: 2.4rem, | |
| 15 | + font-size-heading-md: 2rem, | |
| 16 | + font-size-heading-sm: 1.7rem, | |
| 17 | + font-size-heading-xsm: 1.5rem, | |
| 18 | + font-size-heading-2xsm: 1.3rem, | |
| 19 | + font-size-body-lg: 1.5rem, | |
| 20 | + font-size-body-md: 1.3rem, | |
| 21 | + font-size-body-sm: 1.2rem, | |
| 22 | + font-size-body-xsm: 1rem, | |
| 23 | + font-size-label-lg: 1.5rem, | |
| 24 | + font-size-label-md: 1.3rem, | |
| 25 | + font-size-label-sm: 1.2rem, | |
| 26 | + font-size-label-xsm: 1rem, | |
| 27 | + form-radius-xsm: 0.4rem, | |
| 28 | + form-radius-sm: 0.4rem, | |
| 29 | + form-radius-md: 0.8rem, | |
| 30 | + form-radius-lg: 1.2rem, | |
| 31 | + form-padding-lg: 2rem, | |
| 32 | + form-padding-md: 1.2rem, | |
| 33 | + form-padding-sm: 0.8rem, | |
| 34 | + form-padding-xsm: 0.4rem, | |
| 35 | + form-icon-lg: 2rem, | |
| 36 | + form-icon-md: 1.6rem, | |
| 37 | + form-icon-sm: 1.2rem, | |
| 38 | + form-icon-xsm: 1.2rem, | |
| 39 | + form-height-xsm: 2rem, | |
| 40 | + form-height-sm: 2.8rem, | |
| 41 | + form-height-md: 4rem, | |
| 42 | + form-height-lg: 4.8rem, | |
| 43 | + grid-wrap-full: 38rem, | |
| 44 | + grid-wrap-default: 34.8rem, | |
| 45 | + grid-wrap-sm: 32rem, | |
| 46 | + grid-wrap-xsm: 28rem, | |
| 47 | + grid-margin-full: 0, | |
| 48 | + grid-margin-default: 1.6rem, | |
| 49 | + grid-margin-sm: 3rem, | |
| 50 | + grid-margin-xsm: 5rem, | |
| 51 | + grid-gutter-sm: 0.8rem, | |
| 52 | + grid-gutter-md: 1.6rem, | |
| 53 | + grid-gutter-lg: 2rem, | |
| 54 | + grid-gutter-xlg: 2rem, | |
| 55 | + grid-gutter-2xlg: 4rem, | |
| 56 | + card-radius-xlg: 1.6rem, | |
| 57 | + card-radius-lg: 1.6rem, | |
| 58 | + card-radius-md: 1.2rem, | |
| 59 | + card-radius-sm: 0.8rem, | |
| 60 | + card-padding-xlg: 2.4rem, | |
| 61 | + card-padding-lg: 2rem, | |
| 62 | + card-padding-md: 1.6rem, | |
| 63 | + card-padding-sm: 1.2rem, | |
| 64 | + modal-width-full: 38rem, | |
| 65 | + modal-width-lg: 34.8rem, | |
| 66 | + modal-width-md: 34.8rem, | |
| 67 | + modal-width-sm: 34.8rem, | |
| 68 | + modal-padding-default: 2.4rem, | |
| 69 | + modal-btn-close: 5.6rem, | |
| 70 | + spacing-top-2xlg: 8rem, | |
| 71 | + spacing-top-xlg: 5.6rem, | |
| 72 | + spacing-top-lg: 3.2rem, | |
| 73 | + spacing-top-md: 2rem, | |
| 74 | + spacing-top-sm: 1.2rem, | |
| 75 | + spacing-top-none: 0, | |
| 76 | + spacing-bottom-xlg: 4.8rem, | |
| 77 | + spacing-bottom-lg: 2.8rem, | |
| 78 | + spacing-bottom-md: 2rem, | |
| 79 | + spacing-bottom-sm: 1rem, | |
| 80 | + spacing-bottom-none: 0, | |
| 81 | + section-spacing-lg: 8rem, | |
| 82 | + section-spacing-md: 4rem, | |
| 83 | + section-spacing-sm: 2.4rem, | |
| 84 | + section-spacing-xsm: 1.6rem | |
| 85 | +); | |
| 86 | + | |
| 87 | +$responsive-pc: ( | |
| 88 | + font-size-display-lg: 4rem, | |
| 89 | + font-size-display-md: 3.2rem, | |
| 90 | + font-size-display-sm: 2.6rem, | |
| 91 | + font-size-heading-xlg: 3.2rem, | |
| 92 | + font-size-heading-lg: 2.6rem, | |
| 93 | + font-size-heading-md: 2rem, | |
| 94 | + font-size-heading-sm: 1.7rem, | |
| 95 | + font-size-heading-xsm: 1.5rem, | |
| 96 | + font-size-heading-2xsm: 1.3rem, | |
| 97 | + font-size-body-lg: 1.7rem, | |
| 98 | + font-size-body-md: 1.5rem, | |
| 99 | + font-size-body-sm: 1.3rem, | |
| 100 | + font-size-body-xsm: 1.1rem, | |
| 101 | + font-size-label-lg: 1.7rem, | |
| 102 | + font-size-label-md: 1.5rem, | |
| 103 | + font-size-label-sm: 1.3rem, | |
| 104 | + font-size-label-xsm: 1.1rem, | |
| 105 | + form-radius-xsm: 0.4rem, | |
| 106 | + form-radius-sm: 0.4rem, | |
| 107 | + form-radius-md: 0.8rem, | |
| 108 | + form-radius-lg: 1.2rem, | |
| 109 | + form-padding-lg: 2rem, | |
| 110 | + form-padding-md: 1.2rem, | |
| 111 | + form-padding-sm: 0.8rem, | |
| 112 | + form-padding-xsm: 0.8rem, | |
| 113 | + form-icon-lg: 2.4rem, | |
| 114 | + form-icon-md: 2rem, | |
| 115 | + form-icon-sm: 1.6rem, | |
| 116 | + form-icon-xsm: 1.2rem, | |
| 117 | + form-height-xsm: 2.4rem, | |
| 118 | + form-height-sm: 2.8rem, | |
| 119 | + form-height-md: 4rem, | |
| 120 | + form-height-lg: 5.6rem, | |
| 121 | + grid-wrap-full: 192rem, | |
| 122 | + grid-wrap-default: 155.2rem, | |
| 123 | + grid-wrap-sm: 108rem, | |
| 124 | + grid-wrap-xsm: 80rem, | |
| 125 | + grid-margin-full: 0, | |
| 126 | + grid-margin-default: 4.8rem, | |
| 127 | + grid-margin-sm: 42rem, | |
| 128 | + grid-margin-xsm: 56rem, | |
| 129 | + grid-gutter-sm: 0.8rem, | |
| 130 | + grid-gutter-md: 1.6rem, | |
| 131 | + grid-gutter-lg: 2.4rem, | |
| 132 | + grid-gutter-xlg: 4rem, | |
| 133 | + grid-gutter-2xlg: 6.4rem, | |
| 134 | + card-radius-xlg: 2.4rem, | |
| 135 | + card-radius-lg: 2.4rem, | |
| 136 | + card-radius-md: 1.6rem, | |
| 137 | + card-radius-sm: 1.2rem, | |
| 138 | + card-padding-xlg: 4rem, | |
| 139 | + card-padding-lg: 3.2rem, | |
| 140 | + card-padding-md: 2.4rem, | |
| 141 | + card-padding-sm: 1.6rem, | |
| 142 | + modal-width-full: 192rem, | |
| 143 | + modal-width-lg: 76rem, | |
| 144 | + modal-width-md: 56rem, | |
| 145 | + modal-width-sm: 40rem, | |
| 146 | + modal-padding-default: 4rem, | |
| 147 | + modal-btn-close: 6.4rem, | |
| 148 | + spacing-top-2xlg: 8rem, | |
| 149 | + spacing-top-xlg: 6.4rem, | |
| 150 | + spacing-top-lg: 4rem, | |
| 151 | + spacing-top-md: 2.4rem, | |
| 152 | + spacing-top-sm: 1.6rem, | |
| 153 | + spacing-top-none: 0, | |
| 154 | + spacing-bottom-xlg: 3.6rem, | |
| 155 | + spacing-bottom-lg: 2.8rem, | |
| 156 | + spacing-bottom-md: 2rem, | |
| 157 | + spacing-bottom-sm: 1rem, | |
| 158 | + spacing-bottom-none: 0, | |
| 159 | + section-spacing-lg: 12rem, | |
| 160 | + section-spacing-md: 8rem, | |
| 161 | + section-spacing-sm: 4rem, | |
| 162 | + section-spacing-xsm: 2.4rem | |
| 163 | +); |
+++ @fox/styles/tokens/_size.scss
... | ... | @@ -0,0 +1,62 @@ |
| 1 | +// ⚠️ 이 파일은 Figma Design Tokens export에서 **자동 생성**되었습니다. | |
| 2 | +// 원본: size.tokens.json | |
| 3 | +// 손으로 고치지 말고 원본 JSON을 다시 export한 뒤 변환기를 재실행하세요. | |
| 4 | + | |
| 5 | +// 모드와 무관한 크기 스케일. 값은 1rem = 10px 기준 rem. | |
| 6 | +$size: ( | |
| 7 | + border-0: 0, | |
| 8 | + border-1: 0.1rem, | |
| 9 | + border-2: 0.2rem, | |
| 10 | + border-3: 0.4rem, | |
| 11 | + padding-1: 0.2rem, | |
| 12 | + padding-2: 0.4rem, | |
| 13 | + padding-3: 0.8rem, | |
| 14 | + padding-4: 1rem, | |
| 15 | + padding-5: 1.2rem, | |
| 16 | + padding-6: 1.6rem, | |
| 17 | + padding-7: 2rem, | |
| 18 | + padding-8: 2.4rem, | |
| 19 | + padding-9: 3.2rem, | |
| 20 | + padding-10: 4rem, | |
| 21 | + padding-11: 6.4rem, | |
| 22 | + gap-1: 0.2rem, | |
| 23 | + gap-2: 0.4rem, | |
| 24 | + gap-3: 0.8rem, | |
| 25 | + gap-4: 1.2rem, | |
| 26 | + gap-5: 1.6rem, | |
| 27 | + gap-6: 2rem, | |
| 28 | + gap-7: 2.4rem, | |
| 29 | + gap-8: 3.2rem, | |
| 30 | + gap-9: 4rem, | |
| 31 | + gap-10: 4.8rem, | |
| 32 | + gap-11: 6.4rem, | |
| 33 | + gap-12: 8rem, | |
| 34 | + radius-1: 0.2rem, | |
| 35 | + radius-2: 0.4rem, | |
| 36 | + radius-3: 0.8rem, | |
| 37 | + radius-4: 1.2rem, | |
| 38 | + radius-5: 1.6rem, | |
| 39 | + radius-6: 2.4rem, | |
| 40 | + radius-7: 4rem, | |
| 41 | + radius-max: 100rem, | |
| 42 | + icon-1: 0.8rem, | |
| 43 | + icon-2: 1.2rem, | |
| 44 | + icon-3: 1.6rem, | |
| 45 | + icon-4: 2rem, | |
| 46 | + icon-5: 2.4rem, | |
| 47 | + icon-6: 3.2rem, | |
| 48 | + icon-7: 4rem, | |
| 49 | + shadow-location1: 0.1rem, | |
| 50 | + shadow-location2: 0.2rem, | |
| 51 | + shadow-location3: 0.4rem, | |
| 52 | + shadow-location4: 0.8rem, | |
| 53 | + shadow-location5: 1.6rem, | |
| 54 | + shadow-blur1: 0.2rem, | |
| 55 | + shadow-blur2: 0.4rem, | |
| 56 | + shadow-blur3: 0.8rem, | |
| 57 | + shadow-blur4: 1.6rem, | |
| 58 | + shadow-blur5: 3.2rem, | |
| 59 | + backdrop-blur1: 1rem, | |
| 60 | + backdrop-blur2: 2.4rem, | |
| 61 | + backdrop-blur3: 4rem | |
| 62 | +); |
+++ @fox/styles/tokens/_theme.scss
... | ... | @@ -0,0 +1,40 @@ |
| 1 | +// ⚠️ 이 파일은 Figma Design Tokens export에서 **자동 생성**되었습니다. | |
| 2 | +// 원본: theme.tokens.json | |
| 3 | +// 손으로 고치지 말고 원본 JSON을 다시 export한 뒤 변환기를 재실행하세요. | |
| 4 | + | |
| 5 | +// 브랜드 램프 — primary / secondary / accent. | |
| 6 | +$theme: ( | |
| 7 | + primary-5: #ECF2FE, | |
| 8 | + primary-10: #D8E5FD, | |
| 9 | + primary-20: #B1CEFB, | |
| 10 | + primary-30: #86AFF9, | |
| 11 | + primary-40: #4C87F6, | |
| 12 | + primary-50: #256EF4, | |
| 13 | + primary-60: #0B50D0, | |
| 14 | + primary-70: #083891, | |
| 15 | + primary-80: #052561, | |
| 16 | + primary-90: #03163A, | |
| 17 | + primary-95: #020F27, | |
| 18 | + secondary-5: #EEF2F7, | |
| 19 | + secondary-10: #D6E0EB, | |
| 20 | + secondary-20: #BACBDE, | |
| 21 | + secondary-30: #90B0D5, | |
| 22 | + secondary-40: #6B96C7, | |
| 23 | + secondary-50: #346FB2, | |
| 24 | + secondary-60: #1C589C, | |
| 25 | + secondary-70: #063A74, | |
| 26 | + secondary-80: #052B57, | |
| 27 | + secondary-90: #031F3F, | |
| 28 | + secondary-95: #02162C, | |
| 29 | + accent-5: #FBEFF0, | |
| 30 | + accent-10: #F5D6D9, | |
| 31 | + accent-20: #EBADB2, | |
| 32 | + accent-30: #E0858C, | |
| 33 | + accent-40: #D65C66, | |
| 34 | + accent-50: #D63D4A, | |
| 35 | + accent-60: #AB2B36, | |
| 36 | + accent-70: #7A1F26, | |
| 37 | + accent-80: #521419, | |
| 38 | + accent-90: #310C0F, | |
| 39 | + accent-95: #21080A | |
| 40 | +); |
+++ @fox/tools/build-tokens.py
... | ... | @@ -0,0 +1,486 @@ |
| 1 | +"""Figma Design Tokens(JSON) → @fox/styles SCSS 토큰 변환기. | |
| 2 | + | |
| 3 | +입력: $FOX_TOKENS_SRC/*.tokens.json (기본 ~/Downloads/mode · 7개, Figma 컬렉션 5개/모드 7개) | |
| 4 | +출력: @fox/styles/tokens/*.scss + _root.scss + _functions.scss + _mixins.scss | |
| 5 | + | |
| 6 | +실행: python3 @fox/tools/build-tokens.py | |
| 7 | + | |
| 8 | +규칙 | |
| 9 | +- 길이 값은 1rem = 10px 기준으로 value/10 → rem. font.weight만 무단위. | |
| 10 | +- alpha < 1 색상은 rgb(R G B / A)로 출력(hex는 알파를 담지 못한다). | |
| 11 | +- Figma의 alias(참조)는 CSS에서도 var() 참조로 보존한다 — 원본 계층이 유지되어 | |
| 12 | + primitive를 바꾸면 semantic까지 전파된다. 단, 참조 대상이 제공된 파일에 없거나 | |
| 13 | + 값이 일치하지 않으면 리터럴로 떨어뜨린다(무결성 우선). | |
| 14 | +""" | |
| 15 | + | |
| 16 | +import json | |
| 17 | +import os | |
| 18 | +from collections import OrderedDict | |
| 19 | + | |
| 20 | +SRC = os.environ.get("FOX_TOKENS_SRC", os.path.expanduser("~/Downloads/mode")) | |
| 21 | +OUT = os.environ.get("FOX_OUT", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "styles")) | |
| 22 | + | |
| 23 | +FILES = { | |
| 24 | + "primitive": "primitive.tokens.json", | |
| 25 | + "theme": "theme.tokens.json", | |
| 26 | + "size": "size.tokens.json", | |
| 27 | + "light": "light.tokens.json", | |
| 28 | + "dark": "dark.tokens.json", | |
| 29 | + "pc": "pc.tokens.json", | |
| 30 | + "mobile": "mobile.tokens.json", | |
| 31 | +} | |
| 32 | + | |
| 33 | + | |
| 34 | +def leaves(node, path=""): | |
| 35 | + if isinstance(node, dict): | |
| 36 | + if "$type" in node: | |
| 37 | + yield path, node | |
| 38 | + else: | |
| 39 | + for k, v in node.items(): | |
| 40 | + if k == "$extensions": | |
| 41 | + continue | |
| 42 | + yield from leaves(v, f"{path}.{k}" if path else k) | |
| 43 | + | |
| 44 | + | |
| 45 | +raw = {} | |
| 46 | +for key, fname in FILES.items(): | |
| 47 | + doc = json.load(open(os.path.join(SRC, fname), encoding="utf-8")) | |
| 48 | + raw[key] = OrderedDict(leaves(doc)) | |
| 49 | + | |
| 50 | + | |
| 51 | +def resolve_refs(table): | |
| 52 | + """같은 파일 안의 {a.b.c} 참조를 실제 값으로 치환한다.""" | |
| 53 | + for path, node in table.items(): | |
| 54 | + val = node["$value"] | |
| 55 | + seen = set() | |
| 56 | + while isinstance(val, str) and val.startswith("{") and val.endswith("}"): | |
| 57 | + target = val[1:-1] | |
| 58 | + if target in seen or target not in table: | |
| 59 | + raise SystemExit(f"참조 해결 실패: {path} -> {val}") | |
| 60 | + seen.add(target) | |
| 61 | + val = table[target]["$value"] | |
| 62 | + node["$value"] = val | |
| 63 | + | |
| 64 | + | |
| 65 | +for table in raw.values(): | |
| 66 | + resolve_refs(table) | |
| 67 | + | |
| 68 | + | |
| 69 | +def css_color(value): | |
| 70 | + comps = value["components"] | |
| 71 | + alpha = value.get("alpha", 1) | |
| 72 | + if alpha >= 1: | |
| 73 | + return value["hex"].upper() | |
| 74 | + r, g, b = (round(c * 255) for c in comps) | |
| 75 | + a = round(alpha, 4) | |
| 76 | + return f"rgb({r} {g} {b} / {a})" | |
| 77 | + | |
| 78 | + | |
| 79 | +def css_len(value): | |
| 80 | + """px 수치를 rem으로. 1rem = 10px.""" | |
| 81 | + if value == 0: | |
| 82 | + return "0" | |
| 83 | + rem = value / 10 | |
| 84 | + text = f"{rem:.10f}".rstrip("0").rstrip(".") | |
| 85 | + return f"{text}rem" | |
| 86 | + | |
| 87 | + | |
| 88 | +# ── 변수명 매핑 ─────────────────────────────────────────────────────────────── | |
| 89 | +def name_primitive(path): | |
| 90 | + p = path.split(".") | |
| 91 | + if p[0] == "color": | |
| 92 | + return f"primitive-{p[1]}-{p[2]}" | |
| 93 | + if p[0] == "font": | |
| 94 | + return f"font-{p[1]}-{p[2]}" | |
| 95 | + if p[0] == "number": | |
| 96 | + return f"number-{p[1]}" | |
| 97 | + raise SystemExit(f"primitive 이름 매핑 없음: {path}") | |
| 98 | + | |
| 99 | + | |
| 100 | +def name_theme(path): | |
| 101 | + p = path.split(".") | |
| 102 | + assert p[0] == "color" | |
| 103 | + return f"theme-{p[1]}-{p[2]}" | |
| 104 | + | |
| 105 | + | |
| 106 | +def name_size(path): | |
| 107 | + p = path.split(".") | |
| 108 | + if p[0] == "effect": | |
| 109 | + return f"{p[1]}-{p[2]}" # effect.shadow.blur3 → shadow-blur3 | |
| 110 | + return "-".join(p) # padding.6 → padding-6 | |
| 111 | + | |
| 112 | + | |
| 113 | +CHART_ALT_GROUP = {} | |
| 114 | +for k in [str(i) for i in range(1, 11)]: | |
| 115 | + CHART_ALT_GROUP[k] = f"chart-alt-level-{k}" | |
| 116 | +for k in ("bar", "surface", "line", "disabled"): | |
| 117 | + CHART_ALT_GROUP[k] = f"chart-alt-default-{k}" | |
| 118 | +for i in range(1, 6): | |
| 119 | + for s in ("bar", "line", "surface"): | |
| 120 | + CHART_ALT_GROUP[f"{i}-{s}"] = f"chart-alt-type-{i}-{s}" | |
| 121 | + | |
| 122 | + | |
| 123 | +def name_semantic(path): | |
| 124 | + if path in CHART_ALT_GROUP: | |
| 125 | + return f"color-{CHART_ALT_GROUP[path]}" | |
| 126 | + p = path.split(".") | |
| 127 | + assert p[0] == "color", path | |
| 128 | + return "color-" + "-".join(p[1:]) | |
| 129 | + | |
| 130 | + | |
| 131 | +def name_responsive(path): | |
| 132 | + p = path.split(".") | |
| 133 | + if p[0] == "font" and p[1] == "size": | |
| 134 | + return "font-size-" + "-".join(p[2:]) # font.size.body.md → font-size-body-md | |
| 135 | + return "-".join(p) # form.height.md → form-height-md | |
| 136 | + | |
| 137 | + | |
| 138 | +# ── alias 역참조 테이블 (Figma 변수명 → 우리 CSS 변수명) ────────────────────── | |
| 139 | +alias_lookup = {} # (컬렉션명, "color/neutral/0") → (varname, 원본값) | |
| 140 | +for coll, namer in (("primitive", name_primitive), ("theme", name_theme), ("size", name_size)): | |
| 141 | + for path, node in raw[coll].items(): | |
| 142 | + alias_lookup[(coll, path.replace(".", "/"))] = (namer(path), node["$value"]) | |
| 143 | + | |
| 144 | + | |
| 145 | +def same_value(a, b): | |
| 146 | + if isinstance(a, dict) and isinstance(b, dict): | |
| 147 | + return css_color(a) == css_color(b) | |
| 148 | + return a == b | |
| 149 | + | |
| 150 | + | |
| 151 | +stats = {"var": 0, "literal": 0, "no-alias": 0} | |
| 152 | + | |
| 153 | + | |
| 154 | +def color_expr(node): | |
| 155 | + """alias가 검증되면 var() 참조, 아니면 리터럴 색상.""" | |
| 156 | + ext = node.get("$extensions", {}) | |
| 157 | + alias = ext.get("com.figma.aliasData") | |
| 158 | + if alias: | |
| 159 | + key = (alias["targetVariableSetName"], alias["targetVariableName"]) | |
| 160 | + hit = alias_lookup.get(key) | |
| 161 | + if hit and same_value(hit[1], node["$value"]): | |
| 162 | + stats["var"] += 1 | |
| 163 | + return f"var(--fox-{hit[0]})" | |
| 164 | + stats["literal"] += 1 | |
| 165 | + else: | |
| 166 | + stats["no-alias"] += 1 | |
| 167 | + return css_color(node["$value"]) | |
| 168 | + | |
| 169 | + | |
| 170 | +# ── 맵 생성 ────────────────────────────────────────────────────────────────── | |
| 171 | +def build(entries): | |
| 172 | + return OrderedDict(entries) | |
| 173 | + | |
| 174 | + | |
| 175 | +primitive_map, font_map, number_map = OrderedDict(), OrderedDict(), OrderedDict() | |
| 176 | +for path, node in raw["primitive"].items(): | |
| 177 | + name = name_primitive(path) | |
| 178 | + if name.startswith("primitive-"): | |
| 179 | + primitive_map[name[len("primitive-"):]] = css_color(node["$value"]) | |
| 180 | + elif name.startswith("font-"): | |
| 181 | + v = node["$value"] | |
| 182 | + font_map[name[len("font-"):]] = v if isinstance(v, (str, int)) else css_color(v) | |
| 183 | + else: | |
| 184 | + number_map[name[len("number-"):]] = css_len(node["$value"]) | |
| 185 | + | |
| 186 | +theme_map = OrderedDict( | |
| 187 | + (name_theme(p)[len("theme-"):], css_color(n["$value"])) for p, n in raw["theme"].items() | |
| 188 | +) | |
| 189 | + | |
| 190 | +size_map = OrderedDict((name_size(p), css_len(n["$value"])) for p, n in raw["size"].items()) | |
| 191 | + | |
| 192 | +# 시맨틱 색상: light/dark 를 light-dark() 한 줄로. | |
| 193 | +# 소속이 불분명해 chart-alt로 묶은 29개는 뒤로 보낸다 — 원본 JSON에서는 이들이 루트에 | |
| 194 | +# 먼저 오지만, 순서에 의미가 없고 카탈로그 첫 화면이 주요 색상이어야 읽기 쉽다. | |
| 195 | +color_map = OrderedDict() | |
| 196 | +ordered_light = sorted(raw["light"].items(), key=lambda kv: kv[0] in CHART_ALT_GROUP) | |
| 197 | +for path, lnode in ordered_light: | |
| 198 | + dnode = raw["dark"][path] | |
| 199 | + key = name_semantic(path)[len("color-"):] | |
| 200 | + color_map[key] = f"light-dark({color_expr(lnode)}, {color_expr(dnode)})" | |
| 201 | + | |
| 202 | +# 반응형: 모바일 기본 + pc 오버라이드 | |
| 203 | +resp_mobile = OrderedDict( | |
| 204 | + (name_responsive(p), css_len(n["$value"])) for p, n in raw["mobile"].items() | |
| 205 | +) | |
| 206 | +resp_pc = OrderedDict((name_responsive(p), css_len(n["$value"])) for p, n in raw["pc"].items()) | |
| 207 | +assert list(resp_mobile) == list(resp_pc) | |
| 208 | + | |
| 209 | +BREAKPOINT_PC = "768px" | |
| 210 | + | |
| 211 | + | |
| 212 | +# ── SCSS 출력 ──────────────────────────────────────────────────────────────── | |
| 213 | +def scss_map(name, mapping, indent=" "): | |
| 214 | + lines = [f"${name}: ("] | |
| 215 | + items = list(mapping.items()) | |
| 216 | + for i, (k, v) in enumerate(items): | |
| 217 | + comma = "," if i < len(items) - 1 else "" | |
| 218 | + key = f'"{k}"' if not k.replace("-", "").replace("_", "").isalnum() else k | |
| 219 | + lines.append(f"{indent}{key}: {v}{comma}") | |
| 220 | + lines.append(");") | |
| 221 | + return "\n".join(lines) | |
| 222 | + | |
| 223 | + | |
| 224 | +HEADER = """// ⚠️ 이 파일은 Figma Design Tokens export에서 **자동 생성**되었습니다. | |
| 225 | +// 원본: {src} | |
| 226 | +// 손으로 고치지 말고 원본 JSON을 다시 export한 뒤 변환기를 재실행하세요. | |
| 227 | +""" | |
| 228 | + | |
| 229 | + | |
| 230 | +def write(rel, body): | |
| 231 | + path = os.path.join(OUT, rel) | |
| 232 | + os.makedirs(os.path.dirname(path), exist_ok=True) | |
| 233 | + open(path, "w", encoding="utf-8").write(body) | |
| 234 | + return rel | |
| 235 | + | |
| 236 | + | |
| 237 | +written = [] | |
| 238 | + | |
| 239 | +written.append(write("tokens/_primitive.scss", HEADER.format(src="primitive.tokens.json") + f""" | |
| 240 | +// 원시 팔레트 — 화면에서 직접 쓰지 않는다. 시맨틱 토큰(`$color`)이 참조하는 바닥층이다. | |
| 241 | +{scss_map("primitive", primitive_map)} | |
| 242 | + | |
| 243 | +// 서체 — family는 문자열, weight는 무단위 수치(길이가 아니므로 rem 변환 대상이 아니다). | |
| 244 | +{scss_map("font", font_map)} | |
| 245 | + | |
| 246 | +// 원시 수치 스케일 — size 컬렉션이 참조하는 값들. 1rem = 10px 기준으로 변환됨. | |
| 247 | +{scss_map("number", number_map)} | |
| 248 | +""")) | |
| 249 | + | |
| 250 | +written.append(write("tokens/_theme.scss", HEADER.format(src="theme.tokens.json") + f""" | |
| 251 | +// 브랜드 램프 — primary / secondary / accent. | |
| 252 | +{scss_map("theme", theme_map)} | |
| 253 | +""")) | |
| 254 | + | |
| 255 | +written.append(write("tokens/_size.scss", HEADER.format(src="size.tokens.json") + f""" | |
| 256 | +// 모드와 무관한 크기 스케일. 값은 1rem = 10px 기준 rem. | |
| 257 | +{scss_map("size", size_map)} | |
| 258 | +""")) | |
| 259 | + | |
| 260 | +written.append(write("tokens/_color.scss", HEADER.format(src="light.tokens.json / dark.tokens.json") + f""" | |
| 261 | +// 시맨틱 색상 — 라이트/다크 값을 `light-dark()` 한 줄에 함께 보유한다. | |
| 262 | +// 전환은 `_root.scss`의 `color-scheme`이 담당하므로 다크 전용 블록이 없고, | |
| 263 | +// 따라서 한쪽만 고쳐서 생기는 테마 불일치가 구조적으로 불가능하다. | |
| 264 | +// | |
| 265 | +// 값이 `var(--fox-primitive-*)` / `var(--fox-theme-*)`인 항목은 Figma의 alias를 | |
| 266 | +// 그대로 옮긴 것이다 — 바닥층을 바꾸면 여기까지 전파된다. | |
| 267 | +{scss_map("color", color_map)} | |
| 268 | +""")) | |
| 269 | + | |
| 270 | +written.append(write("tokens/_responsive.scss", HEADER.format(src="mobile.tokens.json / pc.tokens.json") + f""" | |
| 271 | +// 뷰포트에 따라 달라지는 크기 토큰. 키는 두 모드가 완전히 동일하다. | |
| 272 | +// 모바일 우선 — `$responsive`가 기본값이고 `$responsive-pc`가 브레이크포인트 이상에서 덮는다. | |
| 273 | +$breakpoint-pc: {BREAKPOINT_PC}; | |
| 274 | + | |
| 275 | +{scss_map("responsive", resp_mobile)} | |
| 276 | + | |
| 277 | +{scss_map("responsive-pc", resp_pc)} | |
| 278 | +""")) | |
| 279 | + | |
| 280 | +written.append(write("tokens/_index.scss", """// 토큰 map 모음 — **CSS를 한 줄도 출력하지 않는다.** | |
| 281 | +// 값 선언(이 폴더)과 CSS 출력(`../_root.scss`)을 분리해 두었기 때문에, 컴포넌트의 | |
| 282 | +// `.module.scss`가 몇 개든 `abstracts`를 `@use`해도 `:root` 블록이 중복 출력되지 않는다 | |
| 283 | +// (각 CSS Module은 별도 컴파일 단위라 CSS를 내보내는 모듈을 공유하면 파일마다 복제된다). | |
| 284 | + | |
| 285 | +@forward "primitive"; | |
| 286 | +@forward "theme"; | |
| 287 | +@forward "size"; | |
| 288 | +@forward "color"; | |
| 289 | +@forward "responsive"; | |
| 290 | +""")) | |
| 291 | + | |
| 292 | +# _root.scss | |
| 293 | +root_lines = [] | |
| 294 | +root_lines.append("""// 토큰 CSS 출력 — `tokens/`의 SCSS map을 `--fox-*` 커스텀 프로퍼티로 선언한다. | |
| 295 | +// | |
| 296 | +// **앱 전체에서 딱 한 번만 로드되어야 한다** (`index.scss` 경유). | |
| 297 | +// 컴포넌트의 `.module.scss`에서는 절대 `@use` 하지 않는다. | |
| 298 | +// | |
| 299 | +// map을 순회해 생성하므로 토큰을 추가할 때 이 파일은 손대지 않는다. | |
| 300 | + | |
| 301 | +@use "sass:map"; | |
| 302 | +@use "tokens"; | |
| 303 | + | |
| 304 | +:root { | |
| 305 | + // 기본은 OS 설정 추종. 아래 `[data-theme]` 규칙이 이 값만 덮어써 수동 선택을 | |
| 306 | + // 처리하므로, 색상 값 자체는 `tokens/_color.scss` 한 곳에만 존재한다. | |
| 307 | + color-scheme: light dark; | |
| 308 | +""") | |
| 309 | +for var_prefix, map_name in ( | |
| 310 | + ("primitive", "primitive"), | |
| 311 | + ("font", "font"), | |
| 312 | + ("number", "number"), | |
| 313 | + ("theme", "theme"), | |
| 314 | +): | |
| 315 | + root_lines.append(f""" @each $name, $value in tokens.${map_name} {{ | |
| 316 | + --fox-{var_prefix}-#{{$name}}: #{{$value}}; | |
| 317 | + }} | |
| 318 | +""") | |
| 319 | +root_lines.append(""" // size / color / responsive 는 이름에 그룹이 이미 들어 있어 접두사가 `--fox-` 뿐이다. | |
| 320 | + @each $name, $value in tokens.$size { | |
| 321 | + --fox-#{$name}: #{$value}; | |
| 322 | + } | |
| 323 | + | |
| 324 | + @each $name, $value in tokens.$color { | |
| 325 | + --fox-color-#{$name}: #{$value}; | |
| 326 | + } | |
| 327 | + | |
| 328 | + // 모바일 우선 기본값. | |
| 329 | + @each $name, $value in tokens.$responsive { | |
| 330 | + --fox-#{$name}: #{$value}; | |
| 331 | + } | |
| 332 | +} | |
| 333 | + | |
| 334 | +// PC 모드 — 반응형 토큰만 덮어쓴다. 색상은 뷰포트와 무관하므로 여기 오지 않는다. | |
| 335 | +@media (min-width: tokens.$breakpoint-pc) { | |
| 336 | + :root { | |
| 337 | + @each $name, $value in tokens.$responsive-pc { | |
| 338 | + --fox-#{$name}: #{$value}; | |
| 339 | + } | |
| 340 | + } | |
| 341 | +} | |
| 342 | + | |
| 343 | +// 수동 테마 선택 — `color-scheme`만 고정하면 `light-dark()`가 해당 분기로 확정된다. | |
| 344 | +// 속성이 없거나 `"system"`이면 위 `:root`의 `light dark`가 유지되어 OS를 따른다. | |
| 345 | +:root[data-theme="light"] { | |
| 346 | + color-scheme: light; | |
| 347 | +} | |
| 348 | + | |
| 349 | +:root[data-theme="dark"] { | |
| 350 | + color-scheme: dark; | |
| 351 | +} | |
| 352 | + | |
| 353 | +// ⚠️ 1rem = 10px 규약의 근거. 브라우저 기본 글자 크기(16px)의 62.5%가 10px다. | |
| 354 | +// 모든 토큰의 rem 값이 이 선언을 전제로 계산돼 있다. | |
| 355 | +// 미디어 쿼리 안의 rem은 이 값의 영향을 받지 않는다(항상 브라우저 기본 크기 기준). | |
| 356 | +html { | |
| 357 | + font-size: 62.5%; | |
| 358 | +} | |
| 359 | +""") | |
| 360 | +written.append(write("_root.scss", "\n".join(root_lines))) | |
| 361 | + | |
| 362 | +# _functions.scss | |
| 363 | +GROUPS = [ | |
| 364 | + # (함수명, 대상 map, 키 접두사(없으면 None), CSS 변수 접두사) | |
| 365 | + ("color", "color", None, "color-"), | |
| 366 | + ("primitive", "primitive", None, "primitive-"), | |
| 367 | + ("theme", "theme", None, "theme-"), | |
| 368 | + ("number", "number", None, "number-"), | |
| 369 | + ("font-family", "font", "family", "font-"), | |
| 370 | + ("font-weight", "font", "weight", "font-"), | |
| 371 | + ("border", "size", "border", ""), | |
| 372 | + ("padding", "size", "padding", ""), | |
| 373 | + ("gap", "size", "gap", ""), | |
| 374 | + ("radius", "size", "radius", ""), | |
| 375 | + ("icon", "size", "icon", ""), | |
| 376 | + ("shadow", "size", "shadow", ""), | |
| 377 | + ("backdrop", "size", "backdrop", ""), | |
| 378 | + ("font-size", "responsive", "font-size", ""), | |
| 379 | + ("form", "responsive", "form", ""), | |
| 380 | + ("grid", "responsive", "grid", ""), | |
| 381 | + ("card", "responsive", "card", ""), | |
| 382 | + ("modal", "responsive", "modal", ""), | |
| 383 | + ("spacing", "responsive", "spacing", ""), | |
| 384 | + ("section", "responsive", "section", ""), | |
| 385 | +] | |
| 386 | + | |
| 387 | +fn_lines = ["""// 토큰 접근 함수 — 디자인 시스템 규율을 **컴파일러가 강제**하는 지점. | |
| 388 | +// | |
| 389 | +// 화면 코드는 `color: #256EF4`나 `padding: 13px`를 쓸 수 없고, 반드시 | |
| 390 | +// `color: fox.color(font-neutral-strong)` / `padding: fox.padding(6)`로 토큰을 | |
| 391 | +// 지목해야 한다. 존재하지 않는 이름을 쓰면 `@error`로 **빌드가 실패**하고 사용 | |
| 392 | +// 가능한 토큰 목록이 함께 출력된다. | |
| 393 | +// | |
| 394 | +// 반환값은 CSS 커스텀 프로퍼티 참조(`var(--fox-*)`)다. 값이 아니라 참조를 돌려주므로 | |
| 395 | +// (1) 라이트/다크와 pc/모바일이 런타임에 전환되고 (2) 특정 영역만 토큰을 덮어쓰는 | |
| 396 | +// 스코프 오버라이드가 가능하며 (3) devtools에서 어떤 토큰인지 그대로 보인다. | |
| 397 | + | |
| 398 | +@use "sass:map"; | |
| 399 | +@use "tokens"; | |
| 400 | + | |
| 401 | +/// 토큰 이름을 검증하고 커스텀 프로퍼티 참조를 반환한다. | |
| 402 | +@function _token($group, $key, $registry, $var-name) { | |
| 403 | + @if not map.has-key($registry, $key) { | |
| 404 | + @error "[@fox] 알 수 없는 #{$group} 토큰: `#{$key}` — 사용 가능한 값: #{map.keys($registry)}"; | |
| 405 | + } | |
| 406 | + | |
| 407 | + @return var(--fox-#{$var-name}); | |
| 408 | +} | |
| 409 | +"""] | |
| 410 | +for fn, mapname, prefix, varprefix in GROUPS: | |
| 411 | + if prefix is None: | |
| 412 | + fn_lines.append(f""" | |
| 413 | +@function {fn}($name) {{ | |
| 414 | + @return _token("{fn}", $name, tokens.${mapname}, "{varprefix}#{{$name}}"); | |
| 415 | +}}""") | |
| 416 | + else: | |
| 417 | + fn_lines.append(f""" | |
| 418 | +@function {fn}($name) {{ | |
| 419 | + @return _token("{fn}", "{prefix}-#{{$name}}", tokens.${mapname}, "{varprefix}{prefix}-#{{$name}}"); | |
| 420 | +}}""") | |
| 421 | +written.append(write("_functions.scss", "\n".join(fn_lines) + "\n")) | |
| 422 | + | |
| 423 | +written.append(write("_mixins.scss", f"""// 믹스인 — 여러 선언이 항상 함께 가야 하는 패턴을 묶는다. | |
| 424 | + | |
| 425 | +@use "tokens"; | |
| 426 | + | |
| 427 | +/// PC 이상(>= {BREAKPOINT_PC})에서 적용. 모바일 우선이므로 기본 스타일은 밖에 쓴다. | |
| 428 | +/// | |
| 429 | +/// ⚠️ 미디어 쿼리 조건부는 `var()`를 해석하지 못하므로 브레이크포인트만은 CSS 변수가 | |
| 430 | +/// 아닌 컴파일타임 값이다. 그래서 이 믹스인 경유가 강제된다. | |
| 431 | +@mixin pc {{ | |
| 432 | + @media (min-width: tokens.$breakpoint-pc) {{ | |
| 433 | + @content; | |
| 434 | + }} | |
| 435 | +}} | |
| 436 | + | |
| 437 | +/// PC 미만에서만 적용. `pc`와 경계가 겹치지 않도록 0.02px을 뺀다. | |
| 438 | +@mixin mobile {{ | |
| 439 | + @media (max-width: tokens.$breakpoint-pc - 0.02px) {{ | |
| 440 | + @content; | |
| 441 | + }} | |
| 442 | +}} | |
| 443 | +""")) | |
| 444 | + | |
| 445 | +written.append(write("abstracts.scss", """// 저작(authoring) 진입점 — 컴포넌트의 `.module.scss`가 `@use`하는 파일. | |
| 446 | +// | |
| 447 | +// @use "@fox/styles/abstracts" as fox; | |
| 448 | +// | |
| 449 | +// .button { | |
| 450 | +// padding: fox.padding(5) fox.padding(7); | |
| 451 | +// background: fox.color(button-primary-surface); | |
| 452 | +// border-radius: fox.radius(3); | |
| 453 | +// font-size: fox.font-size(label-md); | |
| 454 | +// } | |
| 455 | +// | |
| 456 | +// ⚠️ **이 파일은 CSS를 한 줄도 출력하지 않는다.** 함수·믹스인·map 정의만 있다. | |
| 457 | +// 실제 CSS 출력(토큰 선언)은 앱이 딱 한 번 import하는 `index.scss`가 전담한다. | |
| 458 | + | |
| 459 | +@forward "tokens"; | |
| 460 | +@forward "functions"; | |
| 461 | +@forward "mixins"; | |
| 462 | +""")) | |
| 463 | + | |
| 464 | +written.append(write("index.scss", """// 글로벌 스타일 진입점 — **호스트 앱이 딱 한 번 import한다.** | |
| 465 | +// | |
| 466 | +// // app/globals.scss | |
| 467 | +// @use "@fox/styles"; | |
| 468 | +// | |
| 469 | +// 여기서만 CSS가 출력된다. 컴포넌트의 `.module.scss`는 이 파일이 아니라 | |
| 470 | +// `abstracts.scss`를 `@use`해야 한다 — 그쪽은 CSS 출력이 0이라 몇 번을 참조해도 | |
| 471 | +// 중복이 생기지 않는다. | |
| 472 | + | |
| 473 | +@use "root"; | |
| 474 | +""")) | |
| 475 | + | |
| 476 | +print("생성된 파일:") | |
| 477 | +for w in written: | |
| 478 | + print(" ", w) | |
| 479 | +print() | |
| 480 | +print(f"토큰 수 — primitive {len(primitive_map)} / font {len(font_map)} / number {len(number_map)}" | |
| 481 | + f" / theme {len(theme_map)} / size {len(size_map)} / color {len(color_map)}" | |
| 482 | + f" / responsive {len(resp_mobile)}") | |
| 483 | +print(f"색상 표현 — var() 참조 {stats['var']} · 리터럴(참조 검증 실패) {stats['literal']}" | |
| 484 | + f" · alias 없음 {stats['no-alias']}") | |
| 485 | +total = len(primitive_map) + len(font_map) + len(number_map) + len(theme_map) + len(size_map) + len(color_map) + len(resp_mobile) | |
| 486 | +print(f"총 CSS 커스텀 프로퍼티: {total}") |
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { revalidatePath } from 'next/cache';
|
| 4 | 4 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 |
+import { BackendRequestError } from '@/lib/http/backend-fetch';
|
|
| 5 | 6 |
import {
|
| 6 | 7 |
createAdminMember, |
| 7 | 8 |
deleteAdminMember, |
... | ... | @@ -15,7 +16,7 @@ |
| 15 | 16 |
validateAdminMemberCreate, |
| 16 | 17 |
validateAdminMemberUpdate, |
| 17 | 18 |
type AdminMemberEditableValues, |
| 18 |
- type AdminMemberFormErrors, |
|
| 19 |
+ type AdminMemberFormState, |
|
| 19 | 20 |
} from '@/lib/domain/admin-member-form'; |
| 20 | 21 |
import { ADMIN_MEMBERS_PATH } from '@/lib/domain/admin-member-query';
|
| 21 | 22 |
|
... | ... | @@ -28,19 +29,14 @@ |
| 28 | 29 |
* 검증은 화면이 아니라 여기서 확정한다(`lib/domain/admin-member-form.ts`의 규칙을 호출) — |
| 29 | 30 |
* 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다. |
| 30 | 31 |
* |
| 31 |
- * 실제 저장은 Repository에 맡긴다. 백엔드에 등록/수정/삭제 API가 아직 없어 Repository가 mock |
|
| 32 |
- * 저장소로 위임하고 있지만, **이 파일은 그 사실을 알지 못한다** — 백엔드 API가 생겨도 이 파일은 |
|
| 33 |
- * 바뀌지 않는다. |
|
| 32 |
+ * 실제 저장은 Repository에 맡긴다 — 어느 항목이 백엔드로 가고 어느 것이 mock인지는 이 파일이 |
|
| 33 |
+ * 알지 못한다(현재 삭제만 mock이다). |
|
| 34 |
+ * |
|
| 35 |
+ * `AdminMemberFormState` 타입과 그 초깃값(`INITIAL_ADMIN_MEMBER_FORM_STATE`)은 이 파일이 아니라 |
|
| 36 |
+ * `lib/domain/admin-member-form.ts`에 있다 — Next.js가 `'use server'` 파일에서 함수가 아닌 값을 |
|
| 37 |
+ * export하는 것을 런타임에 거부하기 때문이다(해당 타입 주석 참조). 타입만 이 파일에서 다시 쓰는 |
|
| 38 |
+ * 것은 문제 없다 — 타입은 컴파일 시 지워져 런타임 export로 남지 않는다. |
|
| 34 | 39 |
*/ |
| 35 |
- |
|
| 36 |
-export type AdminMemberFormState = |
|
| 37 |
- | { status: 'idle' }
|
|
| 38 |
- | { status: 'error'; message?: string; errors?: AdminMemberFormErrors }
|
|
| 39 |
- | { status: 'success' };
|
|
| 40 |
- |
|
| 41 |
-export const INITIAL_ADMIN_MEMBER_FORM_STATE: AdminMemberFormState = {
|
|
| 42 |
- status: 'idle', |
|
| 43 |
-}; |
|
| 44 | 40 |
|
| 45 | 41 |
const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; |
| 46 | 42 |
const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.'; |
... | ... | @@ -81,6 +77,27 @@ |
| 81 | 77 |
}; |
| 82 | 78 |
} |
| 83 | 79 |
|
| 80 |
+/** |
|
| 81 |
+ * 쓰기 호출의 백엔드 실패를 폼 상태로 바꾼다. 실패하면 그 상태를, 성공하면 null을 돌려준다. |
|
| 82 |
+ * |
|
| 83 |
+ * 백엔드가 주는 문구는 그대로 보여준다 — 등록 시 아이디 선점처럼 사용자가 조치할 수 있는 사유가 |
|
| 84 |
+ * 이 경로로 온다("이미 등록된 아이디 입니다"). 통신 오류·타임아웃은 `backend-fetch`가 이미
|
|
| 85 |
+ * 일반화된 문구로 바꿔 두므로 내부 사정이 새어 나가지 않는다. |
|
| 86 |
+ */ |
|
| 87 |
+async function runWrite( |
|
| 88 |
+ write: () => Promise<void> |
|
| 89 |
+): Promise<AdminMemberFormState | null> {
|
|
| 90 |
+ try {
|
|
| 91 |
+ await write(); |
|
| 92 |
+ return null; |
|
| 93 |
+ } catch (error) {
|
|
| 94 |
+ if (error instanceof BackendRequestError) {
|
|
| 95 |
+ return { status: 'error', message: error.message };
|
|
| 96 |
+ } |
|
| 97 |
+ throw error; |
|
| 98 |
+ } |
|
| 99 |
+} |
|
| 100 |
+ |
|
| 84 | 101 |
/** 시안 ADM_ADM_102_p — 관리자 등록. */ |
| 85 | 102 |
export async function createAdminMemberAction( |
| 86 | 103 |
_prevState: AdminMemberFormState, |
... | ... | @@ -98,7 +115,7 @@ |
| 98 | 115 |
return { status: 'error', errors: validation.errors };
|
| 99 | 116 |
} |
| 100 | 117 |
|
| 101 |
- const { name, loginId, phoneNumber, email, roleCode, menuCodes } =
|
|
| 118 |
+ const { name, loginId, password, phoneNumber, email, roleCode } =
|
|
| 102 | 119 |
validation.values; |
| 103 | 120 |
|
| 104 | 121 |
// 화면의 [중복 확인]은 편의 기능일 뿐이라 저장 직전에 다시 확인한다 — 확인을 누르지 않고 |
... | ... | @@ -107,16 +124,13 @@ |
| 107 | 124 |
return { status: 'error', errors: { loginId: DUPLICATE_LOGIN_ID_MESSAGE } };
|
| 108 | 125 |
} |
| 109 | 126 |
|
| 110 |
- // 비밀번호는 형식만 검증하고 값은 넘기지 않는다 — 저장할 백엔드 API가 아직 없고, mock |
|
| 111 |
- // 저장소에 평문 비밀번호를 보관하지 않기 때문이다(`admin-member-store.ts` 주석 참조). |
|
| 112 |
- await createAdminMember({
|
|
| 113 |
- name, |
|
| 114 |
- loginId, |
|
| 115 |
- phoneNumber, |
|
| 116 |
- email, |
|
| 117 |
- roleCode, |
|
| 118 |
- menuCodes, |
|
| 119 |
- }); |
|
| 127 |
+ // 메뉴 선택은 넘기지 않는다 — 백엔드에 저장할 곳이 없다(Repository 주석 참조). |
|
| 128 |
+ const failure = await runWrite(() => |
|
| 129 |
+ createAdminMember({ name, loginId, password, phoneNumber, email, roleCode })
|
|
| 130 |
+ ); |
|
| 131 |
+ if (failure) {
|
|
| 132 |
+ return failure; |
|
| 133 |
+ } |
|
| 120 | 134 |
|
| 121 | 135 |
revalidatePath(ADMIN_MEMBERS_PATH); |
| 122 | 136 |
return { status: 'success' };
|
... | ... | @@ -139,16 +153,21 @@ |
| 139 | 153 |
return { status: 'error', errors: validation.errors };
|
| 140 | 154 |
} |
| 141 | 155 |
|
| 142 |
- const { phoneNumber, email, roleCode, menuCodes } = validation.values;
|
|
| 156 |
+ const { password, phoneNumber, email, roleCode } = validation.values;
|
|
| 143 | 157 |
|
| 144 |
- await updateAdminMember(id, {
|
|
| 145 |
- phoneNumber, |
|
| 146 |
- email, |
|
| 147 |
- roleCode, |
|
| 148 |
- menuCodes, |
|
| 149 |
- // 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다. |
|
| 150 |
- isLocked: readString(formData, 'isLocked') === 'true', |
|
| 151 |
- }); |
|
| 158 |
+ const failure = await runWrite(() => |
|
| 159 |
+ updateAdminMember(id, {
|
|
| 160 |
+ password, |
|
| 161 |
+ phoneNumber, |
|
| 162 |
+ email, |
|
| 163 |
+ roleCode, |
|
| 164 |
+ // 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다. |
|
| 165 |
+ isLocked: readString(formData, 'isLocked') === 'true', |
|
| 166 |
+ }) |
|
| 167 |
+ ); |
|
| 168 |
+ if (failure) {
|
|
| 169 |
+ return failure; |
|
| 170 |
+ } |
|
| 152 | 171 |
|
| 153 | 172 |
revalidatePath(ADMIN_MEMBERS_PATH); |
| 154 | 173 |
return { status: 'success' };
|
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
... | ... | @@ -9,9 +9,9 @@ |
| 9 | 9 |
import {
|
| 10 | 10 |
ADMIN_LOGIN_ID_HELP_TEXT, |
| 11 | 11 |
ADMIN_PASSWORD_HELP_TEXT, |
| 12 |
+ INITIAL_ADMIN_MEMBER_FORM_STATE, |
|
| 12 | 13 |
} from '@/lib/domain/admin-member-form'; |
| 13 | 14 |
import {
|
| 14 |
- INITIAL_ADMIN_MEMBER_FORM_STATE, |
|
| 15 | 15 |
checkAdminLoginId, |
| 16 | 16 |
createAdminMemberAction, |
| 17 | 17 |
type LoginIdCheckResult, |
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
... | ... | @@ -11,11 +11,11 @@ |
| 11 | 11 |
ADMIN_LOCK_STATUS_OPTIONS, |
| 12 | 12 |
type AdminMember, |
| 13 | 13 |
} from '@/lib/domain/admin-member'; |
| 14 |
-import { ADMIN_PASSWORD_HELP_TEXT } from '@/lib/domain/admin-member-form';
|
|
| 15 | 14 |
import {
|
| 15 |
+ ADMIN_PASSWORD_HELP_TEXT, |
|
| 16 | 16 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
| 17 |
- updateAdminMemberAction, |
|
| 18 |
-} from '../_actions'; |
|
| 17 |
+} from '@/lib/domain/admin-member-form'; |
|
| 18 |
+import { updateAdminMemberAction } from '../_actions';
|
|
| 19 | 19 |
import {
|
| 20 | 20 |
AdminMemberFormFields, |
| 21 | 21 |
FieldError, |
... | ... | @@ -34,9 +34,13 @@ |
| 34 | 34 |
* 이름·ID는 읽기 전용이다(시안 ①). 다만 화면의 readOnly는 표시일 뿐이라 Server Action은 두 값을 |
| 35 | 35 |
* 아예 반영하지 않는다 — 직접 POST로 다른 값을 보내도 바뀌지 않는다. |
| 36 | 36 |
* |
| 37 |
- * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 비워 두면 기존 비밀번호를 |
|
| 38 |
- * 유지한다는 뜻이라 Server Action도 그때는 형식 검증을 건너뛴다. 지금은 저장할 백엔드 API가 없어 |
|
| 39 |
- * 입력값이 검증 후 버려진다 — 흐름만 완성해 둔 상태다. |
|
| 37 |
+ * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 다만 비워 둔 채로는 수정이 |
|
| 38 |
+ * 저장되지 않는다 — 백엔드 UPDATE가 비밀번호를 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 |
|
| 39 |
+ * 불가가 되기 때문이다(`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석). 비활성 input은 FormData에 |
|
| 40 |
+ * 실리지 않으므로 버튼을 누르지 않고 제출하면 그 안내가 그대로 뜬다. |
|
| 41 |
+ * |
|
| 42 |
+ * 휴대전화번호·이메일은 항상 빈 칸으로 시작한다 — 백엔드가 두 값을 되돌려주지 않아 기존 값을 |
|
| 43 |
+ * 채울 수단이 없다(Repository 주석). 입력한 값으로 덮어쓰는 동작이다. |
|
| 40 | 44 |
* |
| 41 | 45 |
* "잠김여부"는 활성/비활성으로 표기되지만 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가 |
| 42 | 46 |
* 뒤집혀 있다. 그 변환은 도메인(`ADMIN_LOCK_STATUS_OPTIONS`)이 한 곳에서 책임진다. |
--- app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
... | ... | @@ -27,6 +27,11 @@ |
| 27 | 27 |
* |
| 28 | 28 |
* 휴대전화 번호는 시안대로 3칸으로 나눠 받고 Server Action이 하나로 합친다 |
| 29 | 29 |
* (`joinPhoneNumber`) — 칸을 나누는 것은 화면 표현이고, 저장 형식은 도메인이 정한다. |
| 30 |
+ * |
|
| 31 |
+ * ⚠️ **메뉴 선택은 아직 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한 개념이 없어 보낼 곳이 |
|
| 32 |
+ * 없다(등록·수정 VO에 필드가 없고 `/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리와 |
|
| 33 |
+ * 필수 검증은 유지하되 값은 Repository까지 가지 않는다 — 권한 API가 생기면 여기는 그대로 두고 |
|
| 34 |
+ * Server Action이 값을 넘기기만 하면 된다. |
|
| 30 | 35 |
*/ |
| 31 | 36 |
export function AdminMemberFormFields({
|
| 32 | 37 |
member, |
+++ app/(protected)/(basic)/decoration-items/_actions.ts
... | ... | @@ -0,0 +1,214 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { verifySession } from '@/lib/auth/dal'; | |
| 5 | +import { | |
| 6 | + createDecorationItem, | |
| 7 | + deleteDecorationItem, | |
| 8 | + updateDecorationItem, | |
| 9 | + uploadDecorationItemImage, | |
| 10 | +} from '@/lib/data/repositories/decoration-item-repository'; | |
| 11 | +import type { DecorationItemType } from '@/lib/domain/decoration-item'; | |
| 12 | +import { | |
| 13 | + validateDecorationItemCreate, | |
| 14 | + validateDecorationItemUpdate, | |
| 15 | + type DecorationItemEditableValues, | |
| 16 | + type DecorationItemFormState, | |
| 17 | +} from '@/lib/domain/decoration-item-form'; | |
| 18 | +import { DECORATION_ITEMS_PATH } from '@/lib/domain/decoration-item-query'; | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * 꾸미기 아이템 등록/수정/삭제 Server Action. | |
| 22 | + * | |
| 23 | + * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접 | |
| 24 | + * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §3 SRP 체크). | |
| 25 | + * | |
| 26 | + * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/decoration-item-form.ts`의 규칙을 호출) — | |
| 27 | + * 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다. | |
| 28 | + * | |
| 29 | + * **이미지 업로드도 이 계층을 지난다** — 브라우저는 백엔드를 직접 호출하지 않으므로(토큰이 | |
| 30 | + * httpOnly 세션 안에만 있다) 폼이 실어 보낸 File을 여기서 받아 Repository를 통해 백엔드에 | |
| 31 | + * 중계하고, 돌려받은 파일 ID를 그대로 아이템 저장에 쓴다. | |
| 32 | + * | |
| 33 | + * `DecorationItemFormState` 타입과 그 초깃값은 이 파일이 아니라 domain 계층에 있다 — Next.js가 | |
| 34 | + * `'use server'` 파일에서 함수가 아닌 값을 export하는 것을 런타임에 거부하기 때문이다 | |
| 35 | + * (`decoration-item-form.ts`의 해당 타입 주석 참조). | |
| 36 | + */ | |
| 37 | + | |
| 38 | +const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; | |
| 39 | +const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 40 | +const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 41 | +const IMAGE_UPLOAD_FAILED_MESSAGE = '이미지를 업로드하지 못했습니다.'; | |
| 42 | + | |
| 43 | +function readString(formData: FormData, key: string): string { | |
| 44 | + const value = formData.get(key); | |
| 45 | + return typeof value === 'string' ? value : ''; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * 폼 숫자 입력을 파싱한다. `Number('')`이 조용히 `0`이 되는 JS 함정을 피하려고 빈 문자열은 | |
| 50 | + * 명시적으로 `NaN`으로 취급한다 — 그래야 `Number.isInteger` 검증이 "값이 비었음"을 정상적으로 | |
| 51 | + * 잡아낸다(포인트 0은 유효한 값이라 "비어서 0"과 "실제로 0"을 구분해야 한다). | |
| 52 | + */ | |
| 53 | +function parseFormNumber(formData: FormData, key: string): number { | |
| 54 | + const trimmed = readString(formData, key).trim(); | |
| 55 | + return trimmed === '' ? NaN : Number(trimmed); | |
| 56 | +} | |
| 57 | + | |
| 58 | +function parseItemSn(formData: FormData): number | null { | |
| 59 | + const value = parseFormNumber(formData, 'itemSn'); | |
| 60 | + return Number.isInteger(value) && value > 0 ? value : null; | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** | |
| 64 | + * 새로 선택한 파일과 폼이 유지하고 있던 기존 파일 ID를 함께 읽는다. 등록 화면에는 기존 ID가 | |
| 65 | + * 없으므로 파일을 고르지 않으면 둘 다 비고, 그 상태는 도메인 검증(`imageFileId` 필수)이 잡는다. | |
| 66 | + */ | |
| 67 | +function readImageInput(formData: FormData): { | |
| 68 | + newFile: File | null; | |
| 69 | + currentImageFileId: string; | |
| 70 | +} { | |
| 71 | + const file = formData.get('imageFile'); | |
| 72 | + return { | |
| 73 | + newFile: file instanceof File && file.size > 0 ? file : null, | |
| 74 | + currentImageFileId: readString(formData, 'imageFileId').trim(), | |
| 75 | + }; | |
| 76 | +} | |
| 77 | + | |
| 78 | +/** | |
| 79 | + * 업로드 전 검증 단계에서만 쓰는 자리표시자 — "새 파일이 선택됐으므로 이미지 필수 조건은 | |
| 80 | + * 충족될 예정"이라는 뜻이다. 실제 저장 값으로는 절대 쓰이지 않는다(업로드 성공 후 진짜 파일 | |
| 81 | + * ID로 교체된다). | |
| 82 | + * | |
| 83 | + * 검증을 업로드보다 먼저 하기 위해 필요하다 — 순서를 반대로 하면 다른 항목이 잘못됐을 때 | |
| 84 | + * 파일만 백엔드에 올라간 채 저장은 실패해 고아 파일이 쌓인다(파일 삭제 API를 부를 방법도 없다). | |
| 85 | + */ | |
| 86 | +const PENDING_UPLOAD_PLACEHOLDER = 'pending-upload'; | |
| 87 | + | |
| 88 | +/** | |
| 89 | + * 등록·수정이 공유하는 입력 항목을 읽는다. 유형·카테고리는 select/radio 값을 그대로 읽고, | |
| 90 | + * 허용 목록을 벗어난 값(위조된 요청 포함)은 뒤이은 검증이 걸러낸다 — 여기서 하는 캐스팅은 타입을 | |
| 91 | + * 맞추는 것일 뿐 신뢰를 부여하지 않는다. | |
| 92 | + */ | |
| 93 | +function readEditableValues( | |
| 94 | + formData: FormData, | |
| 95 | + imageFileId: string | |
| 96 | +): DecorationItemEditableValues { | |
| 97 | + return { | |
| 98 | + itemType: readString(formData, 'itemType') as DecorationItemType, | |
| 99 | + name: readString(formData, 'name'), | |
| 100 | + categoryCode: readString(formData, 'categoryCode'), | |
| 101 | + points: parseFormNumber(formData, 'points'), | |
| 102 | + description: readString(formData, 'description'), | |
| 103 | + isActive: readString(formData, 'isActive') === 'true', | |
| 104 | + sortOrder: parseFormNumber(formData, 'sortOrder'), | |
| 105 | + imageFileId, | |
| 106 | + }; | |
| 107 | +} | |
| 108 | + | |
| 109 | +/** 시안 ADM_ITM_102_p — 꾸미기 아이템 등록. 아이템ID(itemSn)는 백엔드가 자동 채번한다. */ | |
| 110 | +export async function createDecorationItemAction( | |
| 111 | + _prevState: DecorationItemFormState, | |
| 112 | + formData: FormData | |
| 113 | +): Promise<DecorationItemFormState> { | |
| 114 | + await verifySession(); | |
| 115 | + | |
| 116 | + const { newFile, currentImageFileId } = readImageInput(formData); | |
| 117 | + | |
| 118 | + const validation = validateDecorationItemCreate( | |
| 119 | + readEditableValues( | |
| 120 | + formData, | |
| 121 | + newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId | |
| 122 | + ) | |
| 123 | + ); | |
| 124 | + if (!validation.ok) { | |
| 125 | + return { status: 'error', errors: validation.errors }; | |
| 126 | + } | |
| 127 | + | |
| 128 | + let imageFileId = currentImageFileId; | |
| 129 | + if (newFile) { | |
| 130 | + try { | |
| 131 | + imageFileId = await uploadDecorationItemImage(newFile); | |
| 132 | + } catch { | |
| 133 | + return { status: 'error', message: IMAGE_UPLOAD_FAILED_MESSAGE }; | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + try { | |
| 138 | + await createDecorationItem({ ...validation.values, imageFileId }); | |
| 139 | + } catch { | |
| 140 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 141 | + } | |
| 142 | + | |
| 143 | + revalidatePath(DECORATION_ITEMS_PATH); | |
| 144 | + return { status: 'success' }; | |
| 145 | +} | |
| 146 | + | |
| 147 | +/** 시안 ADM_ITM_103_p — 꾸미기 아이템 수정. 아이템ID는 읽기 전용이라 변경 대상이 아니다. */ | |
| 148 | +export async function updateDecorationItemAction( | |
| 149 | + _prevState: DecorationItemFormState, | |
| 150 | + formData: FormData | |
| 151 | +): Promise<DecorationItemFormState> { | |
| 152 | + await verifySession(); | |
| 153 | + | |
| 154 | + const itemSn = parseItemSn(formData); | |
| 155 | + if (itemSn === null) { | |
| 156 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 157 | + } | |
| 158 | + | |
| 159 | + const { newFile, currentImageFileId } = readImageInput(formData); | |
| 160 | + | |
| 161 | + const validation = validateDecorationItemUpdate( | |
| 162 | + readEditableValues( | |
| 163 | + formData, | |
| 164 | + newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId | |
| 165 | + ) | |
| 166 | + ); | |
| 167 | + if (!validation.ok) { | |
| 168 | + return { status: 'error', errors: validation.errors }; | |
| 169 | + } | |
| 170 | + | |
| 171 | + let imageFileId = currentImageFileId; | |
| 172 | + if (newFile) { | |
| 173 | + try { | |
| 174 | + imageFileId = await uploadDecorationItemImage(newFile); | |
| 175 | + } catch { | |
| 176 | + return { status: 'error', message: IMAGE_UPLOAD_FAILED_MESSAGE }; | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + try { | |
| 181 | + await updateDecorationItem(itemSn, { ...validation.values, imageFileId }); | |
| 182 | + } catch { | |
| 183 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 184 | + } | |
| 185 | + | |
| 186 | + revalidatePath(DECORATION_ITEMS_PATH); | |
| 187 | + return { status: 'success' }; | |
| 188 | +} | |
| 189 | + | |
| 190 | +/** | |
| 191 | + * 시안 ADM_ITM_101 — 삭제. 확인 얼럿은 화면(`decoration-item-row-actions.tsx`)이 띄우고, | |
| 192 | + * 여기서는 인증과 입력만 확인한다. | |
| 193 | + * | |
| 194 | + * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 | |
| 195 | + * (prevState, formData) 규약 대신 식별자를 직접 받는다. | |
| 196 | + */ | |
| 197 | +export async function deleteDecorationItemAction( | |
| 198 | + itemSn: number | |
| 199 | +): Promise<DecorationItemFormState> { | |
| 200 | + await verifySession(); | |
| 201 | + | |
| 202 | + if (!Number.isInteger(itemSn) || itemSn <= 0) { | |
| 203 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 204 | + } | |
| 205 | + | |
| 206 | + try { | |
| 207 | + await deleteDecorationItem(itemSn); | |
| 208 | + } catch { | |
| 209 | + return { status: 'error', message: DELETE_FAILED_MESSAGE }; | |
| 210 | + } | |
| 211 | + | |
| 212 | + revalidatePath(DECORATION_ITEMS_PATH); | |
| 213 | + return { status: 'success' }; | |
| 214 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
... | ... | @@ -0,0 +1,94 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import { Field } from '@/components/ui/field'; | |
| 6 | +import { Input } from '@/components/ui/input'; | |
| 7 | +import { Modal } from '@/components/ui/modal'; | |
| 8 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 9 | +import type { DecorationItemType } from '@/lib/domain/decoration-item'; | |
| 10 | +import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form'; | |
| 11 | +import { createDecorationItemAction } from '../_actions'; | |
| 12 | +import { DecorationItemFormFields } from './decoration-item-form-fields'; | |
| 13 | + | |
| 14 | +interface DecorationItemCreateModalProps { | |
| 15 | + /** 등록 버튼을 누른 시점의 활성 탭(유형) — 폼의 유형 기본값으로 쓴다. */ | |
| 16 | + defaultItemType: DecorationItemType; | |
| 17 | + typeTotalCount: number; | |
| 18 | + onClose: () => void; | |
| 19 | +} | |
| 20 | + | |
| 21 | +const FORM_ID = 'decoration-item-create-form'; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * 꾸미기 아이템 등록 팝업(시안 ADM_ITM_102_p). | |
| 25 | + * | |
| 26 | + * 아이템ID는 입력 항목이 아니다 — 시안대로 "등록 후 자동발급됩니다."를 읽기 전용으로 보여주고, | |
| 27 | + * 실제 값은 백엔드가 `itemSn`(자동증가)으로 채운다. | |
| 28 | + * | |
| 29 | + * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다(admins의 등록 팝업과 | |
| 30 | + * 동일한 패턴) — 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 | |
| 31 | + * form을 제출한다. | |
| 32 | + */ | |
| 33 | +export function DecorationItemCreateModal({ | |
| 34 | + defaultItemType, | |
| 35 | + typeTotalCount, | |
| 36 | + onClose, | |
| 37 | +}: DecorationItemCreateModalProps) { | |
| 38 | + const { showToast } = useFeedback(); | |
| 39 | + const [state, formAction, isPending] = useActionState( | |
| 40 | + createDecorationItemAction, | |
| 41 | + INITIAL_DECORATION_ITEM_FORM_STATE | |
| 42 | + ); | |
| 43 | + | |
| 44 | + useEffect(() => { | |
| 45 | + if (state.status === 'success') { | |
| 46 | + showToast({ variant: 'success', message: '아이템을 등록했습니다.' }); | |
| 47 | + onClose(); | |
| 48 | + } | |
| 49 | + }, [state, showToast, onClose]); | |
| 50 | + | |
| 51 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 52 | + | |
| 53 | + return ( | |
| 54 | + <Modal | |
| 55 | + title="꾸미기 아이템 등록" | |
| 56 | + onClose={onClose} | |
| 57 | + footer={ | |
| 58 | + <> | |
| 59 | + <Button type="button" variant="ghost" onClick={onClose}> | |
| 60 | + 취소 | |
| 61 | + </Button> | |
| 62 | + <Button | |
| 63 | + type="submit" | |
| 64 | + form={FORM_ID} | |
| 65 | + variant="primary" | |
| 66 | + disabled={isPending} | |
| 67 | + > | |
| 68 | + {isPending ? '등록 중...' : '등록'} | |
| 69 | + </Button> | |
| 70 | + </> | |
| 71 | + } | |
| 72 | + > | |
| 73 | + <form id={FORM_ID} action={formAction} className="flex flex-col gap-4"> | |
| 74 | + <p className="text-right text-body-sm text-danger"> | |
| 75 | + * 는 필수 항목입니다. | |
| 76 | + </p> | |
| 77 | + | |
| 78 | + <Field label="아이템 ID"> | |
| 79 | + <Input type="text" value="등록 후 자동발급됩니다." readOnly /> | |
| 80 | + </Field> | |
| 81 | + | |
| 82 | + <DecorationItemFormFields | |
| 83 | + defaultItemType={defaultItemType} | |
| 84 | + typeTotalCount={typeTotalCount} | |
| 85 | + errors={errors} | |
| 86 | + /> | |
| 87 | + | |
| 88 | + {state.status === 'error' && state.message && ( | |
| 89 | + <p className="text-body-sm text-danger">{state.message}</p> | |
| 90 | + )} | |
| 91 | + </form> | |
| 92 | + </Modal> | |
| 93 | + ); | |
| 94 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
... | ... | @@ -0,0 +1,95 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import { Field } from '@/components/ui/field'; | |
| 6 | +import { Input } from '@/components/ui/input'; | |
| 7 | +import { Modal } from '@/components/ui/modal'; | |
| 8 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 9 | +import type { DecorationItem } from '@/lib/domain/decoration-item'; | |
| 10 | +import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form'; | |
| 11 | +import { updateDecorationItemAction } from '../_actions'; | |
| 12 | +import { DecorationItemFormFields } from './decoration-item-form-fields'; | |
| 13 | + | |
| 14 | +interface DecorationItemEditModalProps { | |
| 15 | + item: DecorationItem; | |
| 16 | + typeTotalCount: number; | |
| 17 | + onClose: () => void; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const FORM_ID = 'decoration-item-edit-form'; | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록과 동일 항목이되 **아이템ID는 읽기 | |
| 24 | + * 전용**이다. | |
| 25 | + * | |
| 26 | + * readOnly로 보여주는 아이템ID `Input`에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고, | |
| 27 | + * 실제 수정 대상은 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만 | |
| 28 | + * 읽으므로 readOnly 필드를 위조해서 보내도 수정 대상이 바뀌지 않는다(admins의 이름/ID readOnly | |
| 29 | + * 처리와 동일한 방어 방식). | |
| 30 | + */ | |
| 31 | +export function DecorationItemEditModal({ | |
| 32 | + item, | |
| 33 | + typeTotalCount, | |
| 34 | + onClose, | |
| 35 | +}: DecorationItemEditModalProps) { | |
| 36 | + const { showToast } = useFeedback(); | |
| 37 | + const [state, formAction, isPending] = useActionState( | |
| 38 | + updateDecorationItemAction, | |
| 39 | + INITIAL_DECORATION_ITEM_FORM_STATE | |
| 40 | + ); | |
| 41 | + | |
| 42 | + useEffect(() => { | |
| 43 | + if (state.status === 'success') { | |
| 44 | + showToast({ variant: 'success', message: '아이템 정보를 수정했습니다.' }); | |
| 45 | + onClose(); | |
| 46 | + } | |
| 47 | + }, [state, showToast, onClose]); | |
| 48 | + | |
| 49 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 50 | + | |
| 51 | + return ( | |
| 52 | + <Modal | |
| 53 | + title="꾸미기 아이템 수정" | |
| 54 | + onClose={onClose} | |
| 55 | + footer={ | |
| 56 | + <> | |
| 57 | + <Button type="button" variant="ghost" onClick={onClose}> | |
| 58 | + 취소 | |
| 59 | + </Button> | |
| 60 | + <Button | |
| 61 | + type="submit" | |
| 62 | + form={FORM_ID} | |
| 63 | + variant="primary" | |
| 64 | + disabled={isPending} | |
| 65 | + > | |
| 66 | + {isPending ? '수정 중...' : '수정'} | |
| 67 | + </Button> | |
| 68 | + </> | |
| 69 | + } | |
| 70 | + > | |
| 71 | + <form id={FORM_ID} action={formAction} className="flex flex-col gap-4"> | |
| 72 | + <input type="hidden" name="itemSn" value={item.itemSn} /> | |
| 73 | + | |
| 74 | + <p className="text-right text-body-sm text-danger"> | |
| 75 | + * 는 필수 항목입니다. | |
| 76 | + </p> | |
| 77 | + | |
| 78 | + <Field label="아이템 ID"> | |
| 79 | + <Input type="text" value={item.itemSn} readOnly /> | |
| 80 | + </Field> | |
| 81 | + | |
| 82 | + <DecorationItemFormFields | |
| 83 | + item={item} | |
| 84 | + defaultItemType={item.itemType} | |
| 85 | + typeTotalCount={typeTotalCount} | |
| 86 | + errors={errors} | |
| 87 | + /> | |
| 88 | + | |
| 89 | + {state.status === 'error' && state.message && ( | |
| 90 | + <p className="text-body-sm text-danger">{state.message}</p> | |
| 91 | + )} | |
| 92 | + </form> | |
| 93 | + </Modal> | |
| 94 | + ); | |
| 95 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
... | ... | @@ -0,0 +1,159 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { Field } from '@/components/ui/field'; | |
| 4 | +import { Input } from '@/components/ui/input'; | |
| 5 | +import { RadioGroup } from '@/components/ui/radio-group'; | |
| 6 | +import { Select } from '@/components/ui/select'; | |
| 7 | +import { | |
| 8 | + DECORATION_ITEM_CATEGORIES, | |
| 9 | + DECORATION_ITEM_TYPE_OPTIONS, | |
| 10 | + DEFAULT_DECORATION_ITEM_CATEGORY_CODE, | |
| 11 | + type DecorationItem, | |
| 12 | + type DecorationItemType, | |
| 13 | +} from '@/lib/domain/decoration-item'; | |
| 14 | +import type { DecorationItemFormErrors } from '@/lib/domain/decoration-item-form'; | |
| 15 | + | |
| 16 | +const ACTIVE_STATUS_OPTIONS = [ | |
| 17 | + { value: 'true', label: '사용' }, | |
| 18 | + { value: 'false', label: '미사용' }, | |
| 19 | +]; | |
| 20 | + | |
| 21 | +interface DecorationItemFormFieldsProps { | |
| 22 | + /** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼 + 기본값). */ | |
| 23 | + item?: DecorationItem; | |
| 24 | + /** 등록 팝업을 연 시점의 활성 탭(유형) — 수정 팝업에서는 `item.itemType`이 대신 쓰인다. */ | |
| 25 | + defaultItemType: DecorationItemType; | |
| 26 | + /** "정렬순서" 라벨의 "(총 등록 N개)" 힌트와 등록 시 기본값 계산에 쓰는, 현재 유형의 전체 | |
| 27 | + * 등록 건수(검색어 무관). */ | |
| 28 | + typeTotalCount: number; | |
| 29 | + errors: DecorationItemFormErrors; | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * 등록·수정 팝업이 공유하는 입력 항목 — 유형/아이템명/카테고리/포인트/썸네일 이미지/설명/ | |
| 34 | + * 사용여부/정렬순서(시안 ADM_ITM_102_p / 103_p). 아이템ID 표시만 각 팝업이 따로 그린다. | |
| 35 | + * | |
| 36 | + * **유형(개별/셋트) 전환 시 폼 구성이 바뀔 수 있다는 시안 설명이 있지만, 두 유형의 입력 항목이 | |
| 37 | + * 완전히 같아 조건부 렌더링을 두지 않았다** — 유형 값 자체만 폼에 실어 저장한다. | |
| 38 | + * | |
| 39 | + * **이미지는 파일 하나만 받는다** — 시안의 "썸네일, 아이템 이미지 각각 등록? → NO"에 따른 것이고, | |
| 40 | + * 저장은 백엔드의 `atchFileId`에만 한다(썸네일은 추후 백엔드가 자동생성할 예정, 사용자 확정 사항). | |
| 41 | + * 수정 팝업에서 파일을 새로 고르지 않으면 hidden 필드로 유지한 기존 파일 ID가 그대로 저장된다. | |
| 42 | + * | |
| 43 | + * 설명은 여러 줄 입력이 자연스럽지만 공용 textarea 컴포넌트가 없어 로직 우선 단계에서는 단일행 | |
| 44 | + * `Input`으로 둔다(신설은 design 레인 소관). | |
| 45 | + */ | |
| 46 | +export function DecorationItemFormFields({ | |
| 47 | + item, | |
| 48 | + defaultItemType, | |
| 49 | + typeTotalCount, | |
| 50 | + errors, | |
| 51 | +}: DecorationItemFormFieldsProps) { | |
| 52 | + return ( | |
| 53 | + <> | |
| 54 | + <Field label="유형 *"> | |
| 55 | + <RadioGroup | |
| 56 | + name="itemType" | |
| 57 | + // RadioGroup의 options는 mutable 배열을 요구해 도메인의 ReadonlyArray를 그대로 넘길 | |
| 58 | + // 수 없다(components/ui는 design 레인 소관이라 시그니처를 바꾸지 않는다) — 얕은 | |
| 59 | + // 복사로 새 mutable 배열을 만들어 넘긴다. | |
| 60 | + options={[...DECORATION_ITEM_TYPE_OPTIONS]} | |
| 61 | + defaultValue={item?.itemType ?? defaultItemType} | |
| 62 | + /> | |
| 63 | + </Field> | |
| 64 | + <FieldError message={errors.itemType} /> | |
| 65 | + | |
| 66 | + <Field label="아이템명 *"> | |
| 67 | + <Input | |
| 68 | + type="text" | |
| 69 | + name="name" | |
| 70 | + defaultValue={item?.name ?? ''} | |
| 71 | + placeholder="아이템명을 입력하세요." | |
| 72 | + /> | |
| 73 | + </Field> | |
| 74 | + <FieldError message={errors.name} /> | |
| 75 | + | |
| 76 | + <Field label="카테고리 *"> | |
| 77 | + <Select | |
| 78 | + name="categoryCode" | |
| 79 | + defaultValue={ | |
| 80 | + item?.categoryCode ?? DEFAULT_DECORATION_ITEM_CATEGORY_CODE | |
| 81 | + } | |
| 82 | + > | |
| 83 | + {DECORATION_ITEM_CATEGORIES.map((category) => ( | |
| 84 | + <option key={category.code} value={category.code}> | |
| 85 | + {category.label} | |
| 86 | + </option> | |
| 87 | + ))} | |
| 88 | + </Select> | |
| 89 | + </Field> | |
| 90 | + <FieldError message={errors.categoryCode} /> | |
| 91 | + | |
| 92 | + <Field label="포인트(오픈 가능한 포인트) *"> | |
| 93 | + <Input | |
| 94 | + type="number" | |
| 95 | + name="points" | |
| 96 | + min={0} | |
| 97 | + step={1} | |
| 98 | + defaultValue={item?.points} | |
| 99 | + placeholder="오픈 가능한 포인트" | |
| 100 | + /> | |
| 101 | + </Field> | |
| 102 | + <FieldError message={errors.points} /> | |
| 103 | + | |
| 104 | + <Field label="썸네일 이미지 *"> | |
| 105 | + <Input type="file" name="imageFile" accept="image/*" /> | |
| 106 | + </Field> | |
| 107 | + {/* 수정 팝업에서 파일을 새로 고르지 않았을 때 기존 이미지를 유지하는 값. 등록 팝업에서는 | |
| 108 | + 빈 문자열이라 파일을 고르지 않으면 검증에서 걸린다. */} | |
| 109 | + <input | |
| 110 | + type="hidden" | |
| 111 | + name="imageFileId" | |
| 112 | + defaultValue={item?.imageFileId ?? ''} | |
| 113 | + /> | |
| 114 | + {item?.imageFileId && ( | |
| 115 | + <p className="text-body-sm text-foreground-muted"> | |
| 116 | + 현재 등록된 이미지가 있습니다. 새로 선택하면 교체됩니다. | |
| 117 | + </p> | |
| 118 | + )} | |
| 119 | + <FieldError message={errors.imageFileId} /> | |
| 120 | + | |
| 121 | + <Field label="설명"> | |
| 122 | + <Input | |
| 123 | + type="text" | |
| 124 | + name="description" | |
| 125 | + defaultValue={item?.description ?? ''} | |
| 126 | + placeholder="설명을 입력하세요." | |
| 127 | + /> | |
| 128 | + </Field> | |
| 129 | + <FieldError message={errors.description} /> | |
| 130 | + | |
| 131 | + <Field label="사용여부"> | |
| 132 | + <RadioGroup | |
| 133 | + name="isActive" | |
| 134 | + options={ACTIVE_STATUS_OPTIONS} | |
| 135 | + defaultValue={String(item?.isActive ?? true)} | |
| 136 | + /> | |
| 137 | + </Field> | |
| 138 | + | |
| 139 | + <Field label={`정렬순서 (총 등록 ${typeTotalCount}개)`}> | |
| 140 | + <Input | |
| 141 | + type="number" | |
| 142 | + name="sortOrder" | |
| 143 | + min={1} | |
| 144 | + step={1} | |
| 145 | + defaultValue={item?.sortOrder ?? typeTotalCount + 1} | |
| 146 | + /> | |
| 147 | + </Field> | |
| 148 | + <FieldError message={errors.sortOrder} /> | |
| 149 | + </> | |
| 150 | + ); | |
| 151 | +} | |
| 152 | + | |
| 153 | +/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */ | |
| 154 | +export function FieldError({ message }: { message?: string }) { | |
| 155 | + if (!message) { | |
| 156 | + return null; | |
| 157 | + } | |
| 158 | + return <p className="text-body-sm text-danger">{message}</p>; | |
| 159 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx
... | ... | @@ -0,0 +1,73 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState, type ChangeEvent } from 'react'; | |
| 5 | +import { Button } from '@/components/ui/button'; | |
| 6 | +import { Select } from '@/components/ui/select'; | |
| 7 | +import { | |
| 8 | + DECORATION_ITEM_PAGE_SIZE_OPTIONS, | |
| 9 | + buildDecorationItemHref, | |
| 10 | + type DecorationItemPageSize, | |
| 11 | + type DecorationItemQuery, | |
| 12 | +} from '@/lib/domain/decoration-item-query'; | |
| 13 | +import { DecorationItemCreateModal } from './decoration-item-create-modal'; | |
| 14 | + | |
| 15 | +interface DecorationItemListToolbarProps { | |
| 16 | + query: DecorationItemQuery; | |
| 17 | + /** 현재 유형(개별/셋트)의 검색어 무관 전체 건수 — 등록 팝업의 "(총 등록 N개)" 힌트에 쓴다. */ | |
| 18 | + typeTotalCount: number; | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * 목록 툴바(시안 ADM_ITM_101) — 페이지 크기 select는 변경 즉시 URL을 갱신해 서버 데이터를 | |
| 23 | + * 다시 조회하게 하고 1페이지로 되돌린다. 시안에 admins의 "정렬" select·엑셀다운로드에 | |
| 24 | + * 대응하는 항목이 없어 페이지 크기와 등록 버튼만 둔다. | |
| 25 | + * | |
| 26 | + * [+ 신규 등록]은 현재 유형 탭을 기본값으로 들고 등록 팝업을 연다 — 팝업 열림 상태를 이 | |
| 27 | + * 컴포넌트가 소유한다(admin-member-list-toolbar.tsx와 동일한 패턴). | |
| 28 | + */ | |
| 29 | +export function DecorationItemListToolbar({ | |
| 30 | + query, | |
| 31 | + typeTotalCount, | |
| 32 | +}: DecorationItemListToolbarProps) { | |
| 33 | + const router = useRouter(); | |
| 34 | + const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 35 | + | |
| 36 | + function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 37 | + const pageSize = Number(event.target.value) as DecorationItemPageSize; | |
| 38 | + router.replace(buildDecorationItemHref(query, { pageSize, page: 1 })); | |
| 39 | + } | |
| 40 | + | |
| 41 | + return ( | |
| 42 | + <div className="flex flex-wrap items-center justify-end gap-3"> | |
| 43 | + <Button | |
| 44 | + type="button" | |
| 45 | + variant="primary" | |
| 46 | + onClick={() => setIsCreateOpen(true)} | |
| 47 | + > | |
| 48 | + + 신규 등록 | |
| 49 | + </Button> | |
| 50 | + | |
| 51 | + <Select | |
| 52 | + name="pageSize" | |
| 53 | + aria-label="페이지 크기" | |
| 54 | + defaultValue={query.pageSize} | |
| 55 | + onChange={handlePageSizeChange} | |
| 56 | + > | |
| 57 | + {DECORATION_ITEM_PAGE_SIZE_OPTIONS.map((size) => ( | |
| 58 | + <option key={size} value={size}> | |
| 59 | + {size}개씩 | |
| 60 | + </option> | |
| 61 | + ))} | |
| 62 | + </Select> | |
| 63 | + | |
| 64 | + {isCreateOpen && ( | |
| 65 | + <DecorationItemCreateModal | |
| 66 | + defaultItemType={query.itemType} | |
| 67 | + typeTotalCount={typeTotalCount} | |
| 68 | + onClose={() => setIsCreateOpen(false)} | |
| 69 | + /> | |
| 70 | + )} | |
| 71 | + </div> | |
| 72 | + ); | |
| 73 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
... | ... | @@ -0,0 +1,97 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState, useTransition } from 'react'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 6 | +import type { DecorationItem } from '@/lib/domain/decoration-item'; | |
| 7 | +import { deleteDecorationItemAction } from '../_actions'; | |
| 8 | +import { DecorationItemEditModal } from './decoration-item-edit-modal'; | |
| 9 | + | |
| 10 | +interface DecorationItemRowActionsProps { | |
| 11 | + item: DecorationItem; | |
| 12 | + /** 수정 팝업의 "(총 등록 N개)" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */ | |
| 13 | + typeTotalCount: number; | |
| 14 | +} | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * 목록 행의 "관리" 셀(시안 ADM_ITM_101) — 수정 팝업 열림 상태만 소유하는 최말단 상호작용 | |
| 18 | + * 경계다(테이블 전체를 클라이언트로 내리지 않기 위해 이 셀만 분리했다, admins와 동일 패턴). | |
| 19 | + * | |
| 20 | + * **삭제는 시안대로 확인 얼럿을 거친 뒤 실행한다** — 전역 얼럿(FeedbackProvider)의 actions | |
| 21 | + * 슬롯에 직접 [취소]/[삭제] 버튼을 넣어 구성한다. 삭제는 되돌릴 수 없는 동작이라 얼럿 문구에 | |
| 22 | + * 명시한다. | |
| 23 | + * | |
| 24 | + * 시안은 연필·휴지통 아이콘 버튼을 지정하지만 `components/ui/icons/`에 아직 없어(신규 아이콘 | |
| 25 | + * 추가는 공용 컴포넌트 신설이라 design 레인 소관) admins와 동일하게 텍스트 버튼으로 대체했다. | |
| 26 | + */ | |
| 27 | +export function DecorationItemRowActions({ | |
| 28 | + item, | |
| 29 | + typeTotalCount, | |
| 30 | +}: DecorationItemRowActionsProps) { | |
| 31 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 32 | + const [isEditOpen, setIsEditOpen] = useState(false); | |
| 33 | + const [isDeleting, startDeleting] = useTransition(); | |
| 34 | + | |
| 35 | + function runDelete() { | |
| 36 | + hideAlert(); | |
| 37 | + startDeleting(async () => { | |
| 38 | + const result = await deleteDecorationItemAction(item.itemSn); | |
| 39 | + if (result.status === 'error') { | |
| 40 | + showToast({ | |
| 41 | + variant: 'danger', | |
| 42 | + message: result.message ?? '삭제하지 못했습니다.', | |
| 43 | + }); | |
| 44 | + return; | |
| 45 | + } | |
| 46 | + showToast({ variant: 'success', message: '아이템을 삭제했습니다.' }); | |
| 47 | + }); | |
| 48 | + } | |
| 49 | + | |
| 50 | + function confirmDelete() { | |
| 51 | + showAlert({ | |
| 52 | + variant: 'danger', | |
| 53 | + title: '아이템을 삭제하시겠습니까?', | |
| 54 | + message: `${item.name}(아이템ID ${item.itemSn})을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 55 | + actions: ( | |
| 56 | + <> | |
| 57 | + <Button type="button" variant="ghost" onClick={hideAlert}> | |
| 58 | + 취소 | |
| 59 | + </Button> | |
| 60 | + <Button type="button" variant="primary" onClick={runDelete}> | |
| 61 | + 삭제 | |
| 62 | + </Button> | |
| 63 | + </> | |
| 64 | + ), | |
| 65 | + }); | |
| 66 | + } | |
| 67 | + | |
| 68 | + return ( | |
| 69 | + <div className="flex items-center gap-1"> | |
| 70 | + <Button | |
| 71 | + type="button" | |
| 72 | + variant="ghost" | |
| 73 | + size="sm" | |
| 74 | + onClick={() => setIsEditOpen(true)} | |
| 75 | + > | |
| 76 | + 수정 | |
| 77 | + </Button> | |
| 78 | + <Button | |
| 79 | + type="button" | |
| 80 | + variant="ghost" | |
| 81 | + size="sm" | |
| 82 | + onClick={confirmDelete} | |
| 83 | + disabled={isDeleting} | |
| 84 | + > | |
| 85 | + {isDeleting ? '삭제 중...' : '삭제'} | |
| 86 | + </Button> | |
| 87 | + | |
| 88 | + {isEditOpen && ( | |
| 89 | + <DecorationItemEditModal | |
| 90 | + item={item} | |
| 91 | + typeTotalCount={typeTotalCount} | |
| 92 | + onClose={() => setIsEditOpen(false)} | |
| 93 | + /> | |
| 94 | + )} | |
| 95 | + </div> | |
| 96 | + ); | |
| 97 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx
... | ... | @@ -0,0 +1,74 @@ |
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Button } from '@/components/ui/button'; | |
| 3 | +import { Field } from '@/components/ui/field'; | |
| 4 | +import { Input } from '@/components/ui/input'; | |
| 5 | +import { Select } from '@/components/ui/select'; | |
| 6 | +import { | |
| 7 | + DECORATION_ITEMS_PATH, | |
| 8 | + DECORATION_ITEM_SEARCH_FIELD_OPTIONS, | |
| 9 | + DEFAULT_DECORATION_ITEM_SEARCH_FIELD, | |
| 10 | + buildDecorationItemHref, | |
| 11 | + type DecorationItemQuery, | |
| 12 | +} from '@/lib/domain/decoration-item-query'; | |
| 13 | + | |
| 14 | +interface DecorationItemSearchBarProps { | |
| 15 | + query: DecorationItemQuery; | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * 검색 영역(시안 ADM_ITM_101) — 순수 `<form method="get">` GET 제출로 URL을 갱신한다 | |
| 20 | + * (자바스크립트 없이도 동작). 현재 `itemType`(유형 탭)·`pageSize`는 hidden input으로 함께 | |
| 21 | + * 제출해 검색 후에도 유지되게 하고, 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 | |
| 22 | + * 두지 않아 `parseDecorationItemQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 | |
| 23 | + * 제출이 아니라 기본값(검색대상·검색어)으로 되돌린 링크라 현재 입력값과 무관하게 동작한다 | |
| 24 | + * (유형 탭은 초기화 대상이 아니다 — 지금 보고 있는 목록은 유지). 상호작용이 전부 네이티브 폼 | |
| 25 | + * 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다 | |
| 26 | + * (admin-member-search-bar.tsx와 동일한 설계). | |
| 27 | + */ | |
| 28 | +export function DecorationItemSearchBar({ query }: DecorationItemSearchBarProps) { | |
| 29 | + const resetHref = buildDecorationItemHref(query, { | |
| 30 | + searchField: DEFAULT_DECORATION_ITEM_SEARCH_FIELD, | |
| 31 | + keyword: '', | |
| 32 | + page: 1, | |
| 33 | + }); | |
| 34 | + | |
| 35 | + return ( | |
| 36 | + <form | |
| 37 | + method="get" | |
| 38 | + action={DECORATION_ITEMS_PATH} | |
| 39 | + className="flex flex-wrap items-end gap-3" | |
| 40 | + > | |
| 41 | + <input type="hidden" name="itemType" value={query.itemType} /> | |
| 42 | + <input type="hidden" name="pageSize" value={query.pageSize} /> | |
| 43 | + | |
| 44 | + <Field label="검색 대상"> | |
| 45 | + <Select name="searchField" defaultValue={query.searchField}> | |
| 46 | + {DECORATION_ITEM_SEARCH_FIELD_OPTIONS.map((option) => ( | |
| 47 | + <option key={option.value} value={option.value}> | |
| 48 | + {option.label} | |
| 49 | + </option> | |
| 50 | + ))} | |
| 51 | + </Select> | |
| 52 | + </Field> | |
| 53 | + | |
| 54 | + <Field label="검색어"> | |
| 55 | + <Input | |
| 56 | + type="text" | |
| 57 | + name="keyword" | |
| 58 | + defaultValue={query.keyword} | |
| 59 | + placeholder="검색어를 입력하세요." | |
| 60 | + /> | |
| 61 | + </Field> | |
| 62 | + | |
| 63 | + <Button type="submit" variant="secondary"> | |
| 64 | + 검색 | |
| 65 | + </Button> | |
| 66 | + <Link | |
| 67 | + href={resetHref} | |
| 68 | + className="text-body-md text-foreground-muted underline-offset-2 hover:underline" | |
| 69 | + > | |
| 70 | + 초기화 | |
| 71 | + </Link> | |
| 72 | + </form> | |
| 73 | + ); | |
| 74 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-table.tsx
... | ... | @@ -0,0 +1,109 @@ |
| 1 | +import { | |
| 2 | + Table, | |
| 3 | + TableBody, | |
| 4 | + TableCell, | |
| 5 | + TableHead, | |
| 6 | + TableHeaderCell, | |
| 7 | + TableRow, | |
| 8 | +} from '@/components/ui/table'; | |
| 9 | +import { | |
| 10 | + EMPTY_FIELD_PLACEHOLDER, | |
| 11 | + formatDecorationItemActiveLabel, | |
| 12 | + formatDecorationItemCategory, | |
| 13 | + formatDecorationItemPoints, | |
| 14 | + formatDecorationItemUpdatedAt, | |
| 15 | + type DecorationItem, | |
| 16 | +} from '@/lib/domain/decoration-item'; | |
| 17 | +import { DecorationItemRowActions } from './decoration-item-row-actions'; | |
| 18 | + | |
| 19 | +interface DecorationItemTableProps { | |
| 20 | + items: DecorationItem[]; | |
| 21 | + /** 1-based 현재 페이지. "번호" 열의 표시 순번 계산에 쓴다. */ | |
| 22 | + page: number; | |
| 23 | + pageSize: number; | |
| 24 | + /** 수정 팝업의 "(총 등록 N개)" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */ | |
| 25 | + typeTotalCount: number; | |
| 26 | +} | |
| 27 | + | |
| 28 | +const COLUMN_HEADERS = [ | |
| 29 | + '번호', | |
| 30 | + '아이템ID', | |
| 31 | + '썸네일', | |
| 32 | + '아이템명', | |
| 33 | + '카테고리', | |
| 34 | + '포인트', | |
| 35 | + '사용여부', | |
| 36 | + '정렬순서', | |
| 37 | + '수정일시', | |
| 38 | + '관리', | |
| 39 | +]; | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * 꾸미기 아이템 목록 테이블(시안 ADM_ITM_101, 10컬럼). | |
| 43 | + * | |
| 44 | + * **"번호"는 admins와 달리 오름차순 표시 순번이다**(`offset + index + 1`) — 이 화면의 기본 | |
| 45 | + * 정렬이 정렬순서 오름차순이라(Repository가 보장) "가장 작은 정렬순서가 1번"이 직관적이다. | |
| 46 | + * admins가 전체 건수에서 거꾸로 세는 이유(생성일 최신순 정렬)가 여기에는 없다. | |
| 47 | + * | |
| 48 | + * **썸네일은 백엔드 이미지 URL을 그대로 건다** — 등록한 파일 ID(`atchFileId`)로 만든 | |
| 49 | + * `GET /api/v1/common/file/image` 주소이며, 이 GET은 인증이 필요 없어 브라우저가 백엔드를 직접 | |
| 50 | + * 호출한다(사용자 확정 사항). URL 문자열은 서버에서 만들어 내려주므로 백엔드 주소 환경변수는 | |
| 51 | + * 여전히 서버 전용이다. `next/image`가 아니라 `<img>`를 쓰는 이유는 외부 호스트를 | |
| 52 | + * `next.config`에 등록해야 하는 설정 변경을 이번 범위(로직)에서 하지 않기 때문이다. | |
| 53 | + * | |
| 54 | + * "관리" 열은 행별 수정/삭제 트리거(`DecorationItemRowActions`)에 위임한다 — 상호작용이 | |
| 55 | + * 필요한 것은 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 | |
| 56 | + * 경계로 뗀다. | |
| 57 | + */ | |
| 58 | +export function DecorationItemTable({ | |
| 59 | + items, | |
| 60 | + page, | |
| 61 | + pageSize, | |
| 62 | + typeTotalCount, | |
| 63 | +}: DecorationItemTableProps) { | |
| 64 | + const offset = (page - 1) * pageSize; | |
| 65 | + | |
| 66 | + return ( | |
| 67 | + <Table> | |
| 68 | + <TableHead> | |
| 69 | + <TableRow> | |
| 70 | + {COLUMN_HEADERS.map((header) => ( | |
| 71 | + <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 72 | + ))} | |
| 73 | + </TableRow> | |
| 74 | + </TableHead> | |
| 75 | + <TableBody> | |
| 76 | + {items.map((item, index) => ( | |
| 77 | + <TableRow key={item.itemSn}> | |
| 78 | + <TableCell>{offset + index + 1}</TableCell> | |
| 79 | + <TableCell>{item.itemSn}</TableCell> | |
| 80 | + <TableCell> | |
| 81 | + {item.imageUrl ? ( | |
| 82 | + // eslint-disable-next-line @next/next/no-img-element -- 위 주석 참조(외부 호스트 설정 회피) | |
| 83 | + <img | |
| 84 | + src={item.imageUrl} | |
| 85 | + alt={`${item.name} 썸네일`} | |
| 86 | + className="size-9 rounded-md object-cover" | |
| 87 | + /> | |
| 88 | + ) : ( | |
| 89 | + EMPTY_FIELD_PLACEHOLDER | |
| 90 | + )} | |
| 91 | + </TableCell> | |
| 92 | + <TableCell>{item.name}</TableCell> | |
| 93 | + <TableCell>{formatDecorationItemCategory(item)}</TableCell> | |
| 94 | + <TableCell>{formatDecorationItemPoints(item.points)}</TableCell> | |
| 95 | + <TableCell>{formatDecorationItemActiveLabel(item.isActive)}</TableCell> | |
| 96 | + <TableCell>{item.sortOrder}</TableCell> | |
| 97 | + <TableCell>{formatDecorationItemUpdatedAt(item.updatedAt)}</TableCell> | |
| 98 | + <TableCell> | |
| 99 | + <DecorationItemRowActions | |
| 100 | + item={item} | |
| 101 | + typeTotalCount={typeTotalCount} | |
| 102 | + /> | |
| 103 | + </TableCell> | |
| 104 | + </TableRow> | |
| 105 | + ))} | |
| 106 | + </TableBody> | |
| 107 | + </Table> | |
| 108 | + ); | |
| 109 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-type-tabs.tsx
... | ... | @@ -0,0 +1,59 @@ |
| 1 | +import Link from 'next/link'; | |
| 2 | +import { DECORATION_ITEM_TYPE_OPTIONS } from '@/lib/domain/decoration-item'; | |
| 3 | +import { | |
| 4 | + buildDecorationItemHref, | |
| 5 | + type DecorationItemQuery, | |
| 6 | +} from '@/lib/domain/decoration-item-query'; | |
| 7 | + | |
| 8 | +interface DecorationItemTypeTabsProps { | |
| 9 | + query: DecorationItemQuery; | |
| 10 | +} | |
| 11 | + | |
| 12 | +const TAB_BASE_CLASSES = | |
| 13 | + 'inline-flex h-10 items-center justify-center rounded-md px-4 text-body-md transition-colors duration-200'; | |
| 14 | +const ACTIVE_TAB_CLASSES = 'bg-primary text-on-primary'; | |
| 15 | +const INACTIVE_TAB_CLASSES = | |
| 16 | + 'border border-border bg-surface text-foreground-muted hover:bg-surface-muted hover:text-foreground'; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * 유형 탭(시안 ADM_ITM_101 — 개별아이템/셋트아이템 전환). **셋트아이템은 별도 화면이 없고 이 | |
| 20 | + * 탭이 같은 목록·같은 폼의 유형만 바꾼다**는 것이 시안 두 곳에 명시된 사양이다 | |
| 21 | + * (`decoration-item-query.ts` 상단 주석 참조). | |
| 22 | + * | |
| 23 | + * 값이 URL(`itemType` 쿼리 파라미터)에 실리므로 순수 `next/link` 내비게이션으로 충분해 | |
| 24 | + * 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다. | |
| 25 | + * | |
| 26 | + * 탭 전환 시 검색어/검색대상/페이지크기는 유지하고 페이지만 1로 되돌린다(admin-member의 | |
| 27 | + * 정렬/페이지크기 전환과 같은 규칙) — 다른 유형으로 넘어가면 전체 건수 자체가 달라지기 | |
| 28 | + * 때문이다. 전용 탭 컴포넌트가 없어(§10.3, 신설은 design 레인 소관) 버튼 형태 Link 2개로 | |
| 29 | + * 구성했다. | |
| 30 | + */ | |
| 31 | +export function DecorationItemTypeTabs({ query }: DecorationItemTypeTabsProps) { | |
| 32 | + return ( | |
| 33 | + <div | |
| 34 | + role="tablist" | |
| 35 | + aria-label="아이템 유형" | |
| 36 | + className="flex items-center gap-2" | |
| 37 | + > | |
| 38 | + {DECORATION_ITEM_TYPE_OPTIONS.map((option) => { | |
| 39 | + const isActive = option.value === query.itemType; | |
| 40 | + return ( | |
| 41 | + <Link | |
| 42 | + key={option.value} | |
| 43 | + href={buildDecorationItemHref(query, { | |
| 44 | + itemType: option.value, | |
| 45 | + page: 1, | |
| 46 | + })} | |
| 47 | + role="tab" | |
| 48 | + aria-selected={isActive} | |
| 49 | + className={`${TAB_BASE_CLASSES} ${ | |
| 50 | + isActive ? ACTIVE_TAB_CLASSES : INACTIVE_TAB_CLASSES | |
| 51 | + }`} | |
| 52 | + > | |
| 53 | + {option.label} | |
| 54 | + </Link> | |
| 55 | + ); | |
| 56 | + })} | |
| 57 | + </div> | |
| 58 | + ); | |
| 59 | +} |
+++ app/(protected)/(basic)/decoration-items/error.tsx
... | ... | @@ -0,0 +1,32 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useEffect } from 'react'; | |
| 4 | + | |
| 5 | +interface ErrorPageProps { | |
| 6 | + error: Error & { digest?: string }; | |
| 7 | + unstable_retry: () => void; | |
| 8 | +} | |
| 9 | + | |
| 10 | +// 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 콘솔에 남기고(프로덕션에서는 | |
| 11 | +// Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 치환해 전달한다), | |
| 12 | +// 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 세그먼트 | |
| 13 | +// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다(admins/error.tsx와 동일). | |
| 14 | +export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) { | |
| 15 | + useEffect(() => { | |
| 16 | + console.error(error); | |
| 17 | + }, [error]); | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> | |
| 21 | + <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1> | |
| 22 | + <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p> | |
| 23 | + <button | |
| 24 | + type="button" | |
| 25 | + onClick={() => unstable_retry()} | |
| 26 | + className="mt-4 bg-foreground px-4 py-2 text-background" | |
| 27 | + > | |
| 28 | + 다시 시도 | |
| 29 | + </button> | |
| 30 | + </section> | |
| 31 | + ); | |
| 32 | +} |
+++ app/(protected)/(basic)/decoration-items/loading.tsx
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +import { Spinner } from '@/components/ui/spinner'; | |
| 2 | + | |
| 3 | +// page.tsx가 스트리밍되는 동안의 폴백 — 같은 section 배치(flex-1 items-center justify-center)를 | |
| 4 | +// 써서 콘텐츠 교체 시 레이아웃이 흔들리지 않게 한다. Server Component로 충분해 'use client'를 | |
| 5 | +// 붙이지 않는다(admins/loading.tsx와 동일한 패턴). | |
| 6 | +export default function Loading() { | |
| 7 | + return ( | |
| 8 | + <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> | |
| 9 | + <Spinner size="lg" /> | |
| 10 | + </section> | |
| 11 | + ); | |
| 12 | +} |
+++ app/(protected)/(basic)/decoration-items/page.tsx
... | ... | @@ -0,0 +1,99 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Alert } from '@/components/ui/alert'; | |
| 4 | +import { Pagination } from '@/components/ui/pagination'; | |
| 5 | +import { verifySession } from '@/lib/auth/dal'; | |
| 6 | +import { fetchDecorationItems } from '@/lib/data/repositories/decoration-item-repository'; | |
| 7 | +import { | |
| 8 | + buildDecorationItemHref, | |
| 9 | + parseDecorationItemQuery, | |
| 10 | +} from '@/lib/domain/decoration-item-query'; | |
| 11 | +import { DecorationItemListToolbar } from './_components/decoration-item-list-toolbar'; | |
| 12 | +import { DecorationItemSearchBar } from './_components/decoration-item-search-bar'; | |
| 13 | +import { DecorationItemTable } from './_components/decoration-item-table'; | |
| 14 | +import { DecorationItemTypeTabs } from './_components/decoration-item-type-tabs'; | |
| 15 | + | |
| 16 | +export const metadata: Metadata = { | |
| 17 | + title: '꾸미기 아이템', | |
| 18 | +}; | |
| 19 | + | |
| 20 | +interface PageProps { | |
| 21 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * 꾸미기 아이템 목록(시안 ADM_ITM_101). | |
| 26 | + * | |
| 27 | + * **셋트아이템은 별도 화면이 없다** — 유형 탭(개별/셋트)만 바꿔 같은 목록·같은 폼을 재사용 | |
| 28 | + * 한다(시안 두 곳에 명시된 사양). `query.itemType`이 곧 그 탭 상태이고, 다른 검색·페이지 | |
| 29 | + * 상태와 마찬가지로 URL(searchParams)이 단일 진실원천이다(`useState`로 들지 않는다). | |
| 30 | + * | |
| 31 | + * `verifySession()`을 호출만 하고 반환값을 쓰지 않는 이유는 인증 확인 자체가 목적이기 | |
| 32 | + * 때문이다 — admins의 "본인 계정 삭제 방지" 같은 사용자 조건부 로직이 이 화면에는 없다. | |
| 33 | + */ | |
| 34 | +export default async function Page({ searchParams }: PageProps) { | |
| 35 | + await verifySession(); | |
| 36 | + | |
| 37 | + const query = parseDecorationItemQuery(await searchParams); | |
| 38 | + const { items, totalCount, isTotalCountExact, typeTotalCount } = | |
| 39 | + await fetchDecorationItems(query); | |
| 40 | + | |
| 41 | + // 전체 건수가 확정되지 않았다면(백엔드가 count를 주지 않아 하한값만 아는 상태) 다음 페이지를 | |
| 42 | + // 한 칸 열어 둔다 — 열어 두지 않으면 가득 찬 페이지 뒤의 데이터에 접근할 방법이 없어진다 | |
| 43 | + // (학생 목록과 동일한 보정. Repository 주석 참조). | |
| 44 | + const totalPages = isTotalCountExact | |
| 45 | + ? Math.max(1, Math.ceil(totalCount / query.pageSize)) | |
| 46 | + : query.page + 1; | |
| 47 | + // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐, 또는 유형 탭 전환으로 | |
| 48 | + // 전체 건수가 줄어듦) 마지막 페이지로 맞춘다 — 표의 순번 계산도 이 값을 기준으로 해야 헤더의 | |
| 49 | + // "현재페이지"와 어긋나지 않는다. | |
| 50 | + const currentPage = Math.min(query.page, totalPages); | |
| 51 | + | |
| 52 | + return ( | |
| 53 | + <section className="flex flex-1 flex-col gap-6 p-8"> | |
| 54 | + <h1 className="text-headline-sm text-foreground">꾸미기 아이템 목록</h1> | |
| 55 | + | |
| 56 | + <DecorationItemTypeTabs query={query} /> | |
| 57 | + | |
| 58 | + <DecorationItemSearchBar query={query} /> | |
| 59 | + | |
| 60 | + <DecorationItemListToolbar query={query} typeTotalCount={typeTotalCount} /> | |
| 61 | + | |
| 62 | + <p className="text-body-md text-foreground-muted"> | |
| 63 | + 총 {totalCount}개{isTotalCountExact ? '' : ' 이상'} | 현재페이지{' '} | |
| 64 | + {currentPage} | |
| 65 | + {isTotalCountExact ? `/${totalPages}` : ''} | |
| 66 | + </p> | |
| 67 | + | |
| 68 | + {items.length === 0 ? ( | |
| 69 | + <Alert | |
| 70 | + title="등록된 아이템이 없습니다." | |
| 71 | + actions={ | |
| 72 | + <Link | |
| 73 | + href={buildDecorationItemHref(query, { keyword: '', page: 1 })} | |
| 74 | + className="bg-foreground px-4 py-2 text-background" | |
| 75 | + > | |
| 76 | + 확인 | |
| 77 | + </Link> | |
| 78 | + } | |
| 79 | + > | |
| 80 | + 다른 검색어로 다시 시도해 주세요. | |
| 81 | + </Alert> | |
| 82 | + ) : ( | |
| 83 | + <> | |
| 84 | + <DecorationItemTable | |
| 85 | + items={items} | |
| 86 | + page={currentPage} | |
| 87 | + pageSize={query.pageSize} | |
| 88 | + typeTotalCount={typeTotalCount} | |
| 89 | + /> | |
| 90 | + <Pagination | |
| 91 | + currentPage={currentPage} | |
| 92 | + totalPages={totalPages} | |
| 93 | + buildHref={(page: number) => buildDecorationItemHref(query, { page })} | |
| 94 | + /> | |
| 95 | + </> | |
| 96 | + )} | |
| 97 | + </section> | |
| 98 | + ); | |
| 99 | +} |
--- app/(protected)/_actions.ts
+++ app/(protected)/_actions.ts
... | ... | @@ -3,10 +3,25 @@ |
| 3 | 3 |
import { redirect } from 'next/navigation';
|
| 4 | 4 |
import { revalidatePath } from 'next/cache';
|
| 5 | 5 |
import { deleteSession, readSessionToken } from '@/lib/auth/session';
|
| 6 |
+import { getSessionAccessToken } from '@/lib/auth/dal';
|
|
| 7 |
+import { requestAdminLogout } from '@/lib/data/repositories/auth-repository';
|
|
| 8 |
+import { LOGOUT_ERROR_QUERY_PARAM } from '@/lib/constants/logout';
|
|
| 6 | 9 |
|
| 7 | 10 |
/** |
| 8 | 11 |
* 로그아웃 Server Action — 자기 세션을 파기하는 동작이라 `verifySession()`의 전제(관리자 조회)가 |
| 9 | 12 |
* 성립하지 않는다. 토큰 존재 여부만 확인해 이미 로그아웃된 상태의 중복 호출도 안전하게 처리한다. |
| 13 |
+ * |
|
| 14 |
+ * 백엔드 로그아웃 호출(GET .../auth/logout)은 best-effort다 — 실패해도(백엔드 다운, |
|
| 15 |
+ * accessToken 만료 등) 로컬 세션은 반드시 파기한다. 사용자가 로그아웃 자체를 못 하게 되는 |
|
| 16 |
+ * 상황을 절대 만들지 않기 위함이다. accessToken이 애초에 없으면(getSessionAccessToken()이 |
|
| 17 |
+ * 형식·만료·유저일치 검사에서 이미 null을 반환한 경우) 호출 자체를 건너뛴다 — 보낼 유효한 |
|
| 18 |
+ * 토큰이 없는 것은 "실패"가 아니라 "애초에 알릴 대상이 없음"이므로 실패로 취급하지 않는다. |
|
| 19 |
+ * |
|
| 20 |
+ * 실패 알림은 이 액션이 직접 띄우지 않는다 — Server Action에서 redirect()는 즉시 던져지므로 |
|
| 21 |
+ * (NEXT_REDIRECT) 클라이언트가 반환값을 돌려받아 토스트를 그릴 기회 자체가 없다(redirect와 |
|
| 22 |
+ * 클라이언트 상태 갱신이 같은 턴에서 경쟁하게 됨). 대신 `/login`에 쿼리 파라미터를 실어 보내고, |
|
| 23 |
+ * 그 화면의 클라이언트 컴포넌트(LogoutNotice)가 마운트된 뒤(=redirect가 완전히 끝난 뒤) 토스트를 |
|
| 24 |
+ * 띄운다 — 시점이 분리되어 있어 경쟁이 원천적으로 없다. |
|
| 10 | 25 |
*/ |
| 11 | 26 |
export async function logout(): Promise<void> {
|
| 12 | 27 |
const token = await readSessionToken(); |
... | ... | @@ -14,7 +29,14 @@ |
| 14 | 29 |
redirect('/login');
|
| 15 | 30 |
} |
| 16 | 31 |
|
| 32 |
+ const accessToken = await getSessionAccessToken(); |
|
| 33 |
+ const isBackendLogoutSuccessful = accessToken |
|
| 34 |
+ ? await requestAdminLogout(accessToken) |
|
| 35 |
+ : true; |
|
| 36 |
+ |
|
| 17 | 37 |
await deleteSession(); |
| 18 | 38 |
revalidatePath('/', 'layout');
|
| 19 |
- redirect('/login');
|
|
| 39 |
+ redirect( |
|
| 40 |
+ isBackendLogoutSuccessful ? '/login' : `/login?${LOGOUT_ERROR_QUERY_PARAM}=1`
|
|
| 41 |
+ ); |
|
| 20 | 42 |
} |
+++ app/(public)/(empty)/dev-test/design/page.tsx
... | ... | @@ -0,0 +1,21 @@ |
| 1 | +import { notFound } from "next/navigation"; | |
| 2 | +import { DevTestPage } from "@fox/dev-test"; | |
| 3 | + | |
| 4 | +export const metadata = { | |
| 5 | + title: "@fox 디자인 시스템 테스트", | |
| 6 | +}; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 디자인 시스템 테스트 페이지 — 개발 전용. | |
| 10 | + * | |
| 11 | + * 화면 구현은 전부 `@fox/dev-test`가 소유하고 이 라우트는 마운트만 한다. 그래야 `@fox` | |
| 12 | + * 폴더를 다른 프로젝트로 복사할 때 테스트 하니스가 함께 따라간다(라우트 한 줄만 추가하면 | |
| 13 | + * 된다). 프로덕션 차단은 서버에서 판정해야 하므로 이 파일은 Server Component로 둔다. | |
| 14 | + */ | |
| 15 | +export default function DesignSystemTestPage() { | |
| 16 | + if (process.env.NODE_ENV === "production") { | |
| 17 | + notFound(); | |
| 18 | + } | |
| 19 | + | |
| 20 | + return <DevTestPage />; | |
| 21 | +} |
+++ app/(public)/(empty)/layout.tsx
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +// Empty 레이아웃 — 크롬(AuthCard·헤더·사이드바) 없이 전체 화면을 그대로 쓰는 공개 화면 | |
| 2 | +// 진입점. 상위 `(public)/layout.tsx`가 로그인 화면용 AuthCard를 씌우므로, 그 껍데기가 | |
| 3 | +// 필요 없는 화면은 이 그룹 아래에 둔다. | |
| 4 | +// | |
| 5 | +// 레이아웃이 스타일을 갖지 않는 것이 요점이다 — 화면이 자기 셸을 온전히 소유한다. | |
| 6 | +export default function Layout({ | |
| 7 | + children, | |
| 8 | +}: Readonly<{ | |
| 9 | + children: React.ReactNode; | |
| 10 | +}>) { | |
| 11 | + return children; | |
| 12 | +} |
--- app/(public)/layout.tsx
+++ app/(public)/layout.tsx
... | ... | @@ -1,12 +1,7 @@ |
| 1 |
-import { AuthCard } from '@/components/ui/auth-card';
|
|
| 2 |
- |
|
| 3 |
-// Auth 레이아웃 — design 공용 AuthCard(프라이머리 배경 + 로고 + 카드)로 `(public)` 하위 화면 |
|
| 4 |
-// (로그인 등)을 감싼다. 화면별 제목은 AuthCard의 title 슬롯 대신 각 페이지 콘텐츠가 자체 |
|
| 5 |
-// 렌더한다 — 레이아웃은 여러 화면이 공유하므로 화면별 제목을 여기서 결정하기 어렵다. |
|
| 6 | 1 |
export default function Layout({
|
| 7 | 2 |
children, |
| 8 | 3 |
}: Readonly<{
|
| 9 | 4 |
children: React.ReactNode; |
| 10 | 5 |
}>) {
|
| 11 |
- return <AuthCard>{children}</AuthCard>;
|
|
| 6 |
+ return children; |
|
| 12 | 7 |
} |
+++ app/(public)/login/_components/logout-notice.tsx
... | ... | @@ -0,0 +1,51 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useEffect, useRef } from 'react'; | |
| 4 | +import { useRouter } from 'next/navigation'; | |
| 5 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 6 | + | |
| 7 | +interface LogoutNoticeProps { | |
| 8 | + /** 로그아웃 Server Action이 백엔드 호출 실패를 알리려고 실어 보낸 쿼리 파라미터가 있었는가. */ | |
| 9 | + showError: boolean; | |
| 10 | +} | |
| 11 | + | |
| 12 | +const LOGOUT_ERROR_MESSAGE = | |
| 13 | + '로그아웃 처리 중 일부 오류가 있었지만 로그아웃되었습니다.'; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 화면에 그릴 UI가 없는 알림 트리거 컴포넌트 — `app/(protected)/_actions.ts`의 `logout()`이 | |
| 17 | + * 백엔드 로그아웃 호출 실패를 알리려고 `/login`에 실어 보낸 쿼리 파라미터를 Page(Server | |
| 18 | + * Component)가 읽어 `showError`로 넘겨주면, 마운트 시 1회 토스트로 보여준다. | |
| 19 | + * | |
| 20 | + * Server Action은 redirect() 이후 클라이언트에 값을 돌려줄 방법이 없다(redirect가 즉시 | |
| 21 | + * 던져짐) — 그래서 이 화면에 도착해 마운트된 뒤(=redirect가 끝난 뒤)에만 알릴 수 있다. 이 | |
| 22 | + * 컴포넌트는 오직 이 시점에만 반응하므로 redirect와 토스트 표시가 시점상 겹치지 않는다. | |
| 23 | + * | |
| 24 | + * 표시 후에는 쿼리 파라미터를 제거한다(router.replace) — 새로고침·북마크 재방문 시 지난 실패가 | |
| 25 | + * 다시 뜨는 것을 막는다. | |
| 26 | + * | |
| 27 | + * `hasNotifiedRef`로 실행을 1회로 못박는다 — App Router는 기본 Strict Mode(next.config.ts에 | |
| 28 | + * 별도 설정이 없으면 true, Next.js 13.5.1부터 기본값)라 개발 모드에서 effect가 mount→cleanup→ | |
| 29 | + * remount로 2회 호출된다. 이 effect엔 cleanup이 없어 가드가 없으면 토스트가 두 번 뜬다 — | |
| 30 | + * router.replace가 아직 반영되기 전(같은 커밋 내)에 두 번째 호출이 일어나 showError prop이 | |
| 31 | + * 그대로 true이기 때문이다. 프로덕션은 원래 1회지만, 사용자에게 실제로 보이는 부수효과(알림)는 | |
| 32 | + * Strict Mode 유무와 무관하게 항상 1회여야 한다. | |
| 33 | + */ | |
| 34 | +export function LogoutNotice({ showError }: LogoutNoticeProps) { | |
| 35 | + const router = useRouter(); | |
| 36 | + const { showToast } = useFeedback(); | |
| 37 | + const hasNotifiedRef = useRef(false); | |
| 38 | + | |
| 39 | + useEffect(() => { | |
| 40 | + if (!showError || hasNotifiedRef.current) { | |
| 41 | + return; | |
| 42 | + } | |
| 43 | + hasNotifiedRef.current = true; | |
| 44 | + | |
| 45 | + // 내부 정보(백엔드 message 원문 등)는 노출하지 않는다 — 항상 이 고정 문구만 보여준다. | |
| 46 | + showToast({ variant: 'warning', message: LOGOUT_ERROR_MESSAGE }); | |
| 47 | + router.replace('/login'); | |
| 48 | + }, [showError, showToast, router]); | |
| 49 | + | |
| 50 | + return null; | |
| 51 | +} |
--- app/(public)/login/page.tsx
+++ app/(public)/login/page.tsx
... | ... | @@ -1,16 +1,32 @@ |
| 1 |
-import { redirect } from 'next/navigation';
|
|
| 1 |
+import { AuthCard } from '@/components/ui/auth-card';
|
|
| 2 | 2 |
import { getSessionAdmin } from '@/lib/auth/dal';
|
| 3 |
+import { LOGOUT_ERROR_QUERY_PARAM } from '@/lib/constants/logout';
|
|
| 4 |
+import { redirect } from 'next/navigation';
|
|
| 3 | 5 |
import { LoginForm } from './_components/login-form';
|
| 6 |
+import { LogoutNotice } from './_components/logout-notice';
|
|
| 4 | 7 |
|
| 5 | 8 |
// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를 |
| 6 | 9 |
// 원천 차단하기 위해 명시적으로 강제한다. |
| 7 | 10 |
export const dynamic = 'force-dynamic'; |
| 8 | 11 |
|
| 9 |
-export default async function Page() {
|
|
| 12 |
+interface PageProps {
|
|
| 13 |
+ searchParams: Promise<Record<string, string | string[] | undefined>>; |
|
| 14 |
+} |
|
| 15 |
+ |
|
| 16 |
+export default async function Page({ searchParams }: PageProps) {
|
|
| 10 | 17 |
const admin = await getSessionAdmin(); |
| 11 | 18 |
if (admin) {
|
| 12 | 19 |
redirect('/');
|
| 13 | 20 |
} |
| 14 | 21 |
|
| 15 |
- return <LoginForm />; |
|
| 22 |
+ const resolvedSearchParams = await searchParams; |
|
| 23 |
+ const logoutError = resolvedSearchParams[LOGOUT_ERROR_QUERY_PARAM]; |
|
| 24 |
+ const showLogoutError = typeof logoutError === 'string' && logoutError.length > 0; |
|
| 25 |
+ |
|
| 26 |
+ return ( |
|
| 27 |
+ <AuthCard> |
|
| 28 |
+ <LogoutNotice showError={showLogoutError} />
|
|
| 29 |
+ <LoginForm /> |
|
| 30 |
+ </AuthCard> |
|
| 31 |
+ ); |
|
| 16 | 32 |
} |
--- app/_components/splash-gate.tsx
... | ... | @@ -1,76 +0,0 @@ |
| 1 | -'use client'; | |
| 2 | - | |
| 3 | -import { useEffect, useRef, useState } from 'react'; | |
| 4 | -import { SplashScreen } from '@/components/ui/splash-screen'; | |
| 5 | -import { SPLASH_SHOWN_STORAGE_KEY } from '@/lib/constants/splash'; | |
| 6 | - | |
| 7 | -/** 로딩 자체가 즉시 끝나도 스플래시가 깜빡이지 않도록 유지하는 최소 표시 시간(ms). */ | |
| 8 | -const SPLASH_MIN_DISPLAY_MS = 700; | |
| 9 | - | |
| 10 | -function hasShownSplashThisSession(): boolean { | |
| 11 | - try { | |
| 12 | - return sessionStorage.getItem(SPLASH_SHOWN_STORAGE_KEY) === '1'; | |
| 13 | - } catch { | |
| 14 | - return false; | |
| 15 | - } | |
| 16 | -} | |
| 17 | - | |
| 18 | -function markSplashShown(): void { | |
| 19 | - try { | |
| 20 | - sessionStorage.setItem(SPLASH_SHOWN_STORAGE_KEY, '1'); | |
| 21 | - } catch { | |
| 22 | - // sessionStorage 접근 불가 환경(시크릿 모드 제한 등) — 매 로드마다 재표시되는 것으로 완화한다. | |
| 23 | - } | |
| 24 | -} | |
| 25 | - | |
| 26 | -/** | |
| 27 | - * 스플래시 표시 게이팅 로직 — 비주얼(SplashScreen, design 소유)은 항상 서버 렌더와 동일하게 | |
| 28 | - * 마운트해 hydration 불일치를 만들지 않는다. "이미 이번 세션에 표시했음"으로 인한 깜빡임 방지는 | |
| 29 | - * `app/layout.tsx`의 `<head>` 동기 inline script + CSS가 첫 페인트 전에 `<html>`에 부여한 | |
| 30 | - * data attribute가 담당한다(useEffect는 첫 페인트 이후에 실행되어 깜빡임을 막을 수 없다 — | |
| 31 | - * preventing-flash-before-hydration.md). 이 컴포넌트는 같은 판정을 렌더 중 ref에 1회 계산해두고, | |
| 32 | - * 이미 표시한 세션이면 effect에서 타이머 자체를 걸지 않는다(= state를 effect 본문에서 동기 | |
| 33 | - * 호출하지 않아 cascading render를 만들지 않는다). | |
| 34 | - */ | |
| 35 | -export function SplashGate() { | |
| 36 | - const [isVisible, setIsVisible] = useState(true); | |
| 37 | - const [isExiting, setIsExiting] = useState(false); | |
| 38 | - const skipIntroRef = useRef<boolean | null>(null); | |
| 39 | - | |
| 40 | - if (skipIntroRef.current === null) { | |
| 41 | - skipIntroRef.current = | |
| 42 | - typeof window !== 'undefined' && hasShownSplashThisSession(); | |
| 43 | - } | |
| 44 | - | |
| 45 | - useEffect(() => { | |
| 46 | - if (skipIntroRef.current) { | |
| 47 | - // 이미 <head> inline script + CSS로 숨겨진 상태 — 타이머/퇴장 애니메이션을 돌릴 필요가 없다. | |
| 48 | - return; | |
| 49 | - } | |
| 50 | - | |
| 51 | - const timer = setTimeout(() => { | |
| 52 | - setIsExiting(true); | |
| 53 | - }, SPLASH_MIN_DISPLAY_MS); | |
| 54 | - | |
| 55 | - return () => clearTimeout(timer); | |
| 56 | - }, []); | |
| 57 | - | |
| 58 | - function handleExitAnimationEnd() { | |
| 59 | - markSplashShown(); | |
| 60 | - setIsVisible(false); | |
| 61 | - } | |
| 62 | - | |
| 63 | - if (!isVisible) { | |
| 64 | - return null; | |
| 65 | - } | |
| 66 | - | |
| 67 | - return ( | |
| 68 | - <div | |
| 69 | - data-splash-gate | |
| 70 | - className={isExiting ? 'animate-fade-out' : undefined} | |
| 71 | - onAnimationEnd={isExiting ? handleExitAnimationEnd : undefined} | |
| 72 | - > | |
| 73 | - <SplashScreen /> | |
| 74 | - </div> | |
| 75 | - ); | |
| 76 | -} |
--- app/globals.css
... | ... | @@ -1,273 +0,0 @@ |
| 1 | -@import "tailwindcss"; | |
| 2 | - | |
| 3 | -/* 색상 토큰 출처: edupay-app lib/ui/core/tokens/app_colors.dart | |
| 4 | - (Figma: FOX PAY 1차 제작시안, node 91:10205). 다크 값은 edupay-app에 대응 | |
| 5 | - 다크 시안이 없어 "임의 도출 — 정식 다크 시안 확정 시 교체". | |
| 6 | - 메커니즘: `color-scheme` + `light-dark()`(Chrome 123+/Safari 17.5+/Firefox 120+ | |
| 7 | - 지원, 미지원 브라우저는 값이 무효화되므로 사내 어드민 한정 트레이드오프로 채택 | |
| 8 | - — design 계획 §4.1/§5.3, 2026-07-28 게이트 승인). 각 시맨틱 토큰이 | |
| 9 | - `light-dark(라이트값, 다크값)`으로 라이트/다크 값을 한 선언에 함께 보유해 | |
| 10 | - 드리프트를 구조적으로 차단한다. 수동 전환은 아래 `:root[data-theme]`가 | |
| 11 | - `color-scheme`만 덮어써 처리하므로 값 자체는 이 블록 한 곳에만 존재한다 | |
| 12 | - (Next.js 16 번들 문서 preventing-flash-before-hydration.md §Themes 패턴). */ | |
| 13 | -:root { | |
| 14 | - color-scheme: light dark; | |
| 15 | - | |
| 16 | - /* Brand */ | |
| 17 | - --primary: light-dark(#502ef5, #877aec); | |
| 18 | - --primary-container: light-dark(#6d5ce7, #502ef5); | |
| 19 | - /* on-primary 다크값 대비 보정: 원래 #ffffff였으나 다크 --primary(#877aec) 위에서 | |
| 20 | - 3.49:1로 WCAG AA(4.5:1) 텍스트 기준 미달 확인 → #14161b로 교체해 5.18:1 통과. | |
| 21 | - Material 3 다크 규약(밝은 primary 표면 + 어두운 onPrimary 텍스트)과 일치하며 | |
| 22 | - text-primary on surface(4.61:1)에는 영향 없음 — design 계획 §5.1(i), | |
| 23 | - 2026-07-28 게이트 승인. */ | |
| 24 | - --on-primary: light-dark(#ffffff, #14161b); | |
| 25 | - --on-primary-container: light-dark(#e2e0fb, #e2e0fb); | |
| 26 | - --secondary: light-dark(#877aec, #9b92ff); | |
| 27 | - | |
| 28 | - /* Surface / background (원 토큰: surface / gray0) */ | |
| 29 | - --background: light-dark(#ffffff, #14161b); | |
| 30 | - --surface: light-dark(#ffffff, #1e2128); | |
| 31 | - --surface-muted: light-dark(#f5f7fa, #2c3038); | |
| 32 | - | |
| 33 | - /* Foreground / text (원 토큰: onSurface 계열) */ | |
| 34 | - --foreground: light-dark(#2c3038, #f5f7fa); | |
| 35 | - --foreground-muted: light-dark(#565d6b, #bbc4d8); | |
| 36 | - --foreground-subtle: light-dark(#6b7486, #8790a3); | |
| 37 | - | |
| 38 | - /* Border (원 토큰: outline 계열) */ | |
| 39 | - --border: light-dark(#dae0ed, #424752); | |
| 40 | - --border-subtle: light-dark(#e1e6f1, #343943); | |
| 41 | - | |
| 42 | - /* Status */ | |
| 43 | - --danger: light-dark(#ec2e2f, #ff5c5d); | |
| 44 | - --success: light-dark(#21b770, #3acb93); | |
| 45 | - --warning: light-dark(#ffb020, #ffc24d); | |
| 46 | - | |
| 47 | - /* Overlay */ | |
| 48 | - --scrim: light-dark(rgb(0 0 0 / 0.6), rgb(0 0 0 / 0.7)); | |
| 49 | -} | |
| 50 | - | |
| 51 | -/* 수동 테마 선택 — <html data-theme="light"|"dark">가 `color-scheme`만 덮어써 | |
| 52 | - 위 light-dark() 값이 해당 분기로 고정된다(값 자체는 여기서 재정의하지 않음). | |
| 53 | - data-theme 속성이 없거나 "system"이면 :root의 `color-scheme: light dark`가 | |
| 54 | - 유지되어 OS `prefers-color-scheme`를 그대로 따른다(feature 전달 사양). */ | |
| 55 | -:root[data-theme="light"] { | |
| 56 | - color-scheme: light; | |
| 57 | -} | |
| 58 | - | |
| 59 | -:root[data-theme="dark"] { | |
| 60 | - color-scheme: dark; | |
| 61 | -} | |
| 62 | - | |
| 63 | -@theme inline { | |
| 64 | - --color-primary: var(--primary); | |
| 65 | - --color-primary-container: var(--primary-container); | |
| 66 | - --color-on-primary: var(--on-primary); | |
| 67 | - --color-on-primary-container: var(--on-primary-container); | |
| 68 | - --color-secondary: var(--secondary); | |
| 69 | - | |
| 70 | - --color-background: var(--background); | |
| 71 | - --color-surface: var(--surface); | |
| 72 | - --color-surface-muted: var(--surface-muted); | |
| 73 | - | |
| 74 | - --color-foreground: var(--foreground); | |
| 75 | - --color-foreground-muted: var(--foreground-muted); | |
| 76 | - --color-foreground-subtle: var(--foreground-subtle); | |
| 77 | - | |
| 78 | - --color-border: var(--border); | |
| 79 | - --color-border-subtle: var(--border-subtle); | |
| 80 | - | |
| 81 | - --color-danger: var(--danger); | |
| 82 | - --color-success: var(--success); | |
| 83 | - --color-warning: var(--warning); | |
| 84 | - | |
| 85 | - --color-scrim: var(--scrim); | |
| 86 | - | |
| 87 | - /* WantedSans 연결 전에도 안전한 폴백(feature 레인이 app/layout.tsx에서 | |
| 88 | - next/font/local로 --font-wanted-sans를 정의). */ | |
| 89 | - --font-sans: var(--font-wanted-sans, system-ui), system-ui, | |
| 90 | - "Apple SD Gothic Neo", sans-serif; | |
| 91 | - --font-mono: var(--font-geist-mono); | |
| 92 | - | |
| 93 | - /* 타이포 토큰 출처: edupay-app lib/ui/core/tokens/app_typography.dart | |
| 94 | - (Figma: FOX PAY 1차 제작시안, node 112:11439). voucher/numeric 등 앱 전용 | |
| 95 | - 스타일은 어드민 무관으로 미이식. */ | |
| 96 | - --text-display-lg: 3rem; | |
| 97 | - --text-display-lg--line-height: 1.5; | |
| 98 | - --text-display-lg--font-weight: 700; | |
| 99 | - | |
| 100 | - --text-display-md: 2.25rem; | |
| 101 | - --text-display-md--line-height: 1.5; | |
| 102 | - --text-display-md--letter-spacing: -0.72px; | |
| 103 | - --text-display-md--font-weight: 700; | |
| 104 | - | |
| 105 | - --text-display-sm: 2rem; | |
| 106 | - --text-display-sm--line-height: 1.5; | |
| 107 | - --text-display-sm--letter-spacing: -0.64px; | |
| 108 | - --text-display-sm--font-weight: 700; | |
| 109 | - | |
| 110 | - --text-headline-lg: 1.5rem; | |
| 111 | - --text-headline-lg--line-height: 1.5; | |
| 112 | - --text-headline-lg--letter-spacing: -0.72px; | |
| 113 | - --text-headline-lg--font-weight: 700; | |
| 114 | - | |
| 115 | - --text-headline-md: 1.25rem; | |
| 116 | - --text-headline-md--line-height: 1.5; | |
| 117 | - --text-headline-md--letter-spacing: -0.6px; | |
| 118 | - --text-headline-md--font-weight: 700; | |
| 119 | - | |
| 120 | - --text-headline-sm: 1.125rem; | |
| 121 | - --text-headline-sm--line-height: 1.3; | |
| 122 | - --text-headline-sm--letter-spacing: -0.54px; | |
| 123 | - --text-headline-sm--font-weight: 700; | |
| 124 | - | |
| 125 | - --text-title-lg: 1.125rem; | |
| 126 | - --text-title-lg--line-height: 1.5; | |
| 127 | - --text-title-lg--font-weight: 700; | |
| 128 | - | |
| 129 | - --text-title-md: 1rem; | |
| 130 | - --text-title-md--line-height: 1.5; | |
| 131 | - --text-title-md--font-weight: 700; | |
| 132 | - | |
| 133 | - --text-title-sm: 0.875rem; | |
| 134 | - --text-title-sm--line-height: 1.5; | |
| 135 | - --text-title-sm--letter-spacing: -0.42px; | |
| 136 | - --text-title-sm--font-weight: 700; | |
| 137 | - | |
| 138 | - --text-body-lg: 1rem; | |
| 139 | - --text-body-lg--line-height: 1.5; | |
| 140 | - --text-body-lg--letter-spacing: -0.48px; | |
| 141 | - --text-body-lg--font-weight: 400; | |
| 142 | - | |
| 143 | - --text-body-md: 0.875rem; | |
| 144 | - --text-body-md--line-height: 1.5; | |
| 145 | - --text-body-md--font-weight: 400; | |
| 146 | - | |
| 147 | - --text-body-sm: 0.75rem; | |
| 148 | - --text-body-sm--line-height: 1.5; | |
| 149 | - --text-body-sm--font-weight: 400; | |
| 150 | - | |
| 151 | - --text-label-lg: 1rem; | |
| 152 | - --text-label-lg--line-height: 1.4; | |
| 153 | - --text-label-lg--letter-spacing: -0.48px; | |
| 154 | - --text-label-lg--font-weight: 700; | |
| 155 | - | |
| 156 | - --text-label-md: 0.875rem; | |
| 157 | - --text-label-md--letter-spacing: -0.28px; | |
| 158 | - --text-label-md--font-weight: 400; | |
| 159 | - | |
| 160 | - --text-label-sm: 0.75rem; | |
| 161 | - --text-label-sm--font-weight: 400; | |
| 162 | - | |
| 163 | - /* radius 토큰 출처: edupay-app lib/ui/core/tokens/app_radius.dart. | |
| 164 | - Tailwind 기본 스케일과 다른 값으로 의도적 재정의(앱 일체화, §8-4 승인). | |
| 165 | - pill(999)은 기본 rounded-full로 충족, card(50)은 앱 전용 미이식. */ | |
| 166 | - --radius-xs: 0.25rem; | |
| 167 | - --radius-sm: 0.5rem; | |
| 168 | - --radius-md: 0.75rem; | |
| 169 | - --radius-lg: 1rem; | |
| 170 | - --radius-xl: 1.25rem; | |
| 171 | - --radius-2xl: 1.5rem; | |
| 172 | - | |
| 173 | - /* 그림자 토큰 출처: edupay-app lib/ui/core/tokens/app_shadows.dart — | |
| 174 | - 어드민 유효분만 이식(voucher inner·stacked 등 미이식). */ | |
| 175 | - --shadow-card: 0 5.16px 5.16px rgb(93 100 119 / 0.12); | |
| 176 | - --shadow-list-card: 0 8px 16px rgb(93 100 119 / 0.12); | |
| 177 | - --shadow-dialog: 0 8px 16px rgb(0 0 0 / 0.1); | |
| 178 | - --shadow-popup: 2.16px 6.32px 6px rgb(177 178 180 / 0.24); | |
| 179 | - --shadow-toast: 0 10px 12px rgb(0 0 0 / 0.2); | |
| 180 | - --shadow-subtle: 0 2px 4px rgb(0 0 0 / 0.08); | |
| 181 | - | |
| 182 | - /* 애니메이션 토큰(신규 네임스페이스 — §4.2 승인). 오버레이·다이얼로그·토스트· | |
| 183 | - 스플래시 전환에 사용. --ease-*는 Tailwind 기본값 사용, duration은 값에 | |
| 184 | - 내장(트랜지션 duration 표준은 bare 유틸 duration-200 사용). */ | |
| 185 | - --animate-fade-in: fade-in 200ms var(--ease-out); | |
| 186 | - --animate-fade-out: fade-out 200ms var(--ease-in); | |
| 187 | - --animate-dialog-in: dialog-in 200ms var(--ease-out); | |
| 188 | - --animate-toast-in: toast-in 300ms var(--ease-out); | |
| 189 | -} | |
| 190 | - | |
| 191 | -@keyframes fade-in { | |
| 192 | - from { | |
| 193 | - opacity: 0; | |
| 194 | - } | |
| 195 | - to { | |
| 196 | - opacity: 1; | |
| 197 | - } | |
| 198 | -} | |
| 199 | - | |
| 200 | -/* 스플래시 등 풀스크린 오버레이 퇴장용 — fade-in의 역방향 키프레임. */ | |
| 201 | -@keyframes fade-out { | |
| 202 | - from { | |
| 203 | - opacity: 1; | |
| 204 | - } | |
| 205 | - to { | |
| 206 | - opacity: 0; | |
| 207 | - } | |
| 208 | -} | |
| 209 | - | |
| 210 | -@keyframes dialog-in { | |
| 211 | - from { | |
| 212 | - opacity: 0; | |
| 213 | - transform: scale(0.95); | |
| 214 | - } | |
| 215 | - to { | |
| 216 | - opacity: 1; | |
| 217 | - transform: scale(1); | |
| 218 | - } | |
| 219 | -} | |
| 220 | - | |
| 221 | -@keyframes toast-in { | |
| 222 | - from { | |
| 223 | - opacity: 0; | |
| 224 | - transform: translateY(0.5rem); | |
| 225 | - } | |
| 226 | - to { | |
| 227 | - opacity: 1; | |
| 228 | - transform: translateY(0); | |
| 229 | - } | |
| 230 | -} | |
| 231 | - | |
| 232 | -/* 간격 토큰 매핑 (변경 없음) — edupay-app lib/ui/core/tokens/app_spacing.dart의 | |
| 233 | - 4/8 grid(4·8·12·16·20·24·32·40)는 Tailwind 기본 --spacing: 0.25rem 스케일과 | |
| 234 | - 정확히 일치한다: xs→p-1, sm→p-2, md→p-3, lg→p-4, xl→p-5, xxl→p-6, | |
| 235 | - xxxl→p-8, xxxxl→p-10. */ | |
| 236 | - | |
| 237 | -/* 테마 수동 전환 — feature 소비처(예: 헤더 테마 토글)를 위한 CSS 구동 변형자. | |
| 238 | - design 계획 §9 게이트 교차 검증에서 feature 안(아이콘 3개를 모두 DOM에 렌더하고 | |
| 239 | - CSS만으로 하나를 표시)을 채택했다 — 서버/클라 출력이 항상 동일해 hydration | |
| 240 | - 불일치 표면적이 0이다. `theme-light`/`theme-dark`는 명시적 선택에만 매칭되고, | |
| 241 | - `theme-system`은 data-theme 부재 **및** 명시적 data-theme="system" 값 둘 다 | |
| 242 | - 커버해야 하므로(둘은 동일하게 동작 — feature 전달 사양) 부정 선택자 두 개로 | |
| 243 | - "light도 dark도 아님"을 표현한다(속성 부재 시에도 :not()은 매칭된다). */ | |
| 244 | -@custom-variant theme-system (&:where(html:not([data-theme="light"]):not([data-theme="dark"]) *)); | |
| 245 | -@custom-variant theme-light (&:where(html[data-theme="light"] *)); | |
| 246 | -@custom-variant theme-dark (&:where(html[data-theme="dark"] *)); | |
| 247 | - | |
| 248 | -/* 테마 전환 색상 트랜지션 — feature가 토글 직전 <html>에 [data-theme-transition]을 | |
| 249 | - 부여하고 ~200ms 후 제거하는 시간 한정 opt-in이다(상시 트랜지션이 아니므로 다른 | |
| 250 | - hover/트랜지션 유틸과 경쟁하지 않는다). prefers-reduced-motion: no-preference로 | |
| 251 | - 게이팅해 감소 모션 환경에서는 아무 트랜지션도 걸리지 않는다(전환 없이 즉시 반영). | |
| 252 | - !important는 유틸리티의 transition 계열 클래스(transition-none 포함)가 이 200ms | |
| 253 | - 동안만 존재하는 규칙을 덮어쓰지 않도록 한다. box-shadow는 제외 — --shadow 토큰은 | |
| 254 | - 테마별로 값이 바뀌지 않아 전환할 대상이 없다. */ | |
| 255 | -@media (prefers-reduced-motion: no-preference) { | |
| 256 | - html[data-theme-transition], | |
| 257 | - html[data-theme-transition] *, | |
| 258 | - html[data-theme-transition] *::before, | |
| 259 | - html[data-theme-transition] *::after { | |
| 260 | - transition: | |
| 261 | - background-color 200ms var(--ease-out), | |
| 262 | - border-color 200ms var(--ease-out), | |
| 263 | - color 200ms var(--ease-out), | |
| 264 | - fill 200ms var(--ease-out), | |
| 265 | - stroke 200ms var(--ease-out), | |
| 266 | - outline-color 200ms var(--ease-out) !important; | |
| 267 | - } | |
| 268 | -} | |
| 269 | - | |
| 270 | -body { | |
| 271 | - background: var(--background); | |
| 272 | - color: var(--foreground); | |
| 273 | -} |
+++ app/globals.scss
... | ... | @@ -0,0 +1,24 @@ |
| 1 | +// 앱 글로벌 스타일 — 디자인 시스템 로드 + **이 앱에만 해당하는** 최소 베이스. | |
| 2 | +// | |
| 3 | +// 색·타이포·간격 같은 디자인 값은 여기에 두지 않는다. 전부 `@fox/styles/tokens/`가 | |
| 4 | +// 소유하며, 이 파일은 "@fox를 켜고, 이 앱의 셸 구조를 잡는다"까지만 한다. | |
| 5 | +// 화면·컴포넌트 스타일은 각자의 `.module.scss`로 간다. | |
| 6 | + | |
| 7 | +@use "@fox/styles"; | |
| 8 | + | |
| 9 | +// 공용 컴포넌트 스타일. CSS Modules가 아니라 전역 BEM이라 여기서 한 번 불러온다 — | |
| 10 | +// 그래야 React 밖(다른 프레임워크·순수 SCSS)에서도 같은 방식으로 쓸 수 있다. | |
| 11 | +@use "@fox/styles/components"; | |
| 12 | + | |
| 13 | +// 앱 셸 — 루트 레이아웃이 뷰포트 높이를 채우고 세로로 쌓이는 구조. | |
| 14 | +// 푸터를 바닥에 붙이거나 본문 영역만 스크롤시키는 화면들이 이 전제에 기댄다. | |
| 15 | +html, | |
| 16 | +body { | |
| 17 | + block-size: 100%; | |
| 18 | +} | |
| 19 | + | |
| 20 | +body { | |
| 21 | + display: flex; | |
| 22 | + flex-direction: column; | |
| 23 | + min-block-size: 100%; | |
| 24 | +} |
--- app/layout.tsx
+++ app/layout.tsx
... | ... | @@ -3,13 +3,11 @@ |
| 3 | 3 |
import localFont from "next/font/local"; |
| 4 | 4 |
import { FeedbackHost } from "@/app/_components/feedback-host";
|
| 5 | 5 |
import { FeedbackProvider } from "@/app/_components/feedback-provider";
|
| 6 |
-import { SplashGate } from "@/app/_components/splash-gate";
|
|
| 7 |
-import { SPLASH_SHOWN_STORAGE_KEY } from "@/lib/constants/splash";
|
|
| 8 | 6 |
import {
|
| 9 | 7 |
THEME_ATTRIBUTE_NAME, |
| 10 | 8 |
THEME_STORAGE_KEY, |
| 11 | 9 |
} from "@/lib/constants/theme"; |
| 12 |
-import "./globals.css"; |
|
| 10 |
+import "./globals.scss"; |
|
| 13 | 11 |
|
| 14 | 12 |
// 첫 페인트 전 테마 적용 — <head>는 <body>보다 먼저 파싱되므로 <html>만 조작 가능하다 |
| 15 | 13 |
// (preventing-flash-before-hydration.md의 Themes 패턴). localStorage에 명시 선택("light"
|
... | ... | @@ -20,30 +18,18 @@ |
| 20 | 18 |
// THEME_STORAGE_KEY/THEME_ATTRIBUTE_NAME은 반드시 'use client' 파일이 아닌 |
| 21 | 19 |
// lib/constants/theme에서 import한다 — 'use client' 모듈의 일반 값(문자열) export를 |
| 22 | 20 |
// Server Component가 직접 import하면 클라이언트 레퍼런스 프록시 특성상 undefined로 평가된다 |
| 23 |
-// (컴포넌트 export만 정상적으로 프록시된다. splash 상수와 동일한 이유로 분리됨). |
|
| 21 |
+// (컴포넌트 export만 정상적으로 프록시된다). |
|
| 24 | 22 |
const THEME_BOOTSTRAP_SCRIPT = `(function(){try{var t=localStorage.getItem(${JSON.stringify(
|
| 25 | 23 |
THEME_STORAGE_KEY |
| 26 | 24 |
)});if(t==="light"||t==="dark")document.documentElement.setAttribute(${JSON.stringify(
|
| 27 | 25 |
THEME_ATTRIBUTE_NAME |
| 28 | 26 |
)},t)}catch(e){}})()`;
|
| 29 | 27 |
|
| 30 |
-// 스플래시 재표시 방지 — <head>는 <body>보다 먼저 파싱되므로 body 하위 요소는 아직 존재하지 |
|
| 31 |
-// 않는다(preventing-flash-before-hydration.md의 Themes 패턴과 동일하게 <html>만 조작 가능). |
|
| 32 |
-// 이번 세션에 이미 표시했다면 첫 페인트 전에 <html>에 data attribute를 부여하고, 아래 스타일 |
|
| 33 |
-// 규칙이 SplashGate 루트를 즉시 숨긴다 — useEffect보다 먼저 실행되어 깜빡임이 없다. |
|
| 34 |
-// |
|
| 35 |
-// SPLASH_SHOWN_STORAGE_KEY는 반드시 'use client' 파일이 아닌 lib/constants/splash에서 |
|
| 36 |
-// import한다 — splash-gate.tsx('use client')의 일반 값(문자열) export를 Server Component가
|
|
| 37 |
-// 직접 import하면 클라이언트 레퍼런스 프록시 특성상 undefined로 평가된다(컴포넌트 export만 |
|
| 38 |
-// 정상적으로 프록시된다). |
|
| 39 |
-const SPLASH_HIDE_STYLE = `html[data-splash-shown] [data-splash-gate]{display:none}`;
|
|
| 40 |
-const SPLASH_GATE_SCRIPT = `(function(){try{if(sessionStorage.getItem(${JSON.stringify(
|
|
| 41 |
- SPLASH_SHOWN_STORAGE_KEY |
|
| 42 |
-)}))document.documentElement.setAttribute("data-splash-shown","")}catch(e){}})()`;
|
|
| 43 |
- |
|
| 44 |
-// 어드민 전체 기본 서체(edupay-app 일체화, design 전달 사양) — Geist는 대체되어 제거했다. |
|
| 45 |
-// globals.css `--font-sans`가 이 변수를 우선 참조하고, 로드 전에도 system-ui로 안전하게 |
|
| 46 |
-// 폴백하도록 이미 구성돼 있다. |
|
| 28 |
+// 어드민 전체 기본 서체. 폰트 파일은 **앱이 소유**하고 디자인 시스템은 참조만 한다 — |
|
| 29 |
+// `@fox`는 `--app-font-sans`를 읽을 뿐이라(@fox/README.md "호스트 앱과의 계약") 폴더를 |
|
| 30 |
+// 다른 프로젝트로 복사해도 그 프로젝트의 서체를 그대로 따른다. 변수명이 `--app-*`인 것도 |
|
| 31 |
+// 그 계약의 이름이기 때문이며, 미주입 시에도 `system-ui`로 폴백하도록 토큰 쪽에 폴백이 |
|
| 32 |
+// 들어 있다. |
|
| 47 | 33 |
const wantedSans = localFont({
|
| 48 | 34 |
src: [ |
| 49 | 35 |
{
|
... | ... | @@ -67,14 +53,13 @@ |
| 67 | 53 |
style: "normal", |
| 68 | 54 |
}, |
| 69 | 55 |
], |
| 70 |
- variable: "--font-wanted-sans", |
|
| 56 |
+ variable: "--app-font-sans", |
|
| 71 | 57 |
display: "swap", |
| 72 | 58 |
}); |
| 73 | 59 |
|
| 74 |
-// 코드/숫자 표기용 고정폭 서체 — globals.css `--font-mono`가 여전히 이 변수를 직접 참조하므로 |
|
| 75 |
-// (폴백 체인 없음) 유지한다. |
|
| 60 |
+// 코드/숫자 표기용 고정폭 서체 — sans와 동일하게 `--app-font-mono` 계약으로 주입한다. |
|
| 76 | 61 |
const geistMono = Geist_Mono({
|
| 77 |
- variable: "--font-geist-mono", |
|
| 62 |
+ variable: "--app-font-mono", |
|
| 78 | 63 |
subsets: ["latin"], |
| 79 | 64 |
}); |
| 80 | 65 |
|
... | ... | @@ -107,15 +92,12 @@ |
| 107 | 92 |
<html |
| 108 | 93 |
lang="ko" |
| 109 | 94 |
suppressHydrationWarning |
| 110 |
- className={`${wantedSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
| 95 |
+ className={`${wantedSans.variable} ${geistMono.variable}`}
|
|
| 111 | 96 |
> |
| 112 | 97 |
<head> |
| 113 | 98 |
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
| 114 |
- <style dangerouslySetInnerHTML={{ __html: SPLASH_HIDE_STYLE }} />
|
|
| 115 |
- <script dangerouslySetInnerHTML={{ __html: SPLASH_GATE_SCRIPT }} />
|
|
| 116 | 99 |
</head> |
| 117 |
- <body className="min-h-full flex flex-col"> |
|
| 118 |
- <SplashGate /> |
|
| 100 |
+ <body> |
|
| 119 | 101 |
<FeedbackProvider> |
| 120 | 102 |
{children}
|
| 121 | 103 |
<FeedbackHost /> |
--- components/ui/splash-screen.tsx
... | ... | @@ -1,22 +0,0 @@ |
| 1 | -import Image from "next/image"; | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * 풀스크린 스플래시. 네이티브 스플래시(edupay-app SplashConfig)와 동일하게 | |
| 5 | - * primary 단색 배경 + 백색 로고 240px(logoWidth) 구성을 따른다. | |
| 6 | - * 표시 여부·퇴장 타이밍(fade-out 적용)은 호출부(feature) 책임 — 이 컴포넌트는 | |
| 7 | - * props를 받지 않는다. | |
| 8 | - */ | |
| 9 | -export function SplashScreen() { | |
| 10 | - return ( | |
| 11 | - <div className="fixed inset-0 z-50 flex animate-fade-in items-center justify-center bg-primary"> | |
| 12 | - <Image | |
| 13 | - src="/images/logo-foxpay-white.svg" | |
| 14 | - alt="FoxPay" | |
| 15 | - width={170} | |
| 16 | - height={62} | |
| 17 | - className="h-auto w-60" | |
| 18 | - priority | |
| 19 | - /> | |
| 20 | - </div> | |
| 21 | - ); | |
| 22 | -} |
--- lib/auth/dal.ts
+++ lib/auth/dal.ts
... | ... | @@ -2,7 +2,8 @@ |
| 2 | 2 |
import { cache } from 'react';
|
| 3 | 3 |
import { redirect } from 'next/navigation';
|
| 4 | 4 |
import { readSessionToken } from '@/lib/auth/session';
|
| 5 |
-import { verifySessionToken } from '@/lib/auth/session-token';
|
|
| 5 |
+import { verifySessionToken, type SessionTokenPayload } from '@/lib/auth/session-token';
|
|
| 6 |
+import { decodeJwtPayload } from '@/lib/auth/decode-jwt-payload';
|
|
| 6 | 7 |
import type { AdminUser } from '@/lib/domain/admin-user';
|
| 7 | 8 |
|
| 8 | 9 |
/** |
... | ... | @@ -17,14 +18,78 @@ |
| 17 | 18 |
* (adminId/loginId/admRoleCd)만으로 AdminUser를 구성한다 — Repository 왕복이 없다. |
| 18 | 19 |
*/ |
| 19 | 20 |
|
| 20 |
-/** 세션 검증 결과를 null 허용으로 반환 — redirect 없이 상태만 알고 싶을 때 사용 (예: 로그인 페이지). */ |
|
| 21 |
-export const getSessionAdmin = cache(async (): Promise<AdminUser | null> => {
|
|
| 22 |
- const token = await readSessionToken(); |
|
| 23 |
- if (!token) {
|
|
| 24 |
- return null; |
|
| 21 |
+/** 백엔드 accessToken(JWT) payload에서 신선도 검사에 필요한 클레임만 최소로 본다. */ |
|
| 22 |
+type AccessTokenFreshnessClaims = {
|
|
| 23 |
+ exp: unknown; |
|
| 24 |
+ adminId: unknown; |
|
| 25 |
+ loginId: unknown; |
|
| 26 |
+}; |
|
| 27 |
+ |
|
| 28 |
+/** |
|
| 29 |
+ * 세션에 실린 백엔드 accessToken이 여전히 신선한지 검사한다 — **서명 검증이 아니다.** 위조 |
|
| 30 |
+ * 방지는 이미 이 세션 자체의 HMAC 서명(verifySessionToken)이 담당하고, 이 함수는 그 서명을 |
|
| 31 |
+ * 통과한 세션 안에 실린 accessToken이 그 뒤로 "만료·손상·불일치" 상태가 되지 않았는지만 본다 |
|
| 32 |
+ * (decode-jwt-payload.ts 상단 주석의 신뢰 구분과 동일한 경계). |
|
| 33 |
+ * |
|
| 34 |
+ * 세 가지를 확인한다: |
|
| 35 |
+ * 1. 형식 — JWT 3-파트 구조이고 payload가 파싱 가능한가. `decodeJwtPayload`가 실패하면 |
|
| 36 |
+ * null을 반환하므로 그대로 무효 처리한다. |
|
| 37 |
+ * 2. 만료 — accessToken 자체의 `exp` 클레임이 아직 미래인가. 세션 HMAC 토큰의 `exp` |
|
| 38 |
+ * (verifySessionToken이 이미 검사함)와는 **별개 값**이다 — 로그인 시점엔 두 값이 같지만 |
|
| 39 |
+ * (createSession이 백엔드 accessToken의 exp를 그대로 세션 exp로 쓴다) 그 사실이 이 검사의 |
|
| 40 |
+ * 존재 이유를 없애지 않는다: 세션 HMAC의 exp 검사는 "우리 세션 자체가 안 끊겼는지"만 |
|
| 41 |
+ * 보장하고, 이 검사는 "그 세션이 담고 있는 백엔드 토큰이 여전히 유효한지"를 재확인한다. |
|
| 42 |
+ * 3. 유저 일치 — 토큰 클레임의 adminId·loginId가 세션 payload에 저장된 값(로그인 시점에 같은 |
|
| 43 |
+ * 토큰에서 읽어 저장해 둔 값, lib/data/repositories/auth-repository.ts 참고)과 같은가. |
|
| 44 |
+ * 다르면 토큰 바꿔치기·세션 오염 신호이므로 무효로 취급한다. |
|
| 45 |
+ * |
|
| 46 |
+ * clock skew 허용치는 두지 않는다 — verifySessionToken()의 기존 exp 검사도 허용치 없이 |
|
| 47 |
+ * `exp <= now`로 엄격하게 판정하고(session-token.ts), 이 서버가 백엔드와 시계를 맞출 별도 |
|
| 48 |
+ * 장치가 없는 단일 배포 환경이라 허용치를 두면 "이미 만료된 토큰을 짧게 유효로 오판"하는 |
|
| 49 |
+ * 위험만 늘어난다. |
|
| 50 |
+ */ |
|
| 51 |
+function isAccessTokenFresh(session: SessionTokenPayload): boolean {
|
|
| 52 |
+ const claims = decodeJwtPayload<AccessTokenFreshnessClaims>(session.accessToken); |
|
| 53 |
+ if (!claims) {
|
|
| 54 |
+ return false; |
|
| 25 | 55 |
} |
| 26 | 56 |
|
| 27 |
- const session = verifySessionToken(token); |
|
| 57 |
+ if (typeof claims.exp !== 'number') {
|
|
| 58 |
+ return false; |
|
| 59 |
+ } |
|
| 60 |
+ const now = Math.floor(Date.now() / 1000); |
|
| 61 |
+ if (claims.exp <= now) {
|
|
| 62 |
+ return false; |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 65 |
+ return claims.adminId === session.adminId && claims.loginId === session.loginId; |
|
| 66 |
+} |
|
| 67 |
+ |
|
| 68 |
+/** |
|
| 69 |
+ * 서명·형식·만료·유저 일치까지 모두 통과한 세션 payload만 반환한다 — `getSessionAdmin`· |
|
| 70 |
+ * `getSessionAccessToken` 공통 진입점(이 파일 밖으로 내보내지 않는 내부 헬퍼). 검사 로직을 이 |
|
| 71 |
+ * 함수 하나에만 두어 두 공개 함수가 중복 구현 없이 같은 판단을 공유하게 한다. 렌더 패스당 |
|
| 72 |
+ * 1회로 dedupe(cache()). |
|
| 73 |
+ */ |
|
| 74 |
+const getValidSessionPayload = cache( |
|
| 75 |
+ async (): Promise<SessionTokenPayload | null> => {
|
|
| 76 |
+ const token = await readSessionToken(); |
|
| 77 |
+ if (!token) {
|
|
| 78 |
+ return null; |
|
| 79 |
+ } |
|
| 80 |
+ |
|
| 81 |
+ const session = verifySessionToken(token); |
|
| 82 |
+ if (!session) {
|
|
| 83 |
+ return null; |
|
| 84 |
+ } |
|
| 85 |
+ |
|
| 86 |
+ return isAccessTokenFresh(session) ? session : null; |
|
| 87 |
+ } |
|
| 88 |
+); |
|
| 89 |
+ |
|
| 90 |
+/** 세션 검증 결과를 null 허용으로 반환 — redirect 없이 상태만 알고 싶을 때 사용 (예: 로그인 페이지). */ |
|
| 91 |
+export const getSessionAdmin = cache(async (): Promise<AdminUser | null> => {
|
|
| 92 |
+ const session = await getValidSessionPayload(); |
|
| 28 | 93 |
if (!session) {
|
| 29 | 94 |
return null; |
| 30 | 95 |
} |
... | ... | @@ -42,16 +107,13 @@ |
| 42 | 107 |
* 백엔드 호출에 부착할 accessToken을 세션에서 꺼낸다 — **서버 전용, 화면으로 내려보내지 않는다.** |
| 43 | 108 |
* |
| 44 | 109 |
* `getSessionAdmin()`이 반환하는 `AdminUser`에는 토큰이 없다(민감 값이라 화면 DTO에서 의도적으로 |
| 45 |
- * 제외). 그래서 백엔드 API를 부르는 Repository용 경로를 따로 둔다. 세션이 없거나 만료·위조면 |
|
| 46 |
- * null이며, 그 경우 호출부는 Authorization 없이 요청하게 되고 백엔드가 401로 거절한다(fail-closed). |
|
| 110 |
+ * 제외). 그래서 백엔드 API를 부르는 Repository용 경로를 따로 둔다. 세션이 없거나 위조·만료· |
|
| 111 |
+ * 손상·유저 불일치(모두 `isAccessTokenFresh`가 판정)면 null이며, 그 경우 호출부는 Authorization |
|
| 112 |
+ * 없이 요청하게 되고 백엔드가 401로 거절한다(fail-closed). |
|
| 47 | 113 |
*/ |
| 48 | 114 |
export const getSessionAccessToken = cache(async (): Promise<string | null> => {
|
| 49 |
- const token = await readSessionToken(); |
|
| 50 |
- if (!token) {
|
|
| 51 |
- return null; |
|
| 52 |
- } |
|
| 53 |
- |
|
| 54 |
- return verifySessionToken(token)?.accessToken ?? null; |
|
| 115 |
+ const session = await getValidSessionPayload(); |
|
| 116 |
+ return session?.accessToken ?? null; |
|
| 55 | 117 |
}); |
| 56 | 118 |
|
| 57 | 119 |
/** 보호 라우트·Server Action 진입점 — 세션이 없으면 `/login`으로 redirect한다. */ |
+++ lib/constants/logout.ts
... | ... | @@ -0,0 +1,13 @@ |
| 1 | +/** | |
| 2 | + * 로그아웃 Server Action이 백엔드 호출 실패를 `/login` 화면에 알릴 때 쓰는 쿼리 파라미터 이름. | |
| 3 | + * | |
| 4 | + * `app/(protected)/_actions.ts`(로그아웃 액션 — redirect URL에 값을 싣는다)와 | |
| 5 | + * `app/(public)/login/page.tsx`(읽어서 LogoutNotice에 boolean prop으로 넘긴다) 양쪽이 같은 키를 | |
| 6 | + * 참조해야 하므로, 리터럴 문자열이 두 파일에 중복되어 드리프트하는 것을 막기 위해 상수로 뺀다 | |
| 7 | + * (session-cookie.ts의 SESSION_COOKIE_NAME과 동일한 목적). | |
| 8 | + * | |
| 9 | + * redirect()는 Server Action에서 즉시 던져지므로 클라이언트가 반환값을 읽을 기회가 없다 — 그래서 | |
| 10 | + * 실패 신호를 반환값이 아니라 이동할 URL 자체에 싣는다(§ redirect와 토스트 표시가 경쟁하지 않게 | |
| 11 | + * 하는 설계, app/(protected)/_actions.ts 참고). | |
| 12 | + */ | |
| 13 | +export const LOGOUT_ERROR_QUERY_PARAM = 'logoutError'; |
--- lib/constants/splash.ts
... | ... | @@ -1,10 +0,0 @@ |
| 1 | -/** | |
| 2 | - * 스플래시를 세션당 1회만 표시했음을 기록하는 sessionStorage 키. | |
| 3 | - * | |
| 4 | - * `app/layout.tsx`(Server Component — `<head>` inline script 문자열 생성)와 | |
| 5 | - * `app/_components/splash-gate.tsx`('use client') 양쪽이 같은 값을 참조해야 한다. 'use client' | |
| 6 | - * 모듈의 export는 컴포넌트가 아닌 일반 값(문자열 등)일 경우 Server Component에서 import 시 | |
| 7 | - * `undefined`로 평가된다 — Next.js의 클라이언트 레퍼런스 프록시가 컴포넌트 형태만 가정하기 | |
| 8 | - * 때문이다. 그래서 이 상수는 'use client'가 없는 순수 모듈로 분리한다. | |
| 9 | - */ | |
| 10 | -export const SPLASH_SHOWN_STORAGE_KEY = "edupay-admin-splash-shown"; |
--- lib/constants/theme.ts
+++ lib/constants/theme.ts
... | ... | @@ -4,8 +4,7 @@ |
| 4 | 4 |
* `app/layout.tsx`(Server Component — `<head>` inline script 문자열 생성)와 |
| 5 | 5 |
* `_hooks/use-theme-preference.ts`('use client') 양쪽이 같은 값을 참조해야 한다. 'use client'
|
| 6 | 6 |
* 모듈의 export는 컴포넌트가 아닌 일반 값(문자열 등)일 경우 Server Component에서 import 시 |
| 7 |
- * `undefined`로 평가된다(lib/constants/splash.ts와 동일한 이유) — 그래서 이 상수도 'use client'가 |
|
| 8 |
- * 없는 순수 모듈로 분리한다. |
|
| 7 |
+ * `undefined`로 평가된다 — 그래서 이 상수는 'use client'가 없는 순수 모듈로 분리한다. |
|
| 9 | 8 |
*/ |
| 10 | 9 |
|
| 11 | 10 |
/** 테마 선택값. "system"은 OS 설정을 따르며 `<html>`에 속성을 부여하지 않는 것으로 표현한다. */ |
--- lib/data/mock/admin-member-store.ts
+++ lib/data/mock/admin-member-store.ts
... | ... | @@ -2,164 +2,44 @@ |
| 2 | 2 |
import type { AdminMember } from '@/lib/domain/admin-member';
|
| 3 | 3 |
|
| 4 | 4 |
/** |
| 5 |
- * 관리자 회원 등록/수정/삭제의 **mock 저장소** — 백엔드에 해당 API가 없어 임시로 두는 계층이다. |
|
| 5 |
+ * 관리자 회원 **삭제**의 mock 오버레이 — 백엔드에 삭제 API가 없어 임시로 두는 계층이다. |
|
| 6 |
+ * 등록·수정·중복확인은 실제 API를 쓰므로(`admin-member-repository.ts`) 여기 남는 책임은 삭제뿐이다. |
|
| 6 | 7 |
* |
| 7 |
- * 백엔드(edupay-backend develop b742bb4)의 관리자 도메인에는 목록 조회 |
|
| 8 |
- * (`GET /api/v1/mngr/admin/pagination`)와 로그인 조회밖에 없다 — 등록·수정·삭제·단건 조회 |
|
| 9 |
- * 어느 것도 컨트롤러·서비스·매퍼에 존재하지 않는다(패키지 전체를 읽어 확인). 그래서 시안 |
|
| 10 |
- * (ADM_ADM_101 ③⑤ / 102_p / 103_p)의 쓰기 흐름은 이 파일이 대신 처리한다. |
|
| 8 |
+ * 백엔드(edupay-backend develop 924db37) `MngrAdminApiController`에는 GET 3개(목록·단건·중복확인)와 |
|
| 9 |
+ * POST·PUT만 있고 `@DeleteMapping`이 없다. 수정 API로 비활성화를 대신할 수도 없다 — UPDATE 문이 |
|
| 10 |
+ * `USE_YN`을 건드리지 않아 사용여부를 끌 수단 자체가 없다(바꿀 수 있는 건 `ACCT_LOCK_YN`뿐). |
|
| 11 | 11 |
* |
| 12 |
- * **동작 방식은 "덮어쓰기 오버레이"다.** 목록의 원본은 여전히 백엔드이고, 이 저장소는 그 위에 |
|
| 13 |
- * 얹을 변경분(추가된 행 / 필드 패치 / 삭제된 id)만 들고 있다. Repository가 백엔드 응답을 받은 뒤 |
|
| 14 |
- * `applyMockOverlay`로 합쳐 화면에 넘긴다. |
|
| 12 |
+ * **한계** — 서버 프로세스 메모리에만 있다. 재시작하면 삭제한 행이 되살아나고, 인스턴스가 여럿이면 |
|
| 13 |
+ * 공유되지 않는다. 삭제한 계정의 로그인 ID도 백엔드에는 그대로 남아 재등록 시 중복으로 걸린다. |
|
| 15 | 14 |
* |
| 16 |
- * **한계를 분명히 해 둔다 — 이건 데모용이지 저장소가 아니다.** |
|
| 17 |
- * - 서버 프로세스 메모리에만 있다. 재시작하면 사라지고, 인스턴스가 여럿이면 공유되지 않는다. |
|
| 18 |
- * - 비밀번호는 **보관하지 않는다.** mock이라 검증할 곳이 없고, 평문 비밀번호를 메모리에 들고 |
|
| 19 |
- * 있는 형태를 코드에 남기고 싶지 않다. 등록/수정 시 형식 검증만 하고 값은 버린다. |
|
| 20 |
- * |
|
| 21 |
- * 백엔드 API가 생기면 이 파일을 삭제하고 Repository의 쓰기 함수 본문만 실제 호출로 교체한다 — |
|
| 22 |
- * 화면·Server Action은 그대로다. |
|
| 15 |
+ * 백엔드에 삭제 API가 생기면 이 파일을 지우고 Repository의 `deleteAdminMember` 본문만 교체한다. |
|
| 23 | 16 |
*/ |
| 24 |
- |
|
| 25 |
-/** 신규 등록 행의 id 접두사 — 백엔드 `admUserId`와 섞이지 않게 구분한다. */ |
|
| 26 |
-const MOCK_ID_PREFIX = 'mock-admin-'; |
|
| 27 |
- |
|
| 28 |
-type AdminMemberPatch = Partial< |
|
| 29 |
- Pick< |
|
| 30 |
- AdminMember, |
|
| 31 |
- 'phoneNumber' | 'email' | 'roleCode' | 'menuCodes' | 'isLocked' |
|
| 32 |
- > |
|
| 33 |
->; |
|
| 34 | 17 |
|
| 35 | 18 |
type MockStoreState = {
|
| 36 |
- created: AdminMember[]; |
|
| 37 |
- patches: Map<string, AdminMemberPatch>; |
|
| 38 | 19 |
deletedIds: Set<string>; |
| 39 |
- nextId: number; |
|
| 40 | 20 |
}; |
| 41 | 21 |
|
| 42 |
-/** |
|
| 43 |
- * 개발 중 HMR로 모듈이 다시 평가돼도 상태가 초기화되지 않도록 globalThis에 붙인다 — 저장한 |
|
| 44 |
- * 내용이 코드 수정 때마다 사라지면 mock으로 흐름을 확인하는 의미가 없다. mock 전용 편법이며, |
|
| 45 |
- * 실제 데이터 계층에는 쓰지 않는다. |
|
| 46 |
- */ |
|
| 22 |
+/** HMR로 모듈이 다시 평가돼도 삭제 상태가 초기화되지 않도록 globalThis에 붙인다(mock 전용 편법). */ |
|
| 47 | 23 |
const globalStore = globalThis as typeof globalThis & {
|
| 48 | 24 |
__adminMemberMockStore?: MockStoreState; |
| 49 | 25 |
}; |
| 50 | 26 |
|
| 51 | 27 |
function getState(): MockStoreState {
|
| 52 |
- globalStore.__adminMemberMockStore ??= {
|
|
| 53 |
- created: [], |
|
| 54 |
- patches: new Map(), |
|
| 55 |
- deletedIds: new Set(), |
|
| 56 |
- nextId: 1, |
|
| 57 |
- }; |
|
| 28 |
+ globalStore.__adminMemberMockStore ??= { deletedIds: new Set() };
|
|
| 58 | 29 |
return globalStore.__adminMemberMockStore; |
| 59 | 30 |
} |
| 60 | 31 |
|
| 61 |
-/** 오늘 날짜(YYYY-MM-DD) — 신규 등록 행의 생성일. */ |
|
| 62 |
-function today(): string {
|
|
| 63 |
- return new Date().toISOString().slice(0, 10); |
|
| 32 |
+/** 백엔드 목록에서 mock 삭제된 행을 걷어낸다. 정렬·검색·페이징은 Repository가 한다. */ |
|
| 33 |
+export function applyMockDeletions(backendItems: AdminMember[]): AdminMember[] {
|
|
| 34 |
+ const { deletedIds } = getState();
|
|
| 35 |
+ return backendItems.filter((item) => !deletedIds.has(item.id)); |
|
| 64 | 36 |
} |
| 65 | 37 |
|
| 66 |
-export type CreateAdminMemberInput = {
|
|
| 67 |
- name: string; |
|
| 68 |
- loginId: string; |
|
| 69 |
- phoneNumber: string; |
|
| 70 |
- email: string; |
|
| 71 |
- roleCode: string; |
|
| 72 |
- menuCodes: string[]; |
|
| 73 |
-}; |
|
| 74 |
- |
|
| 75 |
-export type UpdateAdminMemberInput = {
|
|
| 76 |
- phoneNumber: string; |
|
| 77 |
- email: string; |
|
| 78 |
- roleCode: string; |
|
| 79 |
- menuCodes: string[]; |
|
| 80 |
- isLocked: boolean; |
|
| 81 |
-}; |
|
| 82 |
- |
|
| 83 |
-/** |
|
| 84 |
- * 백엔드 목록에 mock 변경분을 얹는다 — 삭제된 행을 걷어내고, 수정된 행에 패치를 입히고, |
|
| 85 |
- * 신규 등록 행을 **앞에** 붙인다(기본 정렬이 생성일 최신순이라 방금 만든 행이 맨 위에 온다). |
|
| 86 |
- * |
|
| 87 |
- * 정렬·검색·페이징은 이 함수가 하지 않는다 — 합쳐진 전체 목록을 대상으로 Repository가 처리한다. |
|
| 88 |
- */ |
|
| 89 |
-export function applyMockOverlay(backendItems: AdminMember[]): AdminMember[] {
|
|
| 90 |
- const { created, patches, deletedIds } = getState();
|
|
| 91 |
- |
|
| 92 |
- const merged = [...created, ...backendItems]; |
|
| 93 |
- |
|
| 94 |
- return merged |
|
| 95 |
- .filter((item) => !deletedIds.has(item.id)) |
|
| 96 |
- .map((item) => {
|
|
| 97 |
- const patch = patches.get(item.id); |
|
| 98 |
- return patch ? { ...item, ...patch } : item;
|
|
| 99 |
- }); |
|
| 38 |
+export function isMockDeleted(id: string): boolean {
|
|
| 39 |
+ return getState().deletedIds.has(id); |
|
| 100 | 40 |
} |
| 101 | 41 |
|
| 102 |
-/** mock으로 등록된 행들의 로그인 ID — 중복 확인이 백엔드 목록과 함께 검사할 수 있게 노출한다. */ |
|
| 103 |
-export function listMockCreatedLoginIds(): string[] {
|
|
| 104 |
- return getState().created.map((item) => item.loginId); |
|
| 105 |
-} |
|
| 106 |
- |
|
| 107 |
-export function createMockAdminMember( |
|
| 108 |
- input: CreateAdminMemberInput |
|
| 109 |
-): AdminMember {
|
|
| 110 |
- const state = getState(); |
|
| 111 |
- const id = `${MOCK_ID_PREFIX}${state.nextId++}`;
|
|
| 112 |
- |
|
| 113 |
- const member: AdminMember = {
|
|
| 114 |
- id, |
|
| 115 |
- name: input.name, |
|
| 116 |
- loginId: input.loginId, |
|
| 117 |
- phoneNumber: input.phoneNumber, |
|
| 118 |
- email: input.email || null, |
|
| 119 |
- roleCode: input.roleCode, |
|
| 120 |
- menuCodes: input.menuCodes, |
|
| 121 |
- createdAt: today(), |
|
| 122 |
- isLocked: false, |
|
| 123 |
- isActive: true, |
|
| 124 |
- loginFailCount: 0, |
|
| 125 |
- }; |
|
| 126 |
- |
|
| 127 |
- state.created.unshift(member); |
|
| 128 |
- return member; |
|
| 129 |
-} |
|
| 130 |
- |
|
| 131 |
-/** |
|
| 132 |
- * 수정 — 신규 등록 행은 원본을 직접 고치고, 백엔드에서 온 행은 패치로 기록해 둔다(원본을 우리가 |
|
| 133 |
- * 소유하지 않으므로 매 조회마다 덧입힌다). |
|
| 134 |
- */ |
|
| 135 |
-export function updateMockAdminMember( |
|
| 136 |
- id: string, |
|
| 137 |
- input: UpdateAdminMemberInput |
|
| 138 |
-): void {
|
|
| 139 |
- const state = getState(); |
|
| 140 |
- |
|
| 141 |
- const patch: AdminMemberPatch = {
|
|
| 142 |
- phoneNumber: input.phoneNumber, |
|
| 143 |
- email: input.email || null, |
|
| 144 |
- roleCode: input.roleCode, |
|
| 145 |
- menuCodes: input.menuCodes, |
|
| 146 |
- isLocked: input.isLocked, |
|
| 147 |
- }; |
|
| 148 |
- |
|
| 149 |
- const createdIndex = state.created.findIndex((item) => item.id === id); |
|
| 150 |
- if (createdIndex >= 0) {
|
|
| 151 |
- state.created[createdIndex] = { ...state.created[createdIndex], ...patch };
|
|
| 152 |
- return; |
|
| 153 |
- } |
|
| 154 |
- |
|
| 155 |
- state.patches.set(id, { ...state.patches.get(id), ...patch });
|
|
| 156 |
-} |
|
| 157 |
- |
|
| 158 |
-export function deleteMockAdminMember(id: string): void {
|
|
| 159 |
- const state = getState(); |
|
| 160 |
- |
|
| 161 |
- state.created = state.created.filter((item) => item.id !== id); |
|
| 162 |
- state.patches.delete(id); |
|
| 163 |
- // 백엔드에서 오는 행은 매 조회마다 다시 등장하므로 삭제 사실을 따로 기억해야 한다. |
|
| 164 |
- state.deletedIds.add(id); |
|
| 42 |
+/** 백엔드 행은 매 조회마다 다시 등장하므로 삭제 사실을 따로 기억해야 한다. */ |
|
| 43 |
+export function markMockDeleted(id: string): void {
|
|
| 44 |
+ getState().deletedIds.add(id); |
|
| 165 | 45 |
} |
--- lib/data/repositories/admin-member-repository.ts
+++ lib/data/repositories/admin-member-repository.ts
... | ... | @@ -7,30 +7,29 @@ |
| 7 | 7 |
AdminMemberSearchField, |
| 8 | 8 |
} from '@/lib/domain/admin-member-query'; |
| 9 | 9 |
import {
|
| 10 |
- applyMockOverlay, |
|
| 11 |
- createMockAdminMember, |
|
| 12 |
- deleteMockAdminMember, |
|
| 13 |
- listMockCreatedLoginIds, |
|
| 14 |
- updateMockAdminMember, |
|
| 15 |
- type CreateAdminMemberInput, |
|
| 16 |
- type UpdateAdminMemberInput, |
|
| 10 |
+ applyMockDeletions, |
|
| 11 |
+ isMockDeleted, |
|
| 12 |
+ markMockDeleted, |
|
| 17 | 13 |
} from '@/lib/data/mock/admin-member-store'; |
| 18 | 14 |
|
| 19 | 15 |
/** |
| 20 |
- * 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터· |
|
| 16 |
+ * 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트·파라미터· |
|
| 21 | 17 |
* 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은 |
| 22 | 18 |
* `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다. |
| 23 | 19 |
* |
| 24 |
- * GET /api/v1/mngr/admin/pagination (ROLE_ADMIN 전용) |
|
| 25 |
- * → data: { list: [{ rnum, admUserId, admNm, loginId, admRoleCd,
|
|
| 26 |
- * loginFailCnt, acctLockYn, useYn }], page, size, totalCount, totalPages } |
|
| 20 |
+ * GET /api/v1/mngr/admin/pagination 목록 |
|
| 21 |
+ * GET /api/v1/mngr/admin/{admUserId} 단건
|
|
| 22 |
+ * GET /api/v1/mngr/admin/duplication/{id} 로그인 ID 중복 확인
|
|
| 23 |
+ * POST /api/v1/mngr/admin 등록 |
|
| 24 |
+ * PUT /api/v1/mngr/admin 수정 |
|
| 27 | 25 |
* |
| 28 |
- * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다 |
|
| 29 |
- * — MngrAdminApiController / MngrAdminServiceImpl / MngrAdminMapper.xml / PaginationUtil. |
|
| 26 |
+ * 아래는 백엔드 저장소(edupay-backend, develop 924db37)의 실제 구현과 dev 서버 응답을 확인한 것이다. |
|
| 30 | 27 |
* |
| 31 |
- * - **응답에 휴대전화번호·이메일·생성일이 없다.** 조회 SQL의 select 목록 자체에 그 컬럼들이 없고 |
|
| 32 |
- * `MngrAdminVo`에도 필드가 없다. 그래서 시안의 해당 열은 `null` → `-`다. 백엔드가 컬럼과 VO |
|
| 33 |
- * 필드를 추가하면 `toAdminMember`의 매핑만 늘리면 되고 화면은 손대지 않는다. |
|
| 28 |
+ * - **등록·수정은 JSON 본문을 받지 않는다.** 두 핸들러 모두 `@RequestBody` 없이 |
|
| 29 |
+ * `@ParameterObject MngrAdmin*RequestVo`를 받아 query/form으로만 바인딩된다. 그래서 `form`으로 보낸다. |
|
| 30 |
+ * - **이메일·휴대전화번호를 되읽을 수 없다.** 저장은 되지만 조회 SQL의 select 목록에 두 컬럼이 없어 |
|
| 31 |
+ * 응답은 항상 `null`이다(실측). 매핑은 미리 해 두었으므로 백엔드가 컬럼을 추가하면 화면까지 그대로 흐른다. |
|
| 32 |
+ * - **이름(`admNm`)은 수정 대상이 아니다** — `MngrAdminUpdateRequestVo`에 필드가 없다. |
|
| 34 | 33 |
* - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY RNUM DESC`가 하드코딩돼 있다. rnum이 |
| 35 | 34 |
* `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, adm_nm DESC)`, 즉 최초등록일시 오름차순 순번이라 |
| 36 | 35 |
* 그것을 뒤집은 고정 순서가 곧 **생성일 최신순**이다. 그래서 생성일순 정렬은 "백엔드가 준 순서를 |
... | ... | @@ -44,7 +43,8 @@ |
| 44 | 43 |
* 캐시: `no-store` — 개인정보 목록이고 검색 조건이 매 요청 다르다. |
| 45 | 44 |
*/ |
| 46 | 45 |
|
| 47 |
-const ADMIN_MEMBER_PAGINATION_PATH = '/api/v1/mngr/admin/pagination'; |
|
| 46 |
+const ADMIN_MEMBER_BASE_PATH = '/api/v1/mngr/admin'; |
|
| 47 |
+const ADMIN_MEMBER_PAGINATION_PATH = `${ADMIN_MEMBER_BASE_PATH}/pagination`;
|
|
| 48 | 48 |
|
| 49 | 49 |
/** |
| 50 | 50 |
* 한 번에 받아올 최대 행 수. **이 화면은 백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다.** |
... | ... | @@ -56,8 +56,8 @@ |
| 56 | 56 |
* 요구하는데, 둘 다 정확한 전체 건수를 전제한다. |
| 57 | 57 |
* 2. **검색·정렬을 백엔드에 맡길 수 없다.** 이름순 정렬 파라미터가 없고, 휴대전화번호 검색 |
| 58 | 58 |
* 분기는 관리자 테이블에 없는 컬럼을 참조한다(`admin-member-query.ts` 주석 참조). |
| 59 |
- * 3. **등록/수정/삭제가 mock이다.** mock으로 만든 행과 백엔드 행이 같은 검색·정렬·페이징 규칙을 |
|
| 60 |
- * 따라야 하는데, 페이징이 백엔드에 있으면 두 출처를 일관되게 합칠 방법이 없다. |
|
| 59 |
+ * 3. **삭제가 mock이다.** 삭제된 행을 걷어낸 뒤 세어야 총건수가 맞는데, 페이징이 백엔드에 있으면 |
|
| 60 |
+ * 페이지마다 구멍이 생겨 건수와 순번이 어긋난다. |
|
| 61 | 61 |
* |
| 62 | 62 |
* 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면 |
| 63 | 63 |
* 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다. |
... | ... | @@ -115,8 +115,9 @@ |
| 115 | 115 |
id: readRequiredString(raw, 'admUserId'), |
| 116 | 116 |
name: readRequiredString(raw, 'admNm'), |
| 117 | 117 |
loginId: readRequiredString(raw, 'loginId'), |
| 118 |
- phoneNumber: null, |
|
| 119 |
- email: null, |
|
| 118 |
+ // 지금은 항상 null이다 — 조회 SQL이 두 컬럼을 select하지 않는다(위 주석 참조). |
|
| 119 |
+ phoneNumber: readOptionalString(raw, 'admTelNo'), |
|
| 120 |
+ email: readOptionalString(raw, 'admEmlAddr'), |
|
| 120 | 121 |
roleCode: readOptionalString(raw, 'admRoleCd') ?? '', |
| 121 | 122 |
menuCodes: [], |
| 122 | 123 |
createdAt: null, |
... | ... | @@ -204,7 +205,7 @@ |
| 204 | 205 |
export async function fetchAdminMembers( |
| 205 | 206 |
query: AdminMemberQuery |
| 206 | 207 |
): Promise<AdminMemberPage> {
|
| 207 |
- const all = applyMockOverlay(await fetchAllAdminMembers()); |
|
| 208 |
+ const all = applyMockDeletions(await fetchAllAdminMembers()); |
|
| 208 | 209 |
const matched = sortItems(filterByKeyword(all, query), query); |
| 209 | 210 |
|
| 210 | 211 |
const offset = (query.page - 1) * query.pageSize; |
... | ... | @@ -215,53 +216,148 @@ |
| 215 | 216 |
}; |
| 216 | 217 |
} |
| 217 | 218 |
|
| 218 |
-/** 단건 조회 — 수정 팝업이 쓰는 진입점. 백엔드에 단건 API가 없어 목록에서 찾는다. */ |
|
| 219 |
+/** 단건 조회 — 수정 팝업이 쓰는 진입점. mock 삭제된 행은 없는 것으로 취급한다. */ |
|
| 219 | 220 |
export async function findAdminMemberById( |
| 220 | 221 |
id: string |
| 221 | 222 |
): Promise<AdminMember | null> {
|
| 222 |
- const all = applyMockOverlay(await fetchAllAdminMembers()); |
|
| 223 |
- return all.find((item) => item.id === id) ?? null; |
|
| 223 |
+ if (isMockDeleted(id)) {
|
|
| 224 |
+ return null; |
|
| 225 |
+ } |
|
| 226 |
+ |
|
| 227 |
+ const accessToken = await getSessionAccessToken(); |
|
| 228 |
+ const result = await backendFetch<unknown>( |
|
| 229 |
+ `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
|
|
| 230 |
+ {
|
|
| 231 |
+ method: 'GET', |
|
| 232 |
+ accessToken: accessToken ?? undefined, |
|
| 233 |
+ cache: 'no-store', |
|
| 234 |
+ // 없는 id면 `{success:true, data:null}`이 온다 — 실패가 아니라 "없음"이다.
|
|
| 235 |
+ canHaveNullData: true, |
|
| 236 |
+ } |
|
| 237 |
+ ); |
|
| 238 |
+ |
|
| 239 |
+ if (!result.ok) {
|
|
| 240 |
+ throw new BackendRequestError(result); |
|
| 241 |
+ } |
|
| 242 |
+ |
|
| 243 |
+ return result.data === null ? null : toAdminMember(result.data); |
|
| 224 | 244 |
} |
| 225 | 245 |
|
| 226 | 246 |
/** |
| 227 |
- * ID 중복 확인(시안 ADM_ADM_102_p ①). 백엔드에 중복 확인 API가 없어 목록에 이미 있는 ID인지로 |
|
| 228 |
- * 판정한다 — 목록은 실제 계정 전부를 담으므로 판정 자체는 맞지만, 확인 시점과 저장 시점 사이에 |
|
| 229 |
- * 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 막지 못한다. 저장 시 유일성 보장은 결국 백엔드 |
|
| 230 |
- * (DB 유니크 제약)의 몫이다. |
|
| 247 |
+ * 로그인 ID 중복 확인(시안 ADM_ADM_102_p ①). |
|
| 248 |
+ * |
|
| 249 |
+ * 이미 쓰는 ID면 해당 관리자 정보를, 아니면 `data: null`을 준다(실측). 확인 시점과 저장 시점 |
|
| 250 |
+ * 사이에 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 이 호출로 막을 수 없다 — 최종 유일성은 |
|
| 251 |
+ * 등록 API가 저장 직전에 다시 검사한다. |
|
| 231 | 252 |
*/ |
| 232 | 253 |
export async function isAdminLoginIdTaken(loginId: string): Promise<boolean> {
|
| 233 |
- const normalized = loginId.trim().toLowerCase(); |
|
| 254 |
+ const accessToken = await getSessionAccessToken(); |
|
| 234 | 255 |
|
| 235 |
- const backendLoginIds = (await fetchAllAdminMembers()).map( |
|
| 236 |
- (item) => item.loginId |
|
| 256 |
+ const result = await backendFetch<unknown>( |
|
| 257 |
+ `${ADMIN_MEMBER_BASE_PATH}/duplication/${encodeURIComponent(loginId.trim())}`,
|
|
| 258 |
+ {
|
|
| 259 |
+ method: 'GET', |
|
| 260 |
+ accessToken: accessToken ?? undefined, |
|
| 261 |
+ cache: 'no-store', |
|
| 262 |
+ canHaveNullData: true, |
|
| 263 |
+ } |
|
| 237 | 264 |
); |
| 238 | 265 |
|
| 239 |
- return [...backendLoginIds, ...listMockCreatedLoginIds()].some( |
|
| 240 |
- (existing) => existing.toLowerCase() === normalized |
|
| 241 |
- ); |
|
| 266 |
+ if (!result.ok) {
|
|
| 267 |
+ throw new BackendRequestError(result); |
|
| 268 |
+ } |
|
| 269 |
+ |
|
| 270 |
+ return result.data !== null; |
|
| 242 | 271 |
} |
| 243 | 272 |
|
| 244 | 273 |
/* |
| 245 | 274 |
* ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── |
| 246 |
- * 백엔드에 등록·수정·삭제 API가 없어 세 함수 모두 mock 저장소에 위임한다 |
|
| 247 |
- * (`lib/data/mock/admin-member-store.ts`의 주석에 한계를 적어 두었다). |
|
| 248 |
- * 백엔드 API가 생기면 **이 세 함수의 본문만** `backendFetch` 호출로 바꾸면 되고, |
|
| 249 |
- * Server Action과 화면은 그대로다 — 그러라고 호출부가 이 계층만 보게 두었다. |
|
| 275 |
+ * 등록·수정은 실제 API를 쓴다. 삭제만 백엔드에 엔드포인트가 없어 mock 오버레이에 남아 있다. |
|
| 276 |
+ * |
|
| 277 |
+ * 두 요청 모두 `form`으로 보낸다 — 백엔드 핸들러가 `@RequestBody` 없이 VO를 받아 JSON 본문을 |
|
| 278 |
+ * 바인딩하지 못하기 때문이다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다. |
|
| 279 |
+ * |
|
| 280 |
+ * 메뉴 선택(`menuCodes`)은 **보내지 않는다** — 백엔드에 관리자별 메뉴 권한 개념이 없다 |
|
| 281 |
+ * (`/api/v1/common/menu`는 개인 북마크용이고 등록·수정 VO에도 해당 필드가 없다). |
|
| 250 | 282 |
*/ |
| 283 |
+ |
|
| 284 |
+export type CreateAdminMemberInput = {
|
|
| 285 |
+ name: string; |
|
| 286 |
+ loginId: string; |
|
| 287 |
+ password: string; |
|
| 288 |
+ phoneNumber: string; |
|
| 289 |
+ email: string; |
|
| 290 |
+ roleCode: string; |
|
| 291 |
+}; |
|
| 292 |
+ |
|
| 293 |
+export type UpdateAdminMemberInput = {
|
|
| 294 |
+ /** |
|
| 295 |
+ * 백엔드 UPDATE 문이 `LOGIN_PW`를 무조건 덮어쓰므로 빈 값을 보내면 그 계정이 로그인 불가가 된다. |
|
| 296 |
+ * 그래서 이 필드는 선택이 아니라 필수다 — 호출부가 빈 값을 걸러 여기까지 오지 않게 한다. |
|
| 297 |
+ */ |
|
| 298 |
+ password: string; |
|
| 299 |
+ phoneNumber: string; |
|
| 300 |
+ email: string; |
|
| 301 |
+ roleCode: string; |
|
| 302 |
+ isLocked: boolean; |
|
| 303 |
+}; |
|
| 304 |
+ |
|
| 305 |
+/** Y/N 플래그로 변환. 백엔드는 `ACCT_LOCK_YN`에 이 문자열을 그대로 넣는다. */ |
|
| 306 |
+function toYesNo(value: boolean): string {
|
|
| 307 |
+ return value ? 'Y' : 'N'; |
|
| 308 |
+} |
|
| 251 | 309 |
|
| 252 | 310 |
export async function createAdminMember( |
| 253 | 311 |
input: CreateAdminMemberInput |
| 254 | 312 |
): Promise<void> {
|
| 255 |
- createMockAdminMember(input); |
|
| 313 |
+ const accessToken = await getSessionAccessToken(); |
|
| 314 |
+ |
|
| 315 |
+ const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
|
|
| 316 |
+ method: 'POST', |
|
| 317 |
+ form: {
|
|
| 318 |
+ admNm: input.name, |
|
| 319 |
+ loginId: input.loginId, |
|
| 320 |
+ loginPw: input.password, |
|
| 321 |
+ admEmlAddr: input.email, |
|
| 322 |
+ admTelNo: input.phoneNumber, |
|
| 323 |
+ admRoleCd: input.roleCode, |
|
| 324 |
+ }, |
|
| 325 |
+ accessToken: accessToken ?? undefined, |
|
| 326 |
+ canHaveNullData: true, |
|
| 327 |
+ }); |
|
| 328 |
+ |
|
| 329 |
+ if (!result.ok) {
|
|
| 330 |
+ // 아이디 중복도 여기로 온다(code 300 "이미 등록된 아이디 입니다") — 호출부가 메시지를 살려 쓴다. |
|
| 331 |
+ throw new BackendRequestError(result); |
|
| 332 |
+ } |
|
| 256 | 333 |
} |
| 257 | 334 |
|
| 258 | 335 |
export async function updateAdminMember( |
| 259 | 336 |
id: string, |
| 260 | 337 |
input: UpdateAdminMemberInput |
| 261 | 338 |
): Promise<void> {
|
| 262 |
- updateMockAdminMember(id, input); |
|
| 339 |
+ const accessToken = await getSessionAccessToken(); |
|
| 340 |
+ |
|
| 341 |
+ const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
|
|
| 342 |
+ method: 'PUT', |
|
| 343 |
+ form: {
|
|
| 344 |
+ admUserId: id, |
|
| 345 |
+ loginPw: input.password, |
|
| 346 |
+ admEmlAddr: input.email, |
|
| 347 |
+ admTelNo: input.phoneNumber, |
|
| 348 |
+ admRoleCd: input.roleCode, |
|
| 349 |
+ acctLockYn: toYesNo(input.isLocked), |
|
| 350 |
+ }, |
|
| 351 |
+ accessToken: accessToken ?? undefined, |
|
| 352 |
+ canHaveNullData: true, |
|
| 353 |
+ }); |
|
| 354 |
+ |
|
| 355 |
+ if (!result.ok) {
|
|
| 356 |
+ throw new BackendRequestError(result); |
|
| 357 |
+ } |
|
| 263 | 358 |
} |
| 264 | 359 |
|
| 360 |
+/** 삭제 API가 없어 화면에서만 감춘다 — 한계는 `admin-member-store.ts` 주석 참조. */ |
|
| 265 | 361 |
export async function deleteAdminMember(id: string): Promise<void> {
|
| 266 |
- deleteMockAdminMember(id); |
|
| 362 |
+ markMockDeleted(id); |
|
| 267 | 363 |
} |
--- lib/data/repositories/auth-repository.ts
+++ lib/data/repositories/auth-repository.ts
... | ... | @@ -29,6 +29,8 @@ |
| 29 | 29 |
accessTokenExpiresAt: number; |
| 30 | 30 |
}; |
| 31 | 31 |
|
| 32 |
+const ADMIN_LOGOUT_PATH = '/api/v1/common/auth/logout'; |
|
| 33 |
+ |
|
| 32 | 34 |
/** 백엔드 accessToken(JWT)의 payload 클레임. */ |
| 33 | 35 |
type AdminAccessTokenClaims = {
|
| 34 | 36 |
sub: string; |
... | ... | @@ -99,3 +101,32 @@ |
| 99 | 101 |
accessTokenExpiresAt: claims.exp, |
| 100 | 102 |
}; |
| 101 | 103 |
} |
| 104 |
+ |
|
| 105 |
+/** |
|
| 106 |
+ * 관리자 로그아웃을 백엔드에 알린다 — `GET /api/v1/common/auth/logout`(인증 필요, AUTH_WHITELIST에 |
|
| 107 |
+ * 없음을 확인함: edupay-backend SecurityConfig.java). |
|
| 108 |
+ * |
|
| 109 |
+ * JWT는 stateless라 이 호출이 accessToken 자체를 무효화하지는 않는다 — 발급된 토큰은 만료까지 |
|
| 110 |
+ * 계속 유효하다. 백엔드가 실질적으로 하는 일은 FCM 토큰 삭제뿐이다(edupay-backend |
|
| 111 |
+ * CmmAuthApiController#actionLogoutJSON: `deleteFcmToken` + `SecurityContextLogoutHandler.logout`, |
|
| 112 |
+ * 후자는 STATELESS 세션 정책상 사실상 무의미). 그래도 정식 절차이므로 호출은 한다. |
|
| 113 |
+ * |
|
| 114 |
+ * 성공 응답의 data는 항상 null이다(`ApiResponseVO.success(null)`) — `canHaveNullData: true`로 |
|
| 115 |
+ * 이 정상 응답을 실패로 오판하지 않게 한다(lib/http/backend-fetch.ts 참고). |
|
| 116 |
+ * |
|
| 117 |
+ * 실패(백엔드 다운·accessToken 만료 등)를 예외로 다루지 않고 boolean으로 반환한다 — 로그아웃은 |
|
| 118 |
+ * 백엔드 호출이 실패해도 반드시 로컬에서는 진행돼야 하므로, 호출부(Server Action)가 try/catch |
|
| 119 |
+ * 없이 결과만 보고 흐름을 결정할 수 있게 한다. |
|
| 120 |
+ * |
|
| 121 |
+ * 캐시 전략: `no-store` — 상태를 변경하는 요청이라 캐시 대상이 아니다. |
|
| 122 |
+ */ |
|
| 123 |
+export async function requestAdminLogout(accessToken: string): Promise<boolean> {
|
|
| 124 |
+ const result = await backendFetch<null>(ADMIN_LOGOUT_PATH, {
|
|
| 125 |
+ method: 'GET', |
|
| 126 |
+ accessToken, |
|
| 127 |
+ cache: 'no-store', |
|
| 128 |
+ canHaveNullData: true, |
|
| 129 |
+ }); |
|
| 130 |
+ |
|
| 131 |
+ return result.ok; |
|
| 132 |
+} |
--- lib/data/repositories/board-post-repository.ts
+++ lib/data/repositories/board-post-repository.ts
... | ... | @@ -1,10 +1,6 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 | 2 |
import { getSessionAccessToken } from '@/lib/auth/dal';
|
| 3 |
-import {
|
|
| 4 |
- BackendRequestError, |
|
| 5 |
- backendCommand, |
|
| 6 |
- backendFetch, |
|
| 7 |
-} from '@/lib/http/backend-fetch'; |
|
| 3 |
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
|
|
| 8 | 4 |
import {
|
| 9 | 5 |
BOARD_SETTING_IDS, |
| 10 | 6 |
BOARD_TYPE_CODE_PARAM, |
... | ... | @@ -292,11 +288,13 @@ |
| 292 | 288 |
): Promise<void> {
|
| 293 | 289 |
const accessToken = await getSessionAccessToken(); |
| 294 | 290 |
|
| 295 |
- const result = await backendCommand(BOARD_BASE_PATH, {
|
|
| 291 |
+ const result = await backendFetch<null>(BOARD_BASE_PATH, {
|
|
| 296 | 292 |
method: 'POST', |
| 297 | 293 |
form: buildWriteParams(boardType, input), |
| 298 | 294 |
accessToken: accessToken ?? undefined, |
| 299 | 295 |
cache: 'no-store', |
| 296 |
+ // 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)). |
|
| 297 |
+ canHaveNullData: true, |
|
| 300 | 298 |
}); |
| 301 | 299 |
|
| 302 | 300 |
if (!result.ok) {
|
... | ... | @@ -311,12 +309,14 @@ |
| 311 | 309 |
): Promise<void> {
|
| 312 | 310 |
const accessToken = await getSessionAccessToken(); |
| 313 | 311 |
|
| 314 |
- const result = await backendCommand(`${BOARD_BASE_PATH}/${bbsId}`, {
|
|
| 312 |
+ const result = await backendFetch<null>(`${BOARD_BASE_PATH}/${bbsId}`, {
|
|
| 315 | 313 |
method: 'PUT', |
| 316 | 314 |
// 수정은 컨트롤러가 `@ParameterObject`라 쿼리스트링으로 받는다(본문이 아니다). |
| 317 | 315 |
query: buildWriteParams(boardType, input), |
| 318 | 316 |
accessToken: accessToken ?? undefined, |
| 319 | 317 |
cache: 'no-store', |
| 318 |
+ // 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)). |
|
| 319 |
+ canHaveNullData: true, |
|
| 320 | 320 |
}); |
| 321 | 321 |
|
| 322 | 322 |
if (!result.ok) {
|
... | ... | @@ -349,11 +349,13 @@ |
| 349 | 349 |
if (input.answerContent) query.ansCn = input.answerContent; |
| 350 | 350 |
if (input.attachmentId) query.atchFileId = input.attachmentId; |
| 351 | 351 |
|
| 352 |
- const result = await backendCommand(`${BOARD_BASE_PATH}/${bbsId}`, {
|
|
| 352 |
+ const result = await backendFetch<null>(`${BOARD_BASE_PATH}/${bbsId}`, {
|
|
| 353 | 353 |
method: 'PUT', |
| 354 | 354 |
query, |
| 355 | 355 |
accessToken: accessToken ?? undefined, |
| 356 | 356 |
cache: 'no-store', |
| 357 |
+ // 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)). |
|
| 358 |
+ canHaveNullData: true, |
|
| 357 | 359 |
}); |
| 358 | 360 |
|
| 359 | 361 |
if (!result.ok) {
|
... | ... | @@ -365,10 +367,12 @@ |
| 365 | 367 |
export async function deleteBoardPost(bbsId: string): Promise<void> {
|
| 366 | 368 |
const accessToken = await getSessionAccessToken(); |
| 367 | 369 |
|
| 368 |
- const result = await backendCommand(`${BOARD_BASE_PATH}/${bbsId}`, {
|
|
| 370 |
+ const result = await backendFetch<null>(`${BOARD_BASE_PATH}/${bbsId}`, {
|
|
| 369 | 371 |
method: 'DELETE', |
| 370 | 372 |
accessToken: accessToken ?? undefined, |
| 371 | 373 |
cache: 'no-store', |
| 374 |
+ // 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)). |
|
| 375 |
+ canHaveNullData: true, |
|
| 372 | 376 |
}); |
| 373 | 377 |
|
| 374 | 378 |
if (!result.ok) {
|
+++ lib/data/repositories/decoration-item-repository.ts
... | ... | @@ -0,0 +1,359 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { getSessionAccessToken } from '@/lib/auth/dal'; | |
| 3 | +import { getApiBaseUrl } from '@/lib/env'; | |
| 4 | +import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; | |
| 5 | +import type { DecorationItem, DecorationItemType } from '@/lib/domain/decoration-item'; | |
| 6 | +import type { DecorationItemEditableValues } from '@/lib/domain/decoration-item-form'; | |
| 7 | +import type { | |
| 8 | + DecorationItemQuery, | |
| 9 | + DecorationItemSearchField, | |
| 10 | +} from '@/lib/domain/decoration-item-query'; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * 꾸미기 아이템 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트· | |
| 14 | + * 파라미터·응답 매핑). 통신 규약은 `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 | |
| 15 | + * 소유하므로 여기에 들어오지 않는다. | |
| 16 | + * | |
| 17 | + * ``` | |
| 18 | + * GET /api/v1/mngr/item/pagination 목록 (ROLE_ADMIN) | |
| 19 | + * GET /api/v1/mngr/item/{itemSn} 단건 | |
| 20 | + * POST /api/v1/mngr/item 등록 | |
| 21 | + * PUT /api/v1/mngr/item/{itemSn} 수정 | |
| 22 | + * DELETE /api/v1/mngr/item/{itemSn} 삭제 | |
| 23 | + * POST /api/v1/common/file/upload/{moduleId} 이미지 업로드(multipart) | |
| 24 | + * ``` | |
| 25 | + * | |
| 26 | + * 아래는 백엔드 저장소(edupay-backend, develop)의 실제 구현을 읽고 확인한 것이다 — | |
| 27 | + * MngrItemApiController / MngrItemServiceImpl / MngrItemMapper.xml / FileCommonApiController. | |
| 28 | + * | |
| 29 | + * - **쓰기 API는 JSON이 아니라 form 인코딩이다.** 컨트롤러가 `@RequestBody`가 아닌 | |
| 30 | + * `@ParameterObject`로 받으므로 JSON을 보내면 전 필드가 null인 채 저장된다(오류도 나지 않는다). | |
| 31 | + * - **목록은 `searchItemType`이 필수다** — SQL의 WHERE에 `AND a.ITEM_TYPE = #{searchItemType}`이 | |
| 32 | + * 무조건 붙는다. 값이 없으면 아무것도 조회되지 않는다. | |
| 33 | + * - **검색은 아이템명(`searchCondition="1"`)만 구현돼 있다.** 아이템ID 검색 분기는 없어, 그 | |
| 34 | + * 값을 보내면 조건 없이 전체가 반환된다(사용자 지시로 화면 선택지는 유지하고 백엔드에 추가 요청). | |
| 35 | + * - **정렬은 고정이다** — `ROW_NUMBER() OVER (ORDER BY SORT_ORDR DESC)`를 다시 역순으로 정렬해 | |
| 36 | + * 결과적으로 정렬순서 오름차순이며(시안 ADM_ITM_101과 일치) 정렬 파라미터는 없다. | |
| 37 | + * - **응답의 `totalCount`는 전체 건수가 아니라 그 페이지의 행 수다**(학생·관리자 목록과 동일한 | |
| 38 | + * `PaginationUtil` 결함). 그대로 믿으면 페이지가 가득 찰 때마다 다음 페이지에 도달할 수 없어 | |
| 39 | + * 하한값으로 보정한다. | |
| 40 | + * | |
| 41 | + * 캐시: 조회는 `no-store` — 개인화 데이터는 아니지만 관리 화면이라 신선도가 우선이고 검색 조건이 | |
| 42 | + * 매 요청 달라진다. 쓰기는 캐시 대상이 아니며 호출부(Server Action)가 `revalidatePath`로 목록을 | |
| 43 | + * 재검증한다. | |
| 44 | + */ | |
| 45 | + | |
| 46 | +const DECORATION_ITEM_PATH = '/api/v1/mngr/item'; | |
| 47 | +const FILE_UPLOAD_PATH = '/api/v1/common/file/upload'; | |
| 48 | +const FILE_IMAGE_PATH = '/api/v1/common/file/image'; | |
| 49 | + | |
| 50 | +/** 파일 업로드 경로 변수 — 아이템 이미지용 모듈 ID(사용자 확정 사항). */ | |
| 51 | +const ITEM_FILE_MODULE_ID = 'MODULE_ITEM'; | |
| 52 | + | |
| 53 | +/** 화면·URL의 유형 값 ↔ 백엔드 `itemType` 코드값(사용자 확정 사항). */ | |
| 54 | +const ITEM_TYPE_CODE: Record<DecorationItemType, string> = { | |
| 55 | + individual: 'N', | |
| 56 | + set: 'S', | |
| 57 | +}; | |
| 58 | + | |
| 59 | +const ITEM_TYPE_BY_CODE: Record<string, DecorationItemType> = { | |
| 60 | + N: 'individual', | |
| 61 | + S: 'set', | |
| 62 | +}; | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * 화면의 "검색 대상" → 백엔드 `searchCondition` 값. | |
| 66 | + * | |
| 67 | + * **백엔드에 구현된 분기는 `"1"`(아이템명) 하나뿐이다.** 아이템ID 검색은 사용자 지시로 화면 | |
| 68 | + * 선택지를 유지한 채 백엔드에 추가를 요청하기로 했고, 그때 쓸 값으로 `"2"`를 예약해 둔다 | |
| 69 | + * (학생 목록이 1=이름·2=아이디·3=휴대전화를 쓰는 관례와 같은 순번). **백엔드가 이 값을 모르는 | |
| 70 | + * 동안에는 검색어가 무시되고 전체 목록이 반환된다.** | |
| 71 | + */ | |
| 72 | +const SEARCH_CONDITION_BY_FIELD: Record<DecorationItemSearchField, string> = { | |
| 73 | + name: '1', | |
| 74 | + itemId: '2', | |
| 75 | +}; | |
| 76 | + | |
| 77 | +/** 이름순 정렬이 아니라 "전체 건수를 알기 위해" 한 번에 받아올 최대 행 수(아래 주석 참조). */ | |
| 78 | +const TYPE_TOTAL_COUNT_FETCH_LIMIT = 1_000; | |
| 79 | + | |
| 80 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 81 | + return value !== null && typeof value === 'object'; | |
| 82 | +} | |
| 83 | + | |
| 84 | +function readString(source: Record<string, unknown>, key: string): string | null { | |
| 85 | + const value = source[key]; | |
| 86 | + return typeof value === 'string' && value.length > 0 ? value : null; | |
| 87 | +} | |
| 88 | + | |
| 89 | +function readNumber(source: Record<string, unknown>, key: string): number | null { | |
| 90 | + const value = source[key]; | |
| 91 | + return typeof value === 'number' ? value : null; | |
| 92 | +} | |
| 93 | + | |
| 94 | +/** 업로드된 파일 ID로 백엔드 이미지 URL을 만든다 — 이 GET은 인증이 필요 없어 브라우저가 직접 부른다. */ | |
| 95 | +function buildImageUrl(imageFileId: string | null): string | null { | |
| 96 | + if (!imageFileId) { | |
| 97 | + return null; | |
| 98 | + } | |
| 99 | + | |
| 100 | + const base = getApiBaseUrl().replace(/\/+$/, ''); | |
| 101 | + const url = new URL(`${base}${FILE_IMAGE_PATH}`); | |
| 102 | + url.searchParams.set('atchFileId', imageFileId); | |
| 103 | + // 아이템은 파일을 1개만 등록하므로 첨부 순번은 항상 1이다(백엔드도 미지정 시 1로 기본값 처리). | |
| 104 | + url.searchParams.set('fileSn', '1'); | |
| 105 | + return url.toString(); | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** | |
| 109 | + * 백엔드 응답 1건 → 도메인 타입. 식별자·이름 등 목록의 존재 이유인 값이 없으면 예외로 끊는다 | |
| 110 | + * (fail-fast) — 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다. | |
| 111 | + * | |
| 112 | + * `rnum`은 담지 않는다 — 표의 "번호"는 현재 페이지 기준 표시 순번이고 표 컴포넌트가 계산한다. | |
| 113 | + */ | |
| 114 | +function toDecorationItem(raw: unknown): DecorationItem { | |
| 115 | + if (!isRecord(raw)) { | |
| 116 | + throw new Error('아이템 응답 항목의 형식이 올바르지 않습니다.'); | |
| 117 | + } | |
| 118 | + | |
| 119 | + const itemSn = readNumber(raw, 'itemSn'); | |
| 120 | + if (itemSn === null) { | |
| 121 | + throw new Error('아이템 응답에 itemSn이 없습니다.'); | |
| 122 | + } | |
| 123 | + | |
| 124 | + const imageFileId = readString(raw, 'atchFileId'); | |
| 125 | + | |
| 126 | + return { | |
| 127 | + itemSn, | |
| 128 | + // 코드값이 비었거나 모르는 값이면 화면 기본 유형으로 떨어뜨리지 않고 개별로 둔다 — | |
| 129 | + // 목록은 유형별로 조회하므로 실제로는 요청한 유형의 행만 온다. | |
| 130 | + itemType: ITEM_TYPE_BY_CODE[readString(raw, 'itemType') ?? ''] ?? 'individual', | |
| 131 | + name: readString(raw, 'itemNm') ?? '', | |
| 132 | + categoryCode: readString(raw, 'itemCateCd') ?? '', | |
| 133 | + categoryName: readString(raw, 'itemCateNm'), | |
| 134 | + points: readNumber(raw, 'itemAmount') ?? 0, | |
| 135 | + description: readString(raw, 'itemExplan'), | |
| 136 | + isActive: readString(raw, 'useYn') === 'Y', | |
| 137 | + sortOrder: readNumber(raw, 'sortOrdr') ?? 0, | |
| 138 | + imageFileId, | |
| 139 | + imageUrl: buildImageUrl(imageFileId), | |
| 140 | + updatedAt: readString(raw, 'lastMdfcnDt'), | |
| 141 | + }; | |
| 142 | +} | |
| 143 | + | |
| 144 | +function buildListParams( | |
| 145 | + query: DecorationItemQuery, | |
| 146 | + pageIndex: number, | |
| 147 | + recordCountPerPage: number | |
| 148 | +): Record<string, string | number> { | |
| 149 | + const keyword = query.keyword.trim(); | |
| 150 | + | |
| 151 | + return { | |
| 152 | + searchItemType: ITEM_TYPE_CODE[query.itemType], | |
| 153 | + searchCondition: keyword ? SEARCH_CONDITION_BY_FIELD[query.searchField] : '', | |
| 154 | + searchKeyword: keyword, | |
| 155 | + // 유효한 페이징 파라미터는 이 둘뿐이다 — 서버가 offset을 직접 계산해 firstIndex· | |
| 156 | + // recordCountPerPage를 덮어쓰고 나머지(pageUnit·pageSize·lastIndex)는 읽지 않는다. | |
| 157 | + pageIndex, | |
| 158 | + recordCountPerPage, | |
| 159 | + }; | |
| 160 | +} | |
| 161 | + | |
| 162 | +/** 목록 조회 1회 — 응답 검증까지만 하고 건수 판단은 호출부에 맡긴다. */ | |
| 163 | +async function requestItemList( | |
| 164 | + query: DecorationItemQuery, | |
| 165 | + pageIndex: number, | |
| 166 | + recordCountPerPage: number | |
| 167 | +): Promise<{ items: DecorationItem[]; reportedTotalCount: number }> { | |
| 168 | + const accessToken = await getSessionAccessToken(); | |
| 169 | + | |
| 170 | + const result = await backendFetch<unknown>( | |
| 171 | + `${DECORATION_ITEM_PATH}/pagination`, | |
| 172 | + { | |
| 173 | + method: 'GET', | |
| 174 | + query: buildListParams(query, pageIndex, recordCountPerPage), | |
| 175 | + accessToken: accessToken ?? undefined, | |
| 176 | + cache: 'no-store', | |
| 177 | + } | |
| 178 | + ); | |
| 179 | + | |
| 180 | + if (!result.ok) { | |
| 181 | + throw new BackendRequestError(result); | |
| 182 | + } | |
| 183 | + | |
| 184 | + const data = result.data; | |
| 185 | + if (!isRecord(data) || !Array.isArray(data.list)) { | |
| 186 | + throw new Error('아이템 목록 응답의 형식이 올바르지 않습니다.'); | |
| 187 | + } | |
| 188 | + | |
| 189 | + return { | |
| 190 | + items: data.list.map(toDecorationItem), | |
| 191 | + reportedTotalCount: | |
| 192 | + typeof data.totalCount === 'number' ? data.totalCount : data.list.length, | |
| 193 | + }; | |
| 194 | +} | |
| 195 | + | |
| 196 | +export type DecorationItemPage = { | |
| 197 | + items: DecorationItem[]; | |
| 198 | + /** 검색어까지 적용한 결과 건수. `isTotalCountExact`가 false면 "적어도 이만큼"이라는 하한값이다. */ | |
| 199 | + totalCount: number; | |
| 200 | + isTotalCountExact: boolean; | |
| 201 | + /** | |
| 202 | + * 검색어와 무관하게 현재 유형(개별/셋트) 전체 등록 건수 — 등록/수정 팝업의 | |
| 203 | + * "정렬순서 (총 등록 N개)" 힌트와 신규 등록 기본 정렬순서 계산에 쓴다. | |
| 204 | + */ | |
| 205 | + typeTotalCount: number; | |
| 206 | +}; | |
| 207 | + | |
| 208 | +/** | |
| 209 | + * 검색·유형·페이징이 적용된 목록을 조회한다. | |
| 210 | + * | |
| 211 | + * **`totalCount` 보정**: 백엔드는 count 쿼리 없이 `list.size()`를 총건수로 내려준다 | |
| 212 | + * (`PaginationUtil.execute`). 그대로 쓰면 한 페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 | |
| 213 | + * 2페이지 이후에 접근할 수 없으므로, 학생 목록과 동일하게 하한값으로 보정한다 — 페이지가 가득 | |
| 214 | + * 차지 않았으면 마지막 페이지이므로 `offset + 행 수`가 확정 총건수이고, 가득 찼으면 하한값만 | |
| 215 | + * 알린다. 백엔드가 진짜 count를 넣으면 `Math.max`가 자동으로 그 값을 채택한다. | |
| 216 | + * | |
| 217 | + * **`typeTotalCount`는 별도 조회다** — 검색 결과가 아니라 그 유형에 실제로 존재하는 전체 개수여야 | |
| 218 | + * 하는데(정렬순서 힌트·기본값의 근거) 백엔드가 총건수를 주지 않으므로, 검색어를 뺀 조건으로 큰 | |
| 219 | + * 상한을 걸어 한 번 더 조회해 길이를 센다. 백엔드에 count가 생기면 이 왕복을 없앨 수 있다. | |
| 220 | + */ | |
| 221 | +export async function fetchDecorationItems( | |
| 222 | + query: DecorationItemQuery | |
| 223 | +): Promise<DecorationItemPage> { | |
| 224 | + const { items, reportedTotalCount } = await requestItemList( | |
| 225 | + query, | |
| 226 | + query.page, | |
| 227 | + query.pageSize | |
| 228 | + ); | |
| 229 | + | |
| 230 | + const offset = (query.page - 1) * query.pageSize; | |
| 231 | + const confirmedCount = offset + items.length; | |
| 232 | + const reachedLastPage = items.length < query.pageSize; | |
| 233 | + | |
| 234 | + const typeTotal = await requestItemList( | |
| 235 | + { ...query, keyword: '' }, | |
| 236 | + 1, | |
| 237 | + TYPE_TOTAL_COUNT_FETCH_LIMIT | |
| 238 | + ); | |
| 239 | + | |
| 240 | + return { | |
| 241 | + items, | |
| 242 | + totalCount: Math.max(reportedTotalCount, confirmedCount), | |
| 243 | + isTotalCountExact: reachedLastPage || reportedTotalCount > confirmedCount, | |
| 244 | + typeTotalCount: typeTotal.items.length, | |
| 245 | + }; | |
| 246 | +} | |
| 247 | + | |
| 248 | +/* | |
| 249 | + * ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── | |
| 250 | + * 컨트롤러가 `@ParameterObject`로 받으므로 전부 form 인코딩으로 보낸다(파일 상단 주석 참조). | |
| 251 | + */ | |
| 252 | + | |
| 253 | +/** 등록·수정이 공유하는 전송 필드. */ | |
| 254 | +function buildWriteForm( | |
| 255 | + values: DecorationItemEditableValues | |
| 256 | +): Record<string, string | number | undefined> { | |
| 257 | + return { | |
| 258 | + itemNm: values.name, | |
| 259 | + itemCateCd: values.categoryCode, | |
| 260 | + itemAmount: values.points, | |
| 261 | + itemExplan: values.description, | |
| 262 | + useYn: values.isActive ? 'Y' : 'N', | |
| 263 | + sortOrdr: values.sortOrder, | |
| 264 | + itemType: ITEM_TYPE_CODE[values.itemType], | |
| 265 | + // 썸네일은 백엔드가 자동생성하도록 바뀔 예정이라 지금은 atchFileId만 보낸다(사용자 확정 사항). | |
| 266 | + atchFileId: values.imageFileId, | |
| 267 | + }; | |
| 268 | +} | |
| 269 | + | |
| 270 | +async function sendWrite( | |
| 271 | + path: string, | |
| 272 | + method: 'POST' | 'PUT', | |
| 273 | + values: DecorationItemEditableValues | |
| 274 | +): Promise<void> { | |
| 275 | + const accessToken = await getSessionAccessToken(); | |
| 276 | + | |
| 277 | + const result = await backendFetch<null>(path, { | |
| 278 | + method, | |
| 279 | + form: buildWriteForm(values), | |
| 280 | + accessToken: accessToken ?? undefined, | |
| 281 | + cache: 'no-store', | |
| 282 | + // 등록·수정 성공 응답은 `ApiResponseVO.success(null)` — data가 정상적으로 null이다. | |
| 283 | + canHaveNullData: true, | |
| 284 | + }); | |
| 285 | + | |
| 286 | + if (!result.ok) { | |
| 287 | + throw new BackendRequestError(result); | |
| 288 | + } | |
| 289 | +} | |
| 290 | + | |
| 291 | +export async function createDecorationItem( | |
| 292 | + values: DecorationItemEditableValues | |
| 293 | +): Promise<void> { | |
| 294 | + await sendWrite(DECORATION_ITEM_PATH, 'POST', values); | |
| 295 | +} | |
| 296 | + | |
| 297 | +/** | |
| 298 | + * 수정. | |
| 299 | + * | |
| 300 | + * ⚠️ **백엔드 결함**: 컨트롤러의 update가 요청 VO에서 `itemType`을 빌더에 담지 않는데 UPDATE 문은 | |
| 301 | + * `ITEM_TYPE = #{itemType}`을 쓴다 — 그래서 수정하면 ITEM_TYPE이 null이 되고, 목록은 | |
| 302 | + * `ITEM_TYPE = searchItemType`으로 필터하므로 그 아이템이 개별·셋트 양쪽 탭에서 사라진다. | |
| 303 | + * 우리가 `itemType`을 보내도 컨트롤러가 버리므로 프론트에서 우회할 수 없다(사용자 지시로 그대로 | |
| 304 | + * 연동하고 이슈로 보고한다). `lastMdfcnDt`도 컨트롤러가 채우지 않아 수정일시가 null이 된다. | |
| 305 | + */ | |
| 306 | +export async function updateDecorationItem( | |
| 307 | + itemSn: number, | |
| 308 | + values: DecorationItemEditableValues | |
| 309 | +): Promise<void> { | |
| 310 | + await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', values); | |
| 311 | +} | |
| 312 | + | |
| 313 | +export async function deleteDecorationItem(itemSn: number): Promise<void> { | |
| 314 | + const accessToken = await getSessionAccessToken(); | |
| 315 | + | |
| 316 | + const result = await backendFetch<null>(`${DECORATION_ITEM_PATH}/${itemSn}`, { | |
| 317 | + method: 'DELETE', | |
| 318 | + accessToken: accessToken ?? undefined, | |
| 319 | + cache: 'no-store', | |
| 320 | + canHaveNullData: true, | |
| 321 | + }); | |
| 322 | + | |
| 323 | + if (!result.ok) { | |
| 324 | + throw new BackendRequestError(result); | |
| 325 | + } | |
| 326 | +} | |
| 327 | + | |
| 328 | +/** | |
| 329 | + * 이미지 업로드 — 성공 시 `atchFileId` 문자열을 돌려준다. | |
| 330 | + * | |
| 331 | + * 브라우저가 백엔드에 직접 올리지 않는다(토큰이 서버에만 있다) — Server Action이 받은 File을 | |
| 332 | + * 그대로 백엔드로 중계한다. | |
| 333 | + */ | |
| 334 | +export async function uploadDecorationItemImage(file: File): Promise<string> { | |
| 335 | + const accessToken = await getSessionAccessToken(); | |
| 336 | + | |
| 337 | + const multipart = new FormData(); | |
| 338 | + multipart.set('file', file); | |
| 339 | + | |
| 340 | + const result = await backendFetch<string>( | |
| 341 | + `${FILE_UPLOAD_PATH}/${ITEM_FILE_MODULE_ID}`, | |
| 342 | + { | |
| 343 | + method: 'POST', | |
| 344 | + multipart, | |
| 345 | + accessToken: accessToken ?? undefined, | |
| 346 | + cache: 'no-store', | |
| 347 | + } | |
| 348 | + ); | |
| 349 | + | |
| 350 | + if (!result.ok) { | |
| 351 | + throw new BackendRequestError(result); | |
| 352 | + } | |
| 353 | + | |
| 354 | + if (typeof result.data !== 'string' || !result.data) { | |
| 355 | + throw new Error('이미지 업로드 응답에 파일 ID가 없습니다.'); | |
| 356 | + } | |
| 357 | + | |
| 358 | + return result.data; | |
| 359 | +} |
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
... | ... | @@ -15,8 +15,8 @@ |
| 15 | 15 |
* 발급된 기존 계정(예: 숫자가 없는 ID)이 **자기 정보를 저장할 수 없게 된다** — 바꾸지도 않는 |
| 16 | 16 |
* 필드 때문에. 그래서 수정은 실제로 바뀔 수 있는 항목만 검증한다. |
| 17 | 17 |
* |
| 18 |
- * 백엔드에 등록/수정 API가 생기면 백엔드 규칙과 대조해 이 파일을 맞춰야 한다 — 지금은 시안이 |
|
| 19 |
- * 유일한 근거다(백엔드에 관리자 등록 관련 코드 자체가 없다). |
|
| 18 |
+ * 형식 규칙(자릿수·조합)의 근거는 여전히 시안뿐이다 — 백엔드 등록·수정 API는 값을 그대로 받아 |
|
| 19 |
+ * 저장할 뿐 형식을 검사하지 않는다(`MngrAdminApiController`). |
|
| 20 | 20 |
*/ |
| 21 | 21 |
|
| 22 | 22 |
import {
|
... | ... | @@ -29,6 +29,17 @@ |
| 29 | 29 |
'영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다.'; |
| 30 | 30 |
export const ADMIN_PASSWORD_HELP_TEXT = |
| 31 | 31 |
'영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상'; |
| 32 |
+ |
|
| 33 |
+/** |
|
| 34 |
+ * 수정 시 비밀번호를 비울 수 없는 이유를 화면에 그대로 보여준다. |
|
| 35 |
+ * |
|
| 36 |
+ * 백엔드 UPDATE 문이 `LOGIN_PW`를 조건 없이 덮어쓴다(`MngrAdminMapper.xml`의 update). 빈 값을 |
|
| 37 |
+ * 보내면 비밀번호가 빈 문자열이 되어 그 계정은 다시 로그인할 수 없다 — 로그인은 암호화된 값과 |
|
| 38 |
+ * 비교하기 때문이다. 그래서 "바꾸지 않음"을 표현할 방법이 없고, 프론트에서 빈 값을 막는 것이 |
|
| 39 |
+ * 유일한 안전장치다. 백엔드가 빈 값을 UPDATE에서 제외하도록 고치면 이 제약을 없앨 수 있다. |
|
| 40 |
+ */ |
|
| 41 |
+export const ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT = |
|
| 42 |
+ '수정하려면 비밀번호를 다시 입력해 주세요. (빈 값으로 저장하면 로그인할 수 없게 됩니다)'; |
|
| 32 | 43 |
|
| 33 | 44 |
const LOGIN_ID_MIN_LENGTH = 4; |
| 34 | 45 |
const LOGIN_ID_MAX_LENGTH = 20; |
... | ... | @@ -62,6 +73,24 @@ |
| 62 | 73 |
export type ValidationResult<T> = |
| 63 | 74 |
| { ok: true; values: T }
|
| 64 | 75 |
| { ok: false; errors: AdminMemberFormErrors };
|
| 76 |
+ |
|
| 77 |
+/** |
|
| 78 |
+ * 등록/수정 Server Action의 `useActionState` 결과 상태. 원래는 `_actions.ts`(`'use server'` |
|
| 79 |
+ * 파일)에 있었으나, Next.js는 **`'use server'` 파일이 async 함수 외의 값을 export하는 것을 |
|
| 80 |
+ * 런타임에 거부한다**("A 'use server' file can only export async functions, found object" —
|
|
| 81 |
+ * `INITIAL_ADMIN_MEMBER_FORM_STATE`처럼 일반 객체 상수를 함께 export하면 그 파일의 Server |
|
| 82 |
+ * Action을 호출하는 즉시 500으로 깨진다. 빌드/타입체크는 통과하고 실제로 폼을 제출해야만 |
|
| 83 |
+ * 드러나는 런타임 전용 제약이라 여기로 옮겼다 — domain 계층은 `'use server'`가 없어 값 export에 |
|
| 84 |
+ * 제약이 없다(`decoration-item-form.ts`의 동일 패턴 참조). |
|
| 85 |
+ */ |
|
| 86 |
+export type AdminMemberFormState = |
|
| 87 |
+ | { status: 'idle' }
|
|
| 88 |
+ | { status: 'error'; message?: string; errors?: AdminMemberFormErrors }
|
|
| 89 |
+ | { status: 'success' };
|
|
| 90 |
+ |
|
| 91 |
+export const INITIAL_ADMIN_MEMBER_FORM_STATE: AdminMemberFormState = {
|
|
| 92 |
+ status: 'idle', |
|
| 93 |
+}; |
|
| 65 | 94 |
|
| 66 | 95 |
/** |
| 67 | 96 |
* 비밀번호 조합 종류 수 — 영문 소문자 / 숫자 / 특수문자 중 몇 종류가 섞였는지 센다. |
... | ... | @@ -128,25 +157,26 @@ |
| 128 | 157 |
/** |
| 129 | 158 |
* 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. |
| 130 | 159 |
* |
| 131 |
- * `isPasswordRequired`로 등록과 수정을 가른다 — 등록은 비밀번호가 필수지만(시안 102_p ①), |
|
| 132 |
- * 수정은 [비밀번호 변경]을 눌러 입력한 경우에만 검사한다(비우면 기존 비밀번호 유지). |
|
| 160 |
+ * 비밀번호는 등록·수정 모두 필수다. 빈 값일 때의 안내 문구만 호출부가 정한다 — 등록은 형식 안내, |
|
| 161 |
+ * 수정은 "비우면 로그인 불가"라는 백엔드 제약을 알려야 하기 때문이다 |
|
| 162 |
+ * (`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석 참조). |
|
| 133 | 163 |
*/ |
| 134 | 164 |
function validateEditableValues( |
| 135 | 165 |
values: AdminMemberEditableValues, |
| 136 | 166 |
errors: AdminMemberFormErrors, |
| 137 |
- options: { isPasswordRequired: boolean }
|
|
| 167 |
+ options: { emptyPasswordMessage: string }
|
|
| 138 | 168 |
): AdminMemberEditableValues {
|
| 139 | 169 |
const password = values.password; |
| 140 | 170 |
const phoneNumber = values.phoneNumber.trim(); |
| 141 | 171 |
const email = values.email.trim(); |
| 142 | 172 |
|
| 143 |
- if (options.isPasswordRequired || password) {
|
|
| 144 |
- if ( |
|
| 145 |
- password.length < PASSWORD_MIN_LENGTH || |
|
| 146 |
- countCharacterKinds(password) < 2 |
|
| 147 |
- ) {
|
|
| 148 |
- errors.password = ADMIN_PASSWORD_HELP_TEXT; |
|
| 149 |
- } |
|
| 173 |
+ if (!password) {
|
|
| 174 |
+ errors.password = options.emptyPasswordMessage; |
|
| 175 |
+ } else if ( |
|
| 176 |
+ password.length < PASSWORD_MIN_LENGTH || |
|
| 177 |
+ countCharacterKinds(password) < 2 |
|
| 178 |
+ ) {
|
|
| 179 |
+ errors.password = ADMIN_PASSWORD_HELP_TEXT; |
|
| 150 | 180 |
} |
| 151 | 181 |
|
| 152 | 182 |
if (!phoneNumber) {
|
... | ... | @@ -191,7 +221,7 @@ |
| 191 | 221 |
} |
| 192 | 222 |
|
| 193 | 223 |
const editable = validateEditableValues(values, errors, {
|
| 194 |
- isPasswordRequired: true, |
|
| 224 |
+ emptyPasswordMessage: ADMIN_PASSWORD_HELP_TEXT, |
|
| 195 | 225 |
}); |
| 196 | 226 |
|
| 197 | 227 |
if (Object.keys(errors).length > 0) {
|
... | ... | @@ -211,7 +241,7 @@ |
| 211 | 241 |
const errors: AdminMemberFormErrors = {};
|
| 212 | 242 |
|
| 213 | 243 |
const editable = validateEditableValues(values, errors, {
|
| 214 |
- isPasswordRequired: false, |
|
| 244 |
+ emptyPasswordMessage: ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT, |
|
| 215 | 245 |
}); |
| 216 | 246 |
|
| 217 | 247 |
if (Object.keys(errors).length > 0) {
|
+++ lib/domain/decoration-item-form.ts
... | ... | @@ -0,0 +1,145 @@ |
| 1 | +/** | |
| 2 | + * 꾸미기 아이템 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). | |
| 3 | + * | |
| 4 | + * 시안(ADM_ITM_102_p/103_p) 기준 필수 항목: 유형·아이템명·카테고리·포인트·썸네일 이미지. | |
| 5 | + * 설명은 선택이고, 정렬순서는 별도 필수(*) 표시가 없다 — 다만 화면이 항상 기본값을 채워 | |
| 6 | + * 제출하므로 실질적으로 비는 경우가 없고, 그래도 숫자 형식은 여기서 검증한다(Server Action은 | |
| 7 | + * UI를 거치지 않고 직접 호출될 수 있다 — 화면의 기본값 채움은 편의일 뿐 신뢰 경계가 아니다). | |
| 8 | + * | |
| 9 | + * **아이템ID는 입력 항목이 아니다** — 시안이 "등록 후 자동발급됩니다(Read Only)"로 명시하고, | |
| 10 | + * 백엔드도 `itemSn`을 자동증가로 채운다. 그래서 등록·수정의 검증 대상이 동일하다(예전 mock | |
| 11 | + * 구현은 사용자가 직접 입력하는 값이었으나 실제 백엔드에는 그런 컬럼이 없다). | |
| 12 | + * | |
| 13 | + * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 거친다. | |
| 14 | + */ | |
| 15 | + | |
| 16 | +import { | |
| 17 | + DECORATION_ITEM_CATEGORIES, | |
| 18 | + DECORATION_ITEM_TYPE_OPTIONS, | |
| 19 | + type DecorationItemType, | |
| 20 | +} from '@/lib/domain/decoration-item'; | |
| 21 | + | |
| 22 | +const NAME_MAX_LENGTH = 100; | |
| 23 | +const DESCRIPTION_MAX_LENGTH = 500; | |
| 24 | + | |
| 25 | +/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목(= 백엔드 쓰기 API가 받는 항목). */ | |
| 26 | +export type DecorationItemEditableValues = { | |
| 27 | + itemType: DecorationItemType; | |
| 28 | + name: string; | |
| 29 | + categoryCode: string; | |
| 30 | + points: number; | |
| 31 | + description: string; | |
| 32 | + isActive: boolean; | |
| 33 | + sortOrder: number; | |
| 34 | + /** | |
| 35 | + * 업로드된 이미지의 파일 ID. 등록 시에는 새로 업로드한 값, 수정 시에는 새로 올리지 않았다면 | |
| 36 | + * 기존 값이 그대로 들어온다(화면이 hidden 필드로 유지한다). | |
| 37 | + */ | |
| 38 | + imageFileId: string; | |
| 39 | +}; | |
| 40 | + | |
| 41 | +/** 필드별 오류 메시지 — 키는 폼 필드 이름과 일치시켜 화면이 그대로 붙여 쓸 수 있게 한다. */ | |
| 42 | +export type DecorationItemFormErrors = Partial< | |
| 43 | + Record<keyof DecorationItemEditableValues, string> | |
| 44 | +>; | |
| 45 | + | |
| 46 | +export type ValidationResult<T> = | |
| 47 | + | { ok: true; values: T } | |
| 48 | + | { ok: false; errors: DecorationItemFormErrors }; | |
| 49 | + | |
| 50 | +/** | |
| 51 | + * 등록/수정 Server Action의 `useActionState` 결과 상태. 원래는 `_actions.ts`(`'use server'` 파일)에 | |
| 52 | + * 두려 했지만, Next.js는 **`'use server'` 파일이 async 함수 외의 값을 export하는 것을 런타임에 | |
| 53 | + * 거부한다**("A 'use server' file can only export async functions, found object" — | |
| 54 | + * `INITIAL_DECORATION_ITEM_FORM_STATE`처럼 일반 객체 상수를 함께 export하면 그 파일의 Server | |
| 55 | + * Action을 호출하는 즉시 500으로 깨진다. 빌드/타입체크는 통과하고 실제로 폼을 제출해야만 | |
| 56 | + * 드러나는 런타임 전용 제약이라 여기로 옮겼다 — domain 계층은 `'use server'`가 없어 값 export에 | |
| 57 | + * 제약이 없다). | |
| 58 | + */ | |
| 59 | +export type DecorationItemFormState = | |
| 60 | + | { status: 'idle' } | |
| 61 | + | { status: 'error'; message?: string; errors?: DecorationItemFormErrors } | |
| 62 | + | { status: 'success' }; | |
| 63 | + | |
| 64 | +export const INITIAL_DECORATION_ITEM_FORM_STATE: DecorationItemFormState = { | |
| 65 | + status: 'idle', | |
| 66 | +}; | |
| 67 | + | |
| 68 | +function isDecorationItemType(value: string): value is DecorationItemType { | |
| 69 | + return DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value); | |
| 70 | +} | |
| 71 | + | |
| 72 | +function isDecorationItemCategoryCode(value: string): boolean { | |
| 73 | + return DECORATION_ITEM_CATEGORIES.some((category) => category.code === value); | |
| 74 | +} | |
| 75 | + | |
| 76 | +/** | |
| 77 | + * 등록·수정 공통 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. | |
| 78 | + * | |
| 79 | + * `points`/`sortOrder`는 `_actions.ts`가 FormData 문자열을 숫자로 변환해 넘긴다 — 빈 문자열을 | |
| 80 | + * `Number.isInteger`가 걸러낼 수 있도록 그 변환은 `Number('')`가 아니라 명시적으로 실패시키는 | |
| 81 | + * 헬퍼(`_actions.ts`의 `parseFormNumber`)를 거친 값이어야 한다(빈 문자열을 그냥 `Number()`에 | |
| 82 | + * 넘기면 0으로 조용히 통과해 필수 값 누락을 놓친다). | |
| 83 | + */ | |
| 84 | +function validateEditableValues( | |
| 85 | + values: DecorationItemEditableValues, | |
| 86 | + errors: DecorationItemFormErrors | |
| 87 | +): DecorationItemEditableValues { | |
| 88 | + const name = values.name.trim(); | |
| 89 | + const description = values.description.trim(); | |
| 90 | + | |
| 91 | + if (!isDecorationItemType(values.itemType)) { | |
| 92 | + errors.itemType = '유형을 선택해 주세요.'; | |
| 93 | + } | |
| 94 | + | |
| 95 | + if (!name) { | |
| 96 | + errors.name = '아이템명을 입력해 주세요.'; | |
| 97 | + } else if (name.length > NAME_MAX_LENGTH) { | |
| 98 | + errors.name = `아이템명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 99 | + } | |
| 100 | + | |
| 101 | + if (!isDecorationItemCategoryCode(values.categoryCode)) { | |
| 102 | + errors.categoryCode = '카테고리를 선택해 주세요.'; | |
| 103 | + } | |
| 104 | + | |
| 105 | + if (!Number.isInteger(values.points) || values.points < 0) { | |
| 106 | + errors.points = '포인트는 0 이상의 숫자로 입력해 주세요.'; | |
| 107 | + } | |
| 108 | + | |
| 109 | + if (description.length > DESCRIPTION_MAX_LENGTH) { | |
| 110 | + errors.description = `설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 111 | + } | |
| 112 | + | |
| 113 | + if (!Number.isInteger(values.sortOrder) || values.sortOrder < 1) { | |
| 114 | + errors.sortOrder = '정렬순서는 1 이상의 숫자로 입력해 주세요.'; | |
| 115 | + } | |
| 116 | + | |
| 117 | + // 시안이 썸네일 이미지를 필수(*)로 표시한다. 업로드 자체의 실패는 Server Action이 별도 | |
| 118 | + // 메시지로 처리하고, 여기서는 "결과 파일 ID가 없는 상태"만 잡는다. | |
| 119 | + if (!values.imageFileId.trim()) { | |
| 120 | + errors.imageFileId = '썸네일 이미지를 등록해 주세요.'; | |
| 121 | + } | |
| 122 | + | |
| 123 | + return { ...values, name, description }; | |
| 124 | +} | |
| 125 | + | |
| 126 | +/** 시안 ADM_ITM_102_p — 등록 검증. */ | |
| 127 | +export function validateDecorationItemCreate( | |
| 128 | + values: DecorationItemEditableValues | |
| 129 | +): ValidationResult<DecorationItemEditableValues> { | |
| 130 | + const errors: DecorationItemFormErrors = {}; | |
| 131 | + const editable = validateEditableValues(values, errors); | |
| 132 | + | |
| 133 | + if (Object.keys(errors).length > 0) { | |
| 134 | + return { ok: false, errors }; | |
| 135 | + } | |
| 136 | + | |
| 137 | + return { ok: true, values: editable }; | |
| 138 | +} | |
| 139 | + | |
| 140 | +/** 시안 ADM_ITM_103_p — 수정 검증. 아이템ID는 읽기 전용이라 검증 대상이 아니다. */ | |
| 141 | +export function validateDecorationItemUpdate( | |
| 142 | + values: DecorationItemEditableValues | |
| 143 | +): ValidationResult<DecorationItemEditableValues> { | |
| 144 | + return validateDecorationItemCreate(values); | |
| 145 | +} |
+++ lib/domain/decoration-item-query.ts
... | ... | @@ -0,0 +1,154 @@ |
| 1 | +/** | |
| 2 | + * 꾸미기 아이템 목록의 유형탭·검색·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만 | |
| 3 | + * 담는다. next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준 | |
| 4 | + * Web API). | |
| 5 | + * | |
| 6 | + * `app/(protected)/(basic)/decoration-items/page.tsx`가 `searchParams`를 | |
| 7 | + * `parseDecorationItemQuery`로 정규화하는 지점이자 단일 진실원천이며, 유형 탭·검색바·툴바· | |
| 8 | + * 페이지네이션은 모두 `buildDecorationItemHref`로 같은 규칙에 따라 URL을 만들어 파라미터 | |
| 9 | + * 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다(admin-member-query.ts와 동일한 | |
| 10 | + * 설계). | |
| 11 | + * | |
| 12 | + * **유형 탭(개별/셋트)도 이 쿼리의 일부다** — 시안이 "셋트아이템도 동일한 항목으로 처리하며 | |
| 13 | + * 별도 화면이 없다"고 명시해, 탭 전환은 라우트 이동이 아니라 이 화면 안에서 `itemType` 값만 | |
| 14 | + * 바뀌는 것으로 구현한다. | |
| 15 | + * | |
| 16 | + * 시안(ADM_ITM_101)에는 admins의 "정렬" select에 대응하는 항목이 없다 — 목록은 항상 정렬순서 | |
| 17 | + * 오름차순 고정이라 이 파일에 정렬 옵션을 두지 않는다(Repository가 고정 규칙으로 정렬한다). | |
| 18 | + */ | |
| 19 | + | |
| 20 | +import { | |
| 21 | + DECORATION_ITEM_TYPE_OPTIONS, | |
| 22 | + DEFAULT_DECORATION_ITEM_TYPE, | |
| 23 | + type DecorationItemType, | |
| 24 | +} from '@/lib/domain/decoration-item'; | |
| 25 | + | |
| 26 | +/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */ | |
| 27 | +export const DECORATION_ITEMS_PATH = '/decoration-items'; | |
| 28 | + | |
| 29 | +/** 검색 대상 — 시안(ADM_ITM_101 ①) "아이템명 / 아이템ID" 그대로. */ | |
| 30 | +export type DecorationItemSearchField = 'name' | 'itemId'; | |
| 31 | + | |
| 32 | +export const DECORATION_ITEM_SEARCH_FIELD_OPTIONS: ReadonlyArray<{ | |
| 33 | + value: DecorationItemSearchField; | |
| 34 | + label: string; | |
| 35 | +}> = [ | |
| 36 | + { value: 'name', label: '아이템명' }, | |
| 37 | + { value: 'itemId', label: '아이템ID' }, | |
| 38 | +]; | |
| 39 | + | |
| 40 | +export const DECORATION_ITEM_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; | |
| 41 | +export type DecorationItemPageSize = | |
| 42 | + (typeof DECORATION_ITEM_PAGE_SIZE_OPTIONS)[number]; | |
| 43 | + | |
| 44 | +export const DEFAULT_DECORATION_ITEM_SEARCH_FIELD: DecorationItemSearchField = | |
| 45 | + 'name'; | |
| 46 | +export const DEFAULT_DECORATION_ITEM_PAGE_SIZE: DecorationItemPageSize = 10; | |
| 47 | +const DEFAULT_PAGE = 1; | |
| 48 | +const MAX_KEYWORD_LENGTH = 100; | |
| 49 | + | |
| 50 | +export type DecorationItemQuery = { | |
| 51 | + itemType: DecorationItemType; | |
| 52 | + searchField: DecorationItemSearchField; | |
| 53 | + keyword: string; | |
| 54 | + page: number; | |
| 55 | + pageSize: DecorationItemPageSize; | |
| 56 | +}; | |
| 57 | + | |
| 58 | +/** Next.js `page.tsx`의 `searchParams`가 리졸브하는 값 형태를 그대로 옮긴 구조 타입 — | |
| 59 | + * next 패키지를 import하지 않고도 같은 shape을 표현해 domain 계층의 무의존 규칙을 지킨다. */ | |
| 60 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 61 | + | |
| 62 | +function readParam(params: RawSearchParams, key: string): string | undefined { | |
| 63 | + const value = params[key]; | |
| 64 | + return Array.isArray(value) ? value[0] : value; | |
| 65 | +} | |
| 66 | + | |
| 67 | +function isDecorationItemType( | |
| 68 | + value: string | undefined | |
| 69 | +): value is DecorationItemType { | |
| 70 | + return ( | |
| 71 | + value !== undefined && | |
| 72 | + DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value) | |
| 73 | + ); | |
| 74 | +} | |
| 75 | + | |
| 76 | +function isDecorationItemSearchField( | |
| 77 | + value: string | undefined | |
| 78 | +): value is DecorationItemSearchField { | |
| 79 | + return ( | |
| 80 | + value !== undefined && | |
| 81 | + DECORATION_ITEM_SEARCH_FIELD_OPTIONS.some((option) => option.value === value) | |
| 82 | + ); | |
| 83 | +} | |
| 84 | + | |
| 85 | +function isDecorationItemPageSize( | |
| 86 | + value: number | |
| 87 | +): value is DecorationItemPageSize { | |
| 88 | + return (DECORATION_ITEM_PAGE_SIZE_OPTIONS as readonly number[]).includes( | |
| 89 | + value | |
| 90 | + ); | |
| 91 | +} | |
| 92 | + | |
| 93 | +/** | |
| 94 | + * URL의 searchParams를 검증된 `DecorationItemQuery`로 정규화한다. 값이 없거나 허용 목록을 | |
| 95 | + * 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라 | |
| 96 | + * 신뢰하지 않는다. | |
| 97 | + */ | |
| 98 | +export function parseDecorationItemQuery( | |
| 99 | + searchParams: RawSearchParams | |
| 100 | +): DecorationItemQuery { | |
| 101 | + const itemTypeRaw = readParam(searchParams, 'itemType'); | |
| 102 | + const searchFieldRaw = readParam(searchParams, 'searchField'); | |
| 103 | + const keywordRaw = readParam(searchParams, 'keyword'); | |
| 104 | + const pageRaw = Number(readParam(searchParams, 'page')); | |
| 105 | + const pageSizeRaw = Number(readParam(searchParams, 'pageSize')); | |
| 106 | + | |
| 107 | + return { | |
| 108 | + itemType: isDecorationItemType(itemTypeRaw) | |
| 109 | + ? itemTypeRaw | |
| 110 | + : DEFAULT_DECORATION_ITEM_TYPE, | |
| 111 | + searchField: isDecorationItemSearchField(searchFieldRaw) | |
| 112 | + ? searchFieldRaw | |
| 113 | + : DEFAULT_DECORATION_ITEM_SEARCH_FIELD, | |
| 114 | + keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH), | |
| 115 | + page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE, | |
| 116 | + pageSize: isDecorationItemPageSize(pageSizeRaw) | |
| 117 | + ? pageSizeRaw | |
| 118 | + : DEFAULT_DECORATION_ITEM_PAGE_SIZE, | |
| 119 | + }; | |
| 120 | +} | |
| 121 | + | |
| 122 | +/** | |
| 123 | + * `DecorationItemQuery`(+ 부분 override)를 `/decoration-items` 링크로 직렬화한다. | |
| 124 | + * `parseDecorationItemQuery`의 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 | |
| 125 | + * 유지한다. | |
| 126 | + */ | |
| 127 | +export function buildDecorationItemHref( | |
| 128 | + query: DecorationItemQuery, | |
| 129 | + overrides: Partial<DecorationItemQuery> = {} | |
| 130 | +): string { | |
| 131 | + const merged = { ...query, ...overrides }; | |
| 132 | + const params = new URLSearchParams(); | |
| 133 | + | |
| 134 | + if (merged.itemType !== DEFAULT_DECORATION_ITEM_TYPE) { | |
| 135 | + params.set('itemType', merged.itemType); | |
| 136 | + } | |
| 137 | + if (merged.searchField !== DEFAULT_DECORATION_ITEM_SEARCH_FIELD) { | |
| 138 | + params.set('searchField', merged.searchField); | |
| 139 | + } | |
| 140 | + if (merged.keyword) { | |
| 141 | + params.set('keyword', merged.keyword); | |
| 142 | + } | |
| 143 | + if (merged.pageSize !== DEFAULT_DECORATION_ITEM_PAGE_SIZE) { | |
| 144 | + params.set('pageSize', String(merged.pageSize)); | |
| 145 | + } | |
| 146 | + if (merged.page !== DEFAULT_PAGE) { | |
| 147 | + params.set('page', String(merged.page)); | |
| 148 | + } | |
| 149 | + | |
| 150 | + const queryString = params.toString(); | |
| 151 | + return queryString | |
| 152 | + ? `${DECORATION_ITEMS_PATH}?${queryString}` | |
| 153 | + : DECORATION_ITEMS_PATH; | |
| 154 | +} |
+++ lib/domain/decoration-item.ts
... | ... | @@ -0,0 +1,116 @@ |
| 1 | +/** | |
| 2 | + * 꾸미기 아이템 도메인 타입 — 순수 데이터 표현, 외부 의존 없음. | |
| 3 | + * | |
| 4 | + * 데이터 출처는 백엔드(edupay-backend)의 `/api/v1/mngr/item/**`(ROLE_ADMIN 전용)이며, | |
| 5 | + * `lib/data/repositories/decoration-item-repository.ts`가 응답을 이 타입으로 매핑한다. | |
| 6 | + * 백엔드 테이블은 `TB_COM_ITEM`이다. | |
| 7 | + */ | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 유형 — 개별아이템 / 셋트아이템. 시안(ADM_ITM_101, ADM_ITM_102_p) 두 곳 모두 "셋트아이템도 | |
| 11 | + * 동일한 항목으로 처리하며 별도 화면이 없다"고 명시한다 — 목록의 유형 탭과 등록/수정 폼의 값이 | |
| 12 | + * 모두 이 타입 하나를 공유한다. | |
| 13 | + * | |
| 14 | + * 값 자체는 화면·URL용 표현이고, 백엔드 코드값(개별 `N` / 셋트 `S`, 사용자 확정 사항)으로의 | |
| 15 | + * 변환은 Repository가 담당한다 — URL(`?itemType=set`)을 읽을 수 있게 유지하기 위해서다. | |
| 16 | + */ | |
| 17 | +export type DecorationItemType = 'individual' | 'set'; | |
| 18 | + | |
| 19 | +export const DECORATION_ITEM_TYPE_OPTIONS: ReadonlyArray<{ | |
| 20 | + value: DecorationItemType; | |
| 21 | + label: string; | |
| 22 | +}> = [ | |
| 23 | + { value: 'individual', label: '개별아이템' }, | |
| 24 | + { value: 'set', label: '셋트아이템' }, | |
| 25 | +]; | |
| 26 | + | |
| 27 | +export const DEFAULT_DECORATION_ITEM_TYPE: DecorationItemType = 'individual'; | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * 카테고리 — **임시 값이다.** 백엔드는 공통코드테이블(`TB_SYS_COM_CD_DTL`, `COM_CD='ITEM_CATE_CD'`)을 | |
| 31 | + * 조인해 `itemCateNm`을 내려주지만 그 코드가 아직 정비되지 않아, 사용자 지시에 따라 프론트에서 | |
| 32 | + * 임의 코드로 개발한다(시안 등록 팝업의 예시값 계절/축하/시즌을 그대로 씀). | |
| 33 | + * | |
| 34 | + * **백엔드에 코드가 추가되면 이 상수를 지우고 `GET /api/v1/common/code/ITEM_CATE_CD`(인증 불필요) | |
| 35 | + * 응답으로 교체한다.** 그때까지 저장되는 `itemCateCd`는 여기 정의된 임시 코드라, 실제 코드 체계가 | |
| 36 | + * 정해지면 기존 데이터의 코드값 마이그레이션이 필요하다. | |
| 37 | + */ | |
| 38 | +export const DECORATION_ITEM_CATEGORIES: ReadonlyArray<{ | |
| 39 | + code: string; | |
| 40 | + label: string; | |
| 41 | +}> = [ | |
| 42 | + { code: 'CATE01', label: '계절' }, | |
| 43 | + { code: 'CATE02', label: '축하' }, | |
| 44 | + { code: 'CATE03', label: '시즌' }, | |
| 45 | +]; | |
| 46 | + | |
| 47 | +export const DEFAULT_DECORATION_ITEM_CATEGORY_CODE = | |
| 48 | + DECORATION_ITEM_CATEGORIES[0].code; | |
| 49 | + | |
| 50 | +export type DecorationItem = { | |
| 51 | + /** | |
| 52 | + * 백엔드 `itemSn` — 자동증가 PK이자 화면의 "아이템ID"로 그대로 노출하는 값(사용자 확정 사항). | |
| 53 | + * 시안은 `ITEM-HAT-001` 형식의 자동발급 ID를 그리지만 백엔드에 그런 컬럼이 없다. | |
| 54 | + * 목록 행의 key이자 수정/삭제 API의 경로 변수다. | |
| 55 | + */ | |
| 56 | + itemSn: number; | |
| 57 | + itemType: DecorationItemType; | |
| 58 | + name: string; | |
| 59 | + /** 백엔드 `itemCateCd` — 저장·전송에 쓰는 코드값. */ | |
| 60 | + categoryCode: string; | |
| 61 | + /** | |
| 62 | + * 백엔드 `itemCateNm` — 공통코드테이블 조인 결과. 코드가 코드테이블에 없으면 null로 온다 | |
| 63 | + * (지금은 임시 코드를 쓰므로 대개 null이다 — 화면은 `formatDecorationItemCategory`로 보완한다). | |
| 64 | + */ | |
| 65 | + categoryName: string | null; | |
| 66 | + /** 백엔드 `itemAmount` — 오픈 가능한 포인트. */ | |
| 67 | + points: number; | |
| 68 | + /** 백엔드 `itemExplan`. */ | |
| 69 | + description: string | null; | |
| 70 | + /** 백엔드 `useYn`('Y'/'N') — 표에서는 O/X로 표기한다. */ | |
| 71 | + isActive: boolean; | |
| 72 | + /** 백엔드 `sortOrdr` — 목록 기본 정렬 기준(시안 ADM_ITM_101). */ | |
| 73 | + sortOrder: number; | |
| 74 | + /** 백엔드 `atchFileId` — 업로드된 이미지의 파일 ID. 등록하지 않았으면 null. */ | |
| 75 | + imageFileId: string | null; | |
| 76 | + /** 위 파일 ID로 만든 백엔드 이미지 URL(공개 GET). 파일이 없으면 null. */ | |
| 77 | + imageUrl: string | null; | |
| 78 | + /** 백엔드 `lastMdfcnDt` — `YYYY-MM-DD`(백엔드가 이 형식으로 포맷해 내려준다). 미수정이면 null. */ | |
| 79 | + updatedAt: string | null; | |
| 80 | +}; | |
| 81 | + | |
| 82 | +/** 값이 없는 항목의 화면 표기. */ | |
| 83 | +export const EMPTY_FIELD_PLACEHOLDER = '-'; | |
| 84 | + | |
| 85 | +/** 사용여부 → 화면 표기(시안: O/X). */ | |
| 86 | +export function formatDecorationItemActiveLabel(isActive: boolean): string { | |
| 87 | + return isActive ? 'O' : 'X'; | |
| 88 | +} | |
| 89 | + | |
| 90 | +/** 포인트 → 화면 표기(시안: "100 P" 형태). */ | |
| 91 | +export function formatDecorationItemPoints(points: number): string { | |
| 92 | + return `${points.toLocaleString('ko-KR')} P`; | |
| 93 | +} | |
| 94 | + | |
| 95 | +/** | |
| 96 | + * 카테고리 표기 — 백엔드가 코드테이블에서 찾은 이름을 우선 쓰고, 없으면 프론트 임시 목록에서 | |
| 97 | + * 찾고, 그것도 없으면 코드값 자체를 보여준다. 임시 코드 단계에서는 두 번째 경로가 주로 쓰이고, | |
| 98 | + * 백엔드 코드가 정비되면 자연스럽게 첫 번째 경로로 넘어간다. | |
| 99 | + */ | |
| 100 | +export function formatDecorationItemCategory(item: DecorationItem): string { | |
| 101 | + if (item.categoryName) { | |
| 102 | + return item.categoryName; | |
| 103 | + } | |
| 104 | + | |
| 105 | + const known = DECORATION_ITEM_CATEGORIES.find( | |
| 106 | + (category) => category.code === item.categoryCode | |
| 107 | + ); | |
| 108 | + return known?.label ?? item.categoryCode ?? EMPTY_FIELD_PLACEHOLDER; | |
| 109 | +} | |
| 110 | + | |
| 111 | +/** 수정일시 표기 — 백엔드가 이미 `YYYY-MM-DD`로 포맷해 주므로 그대로 쓰고 null만 보완한다. */ | |
| 112 | +export function formatDecorationItemUpdatedAt( | |
| 113 | + updatedAt: string | null | |
| 114 | +): string { | |
| 115 | + return updatedAt ?? EMPTY_FIELD_PLACEHOLDER; | |
| 116 | +} |
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
... | ... | @@ -51,19 +51,21 @@ |
| 51 | 51 |
|
| 52 | 52 |
type BackendRequestInit = {
|
| 53 | 53 |
method: 'GET' | 'POST' | 'PUT' | 'DELETE'; |
| 54 |
+ /** JSON 본문. `@RequestBody`로 받는 엔드포인트(예: 로그인)에 쓴다. */ |
|
| 54 | 55 |
body?: unknown; |
| 55 | 56 |
/** |
| 56 |
- * 폼 인코딩(`application/x-www-form-urlencoded`) 본문. |
|
| 57 |
+ * form 인코딩 본문(`application/x-www-form-urlencoded`). `body`와 함께 지정하지 않는다. |
|
| 57 | 58 |
* |
| 58 |
- * 백엔드 컨트롤러 중에는 요청 VO에 `@RequestBody`가 없는 것들이 있다(예: 게시판 등록 |
|
| 59 |
- * `MngrBbsApiController.insert`). 그런 엔드포인트는 JSON 본문을 읽지 못하고 요청 파라미터로만 |
|
| 60 |
- * 바인딩하므로 폼으로 보내야 한다. `body`와 함께 쓰지 않는다. |
|
| 59 |
+ * 백엔드의 일부 쓰기 API는 `@RequestBody`가 아니라 **`@ParameterObject`(= ModelAttribute |
|
| 60 |
+ * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`, |
|
| 61 |
+ * 아이템 등록·수정 `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지 |
|
| 62 |
+ * 않아 **전 필드가 null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로, |
|
| 63 |
+ * 엔드포인트가 어느 쪽인지 확인하고 맞는 형식을 골라야 한다. |
|
| 64 |
+ * |
|
| 65 |
+ * `undefined`인 값은 전송에서 제외한다(백엔드가 "미전송"과 "빈 문자열"을 다르게 볼 수 있다). |
|
| 61 | 66 |
*/ |
| 62 |
- form?: Record<string, string>; |
|
| 63 |
- /** |
|
| 64 |
- * 멀티파트 본문(파일 업로드). `Content-Type`을 **직접 지정하지 않는다** — 헤더에는 파트 경계 |
|
| 65 |
- * (boundary) 문자열이 함께 들어가야 하는데 그 값은 런타임이 FormData에서 생성하기 때문이다. |
|
| 66 |
- */ |
|
| 67 |
+ form?: Record<string, string | number | undefined>; |
|
| 68 |
+ /** multipart 본문. 파일 업로드 전용 — Content-Type은 브라우저/런타임이 boundary와 함께 붙인다. */ |
|
| 67 | 69 |
multipart?: FormData; |
| 68 | 70 |
/** 쿼리 스트링 파라미터. 값은 문자열로 직렬화해 붙인다. */ |
| 69 | 71 |
query?: Record<string, string | number>; |
... | ... | @@ -76,6 +78,17 @@ |
| 76 | 78 |
/** Next.js `fetch` 확장 옵션 — 호출부가 캐시 전략을 명시하는 용도. 둘 중 하나만 지정한다. */ |
| 77 | 79 |
cache?: RequestCache; |
| 78 | 80 |
next?: { revalidate?: number | false; tags?: string[] };
|
| 81 |
+ /** |
|
| 82 |
+ * 이 호출은 성공 응답의 `data`가 정상적으로 `null`일 수 있다(예: 로그아웃처럼 돌려줄 데이터가 |
|
| 83 |
+ * 없는 변경 API). 기본값 false는 기존 계약을 그대로 유지한다 — 지금까지의 모든 호출(로그인· |
|
| 84 |
+ * 목록 조회)은 성공 시 항상 실질 데이터를 반환했으므로 `data === null`은 통신 오류·예상 밖 |
|
| 85 |
+ * 응답의 신호로 다뤄 왔다. true로 지정하지 않으면 `success:true`인데 `data:null`인 정상 성공 |
|
| 86 |
+ * 응답까지 실패로 오판된다 — edupay-backend가 실제로 이 형태를 반환한다(확인: |
|
| 87 |
+ * ApiResponseVO.success(null) → `{success:true, code:200, data:null}`,
|
|
| 88 |
+ * ApiResponseVO.java에 `@JsonInclude(NON_NULL)`이 없어 data 키가 생략되지 않고 명시적으로 |
|
| 89 |
+ * null이 직렬화됨). |
|
| 90 |
+ */ |
|
| 91 |
+ canHaveNullData?: boolean; |
|
| 79 | 92 |
/** 기본 타임아웃보다 오래 걸리는 호출(파일 업로드 등)이 값을 올려 잡는다. */ |
| 80 | 93 |
timeoutMs?: number; |
| 81 | 94 |
}; |
... | ... | @@ -176,45 +189,53 @@ |
| 176 | 189 |
return { ok: true, data: response };
|
| 177 | 190 |
} |
| 178 | 191 |
|
| 179 |
-/** 본문 인코딩 선택 — 멀티파트 > 폼 > JSON 순으로 우선한다. */ |
|
| 192 |
+/** |
|
| 193 |
+ * 본문 형식에 맞는 `body`와 Content-Type을 고른다. 셋 중 하나만 지정된다는 전제이며, 우선순위는 |
|
| 194 |
+ * multipart > form > JSON이다. |
|
| 195 |
+ * |
|
| 196 |
+ * multipart일 때 Content-Type을 **직접 넣지 않는 것이 중요하다** — 직접 넣으면 boundary 파라미터가 |
|
| 197 |
+ * 빠져 서버가 본문을 파싱하지 못한다. FormData를 body로 주면 런타임이 boundary까지 붙여 준다. |
|
| 198 |
+ */ |
|
| 180 | 199 |
function buildRequestBody(init: BackendRequestInit): {
|
| 181 |
- headers: Record<string, string>; |
|
| 182 |
- body: string | FormData | undefined; |
|
| 200 |
+ body: BodyInit | undefined; |
|
| 201 |
+ contentType: string | undefined; |
|
| 183 | 202 |
} {
|
| 184 |
- if (init.multipart !== undefined) {
|
|
| 185 |
- // Content-Type을 비워 둬야 런타임이 boundary를 포함해 채워 넣는다(위 주석 참조). |
|
| 186 |
- return { headers: {}, body: init.multipart };
|
|
| 203 |
+ if (init.multipart) {
|
|
| 204 |
+ return { body: init.multipart, contentType: undefined };
|
|
| 187 | 205 |
} |
| 188 | 206 |
|
| 189 |
- if (init.form !== undefined) {
|
|
| 207 |
+ if (init.form) {
|
|
| 208 |
+ const params = new URLSearchParams(); |
|
| 209 |
+ for (const [key, value] of Object.entries(init.form)) {
|
|
| 210 |
+ if (value !== undefined) {
|
|
| 211 |
+ params.set(key, String(value)); |
|
| 212 |
+ } |
|
| 213 |
+ } |
|
| 190 | 214 |
return {
|
| 191 |
- headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
|
| 192 |
- body: new URLSearchParams(init.form).toString(), |
|
| 215 |
+ body: params.toString(), |
|
| 216 |
+ contentType: 'application/x-www-form-urlencoded', |
|
| 193 | 217 |
}; |
| 194 | 218 |
} |
| 195 | 219 |
|
| 196 | 220 |
return {
|
| 197 |
- headers: { 'Content-Type': 'application/json' },
|
|
| 198 | 221 |
body: init.body !== undefined ? JSON.stringify(init.body) : undefined, |
| 222 |
+ contentType: 'application/json', |
|
| 199 | 223 |
}; |
| 200 | 224 |
} |
| 201 | 225 |
|
| 202 |
-/** |
|
| 203 |
- * 요청 전송 + 실패 정규화까지의 공통 경로. 성공 시 파싱된 봉투를 그대로 돌려주고, `data`를 |
|
| 204 |
- * 어떻게 다룰지(필수인지 없어도 되는지)는 호출부인 `backendFetch`/`backendCommand`가 정한다. |
|
| 205 |
- */ |
|
| 206 |
-async function sendBackendRequest( |
|
| 226 |
+/** 백엔드 REST 호출 단일 진입점. */ |
|
| 227 |
+export async function backendFetch<T>( |
|
| 207 | 228 |
path: string, |
| 208 | 229 |
init: BackendRequestInit |
| 209 |
-): Promise<BackendResult<BackendEnvelope<unknown>>> {
|
|
| 210 |
- const { headers, body } = buildRequestBody(init);
|
|
| 230 |
+): Promise<BackendResult<T>> {
|
|
| 231 |
+ const { body, contentType } = buildRequestBody(init);
|
|
| 211 | 232 |
|
| 212 | 233 |
let response: Response; |
| 213 | 234 |
try {
|
| 214 | 235 |
response = await fetch(resolveUrl(path, init.query), {
|
| 215 | 236 |
method: init.method, |
| 216 | 237 |
headers: {
|
| 217 |
- ...headers, |
|
| 238 |
+ ...(contentType ? { 'Content-Type': contentType } : {}),
|
|
| 218 | 239 |
...(init.accessToken |
| 219 | 240 |
? { Authorization: `Bearer ${init.accessToken}` }
|
| 220 | 241 |
: {}),
|
... | ... | @@ -247,53 +268,20 @@ |
| 247 | 268 |
return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined);
|
| 248 | 269 |
} |
| 249 | 270 |
|
| 250 |
- let envelope: BackendEnvelope<unknown>; |
|
| 271 |
+ let envelope: BackendEnvelope<T>; |
|
| 251 | 272 |
try {
|
| 252 |
- envelope = (await response.json()) as BackendEnvelope<unknown>; |
|
| 273 |
+ envelope = (await response.json()) as BackendEnvelope<T>; |
|
| 253 | 274 |
} catch (error) {
|
| 254 | 275 |
return communicationError('응답 파싱 실패', error);
|
| 255 | 276 |
} |
| 256 | 277 |
|
| 257 |
- if (!envelope.success) {
|
|
| 278 |
+ if (!envelope.success || (envelope.data === null && !init.canHaveNullData)) {
|
|
| 258 | 279 |
return { ok: false, code: envelope.code, message: envelope.message };
|
| 259 | 280 |
} |
| 260 | 281 |
|
| 261 |
- return { ok: true, data: envelope };
|
|
| 262 |
-} |
|
| 263 |
- |
|
| 264 |
-/** |
|
| 265 |
- * 백엔드 REST 호출 단일 진입점 — **응답 데이터를 기대하는 호출용**이다. |
|
| 266 |
- * 봉투의 `data`가 없으면 실패로 본다(조회인데 실을 것이 없다면 계약 위반이다). |
|
| 267 |
- */ |
|
| 268 |
-export async function backendFetch<T>( |
|
| 269 |
- path: string, |
|
| 270 |
- init: BackendRequestInit |
|
| 271 |
-): Promise<BackendResult<T>> {
|
|
| 272 |
- const result = await sendBackendRequest(path, init); |
|
| 273 |
- if (!result.ok) {
|
|
| 274 |
- return result; |
|
| 275 |
- } |
|
| 276 |
- |
|
| 277 |
- const envelope = result.data; |
|
| 278 |
- if (envelope.data === null || envelope.data === undefined) {
|
|
| 279 |
- return { ok: false, code: envelope.code, message: envelope.message };
|
|
| 280 |
- } |
|
| 281 |
- |
|
| 282 |
+ // `canHaveNullData`가 true인 호출은 data가 null이어도 위에서 걸러지지 않으므로, 여기서 |
|
| 283 |
+ // TS는 `envelope.data`를 `T | null` 그대로 본다(narrowing 불가 — null 여부와 무관한 |
|
| 284 |
+ // canHaveNullData 플래그가 조건에 섞여 있어서다). 그 null은 호출부가 명시적으로 허용한 |
|
| 285 |
+ // 값이므로(그리고 그런 호출부는 보통 `backendFetch<null>(...)`로 T=null을 지정한다) 단언한다. |
|
| 282 | 286 |
return { ok: true, data: envelope.data as T };
|
| 283 |
-} |
|
| 284 |
- |
|
| 285 |
-/** |
|
| 286 |
- * **응답 데이터가 없는 쓰기 호출용**(등록·수정·삭제). 성공 판정은 봉투의 `success`만 본다. |
|
| 287 |
- * |
|
| 288 |
- * `backendFetch`와 나눈 이유: 백엔드의 쓰기 API 상당수가 `ApiResponseVO.success(null)`을 반환한다 |
|
| 289 |
- * (예: 게시판 등록·수정·삭제). `backendFetch`는 `data`가 없으면 실패로 보므로 그대로 쓰면 **성공한 |
|
| 290 |
- * 요청이 전부 실패로 보고된다.** 두 규약을 한 함수에 섞으면 조회 쪽의 계약 위반 감지가 무뎌지므로 |
|
| 291 |
- * 함수를 나눴다. |
|
| 292 |
- */ |
|
| 293 |
-export async function backendCommand( |
|
| 294 |
- path: string, |
|
| 295 |
- init: BackendRequestInit |
|
| 296 |
-): Promise<BackendResult<null>> {
|
|
| 297 |
- const result = await sendBackendRequest(path, init); |
|
| 298 |
- return result.ok ? { ok: true, data: null } : result;
|
|
| 299 | 287 |
} |
--- next.config.ts
+++ next.config.ts
... | ... | @@ -1,3 +1,4 @@ |
| 1 |
+import path from "node:path"; |
|
| 1 | 2 |
import type { NextConfig } from "next";
|
| 2 | 3 |
|
| 3 | 4 |
// 다층 캐시·SEO 차단(§4)의 응답 헤더 계층. immutable 정적 자산(_next/static, _next/image)은 |
... | ... | @@ -19,6 +20,14 @@ |
| 19 | 20 |
]; |
| 20 | 21 |
|
| 21 | 22 |
const nextConfig: NextConfig = {
|
| 23 |
+ // SCSS 로드 경로 — 프로젝트 루트를 Sass의 loadPaths에 넣어 `@use "@fox/styles/abstracts"` |
|
| 24 |
+ // 같은 루트 기준 절대 경로가 해석되게 한다. 이게 없으면 컴포넌트마다 `../../../` 상대 |
|
| 25 |
+ // 경로를 써야 해서 @fox 폴더를 다른 프로젝트로 통째로 복사할 수 없다(포터블 요건). |
|
| 26 |
+ // tsconfig의 `@fox/*` alias는 TS/번들러 전용이라 Sass 해석에는 관여하지 않으므로, |
|
| 27 |
+ // 같은 경로를 여기서 한 번 더 선언해야 TS와 SCSS의 import 표기가 일치한다. |
|
| 28 |
+ sassOptions: {
|
|
| 29 |
+ loadPaths: [path.join(process.cwd())], |
|
| 30 |
+ }, |
|
| 22 | 31 |
async headers() {
|
| 23 | 32 |
return [ |
| 24 | 33 |
{
|
--- package-lock.json
+++ package-lock.json
... | ... | @@ -13,27 +13,13 @@ |
| 13 | 13 |
"react-dom": "19.2.4" |
| 14 | 14 |
}, |
| 15 | 15 |
"devDependencies": {
|
| 16 |
- "@tailwindcss/postcss": "^4", |
|
| 17 | 16 |
"@types/node": "^20", |
| 18 | 17 |
"@types/react": "^19", |
| 19 | 18 |
"@types/react-dom": "^19", |
| 20 | 19 |
"eslint": "^9", |
| 21 | 20 |
"eslint-config-next": "16.2.12", |
| 22 |
- "tailwindcss": "^4", |
|
| 21 |
+ "sass": "^1.102.0", |
|
| 23 | 22 |
"typescript": "^5" |
| 24 |
- } |
|
| 25 |
- }, |
|
| 26 |
- "node_modules/@alloc/quick-lru": {
|
|
| 27 |
- "version": "5.2.0", |
|
| 28 |
- "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", |
|
| 29 |
- "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", |
|
| 30 |
- "dev": true, |
|
| 31 |
- "license": "MIT", |
|
| 32 |
- "engines": {
|
|
| 33 |
- "node": ">=10" |
|
| 34 |
- }, |
|
| 35 |
- "funding": {
|
|
| 36 |
- "url": "https://github.com/sponsors/sindresorhus" |
|
| 37 | 23 |
} |
| 38 | 24 |
}, |
| 39 | 25 |
"node_modules/@babel/code-frame": {
|
... | ... | @@ -1246,6 +1232,308 @@ |
| 1246 | 1232 |
"node": ">=12.4.0" |
| 1247 | 1233 |
} |
| 1248 | 1234 |
}, |
| 1235 |
+ "node_modules/@parcel/watcher": {
|
|
| 1236 |
+ "version": "2.6.0", |
|
| 1237 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", |
|
| 1238 |
+ "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", |
|
| 1239 |
+ "dev": true, |
|
| 1240 |
+ "hasInstallScript": true, |
|
| 1241 |
+ "license": "MIT", |
|
| 1242 |
+ "optional": true, |
|
| 1243 |
+ "dependencies": {
|
|
| 1244 |
+ "detect-libc": "^2.0.3", |
|
| 1245 |
+ "is-glob": "^4.0.3", |
|
| 1246 |
+ "node-addon-api": "^7.0.0", |
|
| 1247 |
+ "picomatch": "^4.0.4" |
|
| 1248 |
+ }, |
|
| 1249 |
+ "engines": {
|
|
| 1250 |
+ "node": ">= 10.0.0" |
|
| 1251 |
+ }, |
|
| 1252 |
+ "funding": {
|
|
| 1253 |
+ "type": "opencollective", |
|
| 1254 |
+ "url": "https://opencollective.com/parcel" |
|
| 1255 |
+ }, |
|
| 1256 |
+ "optionalDependencies": {
|
|
| 1257 |
+ "@parcel/watcher-android-arm64": "2.6.0", |
|
| 1258 |
+ "@parcel/watcher-darwin-arm64": "2.6.0", |
|
| 1259 |
+ "@parcel/watcher-darwin-x64": "2.6.0", |
|
| 1260 |
+ "@parcel/watcher-freebsd-x64": "2.6.0", |
|
| 1261 |
+ "@parcel/watcher-linux-arm-glibc": "2.6.0", |
|
| 1262 |
+ "@parcel/watcher-linux-arm-musl": "2.6.0", |
|
| 1263 |
+ "@parcel/watcher-linux-arm64-glibc": "2.6.0", |
|
| 1264 |
+ "@parcel/watcher-linux-arm64-musl": "2.6.0", |
|
| 1265 |
+ "@parcel/watcher-linux-x64-glibc": "2.6.0", |
|
| 1266 |
+ "@parcel/watcher-linux-x64-musl": "2.6.0", |
|
| 1267 |
+ "@parcel/watcher-win32-arm64": "2.6.0", |
|
| 1268 |
+ "@parcel/watcher-win32-x64": "2.6.0" |
|
| 1269 |
+ } |
|
| 1270 |
+ }, |
|
| 1271 |
+ "node_modules/@parcel/watcher-android-arm64": {
|
|
| 1272 |
+ "version": "2.6.0", |
|
| 1273 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", |
|
| 1274 |
+ "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", |
|
| 1275 |
+ "cpu": [ |
|
| 1276 |
+ "arm64" |
|
| 1277 |
+ ], |
|
| 1278 |
+ "dev": true, |
|
| 1279 |
+ "license": "MIT", |
|
| 1280 |
+ "optional": true, |
|
| 1281 |
+ "os": [ |
|
| 1282 |
+ "android" |
|
| 1283 |
+ ], |
|
| 1284 |
+ "engines": {
|
|
| 1285 |
+ "node": ">= 10.0.0" |
|
| 1286 |
+ }, |
|
| 1287 |
+ "funding": {
|
|
| 1288 |
+ "type": "opencollective", |
|
| 1289 |
+ "url": "https://opencollective.com/parcel" |
|
| 1290 |
+ } |
|
| 1291 |
+ }, |
|
| 1292 |
+ "node_modules/@parcel/watcher-darwin-arm64": {
|
|
| 1293 |
+ "version": "2.6.0", |
|
| 1294 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", |
|
| 1295 |
+ "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", |
|
| 1296 |
+ "cpu": [ |
|
| 1297 |
+ "arm64" |
|
| 1298 |
+ ], |
|
| 1299 |
+ "dev": true, |
|
| 1300 |
+ "license": "MIT", |
|
| 1301 |
+ "optional": true, |
|
| 1302 |
+ "os": [ |
|
| 1303 |
+ "darwin" |
|
| 1304 |
+ ], |
|
| 1305 |
+ "engines": {
|
|
| 1306 |
+ "node": ">= 10.0.0" |
|
| 1307 |
+ }, |
|
| 1308 |
+ "funding": {
|
|
| 1309 |
+ "type": "opencollective", |
|
| 1310 |
+ "url": "https://opencollective.com/parcel" |
|
| 1311 |
+ } |
|
| 1312 |
+ }, |
|
| 1313 |
+ "node_modules/@parcel/watcher-darwin-x64": {
|
|
| 1314 |
+ "version": "2.6.0", |
|
| 1315 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", |
|
| 1316 |
+ "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", |
|
| 1317 |
+ "cpu": [ |
|
| 1318 |
+ "x64" |
|
| 1319 |
+ ], |
|
| 1320 |
+ "dev": true, |
|
| 1321 |
+ "license": "MIT", |
|
| 1322 |
+ "optional": true, |
|
| 1323 |
+ "os": [ |
|
| 1324 |
+ "darwin" |
|
| 1325 |
+ ], |
|
| 1326 |
+ "engines": {
|
|
| 1327 |
+ "node": ">= 10.0.0" |
|
| 1328 |
+ }, |
|
| 1329 |
+ "funding": {
|
|
| 1330 |
+ "type": "opencollective", |
|
| 1331 |
+ "url": "https://opencollective.com/parcel" |
|
| 1332 |
+ } |
|
| 1333 |
+ }, |
|
| 1334 |
+ "node_modules/@parcel/watcher-freebsd-x64": {
|
|
| 1335 |
+ "version": "2.6.0", |
|
| 1336 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", |
|
| 1337 |
+ "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", |
|
| 1338 |
+ "cpu": [ |
|
| 1339 |
+ "x64" |
|
| 1340 |
+ ], |
|
| 1341 |
+ "dev": true, |
|
| 1342 |
+ "license": "MIT", |
|
| 1343 |
+ "optional": true, |
|
| 1344 |
+ "os": [ |
|
| 1345 |
+ "freebsd" |
|
| 1346 |
+ ], |
|
| 1347 |
+ "engines": {
|
|
| 1348 |
+ "node": ">= 10.0.0" |
|
| 1349 |
+ }, |
|
| 1350 |
+ "funding": {
|
|
| 1351 |
+ "type": "opencollective", |
|
| 1352 |
+ "url": "https://opencollective.com/parcel" |
|
| 1353 |
+ } |
|
| 1354 |
+ }, |
|
| 1355 |
+ "node_modules/@parcel/watcher-linux-arm-glibc": {
|
|
| 1356 |
+ "version": "2.6.0", |
|
| 1357 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", |
|
| 1358 |
+ "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", |
|
| 1359 |
+ "cpu": [ |
|
| 1360 |
+ "arm" |
|
| 1361 |
+ ], |
|
| 1362 |
+ "dev": true, |
|
| 1363 |
+ "license": "MIT", |
|
| 1364 |
+ "optional": true, |
|
| 1365 |
+ "os": [ |
|
| 1366 |
+ "linux" |
|
| 1367 |
+ ], |
|
| 1368 |
+ "engines": {
|
|
| 1369 |
+ "node": ">= 10.0.0" |
|
| 1370 |
+ }, |
|
| 1371 |
+ "funding": {
|
|
| 1372 |
+ "type": "opencollective", |
|
| 1373 |
+ "url": "https://opencollective.com/parcel" |
|
| 1374 |
+ } |
|
| 1375 |
+ }, |
|
| 1376 |
+ "node_modules/@parcel/watcher-linux-arm-musl": {
|
|
| 1377 |
+ "version": "2.6.0", |
|
| 1378 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", |
|
| 1379 |
+ "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", |
|
| 1380 |
+ "cpu": [ |
|
| 1381 |
+ "arm" |
|
| 1382 |
+ ], |
|
| 1383 |
+ "dev": true, |
|
| 1384 |
+ "license": "MIT", |
|
| 1385 |
+ "optional": true, |
|
| 1386 |
+ "os": [ |
|
| 1387 |
+ "linux" |
|
| 1388 |
+ ], |
|
| 1389 |
+ "engines": {
|
|
| 1390 |
+ "node": ">= 10.0.0" |
|
| 1391 |
+ }, |
|
| 1392 |
+ "funding": {
|
|
| 1393 |
+ "type": "opencollective", |
|
| 1394 |
+ "url": "https://opencollective.com/parcel" |
|
| 1395 |
+ } |
|
| 1396 |
+ }, |
|
| 1397 |
+ "node_modules/@parcel/watcher-linux-arm64-glibc": {
|
|
| 1398 |
+ "version": "2.6.0", |
|
| 1399 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", |
|
| 1400 |
+ "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", |
|
| 1401 |
+ "cpu": [ |
|
| 1402 |
+ "arm64" |
|
| 1403 |
+ ], |
|
| 1404 |
+ "dev": true, |
|
| 1405 |
+ "license": "MIT", |
|
| 1406 |
+ "optional": true, |
|
| 1407 |
+ "os": [ |
|
| 1408 |
+ "linux" |
|
| 1409 |
+ ], |
|
| 1410 |
+ "engines": {
|
|
| 1411 |
+ "node": ">= 10.0.0" |
|
| 1412 |
+ }, |
|
| 1413 |
+ "funding": {
|
|
| 1414 |
+ "type": "opencollective", |
|
| 1415 |
+ "url": "https://opencollective.com/parcel" |
|
| 1416 |
+ } |
|
| 1417 |
+ }, |
|
| 1418 |
+ "node_modules/@parcel/watcher-linux-arm64-musl": {
|
|
| 1419 |
+ "version": "2.6.0", |
|
| 1420 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", |
|
| 1421 |
+ "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", |
|
| 1422 |
+ "cpu": [ |
|
| 1423 |
+ "arm64" |
|
| 1424 |
+ ], |
|
| 1425 |
+ "dev": true, |
|
| 1426 |
+ "license": "MIT", |
|
| 1427 |
+ "optional": true, |
|
| 1428 |
+ "os": [ |
|
| 1429 |
+ "linux" |
|
| 1430 |
+ ], |
|
| 1431 |
+ "engines": {
|
|
| 1432 |
+ "node": ">= 10.0.0" |
|
| 1433 |
+ }, |
|
| 1434 |
+ "funding": {
|
|
| 1435 |
+ "type": "opencollective", |
|
| 1436 |
+ "url": "https://opencollective.com/parcel" |
|
| 1437 |
+ } |
|
| 1438 |
+ }, |
|
| 1439 |
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
|
|
| 1440 |
+ "version": "2.6.0", |
|
| 1441 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", |
|
| 1442 |
+ "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", |
|
| 1443 |
+ "cpu": [ |
|
| 1444 |
+ "x64" |
|
| 1445 |
+ ], |
|
| 1446 |
+ "dev": true, |
|
| 1447 |
+ "license": "MIT", |
|
| 1448 |
+ "optional": true, |
|
| 1449 |
+ "os": [ |
|
| 1450 |
+ "linux" |
|
| 1451 |
+ ], |
|
| 1452 |
+ "engines": {
|
|
| 1453 |
+ "node": ">= 10.0.0" |
|
| 1454 |
+ }, |
|
| 1455 |
+ "funding": {
|
|
| 1456 |
+ "type": "opencollective", |
|
| 1457 |
+ "url": "https://opencollective.com/parcel" |
|
| 1458 |
+ } |
|
| 1459 |
+ }, |
|
| 1460 |
+ "node_modules/@parcel/watcher-linux-x64-musl": {
|
|
| 1461 |
+ "version": "2.6.0", |
|
| 1462 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", |
|
| 1463 |
+ "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", |
|
| 1464 |
+ "cpu": [ |
|
| 1465 |
+ "x64" |
|
| 1466 |
+ ], |
|
| 1467 |
+ "dev": true, |
|
| 1468 |
+ "license": "MIT", |
|
| 1469 |
+ "optional": true, |
|
| 1470 |
+ "os": [ |
|
| 1471 |
+ "linux" |
|
| 1472 |
+ ], |
|
| 1473 |
+ "engines": {
|
|
| 1474 |
+ "node": ">= 10.0.0" |
|
| 1475 |
+ }, |
|
| 1476 |
+ "funding": {
|
|
| 1477 |
+ "type": "opencollective", |
|
| 1478 |
+ "url": "https://opencollective.com/parcel" |
|
| 1479 |
+ } |
|
| 1480 |
+ }, |
|
| 1481 |
+ "node_modules/@parcel/watcher-win32-arm64": {
|
|
| 1482 |
+ "version": "2.6.0", |
|
| 1483 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", |
|
| 1484 |
+ "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", |
|
| 1485 |
+ "cpu": [ |
|
| 1486 |
+ "arm64" |
|
| 1487 |
+ ], |
|
| 1488 |
+ "dev": true, |
|
| 1489 |
+ "license": "MIT", |
|
| 1490 |
+ "optional": true, |
|
| 1491 |
+ "os": [ |
|
| 1492 |
+ "win32" |
|
| 1493 |
+ ], |
|
| 1494 |
+ "engines": {
|
|
| 1495 |
+ "node": ">= 10.0.0" |
|
| 1496 |
+ }, |
|
| 1497 |
+ "funding": {
|
|
| 1498 |
+ "type": "opencollective", |
|
| 1499 |
+ "url": "https://opencollective.com/parcel" |
|
| 1500 |
+ } |
|
| 1501 |
+ }, |
|
| 1502 |
+ "node_modules/@parcel/watcher-win32-x64": {
|
|
| 1503 |
+ "version": "2.6.0", |
|
| 1504 |
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", |
|
| 1505 |
+ "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", |
|
| 1506 |
+ "cpu": [ |
|
| 1507 |
+ "x64" |
|
| 1508 |
+ ], |
|
| 1509 |
+ "dev": true, |
|
| 1510 |
+ "license": "MIT", |
|
| 1511 |
+ "optional": true, |
|
| 1512 |
+ "os": [ |
|
| 1513 |
+ "win32" |
|
| 1514 |
+ ], |
|
| 1515 |
+ "engines": {
|
|
| 1516 |
+ "node": ">= 10.0.0" |
|
| 1517 |
+ }, |
|
| 1518 |
+ "funding": {
|
|
| 1519 |
+ "type": "opencollective", |
|
| 1520 |
+ "url": "https://opencollective.com/parcel" |
|
| 1521 |
+ } |
|
| 1522 |
+ }, |
|
| 1523 |
+ "node_modules/@parcel/watcher/node_modules/picomatch": {
|
|
| 1524 |
+ "version": "4.0.5", |
|
| 1525 |
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", |
|
| 1526 |
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", |
|
| 1527 |
+ "dev": true, |
|
| 1528 |
+ "license": "MIT", |
|
| 1529 |
+ "optional": true, |
|
| 1530 |
+ "engines": {
|
|
| 1531 |
+ "node": ">=12" |
|
| 1532 |
+ }, |
|
| 1533 |
+ "funding": {
|
|
| 1534 |
+ "url": "https://github.com/sponsors/jonschlinkert" |
|
| 1535 |
+ } |
|
| 1536 |
+ }, |
|
| 1249 | 1537 |
"node_modules/@rtsao/scc": {
|
| 1250 | 1538 |
"version": "1.1.0", |
| 1251 | 1539 |
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", |
... | ... | @@ -1260,277 +1548,6 @@ |
| 1260 | 1548 |
"license": "Apache-2.0", |
| 1261 | 1549 |
"dependencies": {
|
| 1262 | 1550 |
"tslib": "^2.8.0" |
| 1263 |
- } |
|
| 1264 |
- }, |
|
| 1265 |
- "node_modules/@tailwindcss/node": {
|
|
| 1266 |
- "version": "4.3.3", |
|
| 1267 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", |
|
| 1268 |
- "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", |
|
| 1269 |
- "dev": true, |
|
| 1270 |
- "license": "MIT", |
|
| 1271 |
- "dependencies": {
|
|
| 1272 |
- "@jridgewell/remapping": "^2.3.5", |
|
| 1273 |
- "enhanced-resolve": "^5.24.1", |
|
| 1274 |
- "jiti": "^2.7.0", |
|
| 1275 |
- "lightningcss": "1.32.0", |
|
| 1276 |
- "magic-string": "^0.30.21", |
|
| 1277 |
- "source-map-js": "^1.2.1", |
|
| 1278 |
- "tailwindcss": "4.3.3" |
|
| 1279 |
- } |
|
| 1280 |
- }, |
|
| 1281 |
- "node_modules/@tailwindcss/oxide": {
|
|
| 1282 |
- "version": "4.3.3", |
|
| 1283 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", |
|
| 1284 |
- "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", |
|
| 1285 |
- "dev": true, |
|
| 1286 |
- "license": "MIT", |
|
| 1287 |
- "engines": {
|
|
| 1288 |
- "node": ">= 20" |
|
| 1289 |
- }, |
|
| 1290 |
- "optionalDependencies": {
|
|
| 1291 |
- "@tailwindcss/oxide-android-arm64": "4.3.3", |
|
| 1292 |
- "@tailwindcss/oxide-darwin-arm64": "4.3.3", |
|
| 1293 |
- "@tailwindcss/oxide-darwin-x64": "4.3.3", |
|
| 1294 |
- "@tailwindcss/oxide-freebsd-x64": "4.3.3", |
|
| 1295 |
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", |
|
| 1296 |
- "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", |
|
| 1297 |
- "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", |
|
| 1298 |
- "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", |
|
| 1299 |
- "@tailwindcss/oxide-linux-x64-musl": "4.3.3", |
|
| 1300 |
- "@tailwindcss/oxide-wasm32-wasi": "4.3.3", |
|
| 1301 |
- "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", |
|
| 1302 |
- "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" |
|
| 1303 |
- } |
|
| 1304 |
- }, |
|
| 1305 |
- "node_modules/@tailwindcss/oxide-android-arm64": {
|
|
| 1306 |
- "version": "4.3.3", |
|
| 1307 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", |
|
| 1308 |
- "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", |
|
| 1309 |
- "cpu": [ |
|
| 1310 |
- "arm64" |
|
| 1311 |
- ], |
|
| 1312 |
- "dev": true, |
|
| 1313 |
- "license": "MIT", |
|
| 1314 |
- "optional": true, |
|
| 1315 |
- "os": [ |
|
| 1316 |
- "android" |
|
| 1317 |
- ], |
|
| 1318 |
- "engines": {
|
|
| 1319 |
- "node": ">= 20" |
|
| 1320 |
- } |
|
| 1321 |
- }, |
|
| 1322 |
- "node_modules/@tailwindcss/oxide-darwin-arm64": {
|
|
| 1323 |
- "version": "4.3.3", |
|
| 1324 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", |
|
| 1325 |
- "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", |
|
| 1326 |
- "cpu": [ |
|
| 1327 |
- "arm64" |
|
| 1328 |
- ], |
|
| 1329 |
- "dev": true, |
|
| 1330 |
- "license": "MIT", |
|
| 1331 |
- "optional": true, |
|
| 1332 |
- "os": [ |
|
| 1333 |
- "darwin" |
|
| 1334 |
- ], |
|
| 1335 |
- "engines": {
|
|
| 1336 |
- "node": ">= 20" |
|
| 1337 |
- } |
|
| 1338 |
- }, |
|
| 1339 |
- "node_modules/@tailwindcss/oxide-darwin-x64": {
|
|
| 1340 |
- "version": "4.3.3", |
|
| 1341 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", |
|
| 1342 |
- "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", |
|
| 1343 |
- "cpu": [ |
|
| 1344 |
- "x64" |
|
| 1345 |
- ], |
|
| 1346 |
- "dev": true, |
|
| 1347 |
- "license": "MIT", |
|
| 1348 |
- "optional": true, |
|
| 1349 |
- "os": [ |
|
| 1350 |
- "darwin" |
|
| 1351 |
- ], |
|
| 1352 |
- "engines": {
|
|
| 1353 |
- "node": ">= 20" |
|
| 1354 |
- } |
|
| 1355 |
- }, |
|
| 1356 |
- "node_modules/@tailwindcss/oxide-freebsd-x64": {
|
|
| 1357 |
- "version": "4.3.3", |
|
| 1358 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", |
|
| 1359 |
- "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", |
|
| 1360 |
- "cpu": [ |
|
| 1361 |
- "x64" |
|
| 1362 |
- ], |
|
| 1363 |
- "dev": true, |
|
| 1364 |
- "license": "MIT", |
|
| 1365 |
- "optional": true, |
|
| 1366 |
- "os": [ |
|
| 1367 |
- "freebsd" |
|
| 1368 |
- ], |
|
| 1369 |
- "engines": {
|
|
| 1370 |
- "node": ">= 20" |
|
| 1371 |
- } |
|
| 1372 |
- }, |
|
| 1373 |
- "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
|
| 1374 |
- "version": "4.3.3", |
|
| 1375 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", |
|
| 1376 |
- "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", |
|
| 1377 |
- "cpu": [ |
|
| 1378 |
- "arm" |
|
| 1379 |
- ], |
|
| 1380 |
- "dev": true, |
|
| 1381 |
- "license": "MIT", |
|
| 1382 |
- "optional": true, |
|
| 1383 |
- "os": [ |
|
| 1384 |
- "linux" |
|
| 1385 |
- ], |
|
| 1386 |
- "engines": {
|
|
| 1387 |
- "node": ">= 20" |
|
| 1388 |
- } |
|
| 1389 |
- }, |
|
| 1390 |
- "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
|
| 1391 |
- "version": "4.3.3", |
|
| 1392 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", |
|
| 1393 |
- "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", |
|
| 1394 |
- "cpu": [ |
|
| 1395 |
- "arm64" |
|
| 1396 |
- ], |
|
| 1397 |
- "dev": true, |
|
| 1398 |
- "license": "MIT", |
|
| 1399 |
- "optional": true, |
|
| 1400 |
- "os": [ |
|
| 1401 |
- "linux" |
|
| 1402 |
- ], |
|
| 1403 |
- "engines": {
|
|
| 1404 |
- "node": ">= 20" |
|
| 1405 |
- } |
|
| 1406 |
- }, |
|
| 1407 |
- "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
|
| 1408 |
- "version": "4.3.3", |
|
| 1409 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", |
|
| 1410 |
- "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", |
|
| 1411 |
- "cpu": [ |
|
| 1412 |
- "arm64" |
|
| 1413 |
- ], |
|
| 1414 |
- "dev": true, |
|
| 1415 |
- "license": "MIT", |
|
| 1416 |
- "optional": true, |
|
| 1417 |
- "os": [ |
|
| 1418 |
- "linux" |
|
| 1419 |
- ], |
|
| 1420 |
- "engines": {
|
|
| 1421 |
- "node": ">= 20" |
|
| 1422 |
- } |
|
| 1423 |
- }, |
|
| 1424 |
- "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
|
| 1425 |
- "version": "4.3.3", |
|
| 1426 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", |
|
| 1427 |
- "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", |
|
| 1428 |
- "cpu": [ |
|
| 1429 |
- "x64" |
|
| 1430 |
- ], |
|
| 1431 |
- "dev": true, |
|
| 1432 |
- "license": "MIT", |
|
| 1433 |
- "optional": true, |
|
| 1434 |
- "os": [ |
|
| 1435 |
- "linux" |
|
| 1436 |
- ], |
|
| 1437 |
- "engines": {
|
|
| 1438 |
- "node": ">= 20" |
|
| 1439 |
- } |
|
| 1440 |
- }, |
|
| 1441 |
- "node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
|
| 1442 |
- "version": "4.3.3", |
|
| 1443 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", |
|
| 1444 |
- "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", |
|
| 1445 |
- "cpu": [ |
|
| 1446 |
- "x64" |
|
| 1447 |
- ], |
|
| 1448 |
- "dev": true, |
|
| 1449 |
- "license": "MIT", |
|
| 1450 |
- "optional": true, |
|
| 1451 |
- "os": [ |
|
| 1452 |
- "linux" |
|
| 1453 |
- ], |
|
| 1454 |
- "engines": {
|
|
| 1455 |
- "node": ">= 20" |
|
| 1456 |
- } |
|
| 1457 |
- }, |
|
| 1458 |
- "node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
|
| 1459 |
- "version": "4.3.3", |
|
| 1460 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", |
|
| 1461 |
- "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", |
|
| 1462 |
- "bundleDependencies": [ |
|
| 1463 |
- "@napi-rs/wasm-runtime", |
|
| 1464 |
- "@emnapi/core", |
|
| 1465 |
- "@emnapi/runtime", |
|
| 1466 |
- "@tybys/wasm-util", |
|
| 1467 |
- "@emnapi/wasi-threads", |
|
| 1468 |
- "tslib" |
|
| 1469 |
- ], |
|
| 1470 |
- "cpu": [ |
|
| 1471 |
- "wasm32" |
|
| 1472 |
- ], |
|
| 1473 |
- "dev": true, |
|
| 1474 |
- "license": "MIT", |
|
| 1475 |
- "optional": true, |
|
| 1476 |
- "dependencies": {
|
|
| 1477 |
- "@emnapi/core": "^1.11.1", |
|
| 1478 |
- "@emnapi/runtime": "^1.11.1", |
|
| 1479 |
- "@emnapi/wasi-threads": "^1.2.2", |
|
| 1480 |
- "@napi-rs/wasm-runtime": "^1.1.4", |
|
| 1481 |
- "@tybys/wasm-util": "^0.10.2", |
|
| 1482 |
- "tslib": "^2.8.1" |
|
| 1483 |
- }, |
|
| 1484 |
- "engines": {
|
|
| 1485 |
- "node": ">=14.0.0" |
|
| 1486 |
- } |
|
| 1487 |
- }, |
|
| 1488 |
- "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
|
| 1489 |
- "version": "4.3.3", |
|
| 1490 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", |
|
| 1491 |
- "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", |
|
| 1492 |
- "cpu": [ |
|
| 1493 |
- "arm64" |
|
| 1494 |
- ], |
|
| 1495 |
- "dev": true, |
|
| 1496 |
- "license": "MIT", |
|
| 1497 |
- "optional": true, |
|
| 1498 |
- "os": [ |
|
| 1499 |
- "win32" |
|
| 1500 |
- ], |
|
| 1501 |
- "engines": {
|
|
| 1502 |
- "node": ">= 20" |
|
| 1503 |
- } |
|
| 1504 |
- }, |
|
| 1505 |
- "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
|
| 1506 |
- "version": "4.3.3", |
|
| 1507 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", |
|
| 1508 |
- "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", |
|
| 1509 |
- "cpu": [ |
|
| 1510 |
- "x64" |
|
| 1511 |
- ], |
|
| 1512 |
- "dev": true, |
|
| 1513 |
- "license": "MIT", |
|
| 1514 |
- "optional": true, |
|
| 1515 |
- "os": [ |
|
| 1516 |
- "win32" |
|
| 1517 |
- ], |
|
| 1518 |
- "engines": {
|
|
| 1519 |
- "node": ">= 20" |
|
| 1520 |
- } |
|
| 1521 |
- }, |
|
| 1522 |
- "node_modules/@tailwindcss/postcss": {
|
|
| 1523 |
- "version": "4.3.3", |
|
| 1524 |
- "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", |
|
| 1525 |
- "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", |
|
| 1526 |
- "dev": true, |
|
| 1527 |
- "license": "MIT", |
|
| 1528 |
- "dependencies": {
|
|
| 1529 |
- "@alloc/quick-lru": "^5.2.0", |
|
| 1530 |
- "@tailwindcss/node": "4.3.3", |
|
| 1531 |
- "@tailwindcss/oxide": "4.3.3", |
|
| 1532 |
- "postcss": "^8.5.16", |
|
| 1533 |
- "tailwindcss": "4.3.3" |
|
| 1534 | 1551 |
} |
| 1535 | 1552 |
}, |
| 1536 | 1553 |
"node_modules/@tybys/wasm-util": {
|
... | ... | @@ -2674,6 +2691,22 @@ |
| 2674 | 2691 |
"url": "https://github.com/chalk/chalk?sponsor=1" |
| 2675 | 2692 |
} |
| 2676 | 2693 |
}, |
| 2694 |
+ "node_modules/chokidar": {
|
|
| 2695 |
+ "version": "5.0.0", |
|
| 2696 |
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", |
|
| 2697 |
+ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", |
|
| 2698 |
+ "devOptional": true, |
|
| 2699 |
+ "license": "MIT", |
|
| 2700 |
+ "dependencies": {
|
|
| 2701 |
+ "readdirp": "^5.0.0" |
|
| 2702 |
+ }, |
|
| 2703 |
+ "engines": {
|
|
| 2704 |
+ "node": ">= 20.19.0" |
|
| 2705 |
+ }, |
|
| 2706 |
+ "funding": {
|
|
| 2707 |
+ "url": "https://paulmillr.com/funding/" |
|
| 2708 |
+ } |
|
| 2709 |
+ }, |
|
| 2677 | 2710 |
"node_modules/client-only": {
|
| 2678 | 2711 |
"version": "0.0.1", |
| 2679 | 2712 |
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", |
... | ... | @@ -2862,8 +2895,8 @@ |
| 2862 | 2895 |
"version": "2.1.2", |
| 2863 | 2896 |
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", |
| 2864 | 2897 |
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", |
| 2865 |
- "devOptional": true, |
|
| 2866 | 2898 |
"license": "Apache-2.0", |
| 2899 |
+ "optional": true, |
|
| 2867 | 2900 |
"engines": {
|
| 2868 | 2901 |
"node": ">=8" |
| 2869 | 2902 |
} |
... | ... | @@ -2909,20 +2942,6 @@ |
| 2909 | 2942 |
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", |
| 2910 | 2943 |
"dev": true, |
| 2911 | 2944 |
"license": "MIT" |
| 2912 |
- }, |
|
| 2913 |
- "node_modules/enhanced-resolve": {
|
|
| 2914 |
- "version": "5.24.3", |
|
| 2915 |
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", |
|
| 2916 |
- "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", |
|
| 2917 |
- "dev": true, |
|
| 2918 |
- "license": "MIT", |
|
| 2919 |
- "dependencies": {
|
|
| 2920 |
- "graceful-fs": "^4.2.4", |
|
| 2921 |
- "tapable": "^2.3.3" |
|
| 2922 |
- }, |
|
| 2923 |
- "engines": {
|
|
| 2924 |
- "node": ">=10.13.0" |
|
| 2925 |
- } |
|
| 2926 | 2945 |
}, |
| 2927 | 2946 |
"node_modules/es-abstract": {
|
| 2928 | 2947 |
"version": "1.24.2", |
... | ... | @@ -3883,13 +3902,6 @@ |
| 3883 | 3902 |
"url": "https://github.com/sponsors/ljharb" |
| 3884 | 3903 |
} |
| 3885 | 3904 |
}, |
| 3886 |
- "node_modules/graceful-fs": {
|
|
| 3887 |
- "version": "4.2.11", |
|
| 3888 |
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", |
|
| 3889 |
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", |
|
| 3890 |
- "dev": true, |
|
| 3891 |
- "license": "ISC" |
|
| 3892 |
- }, |
|
| 3893 | 3905 |
"node_modules/has-bigints": {
|
| 3894 | 3906 |
"version": "1.1.0", |
| 3895 | 3907 |
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", |
... | ... | @@ -4010,6 +4022,13 @@ |
| 4010 | 4022 |
"engines": {
|
| 4011 | 4023 |
"node": ">= 4" |
| 4012 | 4024 |
} |
| 4025 |
+ }, |
|
| 4026 |
+ "node_modules/immutable": {
|
|
| 4027 |
+ "version": "5.1.9", |
|
| 4028 |
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", |
|
| 4029 |
+ "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", |
|
| 4030 |
+ "devOptional": true, |
|
| 4031 |
+ "license": "MIT" |
|
| 4013 | 4032 |
}, |
| 4014 | 4033 |
"node_modules/import-fresh": {
|
| 4015 | 4034 |
"version": "3.3.1", |
... | ... | @@ -4522,6 +4541,8 @@ |
| 4522 | 4541 |
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", |
| 4523 | 4542 |
"dev": true, |
| 4524 | 4543 |
"license": "MIT", |
| 4544 |
+ "optional": true, |
|
| 4545 |
+ "peer": true, |
|
| 4525 | 4546 |
"bin": {
|
| 4526 | 4547 |
"jiti": "lib/jiti-cli.mjs" |
| 4527 | 4548 |
} |
... | ... | @@ -4663,267 +4684,6 @@ |
| 4663 | 4684 |
"node": ">= 0.8.0" |
| 4664 | 4685 |
} |
| 4665 | 4686 |
}, |
| 4666 |
- "node_modules/lightningcss": {
|
|
| 4667 |
- "version": "1.32.0", |
|
| 4668 |
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", |
|
| 4669 |
- "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", |
|
| 4670 |
- "dev": true, |
|
| 4671 |
- "license": "MPL-2.0", |
|
| 4672 |
- "dependencies": {
|
|
| 4673 |
- "detect-libc": "^2.0.3" |
|
| 4674 |
- }, |
|
| 4675 |
- "engines": {
|
|
| 4676 |
- "node": ">= 12.0.0" |
|
| 4677 |
- }, |
|
| 4678 |
- "funding": {
|
|
| 4679 |
- "type": "opencollective", |
|
| 4680 |
- "url": "https://opencollective.com/parcel" |
|
| 4681 |
- }, |
|
| 4682 |
- "optionalDependencies": {
|
|
| 4683 |
- "lightningcss-android-arm64": "1.32.0", |
|
| 4684 |
- "lightningcss-darwin-arm64": "1.32.0", |
|
| 4685 |
- "lightningcss-darwin-x64": "1.32.0", |
|
| 4686 |
- "lightningcss-freebsd-x64": "1.32.0", |
|
| 4687 |
- "lightningcss-linux-arm-gnueabihf": "1.32.0", |
|
| 4688 |
- "lightningcss-linux-arm64-gnu": "1.32.0", |
|
| 4689 |
- "lightningcss-linux-arm64-musl": "1.32.0", |
|
| 4690 |
- "lightningcss-linux-x64-gnu": "1.32.0", |
|
| 4691 |
- "lightningcss-linux-x64-musl": "1.32.0", |
|
| 4692 |
- "lightningcss-win32-arm64-msvc": "1.32.0", |
|
| 4693 |
- "lightningcss-win32-x64-msvc": "1.32.0" |
|
| 4694 |
- } |
|
| 4695 |
- }, |
|
| 4696 |
- "node_modules/lightningcss-android-arm64": {
|
|
| 4697 |
- "version": "1.32.0", |
|
| 4698 |
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", |
|
| 4699 |
- "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", |
|
| 4700 |
- "cpu": [ |
|
| 4701 |
- "arm64" |
|
| 4702 |
- ], |
|
| 4703 |
- "dev": true, |
|
| 4704 |
- "license": "MPL-2.0", |
|
| 4705 |
- "optional": true, |
|
| 4706 |
- "os": [ |
|
| 4707 |
- "android" |
|
| 4708 |
- ], |
|
| 4709 |
- "engines": {
|
|
| 4710 |
- "node": ">= 12.0.0" |
|
| 4711 |
- }, |
|
| 4712 |
- "funding": {
|
|
| 4713 |
- "type": "opencollective", |
|
| 4714 |
- "url": "https://opencollective.com/parcel" |
|
| 4715 |
- } |
|
| 4716 |
- }, |
|
| 4717 |
- "node_modules/lightningcss-darwin-arm64": {
|
|
| 4718 |
- "version": "1.32.0", |
|
| 4719 |
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", |
|
| 4720 |
- "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", |
|
| 4721 |
- "cpu": [ |
|
| 4722 |
- "arm64" |
|
| 4723 |
- ], |
|
| 4724 |
- "dev": true, |
|
| 4725 |
- "license": "MPL-2.0", |
|
| 4726 |
- "optional": true, |
|
| 4727 |
- "os": [ |
|
| 4728 |
- "darwin" |
|
| 4729 |
- ], |
|
| 4730 |
- "engines": {
|
|
| 4731 |
- "node": ">= 12.0.0" |
|
| 4732 |
- }, |
|
| 4733 |
- "funding": {
|
|
| 4734 |
- "type": "opencollective", |
|
| 4735 |
- "url": "https://opencollective.com/parcel" |
|
| 4736 |
- } |
|
| 4737 |
- }, |
|
| 4738 |
- "node_modules/lightningcss-darwin-x64": {
|
|
| 4739 |
- "version": "1.32.0", |
|
| 4740 |
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", |
|
| 4741 |
- "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", |
|
| 4742 |
- "cpu": [ |
|
| 4743 |
- "x64" |
|
| 4744 |
- ], |
|
| 4745 |
- "dev": true, |
|
| 4746 |
- "license": "MPL-2.0", |
|
| 4747 |
- "optional": true, |
|
| 4748 |
- "os": [ |
|
| 4749 |
- "darwin" |
|
| 4750 |
- ], |
|
| 4751 |
- "engines": {
|
|
| 4752 |
- "node": ">= 12.0.0" |
|
| 4753 |
- }, |
|
| 4754 |
- "funding": {
|
|
| 4755 |
- "type": "opencollective", |
|
| 4756 |
- "url": "https://opencollective.com/parcel" |
|
| 4757 |
- } |
|
| 4758 |
- }, |
|
| 4759 |
- "node_modules/lightningcss-freebsd-x64": {
|
|
| 4760 |
- "version": "1.32.0", |
|
| 4761 |
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", |
|
| 4762 |
- "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", |
|
| 4763 |
- "cpu": [ |
|
| 4764 |
- "x64" |
|
| 4765 |
- ], |
|
| 4766 |
- "dev": true, |
|
| 4767 |
- "license": "MPL-2.0", |
|
| 4768 |
- "optional": true, |
|
| 4769 |
- "os": [ |
|
| 4770 |
- "freebsd" |
|
| 4771 |
- ], |
|
| 4772 |
- "engines": {
|
|
| 4773 |
- "node": ">= 12.0.0" |
|
| 4774 |
- }, |
|
| 4775 |
- "funding": {
|
|
| 4776 |
- "type": "opencollective", |
|
| 4777 |
- "url": "https://opencollective.com/parcel" |
|
| 4778 |
- } |
|
| 4779 |
- }, |
|
| 4780 |
- "node_modules/lightningcss-linux-arm-gnueabihf": {
|
|
| 4781 |
- "version": "1.32.0", |
|
| 4782 |
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", |
|
| 4783 |
- "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", |
|
| 4784 |
- "cpu": [ |
|
| 4785 |
- "arm" |
|
| 4786 |
- ], |
|
| 4787 |
- "dev": true, |
|
| 4788 |
- "license": "MPL-2.0", |
|
| 4789 |
- "optional": true, |
|
| 4790 |
- "os": [ |
|
| 4791 |
- "linux" |
|
| 4792 |
- ], |
|
| 4793 |
- "engines": {
|
|
| 4794 |
- "node": ">= 12.0.0" |
|
| 4795 |
- }, |
|
| 4796 |
- "funding": {
|
|
| 4797 |
- "type": "opencollective", |
|
| 4798 |
- "url": "https://opencollective.com/parcel" |
|
| 4799 |
- } |
|
| 4800 |
- }, |
|
| 4801 |
- "node_modules/lightningcss-linux-arm64-gnu": {
|
|
| 4802 |
- "version": "1.32.0", |
|
| 4803 |
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", |
|
| 4804 |
- "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", |
|
| 4805 |
- "cpu": [ |
|
| 4806 |
- "arm64" |
|
| 4807 |
- ], |
|
| 4808 |
- "dev": true, |
|
| 4809 |
- "license": "MPL-2.0", |
|
| 4810 |
- "optional": true, |
|
| 4811 |
- "os": [ |
|
| 4812 |
- "linux" |
|
| 4813 |
- ], |
|
| 4814 |
- "engines": {
|
|
| 4815 |
- "node": ">= 12.0.0" |
|
| 4816 |
- }, |
|
| 4817 |
- "funding": {
|
|
| 4818 |
- "type": "opencollective", |
|
| 4819 |
- "url": "https://opencollective.com/parcel" |
|
| 4820 |
- } |
|
| 4821 |
- }, |
|
| 4822 |
- "node_modules/lightningcss-linux-arm64-musl": {
|
|
| 4823 |
- "version": "1.32.0", |
|
| 4824 |
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", |
|
| 4825 |
- "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", |
|
| 4826 |
- "cpu": [ |
|
| 4827 |
- "arm64" |
|
| 4828 |
- ], |
|
| 4829 |
- "dev": true, |
|
| 4830 |
- "license": "MPL-2.0", |
|
| 4831 |
- "optional": true, |
|
| 4832 |
- "os": [ |
|
| 4833 |
- "linux" |
|
| 4834 |
- ], |
|
| 4835 |
- "engines": {
|
|
| 4836 |
- "node": ">= 12.0.0" |
|
| 4837 |
- }, |
|
| 4838 |
- "funding": {
|
|
| 4839 |
- "type": "opencollective", |
|
| 4840 |
- "url": "https://opencollective.com/parcel" |
|
| 4841 |
- } |
|
| 4842 |
- }, |
|
| 4843 |
- "node_modules/lightningcss-linux-x64-gnu": {
|
|
| 4844 |
- "version": "1.32.0", |
|
| 4845 |
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", |
|
| 4846 |
- "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", |
|
| 4847 |
- "cpu": [ |
|
| 4848 |
- "x64" |
|
| 4849 |
- ], |
|
| 4850 |
- "dev": true, |
|
| 4851 |
- "license": "MPL-2.0", |
|
| 4852 |
- "optional": true, |
|
| 4853 |
- "os": [ |
|
| 4854 |
- "linux" |
|
| 4855 |
- ], |
|
| 4856 |
- "engines": {
|
|
| 4857 |
- "node": ">= 12.0.0" |
|
| 4858 |
- }, |
|
| 4859 |
- "funding": {
|
|
| 4860 |
- "type": "opencollective", |
|
| 4861 |
- "url": "https://opencollective.com/parcel" |
|
| 4862 |
- } |
|
| 4863 |
- }, |
|
| 4864 |
- "node_modules/lightningcss-linux-x64-musl": {
|
|
| 4865 |
- "version": "1.32.0", |
|
| 4866 |
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", |
|
| 4867 |
- "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", |
|
| 4868 |
- "cpu": [ |
|
| 4869 |
- "x64" |
|
| 4870 |
- ], |
|
| 4871 |
- "dev": true, |
|
| 4872 |
- "license": "MPL-2.0", |
|
| 4873 |
- "optional": true, |
|
| 4874 |
- "os": [ |
|
| 4875 |
- "linux" |
|
| 4876 |
- ], |
|
| 4877 |
- "engines": {
|
|
| 4878 |
- "node": ">= 12.0.0" |
|
| 4879 |
- }, |
|
| 4880 |
- "funding": {
|
|
| 4881 |
- "type": "opencollective", |
|
| 4882 |
- "url": "https://opencollective.com/parcel" |
|
| 4883 |
- } |
|
| 4884 |
- }, |
|
| 4885 |
- "node_modules/lightningcss-win32-arm64-msvc": {
|
|
| 4886 |
- "version": "1.32.0", |
|
| 4887 |
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", |
|
| 4888 |
- "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", |
|
| 4889 |
- "cpu": [ |
|
| 4890 |
- "arm64" |
|
| 4891 |
- ], |
|
| 4892 |
- "dev": true, |
|
| 4893 |
- "license": "MPL-2.0", |
|
| 4894 |
- "optional": true, |
|
| 4895 |
- "os": [ |
|
| 4896 |
- "win32" |
|
| 4897 |
- ], |
|
| 4898 |
- "engines": {
|
|
| 4899 |
- "node": ">= 12.0.0" |
|
| 4900 |
- }, |
|
| 4901 |
- "funding": {
|
|
| 4902 |
- "type": "opencollective", |
|
| 4903 |
- "url": "https://opencollective.com/parcel" |
|
| 4904 |
- } |
|
| 4905 |
- }, |
|
| 4906 |
- "node_modules/lightningcss-win32-x64-msvc": {
|
|
| 4907 |
- "version": "1.32.0", |
|
| 4908 |
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", |
|
| 4909 |
- "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", |
|
| 4910 |
- "cpu": [ |
|
| 4911 |
- "x64" |
|
| 4912 |
- ], |
|
| 4913 |
- "dev": true, |
|
| 4914 |
- "license": "MPL-2.0", |
|
| 4915 |
- "optional": true, |
|
| 4916 |
- "os": [ |
|
| 4917 |
- "win32" |
|
| 4918 |
- ], |
|
| 4919 |
- "engines": {
|
|
| 4920 |
- "node": ">= 12.0.0" |
|
| 4921 |
- }, |
|
| 4922 |
- "funding": {
|
|
| 4923 |
- "type": "opencollective", |
|
| 4924 |
- "url": "https://opencollective.com/parcel" |
|
| 4925 |
- } |
|
| 4926 |
- }, |
|
| 4927 | 4687 |
"node_modules/locate-path": {
|
| 4928 | 4688 |
"version": "6.0.0", |
| 4929 | 4689 |
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", |
... | ... | @@ -4968,16 +4728,6 @@ |
| 4968 | 4728 |
"license": "ISC", |
| 4969 | 4729 |
"dependencies": {
|
| 4970 | 4730 |
"yallist": "^3.0.2" |
| 4971 |
- } |
|
| 4972 |
- }, |
|
| 4973 |
- "node_modules/magic-string": {
|
|
| 4974 |
- "version": "0.30.21", |
|
| 4975 |
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", |
|
| 4976 |
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", |
|
| 4977 |
- "dev": true, |
|
| 4978 |
- "license": "MIT", |
|
| 4979 |
- "dependencies": {
|
|
| 4980 |
- "@jridgewell/sourcemap-codec": "^1.5.5" |
|
| 4981 | 4731 |
} |
| 4982 | 4732 |
}, |
| 4983 | 4733 |
"node_modules/math-intrinsics": {
|
... | ... | @@ -5165,6 +4915,14 @@ |
| 5165 | 4915 |
"engines": {
|
| 5166 | 4916 |
"node": "^10 || ^12 || >=14" |
| 5167 | 4917 |
} |
| 4918 |
+ }, |
|
| 4919 |
+ "node_modules/node-addon-api": {
|
|
| 4920 |
+ "version": "7.1.1", |
|
| 4921 |
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", |
|
| 4922 |
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", |
|
| 4923 |
+ "dev": true, |
|
| 4924 |
+ "license": "MIT", |
|
| 4925 |
+ "optional": true |
|
| 5168 | 4926 |
}, |
| 5169 | 4927 |
"node_modules/node-exports-info": {
|
| 5170 | 4928 |
"version": "1.6.2", |
... | ... | @@ -5456,35 +5214,6 @@ |
| 5456 | 5214 |
"node": ">= 0.4" |
| 5457 | 5215 |
} |
| 5458 | 5216 |
}, |
| 5459 |
- "node_modules/postcss": {
|
|
| 5460 |
- "version": "8.5.23", |
|
| 5461 |
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", |
|
| 5462 |
- "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", |
|
| 5463 |
- "dev": true, |
|
| 5464 |
- "funding": [ |
|
| 5465 |
- {
|
|
| 5466 |
- "type": "opencollective", |
|
| 5467 |
- "url": "https://opencollective.com/postcss/" |
|
| 5468 |
- }, |
|
| 5469 |
- {
|
|
| 5470 |
- "type": "tidelift", |
|
| 5471 |
- "url": "https://tidelift.com/funding/github/npm/postcss" |
|
| 5472 |
- }, |
|
| 5473 |
- {
|
|
| 5474 |
- "type": "github", |
|
| 5475 |
- "url": "https://github.com/sponsors/ai" |
|
| 5476 |
- } |
|
| 5477 |
- ], |
|
| 5478 |
- "license": "MIT", |
|
| 5479 |
- "dependencies": {
|
|
| 5480 |
- "nanoid": "^3.3.16", |
|
| 5481 |
- "picocolors": "^1.1.1", |
|
| 5482 |
- "source-map-js": "^1.2.1" |
|
| 5483 |
- }, |
|
| 5484 |
- "engines": {
|
|
| 5485 |
- "node": "^10 || ^12 || >=14" |
|
| 5486 |
- } |
|
| 5487 |
- }, |
|
| 5488 | 5217 |
"node_modules/prelude-ls": {
|
| 5489 | 5218 |
"version": "1.2.1", |
| 5490 | 5219 |
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", |
... | ... | @@ -5565,6 +5294,20 @@ |
| 5565 | 5294 |
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", |
| 5566 | 5295 |
"dev": true, |
| 5567 | 5296 |
"license": "MIT" |
| 5297 |
+ }, |
|
| 5298 |
+ "node_modules/readdirp": {
|
|
| 5299 |
+ "version": "5.1.1", |
|
| 5300 |
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", |
|
| 5301 |
+ "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", |
|
| 5302 |
+ "devOptional": true, |
|
| 5303 |
+ "license": "MIT", |
|
| 5304 |
+ "engines": {
|
|
| 5305 |
+ "node": ">= 20.19.0" |
|
| 5306 |
+ }, |
|
| 5307 |
+ "funding": {
|
|
| 5308 |
+ "type": "individual", |
|
| 5309 |
+ "url": "https://paulmillr.com/funding/" |
|
| 5310 |
+ } |
|
| 5568 | 5311 |
}, |
| 5569 | 5312 |
"node_modules/reflect.getprototypeof": {
|
| 5570 | 5313 |
"version": "1.0.10", |
... | ... | @@ -5742,6 +5485,27 @@ |
| 5742 | 5485 |
}, |
| 5743 | 5486 |
"funding": {
|
| 5744 | 5487 |
"url": "https://github.com/sponsors/ljharb" |
| 5488 |
+ } |
|
| 5489 |
+ }, |
|
| 5490 |
+ "node_modules/sass": {
|
|
| 5491 |
+ "version": "1.102.0", |
|
| 5492 |
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.102.0.tgz", |
|
| 5493 |
+ "integrity": "sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==", |
|
| 5494 |
+ "devOptional": true, |
|
| 5495 |
+ "license": "MIT", |
|
| 5496 |
+ "dependencies": {
|
|
| 5497 |
+ "chokidar": "^5.0.0", |
|
| 5498 |
+ "immutable": "^5.1.5", |
|
| 5499 |
+ "source-map-js": ">=0.6.2 <2.0.0" |
|
| 5500 |
+ }, |
|
| 5501 |
+ "bin": {
|
|
| 5502 |
+ "sass": "sass.js" |
|
| 5503 |
+ }, |
|
| 5504 |
+ "engines": {
|
|
| 5505 |
+ "node": ">=20.19.0" |
|
| 5506 |
+ }, |
|
| 5507 |
+ "optionalDependencies": {
|
|
| 5508 |
+ "@parcel/watcher": "^2.4.1" |
|
| 5745 | 5509 |
} |
| 5746 | 5510 |
}, |
| 5747 | 5511 |
"node_modules/scheduler": {
|
... | ... | @@ -6180,27 +5944,6 @@ |
| 6180 | 5944 |
}, |
| 6181 | 5945 |
"funding": {
|
| 6182 | 5946 |
"url": "https://github.com/sponsors/ljharb" |
| 6183 |
- } |
|
| 6184 |
- }, |
|
| 6185 |
- "node_modules/tailwindcss": {
|
|
| 6186 |
- "version": "4.3.3", |
|
| 6187 |
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", |
|
| 6188 |
- "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", |
|
| 6189 |
- "dev": true, |
|
| 6190 |
- "license": "MIT" |
|
| 6191 |
- }, |
|
| 6192 |
- "node_modules/tapable": {
|
|
| 6193 |
- "version": "2.3.3", |
|
| 6194 |
- "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", |
|
| 6195 |
- "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", |
|
| 6196 |
- "dev": true, |
|
| 6197 |
- "license": "MIT", |
|
| 6198 |
- "engines": {
|
|
| 6199 |
- "node": ">=6" |
|
| 6200 |
- }, |
|
| 6201 |
- "funding": {
|
|
| 6202 |
- "type": "opencollective", |
|
| 6203 |
- "url": "https://opencollective.com/webpack" |
|
| 6204 | 5947 |
} |
| 6205 | 5948 |
}, |
| 6206 | 5949 |
"node_modules/tinyglobby": {
|
--- package.json
+++ package.json
... | ... | @@ -3,9 +3,9 @@ |
| 3 | 3 |
"version": "0.1.0", |
| 4 | 4 |
"private": true, |
| 5 | 5 |
"scripts": {
|
| 6 |
- "dev": "NODE_ENV=development next dev", |
|
| 7 |
- "build": "NODE_ENV=production next build", |
|
| 8 |
- "start": "NODE_ENV=production next start", |
|
| 6 |
+ "dev": "next dev", |
|
| 7 |
+ "build": "next build", |
|
| 8 |
+ "start": "next start", |
|
| 9 | 9 |
"lint": "eslint" |
| 10 | 10 |
}, |
| 11 | 11 |
"dependencies": {
|
... | ... | @@ -14,13 +14,12 @@ |
| 14 | 14 |
"react-dom": "19.2.4" |
| 15 | 15 |
}, |
| 16 | 16 |
"devDependencies": {
|
| 17 |
- "@tailwindcss/postcss": "^4", |
|
| 18 | 17 |
"@types/node": "^20", |
| 19 | 18 |
"@types/react": "^19", |
| 20 | 19 |
"@types/react-dom": "^19", |
| 21 | 20 |
"eslint": "^9", |
| 22 | 21 |
"eslint-config-next": "16.2.12", |
| 23 |
- "tailwindcss": "^4", |
|
| 22 |
+ "sass": "^1.102.0", |
|
| 24 | 23 |
"typescript": "^5" |
| 25 | 24 |
} |
| 26 | 25 |
} |
--- postcss.config.mjs
... | ... | @@ -1,7 +0,0 @@ |
| 1 | -const config = { | |
| 2 | - plugins: { | |
| 3 | - "@tailwindcss/postcss": {}, | |
| 4 | - }, | |
| 5 | -}; | |
| 6 | - | |
| 7 | -export default config; |
--- proxy.ts
+++ proxy.ts
... | ... | @@ -16,12 +16,33 @@ |
| 16 | 16 |
*/ |
| 17 | 17 |
const PUBLIC_PATHS = ['/login']; |
| 18 | 18 |
|
| 19 |
+/** |
|
| 20 |
+ * 개발 환경에서만 인증을 면제하는 경로. 디자인 시스템 테스트 페이지(`/dev-test/design`)는 |
|
| 21 |
+ * 개발 전용 라우트이며 프로덕션에서는 페이지 자체가 `notFound()`로 404를 낸다 — 즉 여기서 |
|
| 22 |
+ * 열어도 프로덕션 노출 경로가 생기지 않는다. 백엔드나 세션 없이도 토큰·컴포넌트를 확인할 |
|
| 23 |
+ * 수 있게 한다. |
|
| 24 |
+ * |
|
| 25 |
+ * fail-closed 원칙은 유지된다: 이 목록은 개발 빌드에서만 평가되고, 프로덕션 빌드에서는 |
|
| 26 |
+ * `PUBLIC_PATHS`만 남는다. |
|
| 27 |
+ */ |
|
| 28 |
+const DEV_ONLY_PUBLIC_PATHS = ['/dev-test/design']; |
|
| 29 |
+ |
|
| 30 |
+function isPublicPath(pathname: string): boolean {
|
|
| 31 |
+ if (PUBLIC_PATHS.includes(pathname)) {
|
|
| 32 |
+ return true; |
|
| 33 |
+ } |
|
| 34 |
+ |
|
| 35 |
+ return ( |
|
| 36 |
+ process.env.NODE_ENV !== 'production' && |
|
| 37 |
+ DEV_ONLY_PUBLIC_PATHS.includes(pathname) |
|
| 38 |
+ ); |
|
| 39 |
+} |
|
| 40 |
+ |
|
| 19 | 41 |
export function proxy(request: NextRequest) {
|
| 20 | 42 |
const { pathname } = request.nextUrl;
|
| 21 |
- const isPublicPath = PUBLIC_PATHS.includes(pathname); |
|
| 22 | 43 |
const hasSessionCookie = request.cookies.has(SESSION_COOKIE_NAME); |
| 23 | 44 |
|
| 24 |
- if (!isPublicPath && !hasSessionCookie) {
|
|
| 45 |
+ if (!isPublicPath(pathname) && !hasSessionCookie) {
|
|
| 25 | 46 |
return NextResponse.redirect(new URL('/login', request.url));
|
| 26 | 47 |
} |
| 27 | 48 |
|
--- tsconfig.json
+++ tsconfig.json
... | ... | @@ -19,7 +19,8 @@ |
| 19 | 19 |
} |
| 20 | 20 |
], |
| 21 | 21 |
"paths": {
|
| 22 |
- "@/*": ["./*"] |
|
| 22 |
+ "@/*": ["./*"], |
|
| 23 |
+ "@fox/*": ["./@fox/*"] |
|
| 23 | 24 |
} |
| 24 | 25 |
}, |
| 25 | 26 |
"include": [ |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?