feat: 폼 유효성 피드백 + 모바일 반응형

폼 검증:
- RegisterPage: 아이디 실시간 검증, 비밀번호 강도, 확인 일치
- LoginPage: aria-describedby 접근성 개선

반응형:
- 768px 이하 레이아웃 최적화, 터치 타겟 44px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 10:52:17 +09:00
parent 555749b953
commit 42567ab6e4
6 changed files with 229 additions and 23 deletions

View File

@@ -305,3 +305,57 @@
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.45);
}
/* Mobile responsive */
@media (max-width: 768px) {
.admin-form {
padding: 16px 12px;
}
.admin-input,
.admin-textarea {
width: 100%;
box-sizing: border-box;
}
.admin-list-item {
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
}
.admin-list-info {
width: 100%;
}
.admin-list-actions {
width: 100%;
justify-content: flex-end;
}
.admin-form-actions {
flex-direction: column;
}
.admin-form-actions button {
width: 100%;
min-height: 44px;
}
.btn-admin-primary,
.btn-admin-secondary,
.btn-admin-edit,
.btn-admin-delete {
min-height: 44px;
}
.admin-meta-row {
flex-direction: column;
}
.admin-deploy-header {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -95,3 +95,43 @@
margin: 0 auto;
padding: 32px 24px 80px;
}
/* Mobile responsive */
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
gap: 12px;
padding: 12px 16px;
}
.admin-header-left {
width: 100%;
justify-content: center;
}
.admin-header-right {
width: 100%;
justify-content: center;
}
.admin-tabs {
padding: 12px 16px 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.admin-tabs::-webkit-scrollbar {
display: none;
}
.admin-tab {
white-space: nowrap;
flex-shrink: 0;
min-height: 44px;
}
.admin-main {
padding: 20px 12px 60px;
}
}

View File

