Добавлена система проектов с автоматическим ресайзом изображений и адаптивным дизайном
Some checks failed
continuous-integration/drone/push Build is failing

- Удалена старая система портфолио (PortfolioCategory, PortfolioItem)
- Расширена модель Project: slug, categories (M2M), thumbnail, media files, meta fields
- Объединены категории: ProjectCategory удалена, используется общая Category
- Автоматический ресайз thumbnail до 600x400px с умным кропом по центру
- Создан /projects/ - страница списка проектов с фильтрацией по категориям
- Создан /project/<pk>/ - детальная страница проекта с галереей Swiper
- Адаптивный дизайн: 3 карточки в ряд (десктоп), 2 (планшет), 1 (мобильный)
- Параллакс-эффект на изображениях при наведении
- Lazy loading для оптимизации загрузки
- Фильтры категорий в виде пилюль как на странице услуг
- Компактные карточки с фиксированной шириной
- Кликабельные проекты в service_detail с отображением всех медиа
This commit is contained in:
2025-11-26 09:44:14 +09:00
parent 5bcf3e8198
commit e7d6d5262d
26 changed files with 3029 additions and 447 deletions

45
create_test_data.py Normal file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env python
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'smartsoltech.settings')
django.setup()
from web.models import ProjectCategory, Project
# Создаём категорию
cat, created = ProjectCategory.objects.get_or_create(
slug='web-development',
defaults={
'name': 'Веб-разработка',
'description': 'Разработка современных веб-приложений',
'icon': 'fas fa-laptop-code',
'order': 1,
'is_active': True
}
)
print(f"{'Создана' if created else 'Найдена'} категория: {cat.name}")
# Обновляем первый проект
project = Project.objects.first()
if project:
project.short_description = 'Корпоративный сайт SmartSolTech с современным дизайном'
project.description = '<h2>О проекте</h2><p>Разработка корпоративного сайта с использованием Django и современного дизайна.</p><h3>Особенности</h3><ul><li>Адаптивный дизайн</li><li>Админ-панель</li><li>Интеграция с Telegram</li></ul>'
if not project.slug:
project.slug = 'smartsoltech-website'
project.technologies = 'Python, Django, PostgreSQL, Bootstrap, JavaScript'
project.duration = '3 месяца'
project.team_size = 4
project.is_featured = True
project.display_order = 1
project.save()
project.categories.add(cat)
print(f"Обновлён проект: {project.name}")
print(f"URL: /project/{project.pk}/")
else:
print("Проектов не найдено")
print("\n=== Статистика ===")
print(f"Категорий: {ProjectCategory.objects.count()}")
print(f"Проектов: {Project.objects.count()}")
print(f"Завершённых проектов: {Project.objects.filter(status='completed').count()}")

View File

@@ -21,4 +21,7 @@ dj-database-url==2.1.0
coverage==7.3.2
pytest==7.4.3
pytest-django==4.7.0
pytest-cov==4.1.0
pytest-cov==4.1.0
django-tinymce==4.1.0
Pillow==10.4.0
django-tinymce==4.1.0

View File

@@ -60,6 +60,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'tinymce',
'web',
'comunication'
]
@@ -158,6 +159,36 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') # Папка для соб
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# TinyMCE Configuration
TINYMCE_DEFAULT_CONFIG = {
'height': 500,
'width': '100%',
'cleanup_on_startup': True,
'custom_undo_redo_levels': 20,
'selector': 'textarea',
'theme': 'silver',
'plugins': '''
textcolor save link image media preview codesample contextmenu
table code lists fullscreen insertdatetime nonbreaking
contextmenu directionality searchreplace wordcount visualblocks
visualchars code fullscreen autolink lists charmap print hr
anchor pagebreak
''',
'toolbar1': '''
fullscreen preview bold italic underline | fontselect,
fontsizeselect | forecolor backcolor | alignleft alignright |
aligncenter alignjustify | indent outdent | bullist numlist table |
| link image media | codesample |
''',
'toolbar2': '''
visualblocks visualchars |
charmap hr pagebreak nonbreaking anchor | code |
''',
'contextmenu': 'formats | link image',
'menubar': True,
'statusbar': True,
}
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field

View File

