From e7d6d5262deedaca431300a3cccc3b1d7caf6d3c Mon Sep 17 00:00:00 2001 From: "Andrew K. Choi" Date: Wed, 26 Nov 2025 09:44:14 +0900 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D1=81=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=BC=20=D1=80=D0=B5=D1=81=D0=B0=D0=B9=D0=B7?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=B8=D0=B7=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B8=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D0=BD=D1=8B=D0=BC=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удалена старая система портфолио (PortfolioCategory, PortfolioItem) - Расширена модель Project: slug, categories (M2M), thumbnail, media files, meta fields - Объединены категории: ProjectCategory удалена, используется общая Category - Автоматический ресайз thumbnail до 600x400px с умным кропом по центру - Создан /projects/ - страница списка проектов с фильтрацией по категориям - Создан /project// - детальная страница проекта с галереей Swiper - Адаптивный дизайн: 3 карточки в ряд (десктоп), 2 (планшет), 1 (мобильный) - Параллакс-эффект на изображениях при наведении - Lazy loading для оптимизации загрузки - Фильтры категорий в виде пилюль как на странице услуг - Компактные карточки с фиксированной шириной - Кликабельные проекты в service_detail с отображением всех медиа --- create_test_data.py | 45 + requirements.txt | 5 +- smartsoltech/smartsoltech/settings.py | 31 + smartsoltech/smartsoltech/urls.py | 8 + .../static/assets/css/modern-styles.css | 22 - .../static/assets/js/modern-scripts.js | 40 - .../staticfiles/assets/css/modern-styles.css | 791 +++++++++++++++++- .../staticfiles/assets/js/modern-scripts.js | 316 +------ smartsoltech/staticfiles/manifest.json | 4 +- smartsoltech/web/admin.py | 147 +++- ...lter_blogpost_content_newspost_and_more.py | 116 +++ ...emove_portfolioitem_categories_and_more.py | 217 +++++ ...ategory_alter_category_options_and_more.py | 51 ++ smartsoltech/web/models.py | 209 ++++- .../web/templates/web/base_modern.html | 5 - smartsoltech/web/templates/web/footer.html | 5 - .../web/templates/web/footer_modern.html | 5 - .../web/templates/web/home_modern.html | 2 +- .../web/templates/web/navbar_modern.html | 8 +- .../web/templates/web/portfolio_detail.html | 305 +++++++ .../web/templates/web/portfolio_list.html | 211 +++++ .../web/templates/web/project_detail.html | 444 ++++++++++ .../web/templates/web/projects_list.html | 362 ++++++++ .../web/templates/web/service_detail.html | 76 +- smartsoltech/web/urls.py | 2 +- smartsoltech/web/views.py | 49 +- 26 files changed, 3029 insertions(+), 447 deletions(-) create mode 100644 create_test_data.py create mode 100644 smartsoltech/web/migrations/0014_portfoliocategory_alter_blogpost_content_newspost_and_more.py create mode 100644 smartsoltech/web/migrations/0015_projectcategory_remove_portfolioitem_categories_and_more.py create mode 100644 smartsoltech/web/migrations/0016_delete_projectcategory_alter_category_options_and_more.py create mode 100644 smartsoltech/web/templates/web/portfolio_detail.html create mode 100644 smartsoltech/web/templates/web/portfolio_list.html create mode 100644 smartsoltech/web/templates/web/project_detail.html create mode 100644 smartsoltech/web/templates/web/projects_list.html diff --git a/create_test_data.py b/create_test_data.py new file mode 100644 index 0000000..cfdbdeb --- /dev/null +++ b/create_test_data.py @@ -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 = '

О проекте

Разработка корпоративного сайта с использованием Django и современного дизайна.

Особенности

