create a frontend with a main menu
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Морской бой</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1815
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "sea-battle-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import CodeInput from './components/CodeInput'
|
||||
import ProfileBar from './components/ProfileBar'
|
||||
|
||||
export default function App() {
|
||||
const handleRandomBattle = () => {
|
||||
// TODO: матчмейкинг случайного боя
|
||||
}
|
||||
|
||||
const handleInvite = () => {
|
||||
// TODO: создание приватной комнаты
|
||||
}
|
||||
|
||||
const handleJoinByCode = (code) => {
|
||||
// TODO: присоединение к комнате по коду
|
||||
console.log('join code:', code)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<main className="menu">
|
||||
<h1 className="title">Морской бой</h1>
|
||||
|
||||
<div className="menu-buttons">
|
||||
<button className="btn btn-big" onClick={handleRandomBattle}>
|
||||
Случайный бой
|
||||
</button>
|
||||
<button className="btn btn-big" onClick={handleInvite}>
|
||||
Пригласить игрока
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="join-section">
|
||||
<div className="join-label">Ввести код приглашения</div>
|
||||
<CodeInput onSubmit={handleJoinByCode} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ProfileBar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
||||
|
||||
async function request(path, { method = 'GET', body, token, formData } = {}) {
|
||||
const headers = {}
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
if (body && !formData) headers['Content-Type'] = 'application/json'
|
||||
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: formData ? formData : body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const message = data.errors ? data.errors.join(', ') : data.error || 'Ошибка сервера'
|
||||
throw new ApiError(message, res.status)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (nickname, password) =>
|
||||
request('/api/login', { method: 'POST', body: { nickname, password } }),
|
||||
|
||||
register: (nickname, password, avatarFile) => {
|
||||
const formData = new FormData()
|
||||
formData.append('user[nickname]', nickname)
|
||||
formData.append('user[password]', password)
|
||||
if (avatarFile) formData.append('user[avatar]', avatarFile)
|
||||
return request('/api/register', { method: 'POST', formData })
|
||||
},
|
||||
|
||||
me: (token) => request('/api/me', { token }),
|
||||
|
||||
logout: (token) => request('/api/logout', { method: 'DELETE', token }),
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { api } from './api'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
const TOKEN_KEY = 'sea_battle_token'
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY))
|
||||
const [loading, setLoading] = useState(!!localStorage.getItem(TOKEN_KEY))
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
api
|
||||
.me(token)
|
||||
.then((data) => setUser(data.user))
|
||||
.catch(() => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [token])
|
||||
|
||||
const login = useCallback(async (nickname, password) => {
|
||||
const data = await api.login(nickname, password)
|
||||
localStorage.setItem(TOKEN_KEY, data.token)
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
}, [])
|
||||
|
||||
const register = useCallback(async (nickname, password, avatarFile) => {
|
||||
const data = await api.register(nickname, password, avatarFile)
|
||||
localStorage.setItem(TOKEN_KEY, data.token)
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
if (token) api.logout(token).catch(() => {})
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
@@ -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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './auth'
|
||||
import './styles.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,344 @@
|
||||
:root {
|
||||
--bg: #f3f8fc;
|
||||
--bg-accent: #e3eff8;
|
||||
--primary: #2f80ed;
|
||||
--primary-dark: #1d6bd8;
|
||||
--primary-soft: #dbeafe;
|
||||
--text: #1e3a5f;
|
||||
--text-soft: #5b7a9d;
|
||||
--danger: #e0483e;
|
||||
--radius: 14px;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(1000px 500px at 80% -10%, var(--bg-accent), transparent),
|
||||
radial-gradient(800px 400px at 10% 110%, #e8f6f3, transparent),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
padding: 40px 20px 80px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(44px, 8vw, 84px);
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
text-shadow: 0 4px 0 rgba(47, 128, 237, 0.12);
|
||||
}
|
||||
|
||||
.menu-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
width: min(360px, 90vw);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.btn-big {
|
||||
padding: 18px 28px;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, var(--primary), var(--primary-dark));
|
||||
box-shadow: 0 6px 0 rgba(29, 107, 216, 0.35), 0 10px 20px rgba(47, 128, 237, 0.25);
|
||||
}
|
||||
|
||||
.btn-big:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 0 rgba(29, 107, 216, 0.35), 0 14px 24px rgba(47, 128, 237, 0.3);
|
||||
}
|
||||
|
||||
.btn-big:active {
|
||||
transform: translateY(2px);
|
||||
box-shadow: 0 3px 0 rgba(29, 107, 216, 0.35);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 22px;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.join-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.join-label {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-soft);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.code-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.code-inputs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.code-cell {
|
||||
width: 58px;
|
||||
height: 68px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
border: 2px solid #c9dcee;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
caret-color: var(--primary);
|
||||
}
|
||||
|
||||
.code-cell:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 4px var(--primary-soft);
|
||||
}
|
||||
|
||||
.code-submit {
|
||||
width: 58px;
|
||||
height: 68px;
|
||||
font-size: 30px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.code-submit:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.code-submit:disabled {
|
||||
background: #b9cfe4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Профиль внизу */
|
||||
.profile-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(30, 58, 95, 0.08);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.profile-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 6px rgba(30, 58, 95, 0.2);
|
||||
}
|
||||
|
||||
.profile-avatar-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.profile-loading {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Модальное окно */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(30, 58, 95, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
width: min(400px, 100%);
|
||||
box-shadow: 0 20px 60px rgba(30, 58, 95, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
color: var(--text-soft);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.field input[type='text'],
|
||||
.field input[type='password'] {
|
||||
padding: 12px 14px;
|
||||
font-size: 16px;
|
||||
border: 2px solid #c9dcee;
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 4px var(--primary-soft);
|
||||
}
|
||||
|
||||
.avatar-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #c9dcee;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
background: #fdecea;
|
||||
color: var(--danger);
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.code-cell {
|
||||
width: 48px;
|
||||
height: 58px;
|
||||
font-size: 26px;
|
||||
}
|
||||
.code-submit {
|
||||
width: 48px;
|
||||
height: 58px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user