@@ -1,8 +1,16 @@
# smartsoltech/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('tinymce/', include('tinymce.urls')),
path('', include('web.urls')), # Включаем маршруты приложения web
]
# Serve media files in development
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@@ -281,28 +281,6 @@ p {
animation: float 3s ease-in-out infinite;
}
/* Dark mode toggle */
.theme-toggle {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--gradient-primary);
border: none;
color: white;
font-size: 1.25rem;
cursor: pointer;
box-shadow: var(--shadow);
transition: all 0.3s ease;
z-index: 1000;
}
.theme-toggle:hover {
transform: scale(1.1);
}
/* Responsive Design */
@media (max-width: 768px) {
h1 {

View File

@@ -21,46 +21,6 @@ document.addEventListener('DOMContentLoaded', function() {
console.log('SmartSolTech: Loading screen not found');
}
// Theme Toggle Functionality
const themeToggle = document.getElementById('theme-toggle');
const html = document.documentElement;
if (themeToggle) {
// Check for saved theme preference
const currentTheme = localStorage.getItem('theme') || 'light';
html.setAttribute('data-theme', currentTheme);
updateThemeIcon(currentTheme);
themeToggle.addEventListener('click', function() {
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
// Add animation
this.style.transform = 'scale(0.8)';
setTimeout(() => {
this.style.transform = 'scale(1)';
}, 150);
});
}
function updateThemeIcon(theme) {
if (!themeToggle) return;
const icon = themeToggle.querySelector('i');
if (icon) {
if (theme === 'dark') {
icon.className = 'fas fa-sun';
themeToggle.setAttribute('aria-label', 'Переключить на светлую тему');
} else {
icon.className = 'fas fa-moon';
themeToggle.setAttribute('aria-label', 'Переключить на темную тему');
}
}
}
// Navbar scroll behavior
const navbar = document.querySelector('.navbar-modern');
if (navbar) {

View File

@@ -281,28 +281,6 @@ p {
animation: float 3s ease-in-out infinite;
}
/* Dark mode toggle */
.theme-toggle {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--gradient-primary);
border: none;
color: white;
font-size: 1.25rem;
cursor: pointer;
box-shadow: var(--shadow);
transition: all 0.3s ease;
z-index: 1000;
}
.theme-toggle:hover {
transform: scale(1.1);
}
/* Responsive Design */
@media (max-width: 768px) {
h1 {
@@ -363,6 +341,15 @@ p {
}
/* Loading Animation */
#loading-screen {
transition: opacity 0.3s ease-out;
}
#loading-screen.hidden {
opacity: 0 !important;
pointer-events: none !important;
}
.loading-spinner {
width: 40px;
height: 40px;
@@ -375,4 +362,764 @@ p {
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Success Checkmark Animation */
.success-checkmark {
width: 80px;
height: 80px;
border-radius: 50%;
display: block;
stroke-width: 2;
stroke: #4CAF50;
stroke-miterlimit: 10;
margin: 10px auto;
position: relative;
}
.success-checkmark.animate .icon-circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 2;
stroke-miterlimit: 10;
stroke: #4CAF50;
fill: none;
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.success-checkmark.animate .icon-line {
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards;
}
.success-checkmark.animate .icon-line.line-tip {
animation-delay: 1.1s;
}
.success-checkmark.animate .icon-line.line-long {
animation-delay: 1.2s;
}
.success-checkmark .icon-circle {
width: 80px;
height: 80px;
position: absolute;
border-radius: 50%;
border: 2px solid #4CAF50;
background-color: rgba(76, 175, 80, 0.1);
}
.success-checkmark .icon-line {
height: 2px;
background-color: #4CAF50;
display: block;
border-radius: 2px;
position: absolute;
z-index: 10;
}
.success-checkmark .icon-line.line-tip {
top: 46px;
left: 14px;
width: 25px;
transform: rotate(45deg);
animation: icon-line-tip 0.75s;
}
.success-checkmark .icon-line.line-long {
top: 38px;
right: 8px;
width: 47px;
transform: rotate(-45deg);
animation: icon-line-long 0.75s;
}
.success-checkmark .icon-fix {
top: 8px;
width: 5px;
left: 26px;
z-index: 1;
height: 85px;
position: absolute;
transform: rotate(-45deg);
background-color: #fff;
}
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
@keyframes icon-line-tip {
0% {
width: 0;
left: 1px;
top: 19px;
}
54% {
width: 0;
left: 1px;
top: 19px;
}
70% {
width: 50px;
left: -8px;
top: 37px;
}
84% {
width: 17px;
left: 21px;
top: 48px;
}
100% {
width: 25px;
left: 14px;
top: 45px;
}
}
@keyframes icon-line-long {
0% {
width: 0;
right: 46px;
top: 54px;
}
65% {
width: 0;
right: 46px;
top: 54px;
}
84% {
width: 55px;
right: 0px;
top: 35px;
}
100% {
width: 47px;
right: 8px;
top: 38px;
}
}
/* Video Support Styles */
.hero-video {
transition: opacity 0.3s ease;
}
.service-video {
transition: transform 0.3s ease;
}
.service-video:hover {
transform: scale(1.05);
}
.carousel-item {
transition: transform 0.6s ease-in-out;
}
.hero-bg {
position: relative;
overflow: hidden;
}
.hero-bg::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, rgba(99, 102, 241, 0.3) 0%, rgba(139, 92, 246, 0.3) 100%);
z-index: 1;
}
/* Video Loading States */
.video-loading {
background: linear-gradient(45deg, #f3f4f6, #e5e7eb);
background-size: 400% 400%;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Video Controls */
.video-controls {
position: absolute;
bottom: 20px;
right: 20px;
z-index: 2;
}
.video-play-btn {
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-color);
font-size: 18px;
transition: all 0.3s ease;
}
.video-play-btn:hover {
background: rgba(255, 255, 255, 1);
transform: scale(1.1);
}
/* Responsive Video */
@media (max-width: 768px) {
.hero-video,
.service-video {
height: 300px !important;
}
.carousel-item {
min-height: 400px;
}
}
/* Hero Container Styles */
.hero-modern {
padding: 0;
background: var(--bg-light);
}
.hero-container {
background: var(--gradient-primary);
border-radius: 24px;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(99, 102, 241, 0.25);
position: relative;
margin: 1.5rem auto;
max-width: 1200px;
}
.hero-bg {
position: relative;
min-height: 600px;
}
.hero-video,
.hero-bg img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.hero-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.8) 0%, rgba(139, 92, 246, 0.6) 100%);
z-index: 2;
}
.hero-content {
position: relative;
z-index: 3;
padding: 4rem 2rem;
display: flex;
align-items: center;
min-height: 600px;
}
/* Custom Carousel Indicators - Pill System */
.carousel-indicators-container {
position: absolute;
bottom: 2.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 4;
}
.outer-pill {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
border-radius: 50px;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
min-height: 50px;
transition: none;
overflow: visible;
}
/* Удаляем старую структуру inner-pill */
.pill-indicators {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: nowrap;
white-space: nowrap;
}
.pill-indicator {
height: 36px;
border-radius: 18px;
border: 2px solid rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.1);
position: relative;
z-index: 1;
transition: width 0.8s cubic-bezier(0.23, 1, 0.32, 1),
padding 0.8s cubic-bezier(0.23, 1, 0.32, 1),
border-color 0.3s ease;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
overflow: visible;
transform-origin: center;
flex-shrink: 0;
}
.pill-indicator.active {
border: 2px solid rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.1);
width: fit-content;
min-width: 36px;
padding: 0 1rem;
border-radius: 18px;
}
.pill-indicator:not(.active):hover {
border-color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.pill-indicator::before {
content: '';
width: 8px;
height: 8px;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
transition: opacity 0.4s cubic-bezier(0.23, 1, 0.32, 1),
transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
display: block;
flex-shrink: 0;
position: absolute;
transform-origin: center;
opacity: 1;
z-index: 1;
}
.pill-indicator.active::before {
opacity: 0;
transform: scale(0);
}
.pill-indicator:hover:not(.active)::before {
background: rgba(255, 255, 255, 1);
transform: scale(1.2);
}
.pill-indicator-title {
font-size: 0.875rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
white-space: nowrap;
opacity: 0;
transform: scale(0.8);
transition: opacity 0.6s cubic-bezier(0.23, 1, 0.32, 1) 0.2s,
transform 0.6s cubic-bezier(0.23, 1, 0.32, 1) 0.2s;
margin: 0;
position: relative;
transform-origin: center;
z-index: 2;
}
.pill-indicator.active .pill-indicator-title {
opacity: 1;
transform: scale(1);
}
/* Плавная анимация как вода */
.pill-indicator:not(.active):hover {
border-color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
transition: all 0.3s ease;
}
/* Новые стили для расширенной главной страницы */
.project-card {
transition: all 0.3s ease;
}
.project-card:hover {
transform: translateY(-10px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15) !important;
}
.project-image {
position: relative;
overflow: hidden;
height: 200px;
}
.project-image img {
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.project-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.project-card:hover .project-overlay {
opacity: 1;
}
.project-card:hover .project-image img {
transform: scale(1.1);
}
.blog-card {
transition: all 0.3s ease;
}
.blog-card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.1) !important;
}
.blog-image {
height: 200px;
overflow: hidden;
}
.blog-image img {
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.blog-card:hover .blog-image img {
transform: scale(1.05);
}
.news-card {
transition: all 0.3s ease;
}
.news-card:hover {
transform: translateY(-3px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1) !important;
}
.career-feature {
transition: all 0.3s ease;
}
.career-feature:hover {
transform: translateY(-5px);
}
.career-icon {
transition: all 0.3s ease;
}
.career-feature:hover .career-icon {
transform: scale(1.1);
}
.career-stats {
transition: all 0.3s ease;
}
.career-stats:hover {
transform: scale(1.05);
}
.hover-lift {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.hover-lift:hover {
transform: translateY(-8px);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15);
}
/* Carousel Controls */
.hero-container {
position: relative;
}
.carousel-control-prev,
.carousel-control-next {
position: absolute;
width: 50px;
height: 50px;
background: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
top: 50%;
transform: translateY(-50%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0.8;
z-index: 5;
}
.carousel-control-prev {
left: 1.5rem;
}
.carousel-control-next {
right: 1.5rem;
}
.carousel-control-prev:hover,
.carousel-control-next:hover {
background: rgba(0, 0, 0, 0.5);
transform: translateY(-50%) scale(1.1);
opacity: 1;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
/* Hero Text Styles */
.hero-title {
font-size: 3.5rem;
font-weight: 800;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
margin-bottom: 1.5rem;
line-height: 1.1;
}
.hero-subtitle {
font-size: 1.5rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 1.5rem;
}
.hero-description {
font-size: 1.25rem;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 2.5rem;
line-height: 1.6;
}
/* Responsive Design */
@media (max-width: 768px) {
.hero-container {
border-radius: 16px;
margin: 1rem;
}
.hero-content {
padding: 2rem 1.5rem;
min-height: 500px;
}
.hero-title {
font-size: 2.5rem;
}
.hero-subtitle {
font-size: 1.25rem;
}
.hero-description {
font-size: 1.125rem;
}
.carousel-indicators-container {
bottom: 8px;
}
.outer-pill {
gap: 0.5rem;
padding: 0.5rem;
}
.pill-indicators {
gap: 0.5rem;
}
.pill-indicator.active {
padding: 0 0.75rem;
font-size: 0.8rem;
}
.pill-indicator-title {
font-size: 0.8rem;
}
.pill-indicator {
width: 32px;
height: 32px;
}
.inner-pill {
height: 32px;
width: 32px;
}
.inner-pill.active {
min-width: 100px;
}
.pill-title {
font-size: 0.75rem;
}
}
/* Дополнительные эффекты для пилюль */
.inner-pill::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg,
transparent 0%,
rgba(99, 102, 241, 0.2) 50%,
transparent 100%);
border-radius: 25px;
opacity: 0;
animation: pillShimmer 3s infinite;
}
.outer-pill.expanded .inner-pill::before {
opacity: 1;
}
/* Эффект пульсации для внешней пилюли */
.outer-pill::after {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(45deg,
rgba(99, 102, 241, 0.3) 0%,
rgba(139, 92, 246, 0.3) 100%);
border-radius: 52px;
opacity: 0;
z-index: -1;
transition: opacity 0.3s ease;
}
.outer-pill.expanded::after {
opacity: 1;
animation: pillGlow 2s infinite alternate;
}
@keyframes pillShimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes pillGlow {
0% {
opacity: 0.3;
}
100% {
opacity: 0.7;
}
}
@keyframes shimmerSlide {
0% {
transform: translateX(-100%);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0;
}
}
/* Animations */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes ripple {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(2);
opacity: 0;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.8s ease-out;
}

View File

@@ -1,109 +1,45 @@
// Modern Scripts for SmartSolTech Website
document.addEventListener('DOMContentLoaded', function() {
console.log('SmartSolTech: DOM loaded, initializing...');
// Hide loading screen
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
console.log('SmartSolTech: Loading screen found, hiding...');
setTimeout(() => {
loadingScreen.style.opacity = '0';
loadingScreen.style.pointerEvents = 'none';
setTimeout(() => {
loadingScreen.style.display = 'none';
// Полностью удаляем элемент из DOM
if (loadingScreen.parentNode) {
loadingScreen.parentNode.removeChild(loadingScreen);
console.log('SmartSolTech: Loading screen completely removed from DOM');
}
}, 300);
}, 1000);
}
// Theme Toggle Functionality
const themeToggle = document.getElementById('theme-toggle');
const html = document.documentElement;
// Check for saved theme preference
const currentTheme = localStorage.getItem('theme') || 'light';
html.setAttribute('data-theme', currentTheme);
updateThemeIcon(currentTheme);
themeToggle.addEventListener('click', function() {
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
// Add animation
this.style.transform = 'scale(0.8)';
setTimeout(() => {
this.style.transform = 'scale(1)';
}, 150);
});
function updateThemeIcon(theme) {
const icon = themeToggle.querySelector('i');
if (theme === 'dark') {
icon.className = 'fas fa-sun';
themeToggle.setAttribute('aria-label', 'Переключить на светлую тему');
} else {
icon.className = 'fas fa-moon';
themeToggle.setAttribute('aria-label', 'Переключить на темную тему');
}
}, 200); // Уменьшили время ожидания до 200ms
} else {
console.log('SmartSolTech: Loading screen not found');
}
// Navbar scroll behavior
const navbar = document.querySelector('.navbar-modern');
let lastScrollTop = 0;
window.addEventListener('scroll', function() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
// Add/remove scrolled class
if (scrollTop > 50) {
navbar.classList.add('scrolled');
} else {
navbar.classList.remove('scrolled');
}
// Hide/show navbar on scroll
if (scrollTop > lastScrollTop && scrollTop > 100) {
navbar.style.transform = 'translateY(-100%)';
} else {
navbar.style.transform = 'translateY(0)';
}
lastScrollTop = scrollTop;
});
// Scroll to top button
const scrollToTopBtn = document.getElementById('scroll-to-top');
window.addEventListener('scroll', function() {
if (window.pageYOffset > 300) {
scrollToTopBtn.style.display = 'block';
scrollToTopBtn.style.opacity = '1';
} else {
scrollToTopBtn.style.opacity = '0';
setTimeout(() => {
if (window.pageYOffset <= 300) {
scrollToTopBtn.style.display = 'none';
}
}, 300);
}
});
scrollToTopBtn.addEventListener('click', function() {
window.scrollTo({
top: 0,
behavior: 'smooth'
if (navbar) {
window.addEventListener('scroll', function() {
if (window.scrollY > 50) {
navbar.classList.add('scrolled');
} else {
navbar.classList.remove('scrolled');
}
});
});
}
// Smooth scrolling for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function(e) {
e.preventDefault();
anchor.addEventListener('click', function (e) {
const target = document.querySelector(this.getAttribute('href'));
if (target) {
const offsetTop = target.offsetTop - 80; // Account for fixed navbar
window.scrollTo({
top: offsetTop,
e.preventDefault();
target.scrollIntoView({
behavior: 'smooth'
});
}
@@ -116,211 +52,19 @@ document.addEventListener('DOMContentLoaded', function() {
rootMargin: '0px 0px -50px 0px'
};
const observer = new IntersectionObserver(function(entries) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-fade-in-up');
// Add stagger delay for child elements
const children = entry.target.querySelectorAll('.service-card, .feature-list > *, .step-card');
children.forEach((child, index) => {
setTimeout(() => {
child.classList.add('animate-fade-in-up');
}, index * 100);
});
entry.target.style.opacity = '1';
entry.target.style.transform = 'translateY(0)';
}
});
}, observerOptions);
// Observe elements for animation
document.querySelectorAll('.service-card, .card-modern, .step-card').forEach(el => {
observer.observe(el);
// Observe cards and service items
document.querySelectorAll('.card-modern, .service-card, .step-card').forEach(card => {
observer.observe(card);
});
// Form enhancements
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', function(e) {
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
const originalContent = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Отправляем...';
submitBtn.disabled = true;
// Re-enable after 3 seconds (in case of slow response)
setTimeout(() => {
submitBtn.innerHTML = originalContent;
submitBtn.disabled = false;
}, 3000);
}
});
});
// Parallax effect for hero section
window.addEventListener('scroll', function() {
const scrolled = window.pageYOffset;
const parallaxElements = document.querySelectorAll('.animate-float');
parallaxElements.forEach(element => {
const speed = 0.5;
element.style.transform = `translateY(${scrolled * speed}px)`;
});
});
// Service cards hover effect
document.querySelectorAll('.service-card').forEach(card => {
card.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-10px) scale(1.02)';
});
card.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0) scale(1)';
});
});
// Card modern hover effects
document.querySelectorAll('.card-modern').forEach(card => {
card.addEventListener('mouseenter', function() {
this.style.boxShadow = '0 25px 50px -12px rgba(0, 0, 0, 0.25)';
});
card.addEventListener('mouseleave', function() {
this.style.boxShadow = 'var(--shadow)';
});
});
// Add loading animation to buttons
document.querySelectorAll('.btn-primary-modern, .btn-secondary-modern').forEach(btn => {
btn.addEventListener('click', function(e) {
// Create ripple effect
const ripple = document.createElement('span');
const rect = this.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = e.clientX - rect.left - size / 2;
const y = e.clientY - rect.top - size / 2;
ripple.style.cssText = `
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transform: scale(0);
animation: ripple 0.6s linear;
width: ${size}px;
height: ${size}px;
left: ${x}px;
top: ${y}px;
`;
this.style.position = 'relative';
this.style.overflow = 'hidden';
this.appendChild(ripple);
setTimeout(() => {
ripple.remove();
}, 600);
});
});
// Typing animation for hero text (optional)
const typingText = document.querySelector('.typing-text');
if (typingText) {
const text = typingText.textContent;
typingText.textContent = '';
let i = 0;
function typeWriter() {
if (i < text.length) {
typingText.textContent += text.charAt(i);
i++;
setTimeout(typeWriter, 100);
}
}
setTimeout(typeWriter, 1000);
}
// Mobile menu enhancements
const navbarToggler = document.querySelector('.navbar-toggler');
const navbarCollapse = document.querySelector('.navbar-collapse');
if (navbarToggler && navbarCollapse) {
navbarToggler.addEventListener('click', function() {
const isExpanded = this.getAttribute('aria-expanded') === 'true';
// Animate the toggler icon
this.style.transform = 'rotate(180deg)';
setTimeout(() => {
this.style.transform = 'rotate(0deg)';
}, 300);
});
// Close menu when clicking on a link
document.querySelectorAll('.navbar-nav .nav-link').forEach(link => {
link.addEventListener('click', () => {
const bsCollapse = new bootstrap.Collapse(navbarCollapse, {
hide: true
});
});
});
}
// Newsletter form
const newsletterForm = document.querySelector('footer form');
if (newsletterForm) {
newsletterForm.addEventListener('submit', function(e) {
e.preventDefault();
const email = this.querySelector('input[type="email"]').value;
if (email) {
// Show success message
const button = this.querySelector('button');
const originalContent = button.innerHTML;
button.innerHTML = '<i class="fas fa-check"></i>';
button.style.background = '#10b981';
setTimeout(() => {
button.innerHTML = originalContent;
button.style.background = '';
this.reset();
}, 2000);
}
});
}
});
// Add CSS for ripple animation
const style = document.createElement('style');
style.textContent = `
@keyframes ripple {
to {
transform: scale(2);
opacity: 0;
}
}
.animate-fade-in-up {
opacity: 1 !important;
transform: translateY(0) !important;
}
/* Smooth transitions */
.navbar-modern {
transition: transform 0.3s ease, background-color 0.3s ease;
}
.service-card, .card-modern {
opacity: 0;
transform: translateY(30px);
transition: all 0.6s ease;
}
.step-card {
opacity: 0;
transform: translateX(-30px);
transition: all 0.6s ease;
}
.step-card:nth-child(even) {
transform: translateX(30px);
}
`;
document.head.appendChild(style);
console.log('SmartSolTech: All scripts loaded successfully');
});