' + 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()}") diff --git a/requirements.txt b/requirements.txt index d733b58..5bfe4b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +pytest-cov==4.1.0 +django-tinymce==4.1.0 +Pillow==10.4.0 +django-tinymce==4.1.0 \ No newline at end of file diff --git a/smartsoltech/smartsoltech/settings.py b/smartsoltech/smartsoltech/settings.py index bd22784..60854f6 100644 --- a/smartsoltech/smartsoltech/settings.py +++ b/smartsoltech/smartsoltech/settings.py @@ -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 diff --git a/smartsoltech/smartsoltech/urls.py b/smartsoltech/smartsoltech/urls.py index 5a2c006..111141b 100644 --- a/smartsoltech/smartsoltech/urls.py +++ b/smartsoltech/smartsoltech/urls.py @@ -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) diff --git a/smartsoltech/static/assets/css/modern-styles.css b/smartsoltech/static/assets/css/modern-styles.css index 802ed6a..3fd79c0 100644 --- a/smartsoltech/static/assets/css/modern-styles.css +++ b/smartsoltech/static/assets/css/modern-styles.css @@ -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 { diff --git a/smartsoltech/static/assets/js/modern-scripts.js b/smartsoltech/static/assets/js/modern-scripts.js index 4b299cf..de594f0 100644 --- a/smartsoltech/static/assets/js/modern-scripts.js +++ b/smartsoltech/static/assets/js/modern-scripts.js @@ -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) { diff --git a/smartsoltech/staticfiles/assets/css/modern-styles.css b/smartsoltech/staticfiles/assets/css/modern-styles.css index 37facb3..3fd79c0 100644 --- a/smartsoltech/staticfiles/assets/css/modern-styles.css +++ b/smartsoltech/staticfiles/assets/css/modern-styles.css @@ -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; } \ No newline at end of file diff --git a/smartsoltech/staticfiles/assets/js/modern-scripts.js b/smartsoltech/staticfiles/assets/js/modern-scripts.js index c88ec49..de594f0 100644 --- a/smartsoltech/staticfiles/assets/js/modern-scripts.js +++ b/smartsoltech/staticfiles/assets/js/modern-scripts.js @@ -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 = 'Отправляем...'; - 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 = ''; - 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); \ No newline at end of file + console.log('SmartSolTech: All scripts loaded successfully'); +}); \ No newline at end of file diff --git a/smartsoltech/staticfiles/manifest.json b/smartsoltech/staticfiles/manifest.json index 05e032a..cd6690b 100644 --- a/smartsoltech/staticfiles/manifest.json +++ b/smartsoltech/staticfiles/manifest.json @@ -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" } diff --git a/smartsoltech/web/admin.py b/smartsoltech/web/admin.py index b023730..aa064ed 100644 --- a/smartsoltech/web/admin.py +++ b/smartsoltech/web/admin.py @@ -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 = "Закрыть" \ No newline at end of file + 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',) + }), + ) diff --git a/smartsoltech/web/migrations/0014_portfoliocategory_alter_blogpost_content_newspost_and_more.py b/smartsoltech/web/migrations/0014_portfoliocategory_alter_blogpost_content_newspost_and_more.py new file mode 100644 index 0000000..04496ab --- /dev/null +++ b/smartsoltech/web/migrations/0014_portfoliocategory_alter_blogpost_content_newspost_and_more.py @@ -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'], + }, + ), + ] diff --git a/smartsoltech/web/migrations/0015_projectcategory_remove_portfolioitem_categories_and_more.py b/smartsoltech/web/migrations/0015_projectcategory_remove_portfolioitem_categories_and_more.py new file mode 100644 index 0000000..5e1f954 --- /dev/null +++ b/smartsoltech/web/migrations/0015_projectcategory_remove_portfolioitem_categories_and_more.py @@ -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', + ), + ] diff --git a/smartsoltech/web/migrations/0016_delete_projectcategory_alter_category_options_and_more.py b/smartsoltech/web/migrations/0016_delete_projectcategory_alter_category_options_and_more.py new file mode 100644 index 0000000..dc91134 --- /dev/null +++ b/smartsoltech/web/migrations/0016_delete_projectcategory_alter_category_options_and_more.py @@ -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='Проект'), + ), + ] diff --git a/smartsoltech/web/models.py b/smartsoltech/web/models.py index e014d6c..4eb9237 100644 --- a/smartsoltech/web/models.py +++ b/smartsoltech/web/models.py @@ -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 - diff --git a/smartsoltech/web/templates/web/base_modern.html b/smartsoltech/web/templates/web/base_modern.html index aeed946..196f7bc 100644 --- a/smartsoltech/web/templates/web/base_modern.html +++ b/smartsoltech/web/templates/web/base_modern.html @@ -72,11 +72,6 @@ {% include 'web/footer_modern.html' %} - - -