Merge pull request #11 from Danieli4/codex/add-offline-event-management-feature-jtwmcv
Add offline mission fixes and profile photo uploads
This commit is contained in:
commit
d89769e4b3
22
backend/alembic/versions/20241012_0009_profile_photos.py
Normal file
22
backend/alembic/versions/20241012_0009_profile_photos.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Добавляем колонку для фото профиля кандидата."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20241012_0009"
|
||||
down_revision = "20241010_0008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("users", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("profile_photo_path", sa.String(length=512), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("users", schema=None) as batch_op:
|
||||
batch_op.drop_column("profile_photo_path")
|
||||
|
|
@ -66,7 +66,6 @@ def register(user_in: UserRegister, db: Session = Depends(get_db)) -> Token | di
|
|||
full_name=user_in.full_name,
|
||||
hashed_password=get_password_hash(user_in.password),
|
||||
role=UserRole.PILOT,
|
||||
preferred_branch=user_in.preferred_branch,
|
||||
motivation=user_in.motivation,
|
||||
current_rank_id=base_rank.id if base_rank else None,
|
||||
is_email_confirmed=not settings.require_email_confirmation,
|
||||
|
|
|
|||
|
|
@ -398,6 +398,17 @@ def get_mission(
|
|||
xp_reward=mission.xp_reward,
|
||||
mana_reward=mission.mana_reward,
|
||||
difficulty=mission.difficulty,
|
||||
format=mission.format,
|
||||
event_location=mission.event_location,
|
||||
event_address=mission.event_address,
|
||||
event_starts_at=mission.event_starts_at,
|
||||
event_ends_at=mission.event_ends_at,
|
||||
registration_deadline=mission.registration_deadline,
|
||||
registration_url=mission.registration_url,
|
||||
registration_notes=mission.registration_notes,
|
||||
capacity=mission.capacity,
|
||||
contact_person=mission.contact_person,
|
||||
contact_phone=mission.contact_phone,
|
||||
is_active=mission.is_active,
|
||||
is_available=is_available,
|
||||
locked_reasons=reasons,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
|
|
@ -12,8 +12,18 @@ from app.models.user import User, UserRole, UserCompetency
|
|||
from app.models.mission import SubmissionStatus
|
||||
from app.schemas.progress import ProgressSnapshot
|
||||
from app.schemas.rank import RankBase
|
||||
from app.schemas.user import LeaderboardEntry, UserCompetencyRead, UserProfile
|
||||
from app.schemas.user import (
|
||||
LeaderboardEntry,
|
||||
ProfilePhotoResponse,
|
||||
UserCompetencyRead,
|
||||
UserProfile,
|
||||
)
|
||||
from app.services.rank import build_progress_snapshot
|
||||
from app.services.storage import (
|
||||
build_photo_data_url,
|
||||
delete_profile_photo,
|
||||
save_profile_photo,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["profile"])
|
||||
|
||||
|
|
@ -29,7 +39,89 @@ def get_profile(
|
|||
_ = item.competency
|
||||
for artifact in current_user.artifacts:
|
||||
_ = artifact.artifact
|
||||
return UserProfile.model_validate(current_user)
|
||||
|
||||
profile = UserProfile.model_validate(current_user)
|
||||
profile.profile_photo_uploaded = bool(current_user.profile_photo_path)
|
||||
profile.profile_photo_updated_at = (
|
||||
current_user.updated_at if current_user.profile_photo_path else None
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me/photo",
|
||||
response_model=ProfilePhotoResponse,
|
||||
summary="Получаем фото профиля кандидата",
|
||||
)
|
||||
def get_profile_photo(
|
||||
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
) -> ProfilePhotoResponse:
|
||||
"""Читаем сохранённое изображение и возвращаем его в виде data URL."""
|
||||
|
||||
db.refresh(current_user)
|
||||
if not current_user.profile_photo_path:
|
||||
return ProfilePhotoResponse(photo=None, detail="Фотография не загружена")
|
||||
|
||||
try:
|
||||
photo = build_photo_data_url(current_user.profile_photo_path)
|
||||
except FileNotFoundError:
|
||||
# Если файл удалили вручную, сбрасываем ссылку в базе, чтобы не мешать пользователю загрузить новую.
|
||||
current_user.profile_photo_path = None
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
return ProfilePhotoResponse(photo=None, detail="Файл не найден")
|
||||
|
||||
return ProfilePhotoResponse(photo=photo)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/me/photo",
|
||||
response_model=ProfilePhotoResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Загружаем фото профиля",
|
||||
)
|
||||
def upload_profile_photo(
|
||||
photo: UploadFile = File(...),
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> ProfilePhotoResponse:
|
||||
"""Сохраняем изображение и возвращаем обновлённый data URL."""
|
||||
|
||||
try:
|
||||
relative_path = save_profile_photo(upload=photo, user_id=current_user.id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
delete_profile_photo(current_user.profile_photo_path)
|
||||
current_user.profile_photo_path = relative_path
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
photo_url = build_photo_data_url(relative_path)
|
||||
return ProfilePhotoResponse(photo=photo_url, detail="Фотография обновлена")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/me/photo",
|
||||
response_model=ProfilePhotoResponse,
|
||||
summary="Удаляем фото профиля",
|
||||
)
|
||||
def delete_profile_photo_endpoint(
|
||||
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
) -> ProfilePhotoResponse:
|
||||
"""Удаляем сохранённое фото и очищаем ссылку в профиле."""
|
||||
|
||||
if not current_user.profile_photo_path:
|
||||
return ProfilePhotoResponse(photo=None, detail="Фотография уже удалена")
|
||||
|
||||
delete_profile_photo(current_user.profile_photo_path)
|
||||
current_user.profile_photo_path = None
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
|
||||
return ProfilePhotoResponse(photo=None, detail="Фотография удалена")
|
||||
|
||||
|
||||
@router.get("/ranks", response_model=list[RankBase], summary="Перечень рангов")
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ def run_migrations() -> None:
|
|||
conn.execute(text("ALTER TABLE users ADD COLUMN preferred_branch VARCHAR(160)"))
|
||||
if "motivation" not in user_columns:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN motivation TEXT"))
|
||||
if "profile_photo_path" not in user_columns:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN profile_photo_path VARCHAR(512)"))
|
||||
|
||||
if "passport_path" not in submission_columns:
|
||||
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN passport_path VARCHAR(512)"))
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ class User(Base, TimestampMixin):
|
|||
preferred_branch: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
|
||||
# Короткая заметка с личной мотивацией — помогает HR при первичном контакте.
|
||||
motivation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
profile_photo_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
|
||||
current_rank = relationship("Rank", back_populates="pilots")
|
||||
competencies: Mapped[List["UserCompetency"]] = relationship(
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ class UserProfile(UserRead):
|
|||
|
||||
competencies: list[UserCompetencyRead]
|
||||
artifacts: list[UserArtifactRead]
|
||||
profile_photo_uploaded: bool = False
|
||||
profile_photo_updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class LeaderboardEntry(BaseModel):
|
||||
|
|
@ -108,6 +110,11 @@ class UserRegister(BaseModel):
|
|||
email: EmailStr
|
||||
full_name: str
|
||||
password: str
|
||||
# Дополнительные сведения помогают персонализировать онбординг и связать пилота с куратором.
|
||||
preferred_branch: Optional[str] = None
|
||||
motivation: Optional[str] = None
|
||||
|
||||
|
||||
class ProfilePhotoResponse(BaseModel):
|
||||
"""Ответ с данными загруженной фотографии."""
|
||||
|
||||
photo: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -187,7 +187,12 @@ def registration_is_open(
|
|||
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
|
||||
if mission.registration_deadline and mission.registration_deadline < current_time:
|
||||
deadline = mission.registration_deadline
|
||||
if deadline and deadline.tzinfo is None:
|
||||
deadline = deadline.replace(tzinfo=timezone.utc)
|
||||
|
||||
if deadline and deadline < current_time:
|
||||
|
||||
return False
|
||||
|
||||
if mission.capacity is not None and participant_count >= mission.capacity:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import mimetypes
|
||||
import base64
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
|
|
@ -40,8 +42,34 @@ def save_submission_document(
|
|||
return relative_path
|
||||
|
||||
|
||||
def delete_submission_document(relative_path: str | None) -> None:
|
||||
"""Удаляем файл вложения, если он существует."""
|
||||
def save_profile_photo(*, upload: UploadFile, user_id: int) -> str:
|
||||
"""Сохраняем фото профиля кандидата."""
|
||||
|
||||
allowed_types = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
content_type = upload.content_type or mimetypes.guess_type(upload.filename or "")[0]
|
||||
if content_type not in allowed_types:
|
||||
raise ValueError("Допустимы только изображения JPG, PNG или WEBP")
|
||||
|
||||
extension = allowed_types[content_type]
|
||||
target_dir = settings.uploads_path / f"user_{user_id}" / "profile"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
target_path = target_dir / f"photo{extension}"
|
||||
with target_path.open("wb") as buffer:
|
||||
upload.file.seek(0)
|
||||
shutil.copyfileobj(upload.file, buffer)
|
||||
upload.file.seek(0)
|
||||
|
||||
return target_path.relative_to(settings.uploads_path).as_posix()
|
||||
|
||||
|
||||
def _delete_relative_file(relative_path: str | None) -> None:
|
||||
"""Удаляем файл и очищаем пустые каталоги."""
|
||||
|
||||
if not relative_path:
|
||||
return
|
||||
|
|
@ -57,3 +85,29 @@ def delete_submission_document(relative_path: str | None) -> None:
|
|||
parent = file_path.parent
|
||||
if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
|
||||
|
||||
def delete_submission_document(relative_path: str | None) -> None:
|
||||
"""Удаляем файл вложения, если он существует."""
|
||||
|
||||
_delete_relative_file(relative_path)
|
||||
|
||||
|
||||
def delete_profile_photo(relative_path: str | None) -> None:
|
||||
"""Удаляем сохранённую фотографию профиля."""
|
||||
|
||||
_delete_relative_file(relative_path)
|
||||
|
||||
|
||||
def build_photo_data_url(relative_path: str) -> str:
|
||||
"""Формируем data URL для изображения, чтобы отдать его фронту."""
|
||||
|
||||
file_path = settings.uploads_path / relative_path
|
||||
_ensure_within_base(file_path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError("Файл не найден")
|
||||
|
||||
mime_type = mimetypes.guess_type(file_path.name)[0] or "image/jpeg"
|
||||
with file_path.open("rb") as fh:
|
||||
encoded = base64.b64encode(fh.read()).decode("ascii")
|
||||
return f"data:{mime_type};base64,{encoded}"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface ProfileResponse {
|
|||
name: string;
|
||||
rarity: string;
|
||||
}>;
|
||||
profile_photo_uploaded: boolean;
|
||||
}
|
||||
|
||||
interface ProgressResponse {
|
||||
|
|
@ -64,6 +65,8 @@ export default async function DashboardPage() {
|
|||
mana={profile.mana}
|
||||
competencies={profile.competencies}
|
||||
artifacts={profile.artifacts}
|
||||
token={session.token}
|
||||
profilePhotoUploaded={profile.profile_photo_uploaded}
|
||||
progress={progress}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ async function registerAction(formData: FormData) {
|
|||
const email = String(formData.get('email') ?? '').trim();
|
||||
const password = String(formData.get('password') ?? '').trim();
|
||||
// Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки.
|
||||
const preferredBranch = String(formData.get('preferredBranch') ?? '').trim() || undefined;
|
||||
const motivation = String(formData.get('motivation') ?? '').trim() || undefined;
|
||||
|
||||
if (!fullName || !email || !password) {
|
||||
|
|
@ -25,7 +24,7 @@ async function registerAction(formData: FormData) {
|
|||
|
||||
try {
|
||||
// 2. Собираем payload в формате, который ожидает FastAPI.
|
||||
const payload = { full_name: fullName, email, password, preferred_branch: preferredBranch, motivation };
|
||||
const payload = { full_name: fullName, email, password, motivation };
|
||||
const response = await apiFetch<any>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
|
|
@ -80,17 +79,6 @@ export default async function RegisterPage({ searchParams }: { searchParams: { e
|
|||
Пароль
|
||||
<input className={styles.input} type="password" name="password" required placeholder="Придумайте пароль" />
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Интересующая ветка (необязательно)
|
||||
<select className={styles.input} name="preferredBranch" defaultValue="">
|
||||
<option value="">Выберите ветку</option>
|
||||
<option value="Получение оффера">Получение оффера</option>
|
||||
<option value="Рекрутинг">Рекрутинг</option>
|
||||
<option value="Квесты">Квесты</option>
|
||||
<option value="Симулятор">Симулятор</option>
|
||||
<option value="Лекторий">Лекторий</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Что хотите добиться?
|
||||
<textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
// Компетенции и артефакты из профиля пользователя.
|
||||
type Competency = {
|
||||
competency: {
|
||||
|
|
@ -18,11 +21,18 @@ type Artifact = {
|
|||
};
|
||||
|
||||
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
|
||||
interface ProfilePhotoResponse {
|
||||
photo: string | null;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface ProfileProps {
|
||||
fullName: string;
|
||||
mana: number;
|
||||
competencies: Competency[];
|
||||
artifacts: Artifact[];
|
||||
token: string;
|
||||
profilePhotoUploaded: boolean;
|
||||
progress: {
|
||||
current_rank: { title: string } | null;
|
||||
next_rank: { title: string } | null;
|
||||
|
|
@ -57,6 +67,44 @@ const Card = styled.div`
|
|||
gap: 1.5rem;
|
||||
`;
|
||||
|
||||
const PhotoSection = styled.div`
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const PhotoPreview = styled.div`
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(108, 92, 231, 0.45);
|
||||
background: rgba(162, 155, 254, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
`;
|
||||
|
||||
const PhotoImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const PhotoActions = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StatusMessage = styled.p<{ $kind: 'success' | 'error' }>`
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: ${({ $kind }) => ($kind === 'success' ? 'var(--accent-light)' : 'var(--error)')};
|
||||
`;
|
||||
|
||||
const ProgressBar = styled.div<{ value: number }>`
|
||||
position: relative;
|
||||
height: 12px;
|
||||
|
|
@ -109,12 +157,163 @@ const InlineBadge = styled.span<{ $kind?: 'success' | 'warning' }>`
|
|||
color: ${({ $kind }) => ($kind === 'success' ? '#55efc4' : '#ff7675')};
|
||||
`;
|
||||
|
||||
export function ProgressOverview({ fullName, mana, competencies, artifacts, progress }: ProfileProps) {
|
||||
export function ProgressOverview({
|
||||
fullName,
|
||||
mana,
|
||||
competencies,
|
||||
artifacts,
|
||||
token,
|
||||
profilePhotoUploaded,
|
||||
progress
|
||||
}: ProfileProps) {
|
||||
const xpPercent = Math.round(progress.xp.progress_percent * 100);
|
||||
const hasNextRank = Boolean(progress.next_rank);
|
||||
const [photoData, setPhotoData] = useState<string | null>(null);
|
||||
const [hasPhoto, setHasPhoto] = useState(profilePhotoUploaded);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [statusKind, setStatusKind] = useState<'success' | 'error'>('success');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHasPhoto(profilePhotoUploaded);
|
||||
}, [profilePhotoUploaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPhoto) {
|
||||
setPhotoData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
async function loadPhoto() {
|
||||
try {
|
||||
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', { authToken: token });
|
||||
if (!cancelled) {
|
||||
setPhotoData(response.photo ?? null);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('Не удалось загрузить фото профиля', error);
|
||||
setStatusKind('error');
|
||||
setStatus('Не получилось загрузить фото. Попробуйте обновить страницу.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadPhoto();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasPhoto, token]);
|
||||
|
||||
async function handleUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||
setStatus('Разрешены только изображения в форматах JPG, PNG или WEBP.');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setStatus('Размер файла не должен превышать 5 МБ.');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('photo', file);
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setStatus(null);
|
||||
setStatusKind('success');
|
||||
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
authToken: token
|
||||
});
|
||||
setPhotoData(response.photo ?? null);
|
||||
setHasPhoto(Boolean(response.photo));
|
||||
setStatusKind('success');
|
||||
setStatus(response.detail ?? 'Фотография обновлена.');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setStatusKind('error');
|
||||
setStatus(error.message);
|
||||
} else {
|
||||
setStatusKind('error');
|
||||
setStatus('Не удалось сохранить фото. Попробуйте позже.');
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
try {
|
||||
setUploading(true);
|
||||
setStatus(null);
|
||||
setStatusKind('success');
|
||||
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', {
|
||||
method: 'DELETE',
|
||||
authToken: token
|
||||
});
|
||||
setPhotoData(null);
|
||||
setHasPhoto(false);
|
||||
setStatusKind('success');
|
||||
setStatus(response.detail ?? 'Фотография удалена.');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setStatusKind('error');
|
||||
setStatus(error.message);
|
||||
} else {
|
||||
setStatusKind('error');
|
||||
setStatus('Не удалось удалить фото. Попробуйте ещё раз.');
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<PhotoSection>
|
||||
<PhotoPreview>
|
||||
{photoData ? <PhotoImage src={photoData} alt="Фото профиля" /> : <span role="img" aria-label="Профиль">🧑🚀</span>}
|
||||
</PhotoPreview>
|
||||
<PhotoActions>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<label className="secondary" style={{ cursor: 'pointer' }}>
|
||||
Загрузить фото
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
<button className="ghost" type="button" onClick={handleRemove} disabled={!hasPhoto || uploading}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
<small style={{ color: 'var(--text-muted)' }}>
|
||||
Добавьте свою фотографию, чтобы HR быстрее узнавал вас при общении на офлайн-миссиях.
|
||||
</small>
|
||||
{status && <StatusMessage $kind={statusKind}>{status}</StatusMessage>}
|
||||
</PhotoActions>
|
||||
</PhotoSection>
|
||||
|
||||
<header>
|
||||
<h2 style={{ margin: 0 }}>{fullName}</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user