View File

@@ -3,12 +3,12 @@
"name": "Smartsoltech",
"icons": [
{
"src": "/static/img/photo_2024-10-06_10-06-08.jpg",
"src": "/static/img/logo.jpg",
"type": "image/jpeg",
"sizes": "1011x702"
},
{
"src": "/static/img/photo_2024-10-06_10-06-08.jpg",
"src": "/static/img/logo.jpg",
"type": "image/jpeg",
"sizes": "1011x702"
}

View File

@@ -1,5 +1,9 @@
from django.contrib import admin
from .models import Service, Project, Client, Order, Review, BlogPost, Category, ServiceRequest, HeroBanner, ContactInfo, Team, Career
from .models import (
Service, Project, Client, Order, Review, BlogPost, Category, ServiceRequest,
HeroBanner, ContactInfo, Team, Career,
ProjectMedia
)
from .forms import ProjectForm
@admin.register(ContactInfo)
@@ -30,20 +34,6 @@ class ServiceAdmin(admin.ModelAdmin):
has_video.boolean = True
has_video.short_description = 'Есть видео'
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
form = ProjectForm
list_display = ('name', 'client','service', 'status', 'order', 'has_video')
list_filter = ('name', 'client','service', 'status', 'order')
search_fields = ('name', 'client','service', 'status', 'order', 'client__first_name', 'client__last_name')
fields = ('name', 'description', 'completion_date', 'client', 'service', 'order',
'category', 'image', 'video', 'video_poster', 'status')
def has_video(self, obj):
return bool(obj.video)
has_video.boolean = True
has_video.short_description = 'Есть видео'
@admin.register(Client)
class ClientAdmin(admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'email', 'phone_number')
@@ -80,8 +70,28 @@ class BlogPostAdmin(admin.ModelAdmin):
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name','description')
search_fields = ('name',)
list_display = ('name', 'slug', 'order', 'is_active', 'services_count', 'projects_count')
list_filter = ('is_active',)
search_fields = ('name', 'description')
prepopulated_fields = {'slug': ('name',)}
list_editable = ('order', 'is_active')
ordering = ('order', 'name')
fieldsets = (
('Основная информация', {
'fields': ('name', 'slug', 'description', 'icon')
}),
('Настройки отображения', {
'fields': ('order', 'is_active')
}),
)
def services_count(self, obj):
return obj.services.count()
services_count.short_description = 'Услуг'
def projects_count(self, obj):
return obj.projects.count()
projects_count.short_description = 'Проектов'
@admin.register(ServiceRequest)
class ServiceRequestAdmin(admin.ModelAdmin):
@@ -179,4 +189,105 @@ class CareerAdmin(admin.ModelAdmin):
def mark_as_closed(self, request, queryset):
updated = queryset.update(status='closed')
self.message_user(request, f'{updated} вакансий закрыты.')
mark_as_closed.short_description = "Закрыть"
mark_as_closed.short_description = "Закрыть"
# ============================================
# ПРОЕКТЫ - АДМИНКИ
# ============================================
class ProjectMediaInline(admin.TabularInline):
"""Inline для медиа-файлов проекта"""
model = ProjectMedia
extra = 1
fields = ('media_type', 'image', 'video', 'video_poster', 'embed_code', 'caption', 'order')
ordering = ('order',)
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
"""Админка для проектов"""
list_display = ('name', 'status', 'is_featured', 'display_order', 'categories_display',
'views_count', 'likes_count', 'media_count', 'completion_date')
list_filter = ('status', 'is_featured', 'categories', 'completion_date')
search_fields = ('name', 'description', 'client__first_name', 'client__last_name', 'technologies')
filter_horizontal = ('categories',)
list_editable = ('is_featured', 'display_order', 'status')
ordering = ('-is_featured', '-display_order', '-completion_date')
date_hierarchy = 'completion_date'
inlines = [ProjectMediaInline]
fieldsets = (
('📋 Основная информация', {
'fields': ('name', 'categories', 'status', 'is_featured', 'display_order')
}),
('📝 Описание', {
'fields': ('short_description', 'description', 'image')
}),
('🏢 Детали проекта', {
'fields': ('client', 'service', 'order', 'category', 'project_url', 'github_url',
'technologies', 'duration', 'team_size', 'completion_date')
}),
('🎬 Видео', {
'fields': ('video', 'video_poster'),
'classes': ('collapse',)
}),
('🔍 SEO', {
'fields': ('meta_title', 'meta_description', 'meta_keywords'),
'classes': ('collapse',)
}),
('📊 Статистика', {
'fields': ('views_count', 'likes_count'),
'classes': ('collapse',)
}),
)
readonly_fields = ('views_count', 'likes_count')
def categories_display(self, obj):
return ', '.join([cat.name for cat in obj.categories.all()[:3]])
categories_display.short_description = 'Категории'
def media_count(self, obj):
return obj.media_files.count()
media_count.short_description = 'Медиа'
actions = ['mark_as_completed', 'mark_as_featured']
def mark_as_completed(self, request, queryset):
updated = queryset.update(status='completed')
self.message_user(request, f'{updated} проектов отмечены как завершённые.')
mark_as_completed.short_description = "Отметить как завершённые"
def mark_as_featured(self, request, queryset):
updated = queryset.update(is_featured=True)
self.message_user(request, f'{updated} проектов отмечены как избранные.')
mark_as_featured.short_description = "Отметить как избранные"
@admin.register(ProjectMedia)
class ProjectMediaAdmin(admin.ModelAdmin):
"""Админка для медиа-файлов проектов"""
list_display = ('id', 'project', 'media_type', 'caption', 'order', 'uploaded_at')
list_filter = ('media_type', 'uploaded_at')
search_fields = ('project__name', 'caption', 'alt_text')
list_editable = ('order',)
ordering = ('project', 'order', '-uploaded_at')
fieldsets = (
('Проект', {
'fields': ('project', 'media_type', 'order')
}),
('Изображение', {
'fields': ('image', 'alt_text'),
'classes': ('collapse',)
}),
('Видео', {
'fields': ('video', 'video_poster', 'embed_code'),
'classes': ('collapse',)
}),
('Описание', {
'fields': ('caption',)
}),
)

View File

@@ -0,0 +1,116 @@
# Generated by Django 5.1.1 on 2025-11-25 23:21
import ckeditor_uploader.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('web', '0013_career_team'),
]
operations = [
migrations.CreateModel(
name='PortfolioCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Название')),
('slug', models.SlugField(max_length=100, unique=True, verbose_name='URL')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('icon', models.CharField(blank=True, help_text='Класс иконки (например: fa-code)', max_length=50, verbose_name='Иконка')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
],
options={
'verbose_name': 'Категория портфолио',
'verbose_name_plural': 'Категории портфолио',
'ordering': ['order', 'name'],
},
),
migrations.AlterField(
model_name='blogpost',
name='content',
field=ckeditor_uploader.fields.RichTextUploadingField(verbose_name='Содержание'),
),
migrations.CreateModel(
name='NewsPost',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200, verbose_name='Заголовок')),
('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL')),
('excerpt', models.TextField(max_length=300, verbose_name='Краткое описание')),
('content', ckeditor_uploader.fields.RichTextUploadingField(verbose_name='Содержание')),
('featured_image', models.ImageField(upload_to='news/', verbose_name='Главное изображение')),
('tags', models.CharField(blank=True, help_text='Разделите запятыми', max_length=200, verbose_name='Теги')),
('is_published', models.BooleanField(default=False, verbose_name='Опубликовано')),
('is_featured', models.BooleanField(default=False, verbose_name='Избранная новость')),
('views_count', models.PositiveIntegerField(default=0, verbose_name='Просмотры')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('published_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата публикации')),
('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='web.category', verbose_name='Категория')),
],
options={
'verbose_name': 'Новость',
'verbose_name_plural': 'Новости',
'ordering': ['-published_at', '-created_at'],
},
),
migrations.CreateModel(
name='PortfolioItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200, verbose_name='Название проекта')),
('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL')),
('short_description', models.TextField(max_length=300, verbose_name='Краткое описание')),
('description', ckeditor_uploader.fields.RichTextUploadingField(blank=True, verbose_name='Полное описание')),
('thumbnail', models.ImageField(upload_to='portfolio/thumbnails/', verbose_name='Превью изображение')),
('client', models.CharField(blank=True, max_length=200, verbose_name='Клиент')),
('project_url', models.URLField(blank=True, verbose_name='Ссылка на проект')),
('github_url', models.URLField(blank=True, verbose_name='GitHub репозиторий')),
('technologies', models.TextField(help_text='Разделите запятыми', verbose_name='Технологии')),
('duration', models.CharField(blank=True, max_length=100, verbose_name='Длительность')),
('team_size', models.PositiveIntegerField(blank=True, null=True, verbose_name='Размер команды')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('published', 'Опубликовано'), ('featured', 'Избранное')], default='draft', max_length=20, verbose_name='Статус')),
('completion_date', models.DateField(blank=True, null=True, verbose_name='Дата завершения')),
('meta_title', models.CharField(blank=True, max_length=200, verbose_name='SEO заголовок')),
('meta_description', models.TextField(blank=True, max_length=300, verbose_name='SEO описание')),
('views_count', models.PositiveIntegerField(default=0, verbose_name='Просмотры')),
('likes_count', models.PositiveIntegerField(default=0, verbose_name='Лайки')),
('is_featured', models.BooleanField(default=False, verbose_name='Избранный проект')),
('display_order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('published_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата публикации')),
('categories', models.ManyToManyField(related_name='portfolio_items', to='web.portfoliocategory', verbose_name='Категории')),
],
options={
'verbose_name': 'Проект портфолио',
'verbose_name_plural': 'Портфолио',
'ordering': ['-is_featured', '-display_order', '-published_at'],
},
),
migrations.CreateModel(
name='PortfolioMedia',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('media_type', models.CharField(choices=[('image', 'Изображение'), ('video', 'Видео'), ('embed', 'Встроенное видео (YouTube, Vimeo)')], default='image', max_length=10, verbose_name='Тип медиа')),
('image', models.ImageField(blank=True, null=True, upload_to='portfolio/gallery/', verbose_name='Изображение')),
('video', models.FileField(blank=True, null=True, upload_to='portfolio/videos/', verbose_name='Видео файл')),
('video_poster', models.ImageField(blank=True, null=True, upload_to='portfolio/posters/', verbose_name='Превью видео')),
('embed_url', models.URLField(blank=True, verbose_name='URL видео (YouTube, Vimeo)')),
('caption', models.CharField(blank=True, max_length=200, verbose_name='Подпись')),
('alt_text', models.CharField(blank=True, max_length=200, verbose_name='Alt текст')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('portfolio_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_files', to='web.portfolioitem', verbose_name='Проект')),
],
options={
'verbose_name': 'Медиа файл портфолио',
'verbose_name_plural': 'Медиа файлы портфолио',
'ordering': ['order', 'uploaded_at'],
},
),
]

