feat: 로그인 화면을 시안대로 구현
시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) login 5435:14598. 시안의 입력·라벨·버튼이 디자인시스템 컴포넌트 인스턴스라 @fox로 거의 그대로 덮인다 — 치수가 우연이 아니라 정확히 같다. FoxInput lg가 h56·px20·radius12·17px·아이콘 24, FoxButton lg가 h56·px20·radius12·17px다. `label`을 주면 FoxInput이 FoxFormLabel까지 함께 그린다. - login.module.scss 신설. 남는 건 화면 셸(배경·중앙 정렬)과 카드(440·padding 40·radius 24· 2겹 그림자), 비밀번호 토글 자리뿐이라 @fox에 넣지 않고 화면 전용으로 뒀다(사용자 확정). - 비밀번호 보기 토글은 로그인 전용이다(사용자 확정). FoxInput에 그 기능이 없고, `icon` 슬롯은 `aria-hidden="true"`라 버튼을 넣으면 보조기술에서 사라진다. 그래서 슬롯 대신 같은 자리에 버튼을 겹쳐 두고 aria-pressed·aria-controls로 상태와 대상을 알린다. - 오류 문구는 FoxInput의 `message`가 아니라 시안의 안내문 자리에 함께 둔다. 어느 칸이 틀렸는지 알리지 않는 편이 맞고(계정 존재 여부가 새지 않는다), 입력칸 아래 메시지가 끼면 토글의 세로 위치도 어긋난다 — 토글을 아래에서 띄우므로 입력칸이 래퍼의 마지막 요소여야 한다. - components/ui/auth-card를 더 이상 쓰지 않는다. 시안에 로고가 없고 그쪽은 Tailwind 제거 이후 무스타일이다(헤더·풋터와 같은 처리). 검증 — 실측이 시안과 일치한다. 배경 #f4f5f6, 카드 440/40/24/gap16 + 그림자 2겹 (1·1·4 / 4·4·8, alpha-black10), 제목 32px 800 #1e2124, 라벨 15px 600, 입력 56/12/20/#58616a, 버튼 56/12/#256ef4/17px, 토글 24×24가 입력칸 세로 정중앙(차이 0px)에 오른쪽 여백 20px. 토글은 password↔text 전환과 aria-pressed·aria-label 갱신까지 확인했다. Co-Authored-By: Claude Opus 5
@0edf3e1fb70b86323c5d555f2475779fec8c08d2
--- app/(public)/login/_components/login-form.tsx
+++ app/(public)/login/_components/login-form.tsx
... | ... | @@ -1,56 +1,89 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useActionState } from 'react';
|
|
| 3 |
+import { useActionState, useId, useState } from 'react';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
+import { FoxInput } from '@fox/core/components/fox-input';
|
|
| 6 |
+import { FoxEyeIcon, FoxEyeSlashIcon } from '@fox/core/icons';
|
|
| 4 | 7 |
import { login, type LoginFormState } from '../_actions';
|
| 8 |
+import styles from '../login.module.scss'; |
|
| 5 | 9 |
|
| 6 | 10 |
const initialState: LoginFormState = {};
|
| 7 | 11 |
|
| 12 |
+/** 시안의 안내 문구. 오류가 없을 때 이 자리에 보인다. */ |
|
| 13 |
+const HELPER_TEXT = '비밀번호 5회 불일치 시 계정 로그인이 차단됩니다.'; |
|
| 14 |
+ |
|
| 15 |
+/** |
|
| 16 |
+ * 로그인 폼 — 시안 login-area(5432:16137)의 제목 아래 부분이다. 입력·라벨·버튼은 시안이 |
|
| 17 |
+ * 디자인시스템 컴포넌트를 그대로 쓰고 있어 FoxInput/FoxButton으로 덮인다(치수·서체가 시안과 |
|
| 18 |
+ * 같다). `label`을 주면 FoxInput이 FoxFormLabel까지 함께 그린다. |
|
| 19 |
+ * |
|
| 20 |
+ * 비밀번호 보기 토글은 로그인 화면 전용이다 — FoxInput에 그 기능이 없고, `icon` 슬롯은 |
|
| 21 |
+ * `aria-hidden="true"`라 버튼을 넣으면 보조기술에서 사라진다(사용자 확정). 그래서 슬롯 대신 |
|
| 22 |
+ * 같은 자리에 버튼을 겹쳐 둔다. |
|
| 23 |
+ * |
|
| 24 |
+ * 오류 문구는 FoxInput의 `message`가 아니라 시안의 안내문 자리에 함께 둔다 — 어느 칸이 |
|
| 25 |
+ * 틀렸는지 알려주지 않는 것이 보안상 맞고(계정 존재 여부가 새지 않는다), 입력칸 아래에 |
|
| 26 |
+ * 메시지가 끼면 보기 토글의 세로 위치도 어긋난다. |
|
| 27 |
+ */ |
|
| 8 | 28 |
export function LoginForm() {
|
| 9 | 29 |
const [state, formAction, isPending] = useActionState(login, initialState); |
| 30 |
+ const [isPasswordVisible, setIsPasswordVisible] = useState(false); |
|
| 31 |
+ const passwordId = useId(); |
|
| 10 | 32 |
|
| 11 | 33 |
return ( |
| 12 |
- <form action={formAction} className="flex w-full flex-col gap-4">
|
|
| 13 |
- <h1 className="text-xl font-semibold text-foreground"> |
|
| 14 |
- 에듀페이 관리자 로그인 |
|
| 15 |
- </h1> |
|
| 34 |
+ <form action={formAction} className={styles.fields}>
|
|
| 35 |
+ <FoxInput |
|
| 36 |
+ size="lg" |
|
| 37 |
+ label="ID" |
|
| 38 |
+ name="loginId" |
|
| 39 |
+ type="text" |
|
| 40 |
+ autoComplete="username" |
|
| 41 |
+ placeholder="아이디를 입력하세요." |
|
| 42 |
+ required |
|
| 43 |
+ state={state.error ? 'error' : 'default'}
|
|
| 44 |
+ /> |
|
| 16 | 45 |
|
| 17 |
- <div className="flex flex-col gap-1"> |
|
| 18 |
- <label htmlFor="loginId" className="text-sm text-foreground/70"> |
|
| 19 |
- 아이디 |
|
| 20 |
- </label> |
|
| 21 |
- <input |
|
| 22 |
- id="loginId" |
|
| 23 |
- name="loginId" |
|
| 24 |
- type="text" |
|
| 25 |
- autoComplete="username" |
|
| 26 |
- required |
|
| 27 |
- className="border border-foreground/25 bg-background px-3 py-2 text-foreground" |
|
| 28 |
- /> |
|
| 29 |
- </div> |
|
| 30 |
- |
|
| 31 |
- <div className="flex flex-col gap-1"> |
|
| 32 |
- <label htmlFor="password" className="text-sm text-foreground/70"> |
|
| 33 |
- 비밀번호 |
|
| 34 |
- </label> |
|
| 35 |
- <input |
|
| 36 |
- id="password" |
|
| 46 |
+ <div className={styles.passwordField}>
|
|
| 47 |
+ <FoxInput |
|
| 48 |
+ id={passwordId}
|
|
| 49 |
+ size="lg" |
|
| 50 |
+ label="PW" |
|
| 37 | 51 |
name="password" |
| 38 |
- type="password" |
|
| 52 |
+ type={isPasswordVisible ? 'text' : 'password'}
|
|
| 39 | 53 |
autoComplete="current-password" |
| 54 |
+ placeholder="비밀번호를 입력하세요." |
|
| 40 | 55 |
required |
| 41 |
- className="border border-foreground/25 bg-background px-3 py-2 text-foreground" |
|
| 56 |
+ state={state.error ? 'error' : 'default'}
|
|
| 42 | 57 |
/> |
| 58 |
+ <button |
|
| 59 |
+ type="button" |
|
| 60 |
+ className={styles.passwordToggle}
|
|
| 61 |
+ aria-controls={passwordId}
|
|
| 62 |
+ aria-pressed={isPasswordVisible}
|
|
| 63 |
+ aria-label={isPasswordVisible ? '비밀번호 숨기기' : '비밀번호 보기'}
|
|
| 64 |
+ onClick={() => setIsPasswordVisible((visible) => !visible)}
|
|
| 65 |
+ > |
|
| 66 |
+ {isPasswordVisible ? (
|
|
| 67 |
+ <FoxEyeSlashIcon weight="bold" /> |
|
| 68 |
+ ) : ( |
|
| 69 |
+ <FoxEyeIcon weight="bold" /> |
|
| 70 |
+ )} |
|
| 71 |
+ </button> |
|
| 43 | 72 |
</div> |
| 44 | 73 |
|
| 45 |
- {state.error && <p className="text-sm text-danger">{state.error}</p>}
|
|
| 74 |
+ <p className={`${styles.helper} ${state.error ? styles.error : ''}`}>
|
|
| 75 |
+ {state.error ?? HELPER_TEXT}
|
|
| 76 |
+ </p> |
|
| 46 | 77 |
|
| 47 |
- <button |
|
| 48 |
- type="submit" |
|
| 78 |
+ <FoxButton |
|
| 79 |
+ type="primary" |
|
| 80 |
+ size="lg" |
|
| 81 |
+ htmlType="submit" |
|
| 82 |
+ fullWidth |
|
| 83 |
+ label={isPending ? '로그인 중...' : '로그인'}
|
|
| 49 | 84 |
disabled={isPending}
|
| 50 |
- className="bg-foreground px-4 py-2 text-background disabled:opacity-50" |
|
| 51 |
- > |
|
| 52 |
- {isPending ? '로그인 중...' : '로그인'}
|
|
| 53 |
- </button> |
|
| 85 |
+ className={styles.submit}
|
|
| 86 |
+ /> |
|
| 54 | 87 |
</form> |
| 55 | 88 |
); |
| 56 | 89 |
} |
+++ app/(public)/login/login.module.scss
... | ... | @@ -0,0 +1,106 @@ |
| 1 | +// 로그인 화면 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) login 5435:14598 | |
| 2 | +// | |
| 3 | +// 입력·라벨·버튼은 시안이 디자인시스템 컴포넌트를 그대로 쓰고 있어 FoxInput/FoxButton으로 | |
| 4 | +// 덮인다. 여기 남는 건 화면 셸(배경·중앙 정렬)과 카드, 그리고 비밀번호 보기 토글의 자리뿐이라 | |
| 5 | +// @fox에 넣지 않고 화면 전용으로 둔다(사용자 확정). 값은 전부 @fox 토큰을 거친다. | |
| 6 | + | |
| 7 | +@use "@fox/styles/abstracts" as fox; | |
| 8 | + | |
| 9 | +.page { | |
| 10 | + display: flex; | |
| 11 | + flex: 1; | |
| 12 | + align-items: center; | |
| 13 | + justify-content: center; | |
| 14 | + padding: fox.padding(8); | |
| 15 | + background: fox.color(background-neutral); | |
| 16 | +} | |
| 17 | + | |
| 18 | +.card { | |
| 19 | + box-sizing: border-box; | |
| 20 | + display: flex; | |
| 21 | + flex-direction: column; | |
| 22 | + align-items: center; | |
| 23 | + gap: fox.gap(5); | |
| 24 | + // 시안 440px. Figma가 변수로 export하지 않는다. | |
| 25 | + inline-size: 44rem; | |
| 26 | + max-inline-size: 100%; | |
| 27 | + padding: fox.padding(10); | |
| 28 | + border-radius: fox.radius(6); | |
| 29 | + background: fox.color(surface-neutral-default); | |
| 30 | + // 시안의 neutral/lv2 — 가까운 그림자와 먼 그림자 두 겹이다. | |
| 31 | + box-shadow: | |
| 32 | + fox.shadow(location1) fox.shadow(location1) fox.shadow(blur2) 0 | |
| 33 | + fox.primitive(alpha-black10), | |
| 34 | + fox.shadow(location3) fox.shadow(location3) fox.shadow(blur3) 0 | |
| 35 | + fox.primitive(alpha-black10); | |
| 36 | +} | |
| 37 | + | |
| 38 | +.title { | |
| 39 | + inline-size: 100%; | |
| 40 | + margin: 0; | |
| 41 | + color: fox.color(font-neutral-strong); | |
| 42 | + font-family: fox.font-family(title); | |
| 43 | + font-size: fox.font-size(display-md); | |
| 44 | + font-weight: fox.font-weight(bold); | |
| 45 | + line-height: 1.5; | |
| 46 | + letter-spacing: -0.025em; | |
| 47 | +} | |
| 48 | + | |
| 49 | +.fields { | |
| 50 | + display: flex; | |
| 51 | + inline-size: 100%; | |
| 52 | + flex-direction: column; | |
| 53 | + gap: fox.gap(5); | |
| 54 | +} | |
| 55 | + | |
| 56 | +// 비밀번호 칸 — 보기 토글을 입력 위에 얹기 위한 위치 기준이다. | |
| 57 | +.passwordField { | |
| 58 | + position: relative; | |
| 59 | +} | |
| 60 | + | |
| 61 | +// FoxInput의 `icon` 슬롯은 `aria-hidden="true"`라 그 안에 버튼을 넣으면 보조기술에서 | |
| 62 | +// 사라진다. 그래서 슬롯을 쓰지 않고 같은 자리에 버튼을 겹쳐 놓는다 — 시안의 아이콘 위치 | |
| 63 | +// (오른쪽 여백 form/padding/lg 안쪽)와 크기(24)를 그대로 맞춘다. | |
| 64 | +// | |
| 65 | +// 아래에서 띄우는 방식이라 **입력칸이 이 래퍼의 마지막 요소여야** 가운데에 온다. 오류 문구를 | |
| 66 | +// FoxInput의 `message`가 아니라 카드의 helper 자리에 모아 두는 이유가 이것이다. | |
| 67 | +.passwordToggle { | |
| 68 | + position: absolute; | |
| 69 | + inset-block-end: calc( | |
| 70 | + (#{fox.form(height-lg)} - #{fox.form(icon-lg)}) / 2 | |
| 71 | + ); | |
| 72 | + inset-inline-end: fox.form(padding-lg); | |
| 73 | + display: inline-flex; | |
| 74 | + align-items: center; | |
| 75 | + justify-content: center; | |
| 76 | + inline-size: fox.form(icon-lg); | |
| 77 | + block-size: fox.form(icon-lg); | |
| 78 | + padding: 0; | |
| 79 | + border: none; | |
| 80 | + background: none; | |
| 81 | + color: fox.color(icon-neutral-default); | |
| 82 | + cursor: pointer; | |
| 83 | +} | |
| 84 | + | |
| 85 | +.passwordToggle > svg { | |
| 86 | + inline-size: 100%; | |
| 87 | + block-size: 100%; | |
| 88 | +} | |
| 89 | + | |
| 90 | +.helper { | |
| 91 | + inline-size: 100%; | |
| 92 | + margin: 0; | |
| 93 | + color: fox.color(font-neutral-subtle); | |
| 94 | + font-family: fox.font-family(body); | |
| 95 | + font-size: fox.font-size(body-sm); | |
| 96 | + line-height: 1.5; | |
| 97 | + letter-spacing: -0.025em; | |
| 98 | +} | |
| 99 | + | |
| 100 | +.error { | |
| 101 | + color: fox.color(font-system-danger); | |
| 102 | +} | |
| 103 | + | |
| 104 | +.submit { | |
| 105 | + inline-size: 100%; | |
| 106 | +} |
--- app/(public)/login/page.tsx
+++ app/(public)/login/page.tsx
... | ... | @@ -1,7 +1,7 @@ |
| 1 |
-import { AuthCard } from '@/components/ui/auth-card';
|
|
| 2 | 1 |
import { getSessionAdmin } from '@/lib/auth/dal';
|
| 3 | 2 |
import { LOGOUT_ERROR_QUERY_PARAM } from '@/lib/constants/logout';
|
| 4 | 3 |
import { redirect } from 'next/navigation';
|
| 4 |
+import styles from './login.module.scss'; |
|
| 5 | 5 |
import { LoginForm } from './_components/login-form';
|
| 6 | 6 |
import { LogoutNotice } from './_components/logout-notice';
|
| 7 | 7 |
|
... | ... | @@ -24,9 +24,12 @@ |
| 24 | 24 |
const showLogoutError = typeof logoutError === 'string' && logoutError.length > 0; |
| 25 | 25 |
|
| 26 | 26 |
return ( |
| 27 |
- <AuthCard> |
|
| 27 |
+ <div className={styles.page}>
|
|
| 28 | 28 |
<LogoutNotice showError={showLogoutError} />
|
| 29 |
- <LoginForm /> |
|
| 30 |
- </AuthCard> |
|
| 29 |
+ <div className={styles.card}>
|
|
| 30 |
+ <h1 className={styles.title}>관리자 로그인</h1>
|
|
| 31 |
+ <LoginForm /> |
|
| 32 |
+ </div> |
|
| 33 |
+ </div> |
|
| 31 | 34 |
); |
| 32 | 35 |
} |
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?