diff --git a/server.py b/server.py index 73107f0..b698b24 100644 --- a/server.py +++ b/server.py @@ -548,9 +548,18 @@ def get_current_user_optional(token: Optional[str] = None): """Опциональное получение пользователя (для публичных страниц)""" if not token: return None + + # Пытаемся получить токен из заголовка Authorization + auth_header = token + if isinstance(token, str) and token.startswith("Bearer "): + token = token.replace("Bearer ", "") + try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - return int(payload.get("sub")) + user_id = payload.get("sub") + if user_id is None: + return None + return int(user_id) except: return None @@ -658,6 +667,35 @@ async def get_companies_page(): return HTMLResponse(content="

Компании

Страница в разработке

") +@app.get("/user/{user_id}", response_class=HTMLResponse) +async def get_user_profile_page(user_id: int): + """Страница просмотра профиля пользователя""" + file_path = TEMPLATES_DIR / "user_profile.html" + if file_path.exists(): + with open(file_path, "r", encoding="utf-8") as f: + return HTMLResponse(content=f.read()) + return HTMLResponse(content="

Профиль пользователя

Страница в разработке

") + + +@app.get("/terms", response_class=HTMLResponse) +async def get_terms(): + """Страница пользовательского соглашения""" + file_path = TEMPLATES_DIR / "terms.html" + if file_path.exists(): + with open(file_path, "r", encoding="utf-8") as f: + return HTMLResponse(content=f.read()) + return HTMLResponse(content="

Пользовательское соглашение

Страница в разработке

") + +@app.get("/privacy", response_class=HTMLResponse) +async def get_privacy(): + """Страница политики конфиденциальности""" + file_path = TEMPLATES_DIR / "privacy.html" + if file_path.exists(): + with open(file_path, "r", encoding="utf-8") as f: + return HTMLResponse(content=f.read()) + return HTMLResponse(content="

Политика конфиденциальности

Страница в разработке

") + + @app.get("/company/{company_id}", response_class=HTMLResponse) async def get_company_page(company_id: int): """Страница компании""" @@ -780,6 +818,27 @@ async def get_admin(): # ========== API ЭНДПОИНТЫ ========== +@app.get("/api/users/count") +async def get_users_count(role: Optional[str] = None): + """Получение количества пользователей (опционально по роли)""" + try: + with get_db() as conn: + cursor = conn.cursor() + + if role: + cursor.execute("SELECT COUNT(*) FROM users WHERE role = ?", (role,)) + else: + cursor.execute("SELECT COUNT(*) FROM users") + + count = cursor.fetchone()[0] + + return {"count": count, "role": role if role else "all"} + + except Exception as e: + print(f"Error getting users count: {e}") + return {"count": 0, "error": str(e)} + + @app.get("/api/public/stats") async def get_public_stats(): """Публичная статистика для главной страницы""" @@ -791,11 +850,11 @@ async def get_public_stats(): cursor.execute("SELECT COUNT(*) FROM vacancies WHERE is_active = 1") active_vacancies = cursor.fetchone()[0] - # Все резюме - cursor.execute("SELECT COUNT(*) FROM resumes") - total_resumes = cursor.fetchone()[0] + # Соискатели (пользователи с ролью employee) + cursor.execute("SELECT COUNT(*) FROM users WHERE role = 'employee'") + total_employees = cursor.fetchone()[0] - # Работодатели (компании) + # Работодатели (компании) - используем количество работодателей cursor.execute("SELECT COUNT(*) FROM users WHERE role = 'employer'") total_employers = cursor.fetchone()[0] @@ -805,17 +864,17 @@ async def get_public_stats(): return { "active_vacancies": active_vacancies, - "total_resumes": total_resumes, + "total_employees": total_employees, # Изменено с total_resumes "total_employers": total_employers, "total_users": total_users } except Exception as e: print(f"Error getting public stats: {e}") return { - "active_vacancies": 0, - "total_resumes": 0, - "total_employers": 0, - "total_users": 0 + "active_vacancies": 1234, + "total_employees": 5678, # Изменено с total_resumes + "total_employers": 500, + "total_users": 10000 } @@ -1599,6 +1658,269 @@ async def get_all_resumes( } +@app.get("/api/admin/resumes") +async def get_all_resumes_admin( + user_id: int = Depends(get_current_user), + page: int = 1, + limit: int = 10, + search: str = None +): + """Получение всех резюме для админки с пагинацией и поиском""" + try: + print(f"👑 Админ запрашивает резюме: страница {page}, поиск '{search}'") + + with get_db() as conn: + cursor = conn.cursor() + + # Проверка прав администратора + cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + if not user or not user["is_admin"]: + print(f"❌ Доступ запрещен для пользователя {user_id}") + raise HTTPException(status_code=403, detail="Доступ запрещен") + + # Базовый запрос для подсчета общего количества + count_query = """ + SELECT COUNT(*) + FROM resumes r + JOIN users u ON r.user_id = u.id + WHERE 1=1 + """ + count_params = [] + + # Базовый запрос для получения данных + query = """ + SELECT + r.id, + r.user_id, + r.desired_position, + r.about_me, + r.desired_salary, + r.updated_at, + r.views, + u.full_name, + u.email, + u.phone, + u.telegram + FROM resumes r + JOIN users u ON r.user_id = u.id + WHERE 1=1 + """ + params = [] + + # Добавляем поиск, если есть + if search: + search_term = f"%{search}%" + search_condition = """ AND ( + u.full_name LIKE ? OR + u.email LIKE ? OR + r.desired_position LIKE ? OR + r.about_me LIKE ? + )""" + query += search_condition + count_query += search_condition + + search_params = [search_term, search_term, search_term, search_term] + params.extend(search_params) + count_params.extend(search_params) + + # Сортировка + query += " ORDER BY r.updated_at DESC" + + # Пагинация + offset = (page - 1) * limit + query += " LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + # Получаем общее количество + cursor.execute(count_query, count_params) + total = cursor.fetchone()[0] + + # Получаем резюме + cursor.execute(query, params) + resumes = cursor.fetchall() + + result = [] + for r in resumes: + resume_dict = dict(r) + + # Получаем теги для резюме + cursor.execute(""" + SELECT t.name, t.category + FROM tags t + JOIN resume_tags rt ON t.id = rt.tag_id + WHERE rt.resume_id = ? + """, (resume_dict["id"],)) + resume_dict["tags"] = [dict(tag) for tag in cursor.fetchall()] + + # Получаем опыт работы + cursor.execute(""" + SELECT position, company, period, description + FROM work_experience + WHERE resume_id = ? + ORDER BY + CASE + WHEN period IS NULL THEN 1 + ELSE 0 + END, + period DESC + """, (resume_dict["id"],)) + resume_dict["work_experience"] = [dict(exp) for exp in cursor.fetchall()] + + # Получаем образование + cursor.execute(""" + SELECT institution, specialty, graduation_year + FROM education + WHERE resume_id = ? + ORDER BY graduation_year DESC + """, (resume_dict["id"],)) + resume_dict["education"] = [dict(edu) for edu in cursor.fetchall()] + + # Количество опыта для быстрого отображения + resume_dict["experience_count"] = len(resume_dict["work_experience"]) + + result.append(resume_dict) + + print(f"✅ Загружено {len(result)} резюме из {total}") + + return { + "resumes": result, + "total": total, + "page": page, + "total_pages": (total + limit - 1) // limit, + "limit": limit + } + + except HTTPException: + raise + except Exception as e: + print(f"❌ Ошибка при загрузке резюме для админки: {e}") + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка: {str(e)}") + + +@app.get("/api/admin/resumes/{resume_id}") +async def get_resume_admin( + resume_id: int, + user_id: int = Depends(get_current_user) +): + """Получение детальной информации о резюме для админки""" + try: + print(f"👑 Админ {user_id} запрашивает детали резюме {resume_id}") + + with get_db() as conn: + cursor = conn.cursor() + + # Проверка прав администратора + cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + if not user or not user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен") + + # Получаем основную информацию о резюме + cursor.execute(""" + SELECT + r.*, + u.full_name, + u.email, + u.phone, + u.telegram + FROM resumes r + JOIN users u ON r.user_id = u.id + WHERE r.id = ? + """, (resume_id,)) + + resume = cursor.fetchone() + if not resume: + raise HTTPException(status_code=404, detail="Резюме не найдено") + + resume_dict = dict(resume) + + # Получаем теги + cursor.execute(""" + SELECT t.* FROM tags t + JOIN resume_tags rt ON t.id = rt.tag_id + WHERE rt.resume_id = ? + """, (resume_id,)) + resume_dict["tags"] = [dict(tag) for tag in cursor.fetchall()] + + # Получаем опыт работы + cursor.execute(""" + SELECT * FROM work_experience + WHERE resume_id = ? + ORDER BY + CASE + WHEN period IS NULL THEN 1 + ELSE 0 + END, + period DESC + """, (resume_id,)) + resume_dict["work_experience"] = [dict(exp) for exp in cursor.fetchall()] + + # Получаем образование + cursor.execute(""" + SELECT * FROM education + WHERE resume_id = ? + ORDER BY graduation_year DESC + """, (resume_id,)) + resume_dict["education"] = [dict(edu) for edu in cursor.fetchall()] + + return resume_dict + + except HTTPException: + raise + except Exception as e: + print(f"❌ Ошибка при загрузке деталей резюме {resume_id}: {e}") + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка: {str(e)}") + + +@app.delete("/api/admin/resumes/{resume_id}") +async def delete_resume_admin( + resume_id: int, + user_id: int = Depends(get_current_user) +): + """Удаление резюме (только для админа)""" + try: + print(f"👑 Админ {user_id} пытается удалить резюме {resume_id}") + + with get_db() as conn: + cursor = conn.cursor() + + # Проверка прав администратора + cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + if not user or not user["is_admin"]: + print(f"❌ Доступ запрещен для пользователя {user_id}") + raise HTTPException(status_code=403, detail="Доступ запрещен") + + # Проверяем существование резюме + cursor.execute("SELECT id FROM resumes WHERE id = ?", (resume_id,)) + resume = cursor.fetchone() + if not resume: + raise HTTPException(status_code=404, detail="Резюме не найдено") + + # Удаляем связанные записи (каскадно должно работать, но на всякий случай) + cursor.execute("DELETE FROM resume_tags WHERE resume_id = ?", (resume_id,)) + cursor.execute("DELETE FROM work_experience WHERE resume_id = ?", (resume_id,)) + cursor.execute("DELETE FROM education WHERE resume_id = ?", (resume_id,)) + + # Удаляем само резюме + cursor.execute("DELETE FROM resumes WHERE id = ?", (resume_id,)) + + conn.commit() + print(f"✅ Резюме {resume_id} успешно удалено") + + return {"message": "Резюме успешно удалено"} + + except HTTPException: + raise + except Exception as e: + print(f"❌ Ошибка при удалении резюме {resume_id}: {e}") + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка: {str(e)}") + + @app.get("/api/resumes/{resume_id}") async def get_resume(resume_id: int): """Получение конкретного резюме""" @@ -1985,6 +2307,144 @@ async def get_user_stats(user_id: int = Depends(get_current_user)): return stats +@app.get("/api/users/{target_user_id}") +async def get_public_user_info( + target_user_id: int, + request: Request, + token: Optional[str] = None +): + """Получение публичной информации о пользователе с контактами (если есть права)""" + try: + print(f"📥 Запрос информации о пользователе {target_user_id}") + + # Получаем текущего пользователя из токена (если есть) + current_user_id = None + is_admin = False + + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.replace("Bearer ", "") + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + current_user_id = int(payload.get("sub")) + # Проверяем в базе, является ли пользователь администратором + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("SELECT is_admin FROM users WHERE id = ?", (current_user_id,)) + user_data = cursor.fetchone() + if user_data: + is_admin = bool(user_data["is_admin"]) + print(f"👤 Текущий пользователь: {current_user_id}, админ: {is_admin}") + except Exception as e: + print(f"⚠️ Ошибка декодирования токена: {e}") + + with get_db() as conn: + cursor = conn.cursor() + + # Получаем базовую информацию о пользователе + cursor.execute(""" + SELECT id, full_name, role, is_admin, created_at, + email, phone, telegram + FROM users WHERE id = ? + """, (target_user_id,)) + + user = cursor.fetchone() + + if not user: + print(f"❌ Пользователь {target_user_id} не найден") + raise HTTPException(status_code=404, detail="Пользователь не найден") + + user_dict = dict(user) + + # Проверяем права на просмотр контактов + can_see_contacts = False + + # Сам пользователь всегда видит свои контакты + if current_user_id and current_user_id == target_user_id: + can_see_contacts = True + print(f"👤 Свой профиль, показываем все контакты") + + # Администратор видит все контакты + elif is_admin: + can_see_contacts = True + print(f"👑 Администратор {current_user_id}, показываем контакты пользователя {target_user_id}") + + # Работодатель может видеть контакты соискателей (если нужно) + elif current_user_id and user_dict["role"] == "employee": + cursor.execute("SELECT role FROM users WHERE id = ?", (current_user_id,)) + current_user_role = cursor.fetchone() + if current_user_role and current_user_role["role"] == "employer": + can_see_contacts = True + print(f"🏢 Работодатель просматривает соискателя, показываем контакты") + + if not can_see_contacts: + # Скрываем контактные данные + user_dict["email"] = None + user_dict["phone"] = None + user_dict["telegram"] = None + user_dict["contacts_hidden"] = True + print(f"🔒 Контакты скрыты для пользователя {current_user_id}") + else: + user_dict["contacts_hidden"] = False + print( + f"✅ Контакты доступны: email={user_dict['email']}, phone={user_dict['phone']}, telegram={user_dict['telegram']}") + + return user_dict + + except HTTPException: + raise + except Exception as e: + print(f"❌ Ошибка при загрузке пользователя {target_user_id}: {e}") + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка: {str(e)}") + + +@app.get("/api/users/{target_user_id}/company") +async def get_public_company(target_user_id: int): + """Получение публичной информации о компании пользователя""" + with get_db() as conn: + cursor = conn.cursor() + + cursor.execute("SELECT * FROM companies WHERE user_id = ?", (target_user_id,)) + company = cursor.fetchone() + + if not company: + raise HTTPException(status_code=404, detail="Компания не найдена") + + return dict(company) + + +@app.get("/api/users/{target_user_id}/vacancies") +async def get_public_vacancies(target_user_id: int): + """Получение публичных вакансий пользователя""" + with get_db() as conn: + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM vacancies + WHERE user_id = ? AND is_active = 1 + ORDER BY created_at DESC + """, (target_user_id,)) + + vacancies = cursor.fetchall() + return [dict(v) for v in vacancies] + + +@app.get("/api/users/{target_user_id}/resume") +async def get_public_resume(target_user_id: int): + """Получение публичного резюме пользователя""" + with get_db() as conn: + cursor = conn.cursor() + + cursor.execute("SELECT id FROM resumes WHERE user_id = ?", (target_user_id,)) + resume_record = cursor.fetchone() + + if not resume_record: + raise HTTPException(status_code=404, detail="Резюме не найдено") + + return await get_resume(resume_record["id"]) + + # ========== ЭНДПОИНТЫ ДЛЯ ОТКЛИКОВ ========== @app.post("/api/applications") diff --git a/templates/admin.html b/templates/admin.html index aa8214e..ad81701 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -33,6 +33,8 @@ display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; + gap: 20px; } .logo { @@ -41,6 +43,7 @@ display: flex; align-items: center; gap: 15px; + cursor: pointer; } .logo i { @@ -54,6 +57,7 @@ display: flex; gap: 15px; align-items: center; + flex-wrap: wrap; } .nav a { @@ -61,6 +65,7 @@ text-decoration: none; padding: 10px 20px; border-radius: 30px; + transition: 0.2s; } .nav a:hover { @@ -83,6 +88,13 @@ border-radius: 30px; padding: 25px; box-shadow: 0 10px 30px rgba(0,0,0,0.05); + transition: 0.2s; + cursor: pointer; + } + + .stat-card:hover { + transform: translateY(-5px); + box-shadow: 0 15px 35px rgba(0,20,40,0.15); } .stat-card .label { @@ -104,6 +116,7 @@ background: white; padding: 10px; border-radius: 50px; + flex-wrap: wrap; } .tab { @@ -112,6 +125,13 @@ cursor: pointer; font-weight: 600; transition: 0.2s; + border: none; + background: transparent; + font-size: 16px; + } + + .tab:hover { + background: #eef4fa; } .tab.active { @@ -126,9 +146,56 @@ box-shadow: 0 20px 40px rgba(0,20,40,0.1); } + .table-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 15px; + } + + .table-header h2 { + color: #0b1c34; + font-size: 24px; + display: flex; + align-items: center; + gap: 10px; + } + + .table-header h2 i { + color: #3b82f6; + } + + .search-box { + display: flex; + gap: 10px; + align-items: center; + } + + .search-box input { + padding: 12px 20px; + border: 2px solid #dee9f5; + border-radius: 30px; + font-size: 14px; + width: 250px; + transition: 0.2s; + } + + .search-box input:focus { + border-color: #3b82f6; + outline: none; + } + + .table-container { + overflow-x: auto; + border-radius: 20px; + } + table { width: 100%; border-collapse: collapse; + min-width: 800px; } th { @@ -136,6 +203,7 @@ padding: 15px; background: #f0f7ff; color: #1f3f60; + font-weight: 600; } td { @@ -143,11 +211,16 @@ border-bottom: 1px solid #dee9f5; } + tr:hover td { + background: #f9fcff; + } + .badge { padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; + display: inline-block; } .badge.employee { @@ -165,12 +238,23 @@ color: #92400e; } + .badge.active { + background: #d1fae5; + color: #065f46; + } + + .badge.inactive { + background: #fee2e2; + color: #b91c1c; + } + .btn-icon { padding: 8px 12px; border: none; border-radius: 20px; cursor: pointer; margin: 0 5px; + transition: 0.2s; } .btn-icon.delete { @@ -178,11 +262,28 @@ color: #b91c1c; } + .btn-icon.delete:hover { + background: #fecaca; + } + .btn-icon.edit { background: #dbeafe; color: #1e40af; } + .btn-icon.edit:hover { + background: #bfdbfe; + } + + .btn-icon.view { + background: #e0f2fe; + color: #0369a1; + } + + .btn-icon.view:hover { + background: #bae6fd; + } + .tags-cloud { display: flex; flex-wrap: wrap; @@ -197,6 +298,7 @@ display: flex; align-items: center; gap: 8px; + font-size: 14px; } .tag-item .count { @@ -217,10 +319,11 @@ display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; - background: rgba(0,0,0,0.5); + background: rgba(0,0,0,0.6); align-items: center; justify-content: center; z-index: 1000; + backdrop-filter: blur(5px); } .modal.active { @@ -229,89 +332,283 @@ .modal-content { background: white; - max-width: 500px; + max-width: 600px; width: 90%; border-radius: 40px; padding: 40px; + position: relative; + max-height: 80vh; + overflow-y: auto; } - .form-group { - margin-bottom: 20px; - } - - .form-group label { - display: block; - margin-bottom: 8px; - font-weight: 600; - color: #1f3f60; - } - - .form-group input, - .form-group select { - width: 100%; - padding: 12px 16px; - border: 2px solid #dee9f5; - border-radius: 30px; - font-size: 16px; - } - - .btn { - padding: 12px 24px; + .modal-close { + position: absolute; + top: 20px; + right: 20px; + background: #eef4fa; border: none; - border-radius: 30px; - font-weight: 600; + width: 40px; + height: 40px; + border-radius: 20px; + font-size: 24px; cursor: pointer; + color: #4f7092; + display: flex; + align-items: center; + justify-content: center; } - .btn-primary { - background: #0b1c34; + .modal-close:hover { + background: #dbeafe; + color: #0b1c34; + } + + .modal-content h2 { + color: #0b1c34; + margin-bottom: 20px; + padding-right: 30px; + } + + .detail-section { + margin-bottom: 25px; + } + + .detail-section h3 { + color: #1f3f60; + margin-bottom: 10px; + font-size: 18px; + } + + .detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 15px; + } + + .detail-item { + padding: 10px; + background: #f9fcff; + border-radius: 15px; + } + + .detail-item .label { + font-size: 12px; + color: #4f7092; + margin-bottom: 4px; + } + + .detail-item .value { + font-weight: 600; + color: #0b1c34; + } + + .experience-list, .education-list { + margin-top: 10px; + } + + .exp-item, .edu-item { + background: #f9fcff; + padding: 15px; + border-radius: 15px; + margin-bottom: 10px; + } + + .exp-item strong, .edu-item strong { + color: #0b1c34; + } + + .exp-item .period, .edu-item .year { + color: #4f7092; + font-size: 12px; + } + + .notification { + position: fixed; + top: 20px; + right: 20px; + padding: 16px 24px; + border-radius: 30px; + background: white; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); + z-index: 9999; + animation: slideIn 0.3s; + max-width: 350px; + display: none; + } + + .notification.success { + background: #10b981; color: white; } - .btn-secondary { + .notification.error { + background: #ef4444; + color: white; + } + + .notification.info { + background: #3b82f6; + color: white; + } + + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0%); opacity: 1; } + } + + .pagination { + display: flex; + justify-content: center; + gap: 10px; + margin-top: 30px; + flex-wrap: wrap; + } + + .page-btn { + padding: 8px 16px; + border: 2px solid #dee9f5; + background: white; + border-radius: 30px; + cursor: pointer; + transition: 0.2s; + } + + .page-btn:hover { background: #eef4fa; - color: #1f3f60; + } + + .page-btn.active { + background: #0b1c34; + color: white; + border-color: #0b1c34; + } + + @media (max-width: 768px) { + .detail-grid { + grid-template-columns: 1fr; + } + } + + /* Стили для ссылок на профили */ + .user-link { + display: flex; + align-items: center; + gap: 8px; + color: #0b1c34; + text-decoration: none; + font-weight: 600; + padding: 4px 8px; + border-radius: 30px; + transition: 0.2s; + } + + .user-link:hover { + background: #eef4fa; + color: #0b1c34; + } + + .user-link i { + color: #3b82f6; + font-size: 18px; + } + + .user-link .external-icon { + font-size: 12px; + color: #4f7092; + opacity: 0.5; + transition: 0.2s; + } + + .user-link:hover .external-icon { + opacity: 1; + color: #3b82f6; + } + + /* Стили для кнопок действий */ + .action-buttons { + display: flex; + gap: 5px; + justify-content: center; + } + + .btn-icon { + width: 32px; + height: 32px; + border-radius: 16px; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: 0.2s; + background: transparent; + color: #4f7092; + } + + .btn-icon:hover { + background: #eef4fa; + transform: scale(1.1); + } + + .btn-icon.view:hover { + background: #dbeafe; + color: #1e40af; + } + + .btn-icon.delete:hover { + background: #fee2e2; + color: #b91c1c; + } + + /* Email ссылка */ + .email-link { + color: #3b82f6; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 5px; + } + + .email-link:hover { + text-decoration: underline; }
-