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);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ? '로그인 중...' : '로그인'}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user