@@ -165,12 +165,65 @@
color: rgba(255, 255, 255, 0.7);
}
.password-strength {
font-size: 0.8rem;
color: rgba(186, 205, 176, 0.7);
margin: -12px 0 0;
/* Validation feedback */
.input-hint {
font-size: 0.75rem;
color: #888;
margin-top: 2px;
}
.password-strength.strength-weak {
.input-hint-error {
color: #e57373;
}
.input-hint-success {
color: #bacdb0;
}
.input-group input.input-valid {
border-color: #bacdb0 !important;
}
.input-group input.input-invalid {
border-color: #e57373 !important;
}
.password-strength {
font-size: 0.75rem;
margin-top: 2px;
}
.strength-weak {
color: #e57373;
}
.strength-medium {
color: #ffd54f;
}
.strength-strong {
color: #bacdb0;
}
/* Mobile responsive */
@media (max-width: 768px) {
.login-panel {
max-width: 100%;
padding: 32px 20px;
margin: 0 12px;
border-radius: 8px;
}
.game-title {
font-size: 2.2rem;
}
.login-header {
margin-bottom: 28px;
}
.btn-login,
.btn-ssafy {
min-height: 44px;
}
}

View File

@@ -124,3 +124,43 @@
margin: 0 auto;
padding: 40px 24px 80px;
}
/* Mobile responsive */
@media (max-width: 768px) {
.home-header {
flex-direction: column;
gap: 12px;
padding: 12px 16px;
text-align: center;
}
.home-user {
width: 100%;
justify-content: center;
}
.hero-banner {
height: 180px;
}
.hero-title {
font-size: 1.8rem;
}
.hero-desc {
font-size: 0.85rem;
}
.home-main {
padding: 24px 16px 60px;
}
.btn-logout,
.btn-admin-link,
.btn-header-login {
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}
}

View File

@@ -51,6 +51,7 @@ export default function LoginPage() {
placeholder="아이디를 입력하세요"
autoComplete="username"
maxLength={50}
aria-describedby={error ? 'login-error' : undefined}
/>
</div>
@@ -64,11 +65,12 @@ export default function LoginPage() {
placeholder="비밀번호를 입력하세요"
autoComplete="current-password"
maxLength={72}
aria-describedby={error ? 'login-error' : undefined}
/>
</div>
{justRegistered && <p className="login-success">회원가입이 완료되었습니다. 로그인해주세요.</p>}
{error && <p className="login-error" role="alert">{error}</p>}
{error && <p id="login-error" className="login-error" role="alert">{error}</p>}
<button type="submit" className="btn-login" disabled={loading}>
{loading ? '로그인 중...' : '로그인'}

View File

@@ -3,29 +3,31 @@ import { useNavigate, Link } from 'react-router-dom';
import { register } from '../api/auth';
import './AuthPage.css';
const USERNAME_REGEX = /^[a-z0-9_-]{3,50}$/;
export default function RegisterPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [usernameTouched, setUsernameTouched] = useState(false);
const navigate = useNavigate();
const isUsernameValid = USERNAME_REGEX.test(username);
const showUsernameError = usernameTouched && username.length > 0 && !isUsernameValid;
const showUsernameValid = usernameTouched && username.length > 0 && isUsernameValid;
const getPasswordStrength = (pw) => {
if (pw.length === 0) return '';
if (pw.length < 6) return '비밀번호는 6자 이상이어야 합니다.';
let strength = 0;
if (/[a-z]/.test(pw)) strength++;
if (/[A-Z]/.test(pw)) strength++;
if (/[0-9]/.test(pw)) strength++;
if (/[^a-zA-Z0-9]/.test(pw)) strength++;
if (strength <= 1) return '약함';
if (strength <= 2) return '보통';
return '강함';
if (pw.length === 0) return { label: '', level: '' };
if (pw.length < 6) return { label: '약함', level: 'weak' };
if (pw.length < 10) return { label: '중간', level: 'medium' };
return { label: '강함', level: 'strong' };
};
const passwordStrength = getPasswordStrength(password);
const isPasswordWeak = password.length > 0 && password.length < 6;
const confirmMismatch = confirm.length > 0 && password !== confirm;
const confirmMatch = confirm.length > 0 && password === confirm;
const handleSubmit = async (e) => {
e.preventDefault();
@@ -80,10 +82,17 @@ export default function RegisterPage() {
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
onBlur={() => setUsernameTouched(true)}
placeholder="아이디를 입력하세요"
autoComplete="username"
maxLength={50}
className={showUsernameValid ? 'input-valid' : showUsernameError ? 'input-invalid' : ''}
aria-describedby="username-hint"
/>
<span id="username-hint" className={`input-hint ${showUsernameError ? 'input-hint-error' : ''}`}>
{showUsernameError ? '3~50자 영문 소문자, 숫자, _, -만 가능합니다' : '3~50자 영문 소문자, 숫자, _, -만 가능'}
{showUsernameValid && ' \u2713'}
</span>
</div>
<div className="input-group">
@@ -96,15 +105,15 @@ export default function RegisterPage() {
placeholder="6자 이상 입력하세요"
autoComplete="new-password"
maxLength={72}
aria-describedby="password-strength"
/>
{passwordStrength.label && (
<span id="password-strength" className={`password-strength strength-${passwordStrength.level}`}>
강도: {passwordStrength.label}
</span>
)}
</div>
{passwordStrength && (
<p className={`password-strength ${isPasswordWeak ? 'strength-weak' : ''}`}>
강도: {passwordStrength}
</p>
)}
<div className="input-group">
<label htmlFor="confirm">비밀번호 확인</label>
<input
@@ -115,7 +124,15 @@ export default function RegisterPage() {
placeholder="비밀번호를 다시 입력하세요"
autoComplete="new-password"
maxLength={72}
className={confirmMatch ? 'input-valid' : confirmMismatch ? 'input-invalid' : ''}
aria-describedby="confirm-hint"
/>
{confirmMismatch && (
<span id="confirm-hint" className="input-hint input-hint-error">비밀번호가 일치하지 않습니다</span>
)}
{confirmMatch && (
<span id="confirm-hint" className="input-hint input-hint-success">비밀번호가 일치합니다 \u2713</span>
)}
</div>
{error && <p className="login-error" role="alert">{error}</p>}