View File

@@ -0,0 +1,217 @@
# Generated by Django 5.1.1 on 2025-11-26 00:02
import django.db.models.deletion
import tinymce.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('web', '0014_portfoliocategory_alter_blogpost_content_newspost_and_more'),
]
operations = [
migrations.CreateModel(
name='ProjectCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Название')),
('slug', models.SlugField(max_length=100, unique=True, verbose_name='URL')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('icon', models.CharField(blank=True, help_text='Класс иконки (например: fa-code)', max_length=50, verbose_name='Иконка')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
],
options={
'verbose_name': 'Категория проекта',
'verbose_name_plural': 'Категории проектов',
'ordering': ['order', 'name'],
},
),
migrations.RemoveField(
model_name='portfolioitem',
name='categories',
),
migrations.RemoveField(
model_name='portfoliomedia',
name='portfolio_item',
),
migrations.AlterModelOptions(
name='project',
options={'ordering': ['-is_featured', '-display_order', '-completion_date'], 'verbose_name': 'Проект', 'verbose_name_plural': 'Проекты'},
),
migrations.AddField(
model_name='project',
name='created_at',
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='project',
name='display_order',
field=models.PositiveIntegerField(default=0, verbose_name='Порядок отображения'),
),
migrations.AddField(
model_name='project',
name='duration',
field=models.CharField(blank=True, max_length=100, verbose_name='Длительность'),
),
migrations.AddField(
model_name='project',
name='github_url',
field=models.URLField(blank=True, verbose_name='GitHub репозиторий'),
),
migrations.AddField(
model_name='project',
name='is_featured',
field=models.BooleanField(default=False, verbose_name='Избранный проект'),
),
migrations.AddField(
model_name='project',
name='likes_count',
field=models.PositiveIntegerField(default=0, verbose_name='Количество лайков'),
),
migrations.AddField(
model_name='project',
name='meta_description',
field=models.TextField(blank=True, max_length=300, verbose_name='SEO описание'),
),
migrations.AddField(
model_name='project',
name='meta_keywords',
field=models.CharField(blank=True, max_length=200, verbose_name='Ключевые слова'),
),
migrations.AddField(
model_name='project',
name='meta_title',
field=models.CharField(blank=True, max_length=200, verbose_name='SEO заголовок'),
),
migrations.AddField(
model_name='project',
name='project_url',
field=models.URLField(blank=True, verbose_name='Ссылка на проект'),
),
migrations.AddField(
model_name='project',
name='short_description',
field=models.TextField(default='Описание проекта', max_length=300, verbose_name='Краткое описание'),
),
migrations.AddField(
model_name='project',
name='slug',
field=models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL'),
),
migrations.AddField(
model_name='project',
name='team_size',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Размер команды'),
),
migrations.AddField(
model_name='project',
name='technologies',
field=models.TextField(blank=True, help_text='Разделите запятыми', verbose_name='Технологии'),
),
migrations.AddField(
model_name='project',
name='thumbnail',
field=models.ImageField(blank=True, null=True, upload_to='static/img/project/thumbnails/', verbose_name='Миниатюра'),
),
migrations.AddField(
model_name='project',
name='updated_at',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='project',
name='views_count',
field=models.PositiveIntegerField(default=0, verbose_name='Количество просмотров'),
),
migrations.AlterField(
model_name='blogpost',
name='content',
field=tinymce.models.HTMLField(verbose_name='Содержание'),
),
migrations.AlterField(
model_name='project',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='web.category', verbose_name='Категория (старая)'),
),
migrations.AlterField(
model_name='project',
name='client',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='web.client', verbose_name='Клиент'),
),
migrations.AlterField(
model_name='project',
name='completion_date',
field=models.DateField(blank=True, null=True, verbose_name='Дата завершения'),
),
migrations.AlterField(
model_name='project',
name='description',
field=tinymce.models.HTMLField(verbose_name='Полное описание'),
),
migrations.AlterField(
model_name='project',
name='image',
field=models.ImageField(blank=True, null=True, upload_to='static/img/project/', verbose_name='Главное изображение'),
),
migrations.AlterField(
model_name='project',
name='name',
field=models.CharField(max_length=200, verbose_name='Название проекта'),
),
migrations.AlterField(
model_name='project',
name='order',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project', to='web.order', verbose_name='Заказ'),
),
migrations.AlterField(
model_name='project',
name='service',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='web.service', verbose_name='Услуга'),
),
migrations.AlterField(
model_name='project',
name='status',
field=models.CharField(choices=[('in_progress', 'В процессе'), ('completed', 'Завершен'), ('archived', 'В архиве')], default='in_progress', max_length=50, verbose_name='Статус'),
),
migrations.AddField(
model_name='project',
name='categories',
field=models.ManyToManyField(blank=True, related_name='projects', to='web.projectcategory', verbose_name='Категории'),
),
migrations.CreateModel(
name='ProjectMedia',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('media_type', models.CharField(choices=[('image', 'Изображение'), ('video', 'Видео'), ('embed_video', 'Встроенное видео (YouTube, Vimeo)')], default='image', max_length=20, verbose_name='Тип медиа')),
('image', models.ImageField(blank=True, null=True, upload_to='static/img/project/gallery/', verbose_name='Изображение')),
('video', models.FileField(blank=True, null=True, upload_to='static/video/project/gallery/', verbose_name='Видео файл')),
('video_poster', models.ImageField(blank=True, null=True, upload_to='static/img/project/gallery/posters/', verbose_name='Превью видео')),
('embed_code', models.TextField(blank=True, help_text='Вставьте iframe код от YouTube или Vimeo', verbose_name='Код встраивания (iframe)')),
('caption', models.CharField(blank=True, max_length=200, verbose_name='Подпись')),
('alt_text', models.CharField(blank=True, max_length=200, verbose_name='Alt текст')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_items', to='web.project', verbose_name='Проект')),
],
options={
'verbose_name': 'Медиа файл проекта',
'verbose_name_plural': 'Медиа файлы проектов',
'ordering': ['order', 'uploaded_at'],
},
),
migrations.DeleteModel(
name='NewsPost',
),
migrations.DeleteModel(
name='PortfolioCategory',
),
migrations.DeleteModel(
name='PortfolioItem',
),
migrations.DeleteModel(
name='PortfolioMedia',
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 5.1.1 on 2025-11-26 00:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('web', '0015_projectcategory_remove_portfolioitem_categories_and_more'),
]
operations = [
migrations.DeleteModel(
name='ProjectCategory',
),
migrations.AlterModelOptions(
name='category',
options={'ordering': ['order', 'name'], 'verbose_name': 'Категория', 'verbose_name_plural': 'Категории'},
),
migrations.AddField(
model_name='category',
name='icon',
field=models.CharField(blank=True, help_text='Класс FontAwesome (например: fa-code)', max_length=50, verbose_name='Иконка'),
),
migrations.AddField(
model_name='category',
name='is_active',
field=models.BooleanField(default=True, verbose_name='Активна'),
),
migrations.AddField(
model_name='category',
name='order',
field=models.PositiveIntegerField(default=0, verbose_name='Порядок'),
),
migrations.AddField(
model_name='category',
name='slug',
field=models.SlugField(blank=True, max_length=100, null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='project',
name='categories',
field=models.ManyToManyField(blank=True, related_name='projects', to='web.category', verbose_name='Категории'),
),
migrations.AlterField(
model_name='projectmedia',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_files', to='web.project', verbose_name='Проект'),
),
]

View File

@@ -1,5 +1,6 @@
from django.db import models
from django.contrib.auth.models import AbstractUser, User
from tinymce.models import HTMLField
import uuid
from django.urls import reverse
@@ -54,15 +55,25 @@ class HeroBanner(models.Model):
class Category(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=100, unique=True, blank=True, null=True, verbose_name="URL")
description = models.TextField(default='Описание категории')
icon = models.CharField(max_length=50, blank=True, verbose_name="Иконка", help_text="Класс FontAwesome (например: fa-code)")
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
is_active = models.BooleanField(default=True, verbose_name="Активна")
class Meta:
verbose_name = 'Категория'
verbose_name_plural = 'Категории'
ordering = ['name']
ordering = ['order', 'name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
from django.utils.text import slugify
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Service(models.Model):
name = models.CharField(max_length=200)
@@ -111,7 +122,7 @@ class Client(models.Model):
class BlogPost(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
content = HTMLField(verbose_name="Содержание")
published_date = models.DateTimeField(auto_now_add=True)
image = models.ImageField(upload_to='static/img/blog/', blank=True, null=True)
video = models.FileField(upload_to='static/video/blog/', blank=True, null=True, help_text='Видео файл для блог поста')
@@ -172,26 +183,199 @@ class Order(models.Model):
def get_absolute_url(self):
return reverse('order_detail', kwargs={'pk': self.pk})
# ПРОЕКТЫ
# ============================================
class Project(models.Model):
name = models.CharField(max_length=200)
description = models.TextField(default='Описание проекта')
completion_date = models.DateField(blank=True, null=True)
client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name='projects')
service = models.ForeignKey(Service, on_delete=models.CASCADE, related_name='projects')
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name='project', null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
image = models.ImageField(upload_to='static/img/project/', blank=True, null=True)
"""Расширенная модель проекта с множественными категориями и медиа"""
STATUS_CHOICES = [
('in_progress', 'В процессе'),
('completed', 'Завершен'),
('archived', 'В архиве'),
]
# Основная информация
name = models.CharField(max_length=200, verbose_name="Название проекта")
slug = models.SlugField(max_length=200, unique=True, verbose_name="URL", blank=True)
# Краткое описание для списка
short_description = models.TextField(max_length=300, verbose_name="Краткое описание", default='Описание проекта')
# Полное описание с WYSIWYG редактором
description = HTMLField(verbose_name="Полное описание")
# Связи с существующими моделями
client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name='projects', verbose_name="Клиент")
service = models.ForeignKey(Service, on_delete=models.CASCADE, related_name='projects', verbose_name="Услуга")
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name='project', null=True, blank=True, verbose_name="Заказ")
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Категория (старая)")
# Множественные категории проектов = категории услуг
categories = models.ManyToManyField(Category, related_name='projects', verbose_name="Категории", blank=True)
# Главное изображение (для обратной совместимости и превью)
image = models.ImageField(upload_to='static/img/project/', blank=True, null=True, verbose_name="Главное изображение")
thumbnail = models.ImageField(upload_to='static/img/project/thumbnails/', blank=True, null=True, verbose_name="Миниатюра")
# Видео (для обратной совместимости)
video = models.FileField(upload_to='static/video/project/', blank=True, null=True, help_text='Видео презентация проекта')
video_poster = models.ImageField(upload_to='static/img/project/posters/', blank=True, null=True, help_text='Превью изображение для видео проекта')
status = models.CharField(max_length=50, choices=[('in_progress', 'В процессе'), ('completed', 'Завершен')], default='in_progress')
# Дополнительная информация о проекте
project_url = models.URLField(blank=True, verbose_name="Ссылка на проект")
github_url = models.URLField(blank=True, verbose_name="GitHub репозиторий")
# Технологии и инструменты
technologies = models.TextField(blank=True, verbose_name="Технологии", help_text="Разделите запятыми")
# Метрики проекта
duration = models.CharField(max_length=100, blank=True, verbose_name="Длительность")
team_size = models.PositiveIntegerField(blank=True, null=True, verbose_name="Размер команды")
# Даты и статус
completion_date = models.DateField(blank=True, null=True, verbose_name="Дата завершения")
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='in_progress', verbose_name="Статус")
# Счетчики и метрики
views_count = models.PositiveIntegerField(default=0, verbose_name='Количество просмотров')
likes_count = models.PositiveIntegerField(default=0, verbose_name='Количество лайков')
# Настройки отображения
is_featured = models.BooleanField(default=False, verbose_name="Избранный проект")
display_order = models.PositiveIntegerField(default=0, verbose_name="Порядок отображения")
# SEO
meta_title = models.CharField(max_length=200, blank=True, verbose_name="SEO заголовок")
meta_description = models.TextField(max_length=300, blank=True, verbose_name="SEO описание")
meta_keywords = models.CharField(max_length=200, blank=True, verbose_name="Ключевые слова")
# Timestamps
created_at = models.DateTimeField(auto_now_add=True, null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True, null=True, blank=True)
class Meta:
verbose_name = 'Проект'
verbose_name_plural = 'Проекты'
ordering = ['-completion_date']
ordering = ['-is_featured', '-display_order', '-completion_date']
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('project_detail', kwargs={'pk': self.pk})
@property
def technologies_list(self):
"""Возвращает список технологий"""
if self.technologies:
return [tech.strip() for tech in self.technologies.split(',') if tech.strip()]
return []
def save(self, *args, **kwargs):
if not self.slug:
from django.utils.text import slugify
self.slug = slugify(self.name)
# Автоматически создаем thumbnail из главного изображения
if self.image and not self.thumbnail:
self.thumbnail = self.image
super().save(*args, **kwargs)
# Ресайз thumbnail после сохранения
if self.thumbnail:
self._resize_thumbnail()
def _resize_thumbnail(self):
"""Автоматический ресайз thumbnail до 600x400px"""
from PIL import Image
from io import BytesIO
from django.core.files.base import ContentFile
import os
if not self.thumbnail:
return
try:
# Открываем изображение
img = Image.open(self.thumbnail.path)
# Конвертируем в RGB если нужно
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGB')
# Целевой размер
target_width = 600
target_height = 400
# Вычисляем соотношение сторон
img_ratio = img.width / img.height
target_ratio = target_width / target_height
# Обрезаем изображение по центру
if img_ratio > target_ratio:
# Изображение шире, обрезаем по ширине
new_width = int(img.height * target_ratio)
left = (img.width - new_width) // 2
img = img.crop((left, 0, left + new_width, img.height))
else:
# Изображение выше, обрезаем по высоте
new_height = int(img.width / target_ratio)
top = (img.height - new_height) // 2
img = img.crop((0, top, img.width, top + new_height))
# Ресайз до целевого размера
img = img.resize((target_width, target_height), Image.Resampling.LANCZOS)
# Сохраняем оптимизированное изображение
img.save(self.thumbnail.path, quality=85, optimize=True)
except Exception as e:
print(f"Ошибка при ресайзе thumbnail для проекта {self.name}: {e}")
class ProjectMedia(models.Model):
"""Медиа-файлы для проектов (множественные фото и видео)"""
MEDIA_TYPE_CHOICES = [
('image', 'Изображение'),
('video', 'Видео'),
('embed_video', 'Встроенное видео (YouTube, Vimeo)'),
]
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='media_files', verbose_name="Проект")
media_type = models.CharField(max_length=20, choices=MEDIA_TYPE_CHOICES, default='image', verbose_name="Тип медиа")
# Для изображений
image = models.ImageField(upload_to='static/img/project/gallery/', blank=True, null=True, verbose_name="Изображение")
# Для видео
video = models.FileField(upload_to='static/video/project/gallery/', blank=True, null=True, verbose_name="Видео файл")
video_poster = models.ImageField(upload_to='static/img/project/gallery/posters/', blank=True, null=True, verbose_name="Превью видео")
# Для встроенных видео
embed_code = models.TextField(blank=True, verbose_name="Код встраивания (iframe)", help_text="Вставьте iframe код от YouTube или Vimeo")
# Описание и метаданные
caption = models.CharField(max_length=200, blank=True, verbose_name="Подпись")
alt_text = models.CharField(max_length=200, blank=True, verbose_name="Alt текст")
# Порядок отображения
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
# Timestamps
uploaded_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = 'Медиа файл проекта'
verbose_name_plural = 'Медиа файлы проектов'
ordering = ['order', 'uploaded_at']
def __str__(self):
return f"{self.get_media_type_display()} для {self.project.name}"
class Review(models.Model):
client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name='reviews')
@@ -382,4 +566,3 @@ class Career(models.Model):
if self.application_deadline and self.application_deadline < timezone.now().date():
return False
return True

