feat: 폼 유효성 피드백 + 모바일 반응형
폼 검증: - RegisterPage: 아이디 실시간 검증, 비밀번호 강도, 확인 일치 - LoginPage: aria-describedby 접근성 개선 반응형: - 768px 이하 레이아웃 최적화, 터치 타겟 44px Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -305,3 +305,57 @@
|
|||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
color: rgba(255, 255, 255, 0.45);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,3 +95,43 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 24px 80px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -165,12 +165,65 @@
|
|||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-strength {
|
/* Validation feedback */
|
||||||
font-size: 0.8rem;
|
.input-hint {
|
||||||
color: rgba(186, 205, 176, 0.7);
|
font-size: 0.75rem;
|
||||||
margin: -12px 0 0;
|
color: #888;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-strength.strength-weak {
|
.input-hint-error {
|
||||||
color: #e57373;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,3 +124,43 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 40px 24px 80px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default function LoginPage() {
|
|||||||
placeholder="아이디를 입력하세요"
|
placeholder="아이디를 입력하세요"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
|
aria-describedby={error ? 'login-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,11 +65,12 @@ export default function LoginPage() {
|
|||||||
placeholder="비밀번호를 입력하세요"
|
placeholder="비밀번호를 입력하세요"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
maxLength={72}
|
maxLength={72}
|
||||||
|
aria-describedby={error ? 'login-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{justRegistered && <p className="login-success">회원가입이 완료되었습니다. 로그인해주세요.</p>}
|
{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}>
|
<button type="submit" className="btn-login" disabled={loading}>
|
||||||
{loading ? '로그인 중...' : '로그인'}
|
{loading ? '로그인 중...' : '로그인'}
|
||||||
|
|||||||
@@ -3,29 +3,31 @@ import { useNavigate, Link } from 'react-router-dom';
|
|||||||
import { register } from '../api/auth';
|
import { register } from '../api/auth';
|
||||||
import './AuthPage.css';
|
import './AuthPage.css';
|
||||||
|
|
||||||
|
const USERNAME_REGEX = /^[a-z0-9_-]{3,50}$/;
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirm, setConfirm] = useState('');
|
const [confirm, setConfirm] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [usernameTouched, setUsernameTouched] = useState(false);
|
||||||
const navigate = useNavigate();
|
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) => {
|
const getPasswordStrength = (pw) => {
|
||||||
if (pw.length === 0) return '';
|
if (pw.length === 0) return { label: '', level: '' };
|
||||||
if (pw.length < 6) return '비밀번호는 6자 이상이어야 합니다.';
|
if (pw.length < 6) return { label: '약함', level: 'weak' };
|
||||||
let strength = 0;
|
if (pw.length < 10) return { label: '중간', level: 'medium' };
|
||||||
if (/[a-z]/.test(pw)) strength++;
|
return { label: '강함', level: 'strong' };
|
||||||
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 '강함';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const passwordStrength = getPasswordStrength(password);
|
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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -80,10 +82,17 @@ export default function RegisterPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
onBlur={() => setUsernameTouched(true)}
|
||||||
placeholder="아이디를 입력하세요"
|
placeholder="아이디를 입력하세요"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
maxLength={50}
|
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>
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
@@ -96,15 +105,15 @@ export default function RegisterPage() {
|
|||||||
placeholder="6자 이상 입력하세요"
|
placeholder="6자 이상 입력하세요"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
maxLength={72}
|
maxLength={72}
|
||||||
|
aria-describedby="password-strength"
|
||||||
/>
|
/>
|
||||||
|
{passwordStrength.label && (
|
||||||
|
<span id="password-strength" className={`password-strength strength-${passwordStrength.level}`}>
|
||||||
|
강도: {passwordStrength.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{passwordStrength && (
|
|
||||||
<p className={`password-strength ${isPasswordWeak ? 'strength-weak' : ''}`}>
|
|
||||||
강도: {passwordStrength}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label htmlFor="confirm">비밀번호 확인</label>
|
<label htmlFor="confirm">비밀번호 확인</label>
|
||||||
<input
|
<input
|
||||||
@@ -115,7 +124,15 @@ export default function RegisterPage() {
|
|||||||
placeholder="비밀번호를 다시 입력하세요"
|
placeholder="비밀번호를 다시 입력하세요"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
maxLength={72}
|
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>
|
</div>
|
||||||
|
|
||||||
{error && <p className="login-error" role="alert">{error}</p>}
|
{error && <p className="login-error" role="alert">{error}</p>}
|
||||||
|
|||||||
Reference in New Issue
Block a user