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,
|
full_name=user_in.full_name,
|
||||||
hashed_password=get_password_hash(user_in.password),
|
hashed_password=get_password_hash(user_in.password),
|
||||||
role=UserRole.PILOT,
|
role=UserRole.PILOT,
|
||||||
preferred_branch=user_in.preferred_branch,
|
|
||||||
motivation=user_in.motivation,
|
motivation=user_in.motivation,
|
||||||
current_rank_id=base_rank.id if base_rank else None,
|
current_rank_id=base_rank.id if base_rank else None,
|
||||||
is_email_confirmed=not settings.require_email_confirmation,
|
is_email_confirmed=not settings.require_email_confirmation,
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,17 @@ def get_mission(
|
||||||
xp_reward=mission.xp_reward,
|
xp_reward=mission.xp_reward,
|
||||||
mana_reward=mission.mana_reward,
|
mana_reward=mission.mana_reward,
|
||||||
difficulty=mission.difficulty,
|
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_active=mission.is_active,
|
||||||
is_available=is_available,
|
is_available=is_available,
|
||||||
locked_reasons=reasons,
|
locked_reasons=reasons,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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 sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
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.models.mission import SubmissionStatus
|
||||||
from app.schemas.progress import ProgressSnapshot
|
from app.schemas.progress import ProgressSnapshot
|
||||||
from app.schemas.rank import RankBase
|
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.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"])
|
router = APIRouter(prefix="/api", tags=["profile"])
|
||||||
|
|
||||||
|
|
@ -29,7 +39,89 @@ def get_profile(
|
||||||
_ = item.competency
|
_ = item.competency
|
||||||
for artifact in current_user.artifacts:
|
for artifact in current_user.artifacts:
|
||||||
_ = artifact.artifact
|
_ = 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="Перечень рангов")
|
@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)"))
|
conn.execute(text("ALTER TABLE users ADD COLUMN preferred_branch VARCHAR(160)"))
|
||||||
if "motivation" not in user_columns:
|
if "motivation" not in user_columns:
|
||||||
conn.execute(text("ALTER TABLE users ADD COLUMN motivation TEXT"))
|
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:
|
if "passport_path" not in submission_columns:
|
||||||
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN passport_path VARCHAR(512)"))
|
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)
|
preferred_branch: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
|
||||||
# Короткая заметка с личной мотивацией — помогает HR при первичном контакте.
|
# Короткая заметка с личной мотивацией — помогает HR при первичном контакте.
|
||||||
motivation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
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")
|
current_rank = relationship("Rank", back_populates="pilots")
|
||||||
competencies: Mapped[List["UserCompetency"]] = relationship(
|
competencies: Mapped[List["UserCompetency"]] = relationship(
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ class UserProfile(UserRead):
|
||||||
|
|
||||||
competencies: list[UserCompetencyRead]
|
competencies: list[UserCompetencyRead]
|
||||||
artifacts: list[UserArtifactRead]
|
artifacts: list[UserArtifactRead]
|
||||||
|
profile_photo_uploaded: bool = False
|
||||||
|
profile_photo_updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardEntry(BaseModel):
|
class LeaderboardEntry(BaseModel):
|
||||||
|
|
@ -108,6 +110,11 @@ class UserRegister(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
full_name: str
|
full_name: str
|
||||||
password: str
|
password: str
|
||||||
# Дополнительные сведения помогают персонализировать онбординг и связать пилота с куратором.
|
|
||||||
preferred_branch: Optional[str] = None
|
|
||||||
motivation: 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)
|
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
|
return False
|
||||||
|
|
||||||
if mission.capacity is not None and participant_count >= mission.capacity:
|
if mission.capacity is not None and participant_count >= mission.capacity:
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
|
import mimetypes
|
||||||
|
import base64
|
||||||
|
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
|
@ -40,8 +42,34 @@ def save_submission_document(
|
||||||
return relative_path
|
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:
|
if not relative_path:
|
||||||
return
|
return
|
||||||
|
|
@ -57,3 +85,29 @@ def delete_submission_document(relative_path: str | None) -> None:
|
||||||
parent = file_path.parent
|
parent = file_path.parent
|
||||||
if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()):
|
if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()):
|
||||||
parent.rmdir()
|
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;
|
name: string;
|
||||||
rarity: string;
|
rarity: string;
|
||||||
}>;
|
}>;
|
||||||
|
profile_photo_uploaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProgressResponse {
|
interface ProgressResponse {
|
||||||
|
|
@ -64,6 +65,8 @@ export default async function DashboardPage() {
|
||||||
mana={profile.mana}
|
mana={profile.mana}
|
||||||
competencies={profile.competencies}
|
competencies={profile.competencies}
|
||||||
artifacts={profile.artifacts}
|
artifacts={profile.artifacts}
|
||||||
|
token={session.token}
|
||||||
|
profilePhotoUploaded={profile.profile_photo_uploaded}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ async function registerAction(formData: FormData) {
|
||||||
const email = String(formData.get('email') ?? '').trim();
|
const email = String(formData.get('email') ?? '').trim();
|
||||||
const password = String(formData.get('password') ?? '').trim();
|
const password = String(formData.get('password') ?? '').trim();
|
||||||
// Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки.
|
// Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки.
|
||||||
const preferredBranch = String(formData.get('preferredBranch') ?? '').trim() || undefined;
|
|
||||||
const motivation = String(formData.get('motivation') ?? '').trim() || undefined;
|
const motivation = String(formData.get('motivation') ?? '').trim() || undefined;
|
||||||
|
|
||||||
if (!fullName || !email || !password) {
|
if (!fullName || !email || !password) {
|
||||||
|
|
@ -25,7 +24,7 @@ async function registerAction(formData: FormData) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Собираем payload в формате, который ожидает FastAPI.
|
// 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', {
|
const response = await apiFetch<any>('/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
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="Придумайте пароль" />
|
<input className={styles.input} type="password" name="password" required placeholder="Придумайте пароль" />
|
||||||
</label>
|
</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}>
|
<label className={styles.field}>
|
||||||
Что хотите добиться?
|
Что хотите добиться?
|
||||||
<textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" />
|
<textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
// Компетенции и артефакты из профиля пользователя.
|
// Компетенции и артефакты из профиля пользователя.
|
||||||
type Competency = {
|
type Competency = {
|
||||||
competency: {
|
competency: {
|
||||||
|
|
@ -18,11 +21,18 @@ type Artifact = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
|
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
|
||||||
|
interface ProfilePhotoResponse {
|
||||||
|
photo: string | null;
|
||||||
|
detail?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProfileProps {
|
export interface ProfileProps {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
mana: number;
|
mana: number;
|
||||||
competencies: Competency[];
|
competencies: Competency[];
|
||||||
artifacts: Artifact[];
|
artifacts: Artifact[];
|
||||||
|
token: string;
|
||||||
|
profilePhotoUploaded: boolean;
|
||||||
progress: {
|
progress: {
|
||||||
current_rank: { title: string } | null;
|
current_rank: { title: string } | null;
|
||||||
next_rank: { title: string } | null;
|
next_rank: { title: string } | null;
|
||||||
|
|
@ -57,6 +67,44 @@ const Card = styled.div`
|
||||||
gap: 1.5rem;
|
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 }>`
|
const ProgressBar = styled.div<{ value: number }>`
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
|
@ -109,12 +157,163 @@ const InlineBadge = styled.span<{ $kind?: 'success' | 'warning' }>`
|
||||||
color: ${({ $kind }) => ($kind === 'success' ? '#55efc4' : '#ff7675')};
|
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 xpPercent = Math.round(progress.xp.progress_percent * 100);
|
||||||
const hasNextRank = Boolean(progress.next_rank);
|
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 (
|
return (
|
||||||
<Card>
|
<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>
|
<header>
|
||||||
<h2 style={{ margin: 0 }}>{fullName}</h2>
|
<h2 style={{ margin: 0 }}>{fullName}</h2>
|
||||||
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user