View File

@@ -72,11 +72,6 @@
<!-- Footer -->
{% include 'web/footer_modern.html' %}
<!-- Theme Toggle Button -->
<button id="theme-toggle" class="theme-toggle" aria-label="Переключить тему">
<i class="fas fa-moon"></i>
</button>
<!-- Scroll to Top Button -->
<button id="scroll-to-top" class="position-fixed bottom-0 end-0 m-4 btn btn-primary-modern rounded-circle" style="width: 50px; height: 50px; display: none; z-index: 999;">
<i class="fas fa-arrow-up"></i>

View File

@@ -65,11 +65,6 @@
О нас
</a>
</li>
<li class="mb-2">
<a href="#" class="text-light opacity-75 text-decoration-none hover-primary">
Портфолио
</a>
</li>
<li class="mb-2">
<a href="#" class="text-light opacity-75 text-decoration-none hover-primary">
Команда

View File

@@ -65,11 +65,6 @@
О нас
</a>
</li>
<li class="mb-2">
<a href="#" class="text-light opacity-75 text-decoration-none hover-primary">
Портфолио
</a>
</li>
<li class="mb-2">
<a href="#" class="text-light opacity-75 text-decoration-none hover-primary">
Команда

View File

@@ -404,7 +404,7 @@
{% endif %}
<div class="text-center mt-5">
<a href="{% url 'portfolio' %}" class="btn btn-primary-modern btn-lg">
<a href="{% url 'projects_list' %}" class="btn btn-primary-modern btn-lg">
<i class="fas fa-th-large me-2"></i>
Смотреть все проекты
</a>

View File

