This commit is contained in:
2026-03-13 19:58:52 +03:00
parent cd1129ea72
commit 65eca64a5f
14 changed files with 6598 additions and 299 deletions

864
templates/favorites.html Normal file
View File

@@ -0,0 +1,864 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Избранное | Rabota.Today</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
background: linear-gradient(145deg, #eef5fa 0%, #e0eaf5 100%);
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #0b1c34;
color: white;
padding: 20px 40px;
border-radius: 40px;
margin-bottom: 40px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.logo {
font-size: 28px;
font-weight: 700;
display: flex;
align-items: center;
gap: 15px;
}
.logo i {
color: #3b82f6;
background: rgba(255,255,255,0.1);
padding: 12px;
border-radius: 20px;
}
.nav {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.nav a {
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 30px;
transition: 0.2s;
}
.nav a:hover {
background: rgba(255,255,255,0.1);
}
.nav .active {
background: #3b82f6;
}
.profile-link {
display: flex;
align-items: center;
gap: 8px;
background: #3b82f6;
padding: 8px 20px !important;
}
.profile-link i {
font-size: 18px;
}
.user-avatar {
width: 35px;
height: 35px;
background: #3b82f6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.user-name {
font-weight: 500;
}
.admin-badge {
background: #f59e0b;
color: white;
padding: 2px 8px;
border-radius: 20px;
font-size: 12px;
margin-left: 5px;
}
.back-to-profile {
display: inline-flex;
align-items: center;
gap: 8px;
color: #4f7092;
text-decoration: none;
margin-bottom: 20px;
padding: 8px 16px;
border-radius: 30px;
background: white;
transition: 0.2s;
}
.back-to-profile:hover {
background: #eef4fa;
color: #0b1c34;
}
.favorites-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
flex-wrap: wrap;
gap: 20px;
}
.favorites-header h1 {
font-size: 32px;
color: #0b1c34;
display: flex;
align-items: center;
gap: 10px;
}
.favorites-header h1 i {
color: #b91c1c;
font-size: 28px;
}
.filter-tabs {
display: flex;
gap: 10px;
background: white;
padding: 8px;
border-radius: 50px;
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
}
.filter-tab {
padding: 10px 24px;
border-radius: 40px;
cursor: pointer;
font-weight: 600;
transition: 0.2s;
color: #4f7092;
}
.filter-tab:hover {
background: #eef4fa;
}
.filter-tab.active {
background: #0b1c34;
color: white;
}
.stats-summary {
background: white;
border-radius: 30px;
padding: 20px 30px;
margin-bottom: 30px;
display: flex;
gap: 40px;
flex-wrap: wrap;
box-shadow: 0 10px 30px rgba(0,20,40,0.05);
}
.stat-item {
display: flex;
align-items: center;
gap: 10px;
}
.stat-item .value {
font-size: 28px;
font-weight: 700;
color: #0b1c34;
}
.stat-item .label {
color: #4f7092;
font-size: 16px;
}
.favorites-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 30px;
}
.favorite-card {
background: white;
border-radius: 40px;
padding: 30px;
transition: 0.3s;
cursor: pointer;
position: relative;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
}
.favorite-card:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0,20,40,0.15);
}
.favorite-card.vacancy {
border-left: 5px solid #3b82f6;
}
.favorite-card.resume {
border-left: 5px solid #10b981;
}
.favorite-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 15px;
}
.favorite-type {
background: #eef4fa;
padding: 6px 14px;
border-radius: 30px;
font-size: 13px;
font-weight: 600;
color: #1f3f60;
}
.favorite-type.vacancy {
background: #dbeafe;
color: #1e40af;
}
.favorite-type.resume {
background: #d1fae5;
color: #065f46;
}
.remove-btn {
background: #fee2e2;
border: none;
color: #b91c1c;
width: 36px;
height: 36px;
border-radius: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: 0.2s;
}
.remove-btn:hover {
background: #fecaca;
transform: scale(1.1);
}
.favorite-title {
font-size: 20px;
font-weight: 700;
color: #0b1c34;
margin-bottom: 8px;
line-height: 1.3;
}
.favorite-subtitle {
color: #3b82f6;
font-weight: 600;
margin-bottom: 10px;
font-size: 15px;
}
.favorite-salary {
font-size: 20px;
font-weight: 700;
color: #0f2b4f;
margin: 15px 0;
padding: 10px 0;
border-top: 1px solid #dee9f5;
border-bottom: 1px solid #dee9f5;
}
.favorite-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 15px 0;
}
.tag {
background: #eef4fa;
padding: 6px 14px;
border-radius: 30px;
font-size: 13px;
color: #1f3f60;
font-weight: 500;
}
.favorite-footer {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #dee9f5;
padding-top: 20px;
margin-top: 10px;
}
.favorite-date {
color: #4f7092;
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
}
.empty-state {
text-align: center;
padding: 80px 40px;
background: white;
border-radius: 40px;
grid-column: 1/-1;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
}
.empty-state i {
font-size: 80px;
color: #cbd5e1;
margin-bottom: 25px;
}
.empty-state h2 {
font-size: 28px;
color: #0b1c34;
margin-bottom: 15px;
}
.empty-state p {
color: #4f7092;
margin-bottom: 30px;
font-size: 18px;
}
.empty-state-actions {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 14px 32px;
border-radius: 40px;
border: none;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
font-size: 16px;
transition: 0.2s;
}
.btn-primary {
background: #0b1c34;
color: white;
}
.btn-primary:hover {
background: #1b3f6b;
transform: scale(1.05);
}
.btn-outline {
background: transparent;
border: 2px solid #3b82f6;
color: #0b1c34;
}
.btn-outline:hover {
background: #eef4fa;
}
.loading {
text-align: center;
padding: 80px;
color: #4f7092;
font-size: 18px;
grid-column: 1/-1;
background: white;
border-radius: 40px;
}
.loading i {
font-size: 40px;
margin-bottom: 15px;
color: #3b82f6;
}
#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;
}
#notification.error {
background: #ef4444;
color: white;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (max-width: 768px) {
.favorites-header {
flex-direction: column;
align-items: flex-start;
}
.filter-tabs {
width: 100%;
justify-content: stretch;
}
.filter-tab {
flex: 1;
text-align: center;
padding: 10px 16px;
}
.stats-summary {
justify-content: space-around;
}
.favorites-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">
<i class="fas fa-briefcase"></i>
Rabota.Today
</div>
<div class="nav" id="nav">
<!-- Навигация будет заполнена динамически -->
</div>
</div>
<a href="/profile" class="back-to-profile">
<i class="fas fa-arrow-left"></i> Вернуться в профиль
</a>
<div class="favorites-header">
<h1>
<i class="fas fa-heart"></i>
Избранное
</h1>
<div class="filter-tabs">
<div class="filter-tab" data-filter="all" onclick="filterFavorites('all')">Все</div>
<div class="filter-tab" data-filter="vacancy" onclick="filterFavorites('vacancy')">Вакансии</div>
<div class="filter-tab" data-filter="resume" onclick="filterFavorites('resume')">Резюме</div>
</div>
</div>
<!-- Статистика избранного -->
<div class="stats-summary" id="statsSummary">
<div class="stat-item">
<span class="value" id="totalCount">0</span>
<span class="label">всего</span>
</div>
<div class="stat-item">
<span class="value" id="vacancyCount">0</span>
<span class="label">вакансий</span>
</div>
<div class="stat-item">
<span class="value" id="resumeCount">0</span>
<span class="label">резюме</span>
</div>
</div>
<div id="favoritesContainer" class="favorites-grid">
<div class="loading">
<i class="fas fa-spinner fa-spin"></i>
<div>Загрузка избранного...</div>
</div>
</div>
</div>
<div id="notification"></div>
<script>
const API_BASE_URL = 'http://localhost:8000/api';
let currentUser = null;
let currentFilter = 'all';
let favorites = [];
// Получаем параметр filter из URL
const urlParams = new URLSearchParams(window.location.search);
const urlFilter = urlParams.get('type');
if (urlFilter && ['all', 'vacancy', 'resume'].includes(urlFilter)) {
currentFilter = urlFilter;
}
// Проверка авторизации
const token = localStorage.getItem('accessToken');
if (!token) {
window.location.href = '/login';
}
// Проверка авторизации и получение данных пользователя
async function checkAuth() {
try {
const response = await fetch(`${API_BASE_URL}/user`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
currentUser = await response.json();
} else {
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
} catch (error) {
console.error('Error checking auth:', error);
}
updateNavigation();
activateTabFromUrl();
}
// Активация таба из URL
function activateTabFromUrl() {
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.classList.remove('active');
if (tab.dataset.filter === currentFilter) {
tab.classList.add('active');
}
});
}
// Обновление навигации
function updateNavigation() {
const nav = document.getElementById('nav');
if (currentUser) {
nav.innerHTML = `
<a href="/">Главная</a>
<a href="/vacancies">Вакансии</a>
<a href="/resumes">Резюме</a>
<a href="/favorites" class="active">Избранное</a>
<a href="/applications">Отклики</a>
<a href="/profile" class="profile-link">
<i class="fas fa-user-circle"></i>
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>
${currentUser.is_admin ? '<span class="admin-badge">Admin</span>' : ''}
</a>
`;
}
}
// Загрузка избранного
async function loadFavorites() {
try {
const response = await fetch(`${API_BASE_URL}/favorites`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Ошибка загрузки');
favorites = await response.json();
// Обновляем статистику
updateStats();
// Отображаем избранное с текущим фильтром
renderFavorites();
} catch (error) {
console.error('Error loading favorites:', error);
document.getElementById('favoritesContainer').innerHTML =
'<div class="loading">Ошибка загрузки избранного</div>';
}
}
// Обновление статистики
function updateStats() {
const vacancyCount = favorites.filter(f => f.item_type === 'vacancy').length;
const resumeCount = favorites.filter(f => f.item_type === 'resume').length;
document.getElementById('totalCount').textContent = favorites.length;
document.getElementById('vacancyCount').textContent = vacancyCount;
document.getElementById('resumeCount').textContent = resumeCount;
}
// Отображение избранного с фильтром
function renderFavorites() {
const container = document.getElementById('favoritesContainer');
const filtered = currentFilter === 'all'
? favorites
: favorites.filter(f => f.item_type === currentFilter);
if (filtered.length === 0) {
let title = '';
let message = '';
let actionButtons = '';
if (currentFilter === 'vacancy') {
title = 'Нет избранных вакансий';
message = 'Добавляйте вакансии в избранное, чтобы не потерять интересные предложения';
actionButtons = `
<div class="empty-state-actions">
<a href="/vacancies" class="btn btn-primary">
<i class="fas fa-search"></i> Найти вакансии
</a>
<a href="/resumes" class="btn btn-outline">
<i class="fas fa-users"></i> Смотреть резюме
</a>
</div>
`;
} else if (currentFilter === 'resume') {
title = 'Нет избранных резюме';
message = 'Сохраняйте резюме кандидатов, чтобы вернуться к ним позже';
actionButtons = `
<div class="empty-state-actions">
<a href="/resumes" class="btn btn-primary">
<i class="fas fa-users"></i> Найти резюме
</a>
<a href="/vacancies" class="btn btn-outline">
<i class="fas fa-briefcase"></i> Смотреть вакансии
</a>
</div>
`;
} else {
title = 'Список избранного пуст';
message = 'Добавляйте вакансии и резюме в избранное, чтобы не потерять их';
actionButtons = `
<div class="empty-state-actions">
<a href="/vacancies" class="btn btn-primary">
<i class="fas fa-search"></i> Найти вакансии
</a>
<a href="/resumes" class="btn btn-primary">
<i class="fas fa-users"></i> Найти резюме
</a>
</div>
`;
}
container.innerHTML = `
<div class="empty-state">
<i class="far fa-heart"></i>
<h2>${title}</h2>
<p>${message}</p>
${actionButtons}
</div>
`;
return;
}
container.innerHTML = filtered.map(f => {
const data = f.item_data;
const date = new Date(f.created_at).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
if (f.item_type === 'vacancy') {
return `
<div class="favorite-card vacancy" onclick="window.location.href='${data.url}'">
<div class="favorite-header">
<span class="favorite-type vacancy">
<i class="fas fa-briefcase"></i> Вакансия
</span>
<button class="remove-btn" onclick="event.stopPropagation(); removeFromFavorites('vacancy', ${f.item_id})">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="favorite-title">${escapeHtml(data.title)}</div>
<div class="favorite-subtitle">
<i class="fas fa-building"></i> ${escapeHtml(data.company || 'Компания')}
</div>
<div class="favorite-salary">${escapeHtml(data.salary || 'Зарплата не указана')}</div>
<div class="favorite-tags">
${(data.tags || []).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
</div>
<div class="favorite-footer">
<span class="favorite-date">
<i class="far fa-calendar"></i> ${date}
</span>
</div>
</div>
`;
} else {
return `
<div class="favorite-card resume" onclick="window.location.href='${data.url}'">
<div class="favorite-header">
<span class="favorite-type resume">
<i class="fas fa-user"></i> Резюме
</span>
<button class="remove-btn" onclick="event.stopPropagation(); removeFromFavorites('resume', ${f.item_id})">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="favorite-title">${escapeHtml(data.name)}</div>
<div class="favorite-subtitle">
<i class="fas fa-briefcase"></i> ${escapeHtml(data.title || 'Должность не указана')}
</div>
<div class="favorite-salary">${escapeHtml(data.salary || 'Зарплата не указана')}</div>
<div class="favorite-tags">
${(data.tags || []).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
</div>
<div class="favorite-footer">
<span class="favorite-date">
<i class="far fa-calendar"></i> ${date}
</span>
</div>
</div>
`;
}
}).join('');
}
// Фильтрация избранного
function filterFavorites(filter) {
currentFilter = filter;
// Обновляем URL без перезагрузки страницы
const url = new URL(window.location);
url.searchParams.set('type', filter);
window.history.pushState({}, '', url);
// Обновляем активный таб
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.classList.remove('active');
if (tab.dataset.filter === filter) {
tab.classList.add('active');
}
});
renderFavorites();
}
// Удаление из избранного
async function removeFromFavorites(itemType, itemId) {
if (!confirm('Удалить из избранного?')) return;
try {
const response = await fetch(`${API_BASE_URL}/favorites`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
item_type: itemType,
item_id: itemId
})
});
if (response.ok) {
// Удаляем из локального массива
favorites = favorites.filter(f => !(f.item_type === itemType && f.item_id === itemId));
updateStats();
renderFavorites();
showNotification('Удалено из избранного', 'success');
} else {
const error = await response.json();
throw new Error(error.detail);
}
} catch (error) {
showNotification(error.message, 'error');
}
}
// Функция для показа уведомлений
function showNotification(message, type = 'success') {
const notification = document.getElementById('notification');
notification.className = type;
notification.innerHTML = message;
notification.style.display = 'block';
setTimeout(() => {
notification.style.display = 'none';
}, 3000);
}
// Экранирование HTML
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Обработка клика по кнопке "Назад"
window.onpopstate = function() {
const params = new URLSearchParams(window.location.search);
const filter = params.get('type') || 'all';
if (['all', 'vacancy', 'resume'].includes(filter)) {
currentFilter = filter;
activateTabFromUrl();
renderFavorites();
}
};
// Загрузка при старте
checkAuth().then(() => {
loadFavorites();
});
</script>
</body>
</html>