create a frontend with a main menu
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../auth'
|
||||
|
||||
export default function AuthModal({ onClose }) {
|
||||
const { login, register } = useAuth()
|
||||
const [mode, setMode] = useState('login') // 'login' | 'register'
|
||||
const [nickname, setNickname] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [avatar, setAvatar] = useState(null)
|
||||
const [avatarPreview, setAvatarPreview] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleAvatarChange = (e) => {
|
||||
const file = e.target.files?.[0] || null
|
||||
setAvatar(file)
|
||||
setAvatarPreview(file ? URL.createObjectURL(file) : null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
await login(nickname.trim(), password)
|
||||
} else {
|
||||
await register(nickname.trim(), password, avatar)
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
<h2 className="modal-title">{mode === 'login' ? 'Вход' : 'Регистрация'}</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<label className="field">
|
||||
<span>Имя</span>
|
||||
<input
|
||||
type="text"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
minLength={mode === 'register' ? 3 : undefined}
|
||||
maxLength={mode === 'register' ? 24 : undefined}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Пароль</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={mode === 'register' ? 6 : undefined}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{mode === 'register' && (
|
||||
<label className="field avatar-field">
|
||||
<span>Аватарка (необязательно)</span>
|
||||
<div className="avatar-picker">
|
||||
{avatarPreview ? (
|
||||
<img src={avatarPreview} alt="Аватар" className="avatar-preview" />
|
||||
) : (
|
||||
<div className="avatar-preview avatar-placeholder">?</div>
|
||||
)}
|
||||
<input type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleAvatarChange} />
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Подождите…' : mode === 'login' ? 'Войти' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
className="link-button"
|
||||
onClick={() => {
|
||||
setMode(mode === 'login' ? 'register' : 'login')
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
{mode === 'login' ? 'Нет аккаунта? Зарегистрироваться' : 'Уже есть аккаунт? Войти'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
const CODE_LENGTH = 5
|
||||
|
||||
export default function CodeInput({ onSubmit }) {
|
||||
const [values, setValues] = useState(Array(CODE_LENGTH).fill(''))
|
||||
const inputsRef = useRef([])
|
||||
|
||||
const code = values.join('')
|
||||
const complete = code.length === CODE_LENGTH
|
||||
|
||||
const focusInput = (i) => inputsRef.current[i]?.focus()
|
||||
|
||||
const handleChange = (i, raw) => {
|
||||
const chars = raw.toUpperCase().replace(/[^A-Z0-9]/g, '')
|
||||
if (!chars) return
|
||||
const next = [...values]
|
||||
// Поддержка вставки нескольких символов (paste в отдельное поле тоже)
|
||||
for (let k = 0; k < chars.length && i + k < CODE_LENGTH; k++) {
|
||||
next[i + k] = chars[k]
|
||||
}
|
||||
setValues(next)
|
||||
const nextIndex = Math.min(i + chars.length, CODE_LENGTH - 1)
|
||||
focusInput(nextIndex)
|
||||
}
|
||||
|
||||
const handleKeyDown = (i, e) => {
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
const next = [...values]
|
||||
if (next[i]) {
|
||||
next[i] = ''
|
||||
setValues(next)
|
||||
} else if (i > 0) {
|
||||
next[i - 1] = ''
|
||||
setValues(next)
|
||||
focusInput(i - 1)
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft' && i > 0) {
|
||||
focusInput(i - 1)
|
||||
} else if (e.key === 'ArrowRight' && i < CODE_LENGTH - 1) {
|
||||
focusInput(i + 1)
|
||||
} else if (e.key === 'Enter' && complete) {
|
||||
onSubmit?.(code)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (e) => {
|
||||
e.preventDefault()
|
||||
const text = e.clipboardData.getData('text').toUpperCase().replace(/[^A-Z0-9]/g, '')
|
||||
if (!text) return
|
||||
const next = Array(CODE_LENGTH).fill('')
|
||||
for (let k = 0; k < Math.min(text.length, CODE_LENGTH); k++) next[k] = text[k]
|
||||
setValues(next)
|
||||
focusInput(Math.min(text.length, CODE_LENGTH - 1))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="code-input-row">
|
||||
<div className="code-inputs" onPaste={handlePaste}>
|
||||
{values.map((v, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => (inputsRef.current[i] = el)}
|
||||
className="code-cell"
|
||||
value={v}
|
||||
maxLength={CODE_LENGTH}
|
||||
inputMode="text"
|
||||
autoComplete="off"
|
||||
onChange={(e) => handleChange(i, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
aria-label={`Символ ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="code-submit"
|
||||
disabled={!complete}
|
||||
onClick={() => onSubmit?.(code)}
|
||||
aria-label="Присоединиться"
|
||||
title="Присоединиться"
|
||||
>
|
||||
➜
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../auth'
|
||||
import AuthModal from './AuthModal'
|
||||
|
||||
export default function ProfileBar() {
|
||||
const { user, loading, logout } = useAuth()
|
||||
const [showAuth, setShowAuth] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="profile-bar">
|
||||
{loading ? (
|
||||
<span className="profile-loading">Загрузка…</span>
|
||||
) : user ? (
|
||||
<div className="profile-user">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt={user.nickname} className="profile-avatar" />
|
||||
) : (
|
||||
<div className="profile-avatar profile-avatar-placeholder">
|
||||
{user.nickname[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="profile-name">{user.nickname}</span>
|
||||
<button className="link-button" onClick={logout}>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => setShowAuth(true)}>
|
||||
Войти
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showAuth && <AuthModal onClose={() => setShowAuth(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user