@@ -25,6 +25,12 @@
<i class="fas fa-cog me-2"></i>Услуги
</a>
</li>
<li class="nav-item">
<a class="nav-link-modern {% if 'project' in request.resolver_match.url_name %}active{% endif %}"
href="{% url 'projects_list' %}">
<i class="fas fa-briefcase me-2"></i>Проекты
</a>
</li>
<li class="nav-item">
<a class="nav-link-modern {% if request.resolver_match.url_name == 'about' %}active{% endif %}"
href="{% url 'about' %}">
@@ -40,7 +46,7 @@
<li class="nav-item">
<a class="nav-link-modern {% if request.resolver_match.url_name == 'career' %}active{% endif %}"
href="{% url 'career' %}">
<i class="fas fa-briefcase me-2"></i>Карьера
<i class="fas fa-user-tie me-2"></i>Карьера
</a>
</li>
<li class="nav-item">

View File

@@ -0,0 +1,305 @@
{% extends 'web/base_modern.html' %}
{% load static %}
{% block title %}{{ portfolio.title }} - Портфолио - SmartSolTech{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.11.3/css/lightbox.min.css">
<style>
.portfolio-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 100px 0 60px;
color: white;
}
.portfolio-gallery {
margin: 3rem 0;
}
.swiper {
width: 100%;
height: 500px;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.swiper-slide img {
width: 100%;
height: 100%;
object-fit: cover;
}
.portfolio-info {
background: white;
border-radius: 20px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
.info-item {
padding: 1rem 0;
border-bottom: 1px solid #eee;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-weight: 600;
color: #667eea;
margin-bottom: 0.5rem;
}
.portfolio-content {
background: white;
border-radius: 20px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
line-height: 1.8;
}
.portfolio-content img {
max-width: 100%;
height: auto;
border-radius: 10px;
margin: 1rem 0;
}
.tech-badge {
display: inline-block;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 20px;
margin: 0.25rem;
font-size: 0.9rem;
}
.similar-project {
border-radius: 15px;
overflow: hidden;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
background: white;
margin-bottom: 1.5rem;
}
.similar-project:hover {
transform: translateY(-5px);
}
.similar-thumb {
height: 200px;
overflow: hidden;
}
.similar-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.similar-content {
padding: 1.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="portfolio-header">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-8">
<h1 class="display-4 fw-bold mb-3">{{ portfolio.title }}</h1>
<p class="lead mb-3">{{ portfolio.short_description }}</p>
<div class="d-flex gap-3 flex-wrap">
{% for category in portfolio.categories.all %}
<span class="badge bg-light text-dark px-3 py-2">
<i class="{{ category.icon }} me-2"></i>{{ category.name }}
</span>
{% endfor %}
</div>
</div>
<div class="col-lg-4 text-lg-end mt-4 mt-lg-0">
<div class="d-flex justify-content-lg-end gap-3">
{% if portfolio.project_url %}
<a href="{{ portfolio.project_url }}" target="_blank" class="btn btn-light btn-lg">
<i class="fas fa-external-link-alt me-2"></i>Посетить сайт
</a>
{% endif %}
{% if portfolio.github_url %}
<a href="{{ portfolio.github_url }}" target="_blank" class="btn btn-outline-light btn-lg">
<i class="fab fa-github me-2"></i>GitHub
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="container py-5">
<div class="row">
<div class="col-lg-8">
{% if media_items %}
<div class="portfolio-gallery">
<div class="swiper portfolioSwiper">
<div class="swiper-wrapper">
{% for media in media_items %}
{% if media.media_type == 'image' %}
<div class="swiper-slide">
<a href="{{ media.image.url }}" data-lightbox="portfolio-{{ portfolio.id }}" data-title="{{ media.caption }}">
<img src="{{ media.image.url }}" alt="{{ media.alt_text }}">
</a>
</div>
{% elif media.media_type == 'video' %}
<div class="swiper-slide">
<video controls style="width:100%; height:100%; object-fit:cover;">
<source src="{{ media.video.url }}" type="video/mp4">
</video>
</div>
{% elif media.media_type == 'embed_video' %}
<div class="swiper-slide">
<div style="width:100%; height:100%; display:flex; align-items:center; justify-content:center;">
{{ media.embed_code|safe }}
</div>
</div>
{% endif %}
{% endfor %}
</div>
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
<div class="swiper-pagination"></div>
</div>
</div>
{% endif %}
<div class="portfolio-content">
<h2 class="mb-4">Описание проекта</h2>
{{ portfolio.description|safe }}
</div>
</div>
<div class="col-lg-4">
<div class="portfolio-info">
<h3 class="mb-4">Информация о проекте</h3>
{% if portfolio.client_name %}
<div class="info-item">
<div class="info-label">
<i class="fas fa-user me-2"></i>Клиент
</div>
<div>{{ portfolio.client_name }}</div>
</div>
{% endif %}
{% if portfolio.completion_date %}
<div class="info-item">
<div class="info-label">
<i class="fas fa-calendar me-2"></i>Дата завершения
</div>
<div>{{ portfolio.completion_date|date:"d.m.Y" }}</div>
</div>
{% endif %}
{% if portfolio.duration %}
<div class="info-item">
<div class="info-label">
<i class="fas fa-clock me-2"></i>Длительность
</div>
<div>{{ portfolio.duration }}</div>
</div>
{% endif %}
{% if portfolio.team_size %}
<div class="info-item">
<div class="info-label">
<i class="fas fa-users me-2"></i>Размер команды
</div>
<div>{{ portfolio.team_size }} человек</div>
</div>
{% endif %}
<div class="info-item">
<div class="info-label">
<i class="fas fa-chart-line me-2"></i>Статистика
</div>
<div>
<i class="fas fa-eye me-1"></i> {{ portfolio.views_count }} просмотров<br>
<i class="fas fa-heart me-1"></i> {{ portfolio.likes_count }} лайков
</div>
</div>
</div>
{% if portfolio.technologies %}
<div class="portfolio-info">
<h3 class="mb-3">Технологии</h3>
<div>
{% for tech in portfolio.technologies.split %}
<span class="tech-badge">{{ tech }}</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% if similar_projects %}
<div class="mt-5">
<h2 class="mb-4">Похожие проекты</h2>
<div class="row">
{% for item in similar_projects %}
<div class="col-md-4">
<div class="similar-project">
<div class="similar-thumb">
{% if item.thumbnail %}
<img src="{{ item.thumbnail.url }}" alt="{{ item.title }}">
{% else %}
<img src="{% static 'img/default-portfolio.jpg' %}" alt="{{ item.title }}">
{% endif %}
</div>
<div class="similar-content">
<h4 class="h6 mb-2">{{ item.title }}</h4>
<p class="text-muted small mb-3">{{ item.short_description|truncatewords:10 }}</p>
<a href="{% url 'portfolio_detail' item.slug %}" class="btn btn-sm btn-primary">
Подробнее
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.11.3/js/lightbox.min.js"></script>
<script>
const swiper = new Swiper('.portfolioSwiper', {
loop: true,
navigation: {
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
},
pagination: {
el: '.swiper-pagination',
clickable: true,
},
autoplay: {
delay: 5000,
disableOnInteraction: false,
},
effect: 'fade',
fadeEffect: {
crossFade: true
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,211 @@
{% extends 'web/base_modern.html' %}
{% load static %}
{% block title %}Портфолио - SmartSolTech{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css">
<style>
.portfolio-hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 100px 0 60px;
color: white;
}
.category-filter {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin: 2rem 0;
justify-content: center;
}
.category-btn {
padding: 0.75rem 1.5rem;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 50px;
transition: all 0.3s ease;
font-weight: 600;
}
.category-btn:hover,
.category-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
}
.portfolio-card {
border-radius: 20px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
transition: all 0.3s ease;
background: white;
margin-bottom: 2rem;
}
.portfolio-card:hover {
transform: translateY(-10px);
box-shadow: 0 20px 50px rgba(0,0,0,0.2);
}
.portfolio-thumb {
height: 250px;
overflow: hidden;
position: relative;
}
.portfolio-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.portfolio-card:hover .portfolio-thumb img {
transform: scale(1.1);
}
.portfolio-content {
padding: 2rem;
}
.portfolio-categories {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.portfolio-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.portfolio-stats {
display: flex;
gap: 1.5rem;
color: #6c757d;
font-size: 0.9rem;
}
.featured-badge {
position: absolute;
top: 1rem;
right: 1rem;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: 600;
z-index: 10;
}
</style>
{% endblock %}
{% block content %}
<div class="portfolio-hero">
<div class="container text-center">
<h1 class="display-4 fw-bold mb-3">Наше Портфолио</h1>
<p class="lead">Проекты, которыми мы гордимся</p>
</div>
</div>
<div class="container py-5">
<div class="category-filter">
<a href="{% url 'portfolio_list' %}" class="category-btn {% if not request.GET.category %}active{% endif %}">
<i class="fas fa-th me-2"></i>Все проекты
</a>
{% for category in categories %}
<a href="?category={{ category.slug }}" class="category-btn {% if request.GET.category == category.slug %}active{% endif %}">
<i class="{{ category.icon }} me-2"></i>{{ category.name }}
</a>
{% endfor %}
</div>
{% if featured %}
<div class="mb-5">
<h2 class="mb-4"><i class="fas fa-star text-warning me-2"></i>Избранные проекты</h2>
<div class="row">
{% for item in featured %}
<div class="col-md-6 col-lg-4">
<div class="portfolio-card">
<div class="portfolio-thumb">
<span class="featured-badge"><i class="fas fa-star me-1"></i>Избранное</span>
{% if item.thumbnail %}
<img src="{{ item.thumbnail.url }}" alt="{{ item.title }}">
{% else %}
<img src="{% static 'img/default-portfolio.jpg' %}" alt="{{ item.title }}">
{% endif %}
</div>
<div class="portfolio-content">
<div class="portfolio-categories">
{% for cat in item.categories.all %}
<span class="portfolio-badge">{{ cat.name }}</span>
{% endfor %}
</div>
<h3 class="h5 mb-2">{{ item.title }}</h3>
<p class="text-muted mb-3">{{ item.short_description|truncatewords:20 }}</p>
<div class="portfolio-stats mb-3">
<span><i class="fas fa-eye me-1"></i>{{ item.views_count }}</span>
<span><i class="fas fa-heart me-1"></i>{{ item.likes_count }}</span>
<span><i class="fas fa-calendar me-1"></i>{{ item.completion_date|date:"Y" }}</span>
</div>
<a href="{% url 'portfolio_detail' item.slug %}" class="btn btn-primary">
Подробнее <i class="fas fa-arrow-right ms-2"></i>
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<h2 class="mb-4">Все проекты</h2>
<div class="row">
{% for item in portfolios %}
<div class="col-md-6 col-lg-4">
<div class="portfolio-card">
<div class="portfolio-thumb">
{% if item.thumbnail %}
<img src="{{ item.thumbnail.url }}" alt="{{ item.title }}">
{% else %}
<img src="{% static 'img/default-portfolio.jpg' %}" alt="{{ item.title }}">
{% endif %}
</div>
<div class="portfolio-content">
<div class="portfolio-categories">
{% for cat in item.categories.all %}
<span class="portfolio-badge">{{ cat.name }}</span>
{% endfor %}
</div>
<h3 class="h5 mb-2">{{ item.title }}</h3>
<p class="text-muted mb-3">{{ item.short_description|truncatewords:20 }}</p>
<div class="portfolio-stats mb-3">
<span><i class="fas fa-eye me-1"></i>{{ item.views_count }}</span>
<span><i class="fas fa-heart me-1"></i>{{ item.likes_count }}</span>
<span><i class="fas fa-calendar me-1"></i>{{ item.completion_date|date:"Y" }}</span>
</div>
<a href="{% url 'portfolio_detail' item.slug %}" class="btn btn-primary">
Подробнее <i class="fas fa-arrow-right ms-2"></i>
</a>
</div>
</div>
</div>
{% empty %}
<div class="col-12 text-center py-5">
<i class="fas fa-folder-open fa-4x text-muted mb-3"></i>
<h3 class="text-muted">Проектов пока нет</h3>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,444 @@
{% extends 'web/base_modern.html' %}
{% load static %}
{% block title %}{{ project.name }} - Проекты - SmartSolTech{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.11.3/css/lightbox.min.css">
<style>
.project-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 100px 0 60px;
color: white;
}
.project-gallery {
margin: 3rem 0;
}
.swiper {
width: 100%;
height: 500px;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.swiper-slide img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.swiper-slide video {
width: 100%;
height: 100%;
object-fit: cover;
}
.project-info {
background: white;
border-radius: 20px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
margin-bottom: 2rem;
position: sticky;
top: 20px;
}
.info-item {
padding: 1rem 0;
border-bottom: 1px solid #eee;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-weight: 600;
color: #667eea;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.project-content {
background: white;
border-radius: 20px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
line-height: 1.8;
}
.project-content img {
max-width: 100%;
height: auto;
border-radius: 10px;
margin: 1rem 0;
}
.tech-badge {
display: inline-block;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 20px;
margin: 0.25rem;
font-size: 0.9rem;
white-space: nowrap;
}
.similar-project {
border-radius: 15px;
overflow: hidden;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
background: white;
margin-bottom: 1.5rem;
}
.similar-project:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
}
.similar-thumb {
height: 200px;
overflow: hidden;
background: linear-gradient(135deg, #667eea20 0%, #764ba220 100%);
}
.similar-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
transition: transform 0.3s ease;
}
.similar-project:hover .similar-thumb img {
transform: scale(1.1);
}
.similar-content {
padding: 1.5rem;
}
.embed-responsive {
position: relative;
width: 100%;
height: 100%;
min-height: 400px;
}
.embed-responsive iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
/* Адаптивность */
@media (max-width: 992px) {
.project-info {
position: static;
margin-top: 2rem;
}
}
@media (max-width: 768px) {
.project-header {
padding: 80px 0 40px;
}
.project-header h1 {
font-size: 2rem;
}
.project-header .lead {
font-size: 1rem;
}
.swiper {
height: 300px;
}
.project-content,
.project-info {
padding: 1.5rem;
}
.similar-thumb {
height: 150px;
}
.embed-responsive {
min-height: 250px;
}
}
@media (max-width: 576px) {
.project-header {
padding: 60px 0 30px;
}
.project-header h1 {
font-size: 1.5rem;
}
.swiper {
height: 250px;
border-radius: 15px;
}
.tech-badge {
font-size: 0.8rem;
padding: 0.4rem 0.8rem;
}
.project-gallery {
margin: 2rem 0;
}
}
/* Swiper навигация для мобильных */
@media (max-width: 768px) {
.swiper-button-next,
.swiper-button-prev {
width: 30px;
height: 30px;
}
.swiper-button-next:after,
.swiper-button-prev:after {
font-size: 20px;
}
.swiper-pagination-bullet {
width: 8px;
height: 8px;
}
}
</style>
{% endblock %}
{% block content %}
<div class="project-header">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-8">
<h1 class="display-4 fw-bold mb-3">{{ project.name }}</h1>
<p class="lead mb-3">{{ project.short_description }}</p>
<div class="d-flex gap-3 flex-wrap">
{% for category in project.categories.all %}
<span class="badge bg-light text-dark px-3 py-2">
<i class="{{ category.icon }} me-2"></i>{{ category.name }}
</span>
{% endfor %}
</div>
</div>
<div class="col-lg-4 text-lg-end mt-4 mt-lg-0">
<div class="d-flex justify-content-lg-end gap-3 flex-wrap">
{% if project.project_url %}
<a href="{{ project.project_url }}" target="_blank" class="btn btn-light btn-lg">
<i class="fas fa-external-link-alt me-2"></i>Посетить сайт
</a>
{% endif %}
{% if project.github_url %}
<a href="{{ project.github_url }}" target="_blank" class="btn btn-outline-light btn-lg">
<i class="fab fa-github me-2"></i>GitHub
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="container py-5">
<div class="row">
<div class="col-lg-8">
{% if project.media_files.exists %}
<div class="project-gallery">
<div class="swiper projectSwiper">
<div class="swiper-wrapper">
{% for media in project.media_files.all %}
{% if media.media_type == 'image' %}
<div class="swiper-slide">
<a href="{{ media.image.url }}" data-lightbox="project-{{ project.id }}" data-title="{{ media.caption }}">
<img src="{{ media.image.url }}" alt="{{ media.alt_text|default:project.name }}">
</a>
</div>
{% elif media.media_type == 'video' %}
<div class="swiper-slide">
<video controls {% if media.video_poster %}poster="{{ media.video_poster.url }}"{% endif %}>
<source src="{{ media.video.url }}" type="video/mp4">
Ваш браузер не поддерживает видео.
</video>
</div>
{% elif media.media_type == 'embed' and media.embed_url %}
<div class="swiper-slide">
<div class="embed-responsive">
<iframe src="{{ media.embed_url }}" frameborder="0" allowfullscreen></iframe>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
<div class="swiper-pagination"></div>
</div>
</div>
{% endif %}
<div class="project-content">
<h2 class="mb-4">Описание проекта</h2>
{{ project.description|safe }}
</div>
</div>
<div class="col-lg-4">
<div class="project-info">
<h3 class="mb-4">Информация о проекте</h3>
{% if project.client %}
<div class="info-item">
<div class="info-label">
<i class="fas fa-user me-2"></i>Клиент
</div>
<div>{{ project.client.name }}</div>
</div>
{% endif %}
{% if project.completion_date %}
<div class="info-item">
<div class="info-label">
<i class="fas fa-calendar me-2"></i>Дата завершения
</div>
<div>{{ project.completion_date|date:"d.m.Y" }}</div>
</div>
{% endif %}
{% if project.duration %}
<div class="info-item">
<div class="info-label">
<i class="fas fa-clock me-2"></i>Длительность
</div>
<div>{{ project.duration }}</div>
</div>
{% endif %}
{% if project.team_size %}
<div class="info-item">
<div class="info-label">
<i class="fas fa-users me-2"></i>Размер команды
</div>
<div>{{ project.team_size }} человек</div>
</div>
{% endif %}
<div class="info-item">
<div class="info-label">
<i class="fas fa-chart-line me-2"></i>Статистика
</div>
<div>
<i class="fas fa-eye me-1"></i> {{ project.views_count }} просмотров<br>
<i class="fas fa-heart me-1"></i> {{ project.likes_count }} лайков
</div>
</div>
{% if project.status %}
<div class="info-item">
<div class="info-label">
<i class="fas fa-info-circle me-2"></i>Статус
</div>
<div>
{% if project.status == 'completed' %}
<span class="badge bg-success">Завершён</span>
{% elif project.status == 'in_progress' %}
<span class="badge bg-warning">В процессе</span>
{% else %}
<span class="badge bg-secondary">{{ project.get_status_display }}</span>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% if project.technologies %}
<div class="project-info">
<h3 class="mb-3">Технологии</h3>
<div>
{% for tech in project.technologies_list %}
<span class="tech-badge">{{ tech }}</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% if similar_projects %}
<div class="mt-5">
<h2 class="mb-4">Похожие проекты</h2>
<div class="row">
{% for item in similar_projects %}
<div class="col-md-4">
<div class="similar-project">
<div class="similar-thumb">
{% if item.thumbnail %}
<img src="{{ item.thumbnail.url }}" alt="{{ item.name }}">
{% elif item.image %}
<img src="{{ item.image.url }}" alt="{{ item.name }}">
{% else %}
<img src="{% static 'img/default-project.jpg' %}" alt="{{ item.name }}">
{% endif %}
</div>
<div class="similar-content">
<h4 class="h6 mb-2">{{ item.name }}</h4>
{% if item.short_description %}
<p class="text-muted small mb-3">{{ item.short_description|truncatewords:10 }}</p>
{% endif %}
<a href="{% url 'project_detail' item.pk %}" class="btn btn-sm btn-primary">
Подробнее
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.11.3/js/lightbox.min.js"></script>
<script>
const swiper = new Swiper('.projectSwiper', {
loop: true,
navigation: {
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
},
pagination: {
el: '.swiper-pagination',
clickable: true,
},
autoplay: {
delay: 5000,
disableOnInteraction: false,
},
effect: 'fade',
fadeEffect: {
crossFade: true
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,362 @@
{% extends 'web/base_modern.html' %}
{% load static %}
{% block title %}Наши проекты - SmartSolTech{% endblock %}
{% block extra_css %}
<style>
/* Projects Page v2.0 - Force Update */
.projects-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 100px 0 60px;
color: white;
margin-bottom: 3rem;
}
.category-filter {
margin-bottom: 3rem;
text-align: center;
}
.category-btn {
display: inline-block;
padding: 0.75rem 1.5rem;
margin: 0.5rem 0.25rem;
border-radius: 25px;
background: white;
border: 2px solid #e2e8f0;
color: #4a5568;
text-decoration: none;
transition: all 0.3s ease;
font-weight: 500;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.category-btn:hover {
border-color: #667eea;
color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.category-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: transparent;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.projects-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
margin-bottom: 3rem;
}
@media (max-width: 992px) {
.projects-grid {
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
}
@media (max-width: 576px) {
.projects-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
.project-card {
background: white;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
transition: box-shadow 0.3s ease !important;
margin-bottom: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.project-card:hover {
box-shadow: 0 15px 40px rgba(0,0,0,0.2) !important;
transform: none !important;
}
.project-thumbnail {
width: 100%;
height: 200px;
overflow: hidden;
position: relative;
background: linear-gradient(135deg, #667eea20 0%, #764ba220 100%);
}
.project-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1) !important;
will-change: transform;
}
.project-card:hover .project-thumbnail img {
transform: scale(1.15) translateY(-10px) !important;
}
.project-thumbnail::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(to top, rgba(0,0,0,0.3), transparent);
pointer-events: none;
}
.project-badge {
position: absolute;
top: 10px;
right: 10px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.35rem 0.75rem;
border-radius: 15px;
font-size: 0.75rem;
font-weight: 600;
z-index: 1;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.project-content {
padding: 1rem;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.project-title {
font-size: 1.1rem;
font-weight: 700;
color: #2d3748;
margin-bottom: 0.5rem;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.project-description {
color: #718096;
margin-bottom: 0.75rem;
flex-grow: 1;
line-height: 1.5;
font-size: 0.9rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.project-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.75rem;
border-top: 1px solid #e2e8f0;
margin-top: auto;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.85rem;
}
.project-stats {
display: flex;
gap: 0.75rem;
font-size: 0.85rem;
color: #718096;
}
.project-stats i {
color: #667eea;
}
.project-categories {
margin-bottom: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.category-tag {
display: inline-block;
padding: 0.2rem 0.6rem;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
color: #667eea;
border-radius: 12px;
font-size: 0.75rem;
font-size: 0.85rem;
white-space: nowrap;
}
.no-projects {
text-align: center;
padding: 4rem 2rem;
color: #718096;
}
.no-projects i {
font-size: 4rem;
color: #e2e8f0;
margin-bottom: 1rem;
}
/* Адаптивность */
@media (max-width: 768px) {
.projects-header {
padding: 80px 0 40px;
}
.projects-header h1 {
font-size: 2rem;
}
.projects-header .lead {
font-size: 1rem;
}
.project-thumbnail {
height: 180px;
}
.project-title {
font-size: 1rem;
}
.project-meta {
font-size: 0.8rem;
}
}
@media (max-width: 576px) {
.project-thumbnail {
height: 160px;
}
.category-btn {
font-size: 0.85rem;
padding: 0.6rem 1.2rem;
}
.project-content {
padding: 0.75rem;
}
}
/* Скелетон загрузка */
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
{% endblock %}
{% block content %}
<div class="projects-header">
<div class="container">
<div class="text-center">
<h1 class="display-4 fw-bold mb-3">Наши проекты</h1>
<p class="lead mb-0">Портфолио завершённых работ и успешных внедрений</p>
</div>
</div>
</div>
<div class="container">
{% if categories %}
<div class="category-filter">
<a href="{% url 'projects_list' %}" class="category-btn {% if not selected_category %}active{% endif %}">
<i class="fas fa-th me-2"></i>Все проекты
</a>
{% for category in categories %}
<a href="{% url 'projects_list' %}?category={{ category.id }}"
class="category-btn {% if selected_category == category.id|stringformat:'s' %}active{% endif %}">
<i class="{{ category.icon }} me-2"></i>{{ category.name }}
</a>
{% endfor %}
</div>
{% endif %}
{% if projects %}
<div class="projects-grid">
{% for project in projects %}
<a href="{% url 'project_detail' project.pk %}" class="text-decoration-none">
<div class="project-card">
<div class="project-thumbnail">
{% if project.thumbnail %}
<img src="{{ project.thumbnail.url }}" alt="{{ project.name }}" loading="lazy" decoding="async">
{% elif project.image %}
<img src="{{ project.image.url }}" alt="{{ project.name }}" loading="lazy" decoding="async">
{% else %}
<img src="{% static 'img/default-project.jpg' %}" alt="{{ project.name }}" loading="lazy" decoding="async">
{% endif %}
{% if project.is_featured %}
<div class="project-badge">
<i class="fas fa-star me-1"></i>Избранное
</div>
{% endif %}
</div>
<div class="project-content">
<h3 class="project-title">{{ project.name }}</h3>
{% if project.categories.exists %}
<div class="project-categories">
{% for category in project.categories.all %}
<span class="category-tag">
<i class="{{ category.icon }} me-1"></i>{{ category.name }}
</span>
{% endfor %}
</div>
{% endif %}
{% if project.short_description %}
<p class="project-description">{{ project.short_description|truncatewords:20 }}</p>
{% endif %}
<div class="project-meta">
<div class="project-stats">
<span><i class="fas fa-eye me-1"></i>{{ project.views_count }}</span>
<span><i class="fas fa-heart me-1"></i>{{ project.likes_count }}</span>
</div>
{% if project.completion_date %}
<small class="text-muted">{{ project.completion_date|date:"Y" }}</small>
{% endif %}
</div>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="no-projects">
<i class="fas fa-folder-open"></i>
<h3>Проектов не найдено</h3>
<p>В данной категории пока нет завершённых проектов</p>
<a href="{% url 'projects_list' %}" class="btn btn-primary-modern mt-3">
<i class="fas fa-th me-2"></i>Посмотреть все проекты
</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -224,32 +224,58 @@
<div class="row g-4">
{% for project in service.projects.all %}
<div class="col-lg-4 col-md-6">
<div class="card-modern h-100">
<div class="card-body">
<div class="mb-3">
<span class="badge bg-primary">{{ project.get_status_display }}</span>
<a href="{% url 'project_detail' project.pk %}" class="text-decoration-none">
<div class="card-modern h-100 hover-lift">
{% if project.thumbnail %}
<div style="height: 200px; overflow: hidden; border-radius: 15px 15px 0 0;">
<img src="{{ project.thumbnail.url }}" alt="{{ project.name }}"
style="width: 100%; height: 100%; object-fit: cover; object-position: center;"
loading="lazy" decoding="async">
</div>
<h5 class="mb-3">{{ project.name }}</h5>
<p class="text-muted mb-3">{{ project.description|truncatewords:15 }}</p>
<div class="mb-3">
<strong>Требования заказчика:</strong>
<p class="text-muted small">{{ project.order.message|truncatewords:10 }}</p>
{% elif project.image %}
<div style="height: 200px; overflow: hidden; border-radius: 15px 15px 0 0;">
<img src="{{ project.image.url }}" alt="{{ project.name }}"
style="width: 100%; height: 100%; object-fit: cover; object-position: center;"
loading="lazy" decoding="async">
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
<i class="fas fa-calendar-check me-1"></i>
{{ project.completion_date|date:"d.m.Y" }}
</small>
<div class="project-rating">
{% for i in "12345" %}
<i class="fas fa-star text-warning"></i>
{% endfor %}
<div class="card-body">
<div class="mb-3">
<span class="badge bg-primary">{{ project.get_status_display }}</span>
{% if project.media_files.count > 0 %}
<span class="badge bg-info ms-2">
<i class="fas fa-images me-1"></i>{{ project.media_files.count }} фото
</span>
{% endif %}
</div>
<h5 class="mb-3 text-dark">{{ project.name }}</h5>
{% if project.short_description %}
<p class="text-muted mb-3">{{ project.short_description|truncatewords:15 }}</p>
{% else %}
<p class="text-muted mb-3">{{ project.description|striptags|truncatewords:15 }}</p>
{% endif %}
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
<i class="fas fa-calendar-check me-1"></i>
{% if project.completion_date %}
{{ project.completion_date|date:"d.m.Y" }}
{% else %}
Не указано
{% endif %}
</small>
<div class="project-stats">
<small class="text-muted">
<i class="fas fa-eye me-1"></i>{{ project.views_count }}
<i class="fas fa-heart ms-2 me-1"></i>{{ project.likes_count }}
</small>
</div>
</div>
</div>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
@@ -510,6 +536,16 @@ document.addEventListener('DOMContentLoaded', function() {
text-align: center;
}
/* Hover lift effect for project cards */
.hover-lift {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-lift:hover {
transform: translateY(-10px);
box-shadow: 0 15px 40px rgba(0,0,0,0.2);
}
/* Success Animation Styles */
.success-checkmark {
width: 80px;

View File

@@ -8,10 +8,10 @@ urlpatterns = [
path('', views.home, name='home'),
path('service/<int:pk>/', views.service_detail, name='service_detail'),
path('project/<int:pk>/', views.project_detail, name='project_detail'),
path('projects/', views.projects_list, name='projects_list'),
path('client/<int:pk>/', views.client_detail, name='client_detail'),
path('blog/<int:pk>/', views.blog_post_detail, name='blog_post_detail'),
path('services/', views.services_view, name='services'),
path('portfolio/', views.portfolio_view, name='portfolio'),
path('blog/', views.blog_view, name='blog'),
path('news/', views.news_view, name='news'),
path('career/', views.career_view, name='career'),

View File

@@ -1,5 +1,9 @@
from django.shortcuts import render, get_object_or_404, redirect
from .models import Service, Project, Client, BlogPost, Review, Order, ServiceRequest, HeroBanner, Category, ContactInfo, Team, Career
from .models import (
Service, Project, Client, BlogPost, Review, Order, ServiceRequest,
HeroBanner, Category, ContactInfo, Team, Career,
ProjectMedia
)
from django.db.models import Avg
from comunication.models import TelegramSettings
import qrcode
@@ -75,7 +79,45 @@ def service_detail(request, pk):
def project_detail(request, pk):
project = get_object_or_404(Project, pk=pk)
return render(request, 'web/project_detail.html', {'project': project})
# Увеличиваем счётчик просмотров
project.views_count += 1
project.save(update_fields=['views_count'])
# Получаем похожие проекты (той же категории или сервиса)
similar_projects = Project.objects.filter(
status='completed'
).exclude(pk=project.pk)
if project.category:
similar_projects = similar_projects.filter(category=project.category)
elif project.service:
similar_projects = similar_projects.filter(service=project.service)
similar_projects = similar_projects[:3]
return render(request, 'web/project_detail.html', {
'project': project,
'similar_projects': similar_projects
})
def projects_list(request):
"""Список всех завершённых проектов"""
projects = Project.objects.filter(status='completed').order_by('-is_featured', '-display_order', '-completion_date')
# Фильтр по категории
category_id = request.GET.get('category')
if category_id:
projects = projects.filter(categories__id=category_id)
# Получаем все категории с проектами
categories = Category.objects.filter(projects__status='completed').distinct().order_by('order', 'name')
return render(request, 'web/projects_list.html', {
'projects': projects,
'categories': categories,
'selected_category': category_id
})
def client_detail(request, pk):
client = get_object_or_404(Client, pk=pk)
@@ -108,9 +150,6 @@ def about_view(request):
contact_info = ContactInfo.get_active()
return render(request, 'web/about.html', {'contact_info': contact_info})
def portfolio_view(request):
projects = Project.objects.all()
return render(request, 'web/portfolio.html', {'projects': projects})
def blog_view(request):
blog_posts = BlogPost.objects.all().order_by('-created_at')