Переделал отображение статей решений и другие мелкие правки

This commit is contained in:
NikDizell 2026-04-09 16:48:37 +03:00
parent cf0d48c800
commit df0e27696c
7 changed files with 422 additions and 70 deletions

View File

@ -22,6 +22,7 @@ class BaseContentAdmin(admin.ModelAdmin):
search_fields = ('title',) search_fields = ('title',)
date_hierarchy = 'time_create' date_hierarchy = 'time_create'
actions = ['make_published', 'make_unpublished'] actions = ['make_published', 'make_unpublished']
readonly_fields = ('time_create', 'time_update')
formfield_overrides = { formfield_overrides = {
models.TextField: {'widget': CKEditor5Widget(config_name='default')}, models.TextField: {'widget': CKEditor5Widget(config_name='default')},
@ -54,6 +55,10 @@ class HomeAdmin(BaseContentAdmin):
'fields': ('content', 'home_image'), 'fields': ('content', 'home_image'),
'description': 'Основной текст главной страницы.', 'description': 'Основной текст главной страницы.',
}), }),
('Служебное', {
'fields': ('time_create', 'time_update'),
'classes': ('collapse',),
}),
) )
@ -61,6 +66,7 @@ class HomeAdmin(BaseContentAdmin):
class SolutionAdmin(BaseContentAdmin): class SolutionAdmin(BaseContentAdmin):
# Поля: title, description, implementation, closing, time_create, time_update, is_published # Поля: title, description, implementation, closing, time_create, time_update, is_published
list_display = ('id', 'title', 'time_create', 'is_published') list_display = ('id', 'title', 'time_create', 'is_published')
list_display_links = ('id', 'title')
search_fields = ('title', 'description', 'implementation') search_fields = ('title', 'description', 'implementation')
fieldsets = ( fieldsets = (
@ -71,6 +77,10 @@ class SolutionAdmin(BaseContentAdmin):
'fields': ('description', 'implementation', 'closing'), 'fields': ('description', 'implementation', 'closing'),
'description': 'Описание, реализация и заключение по проекту.', 'description': 'Описание, реализация и заключение по проекту.',
}), }),
('Служебное', {
'fields': ('time_create', 'time_update'),
'classes': ('collapse',),
}),
) )
@ -88,13 +98,17 @@ class CompetenceAdmin(BaseContentAdmin):
'fields': ('content', 'photo'), 'fields': ('content', 'photo'),
'description': 'Описание компетенции и фото.', 'description': 'Описание компетенции и фото.',
}), }),
('Служебное', {
'fields': ('time_create', 'time_update'),
'classes': ('collapse',),
}),
) )
@admin.register(Recall) @admin.register(Recall)
class RecallAdmin(BaseContentAdmin): class RecallAdmin(BaseContentAdmin):
# Поля: title, content, scan, time_create, time_update, is_published # Поля: title, content, scan, time_create, time_update, is_published
list_display = ('id', 'title', 'time_create', 'scan', 'is_published') list_display = ('id', 'title', 'time_create', 'scan_preview', 'is_published')
search_fields = ('title', 'content') search_fields = ('title', 'content')
fieldsets = ( fieldsets = (
@ -102,10 +116,25 @@ class RecallAdmin(BaseContentAdmin):
'fields': ('title', 'is_published'), 'fields': ('title', 'is_published'),
}), }),
('Содержание', { ('Содержание', {
'fields': ('content', 'scan'), 'fields': ('content', 'scan', 'scan_preview'),
'description': 'Текст отзыва и скан документа.', 'description': 'Текст отзыва и скан документа.',
}), }),
('Служебное', {
'fields': ('time_create', 'time_update'),
'classes': ('collapse',),
}),
) )
readonly_fields = ('time_create', 'time_update', 'scan_preview')
def scan_preview(self, obj):
if obj.scan:
return format_html(
'<img src="{}" style="max-height:200px; max-width:400px; '
'border-radius:4px; border:1px solid #ddd;" />',
obj.scan.url
)
return ''
scan_preview.short_description = 'Превью скана'
# ===== CALLBACK ===== # ===== CALLBACK =====

View File

@ -1,3 +1,5 @@
import logging
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -7,11 +9,13 @@ from django.dispatch import receiver
from django.core.cache import cache from django.core.cache import cache
from .utils.email_notifications import send_callback_notification from .utils.email_notifications import send_callback_notification
logger = logging.getLogger(__name__)
class Recall(models.Model): class Recall(models.Model):
title = models.CharField(max_length=255, verbose_name='Организация') title = models.CharField(max_length=255, verbose_name='Организация')
content = models.TextField(blank=True, verbose_name='Отзыв') content = models.TextField(blank=True, verbose_name='Отзыв')
scan = models.ImageField(upload_to="scan/%Y/%m/%d/", verbose_name='Фото') scan = models.ImageField(upload_to="scan/%Y/%m/%d/", blank=True, null=True, verbose_name='Фото')
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания') time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения') time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
is_published = models.BooleanField(default=True, verbose_name='Опубликован') is_published = models.BooleanField(default=True, verbose_name='Опубликован')
@ -40,7 +44,7 @@ class Recall(models.Model):
class Competence(models.Model): class Competence(models.Model):
title = models.CharField(max_length=255, verbose_name='Программист') title = models.CharField(max_length=255, verbose_name='Программист')
content = models.TextField(blank=True, verbose_name='Компетенция') content = models.TextField(blank=True, verbose_name='Компетенция')
photo = models.ImageField(upload_to="photos/%Y/%m/%d/", verbose_name='Фото') photo = models.ImageField(upload_to="photos/%Y/%m/%d/", blank=True, null=True, verbose_name='Фото')
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания') time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения') time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
is_published = models.BooleanField(default=True, verbose_name='Опубликован') is_published = models.BooleanField(default=True, verbose_name='Опубликован')
@ -78,18 +82,17 @@ class Solution(models.Model):
ordering = ['time_create', 'title'] ordering = ['time_create', 'title']
def get_seo_title(self): def get_seo_title(self):
"""Генерирует SEO-заголовок для проекта"""
return f"Проект: {self.title} | Автоматизация 1С" return f"Проект: {self.title} | Автоматизация 1С"
def get_seo_description(self): def get_seo_description(self):
"""Генерирует SEO-описание для проекта""" # Используем closing если есть, иначе description
if self.description: source = self.closing or self.description
clean_desc = self.description[:160].replace('\n', ' ').strip() if source:
return f"Проект автоматизации: {self.title}. {clean_desc}..." clean = source[:160].replace('\n', ' ').strip()
return f"Проект автоматизации: {self.title}. {clean}..."
return f"Реализация проекта {self.title} - программист 1С Николай Сердюк" return f"Реализация проекта {self.title} - программист 1С Николай Сердюк"
def get_meta_keywords(self): def get_meta_keywords(self):
"""Автоматические ключевые слова для проекта"""
base_keywords = ["проект 1С", "автоматизация 1С", "внедрение 1С"] base_keywords = ["проект 1С", "автоматизация 1С", "внедрение 1С"]
title_words = self.title.lower().split() title_words = self.title.lower().split()
return base_keywords + title_words return base_keywords + title_words
@ -98,7 +101,7 @@ class Solution(models.Model):
class Home(models.Model): class Home(models.Model):
title = models.CharField(max_length=255, verbose_name='Наименование') title = models.CharField(max_length=255, verbose_name='Наименование')
content = models.TextField(blank=True, verbose_name='Статья') content = models.TextField(blank=True, verbose_name='Статья')
home_image = models.ImageField(upload_to="home_image/%Y/%m/%d/", verbose_name='Фото') home_image = models.ImageField(upload_to="home_image/%Y/%m/%d/", blank=True, null=True, verbose_name='Фото')
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания') time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения') time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
is_published = models.BooleanField(default=True, verbose_name='Опубликован') is_published = models.BooleanField(default=True, verbose_name='Опубликован')
@ -118,8 +121,8 @@ class Home(models.Model):
class CallbackRequest(models.Model): class CallbackRequest(models.Model):
name = models.CharField(max_length=100, verbose_name='Имя') name = models.CharField(max_length=100, verbose_name='Имя')
phone = models.CharField(max_length=20, verbose_name='Телефон') phone = models.CharField(max_length=20, verbose_name='Телефон')
email = models.EmailField(blank=True, null=True, verbose_name='Электронная почта') # Сделать необязательным email = models.EmailField(blank=True, null=True, verbose_name='Электронная почта')
question = models.TextField(blank=True, verbose_name='Ваш вопрос') # Сделать необязательным question = models.TextField(blank=True, verbose_name='Ваш вопрос')
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания') time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
is_processed = models.BooleanField(default=False, verbose_name='Обработано') is_processed = models.BooleanField(default=False, verbose_name='Обработано')
is_read = models.BooleanField(default=False, verbose_name='Прочитано') is_read = models.BooleanField(default=False, verbose_name='Прочитано')
@ -134,16 +137,18 @@ class CallbackRequest(models.Model):
ordering = ['-time_create'] ordering = ['-time_create']
# Сигнал для отправки уведомления при создании заявки
@receiver(post_save, sender=CallbackRequest) @receiver(post_save, sender=CallbackRequest)
def send_callback_email_notification(sender, instance, created, **kwargs): def send_callback_email_notification(sender, instance, created, **kwargs):
if created and not instance.notification_sent: if created and not instance.notification_sent:
# Отправляем email уведомление try:
success = send_callback_notification(instance) success = send_callback_notification(instance)
if success: if success:
instance.notification_sent = True sender.objects.filter(pk=instance.pk).update(notification_sent=True)
# Сохраняем без повторного вызова сигнала except Exception:
sender.objects.filter(pk=instance.pk).update(notification_sent=True) # Логируем ошибку, но не прерываем сохранение объекта
logger.exception(
'Не удалось отправить email-уведомление для заявки pk=%s', instance.pk
)
class PageView(models.Model): class PageView(models.Model):
@ -190,8 +195,7 @@ class Profile(models.Model):
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_or_save_user_profile(sender, instance, created, **kwargs): def create_or_save_user_profile(sender, instance, created, **kwargs):
# Получаем или создаём профиль, затем сохраняем if created:
if created:
Profile.objects.get_or_create(user=instance) Profile.objects.get_or_create(user=instance)
@ -200,5 +204,4 @@ def create_or_save_user_profile(sender, instance, created, **kwargs):
@receiver([post_save, post_delete], sender=Competence) @receiver([post_save, post_delete], sender=Competence)
@receiver([post_save, post_delete], sender=Recall) @receiver([post_save, post_delete], sender=Recall)
def clear_sitemap_cache(sender, **kwargs): def clear_sitemap_cache(sender, **kwargs):
"""Очищаем кэш sitemap при изменении контента""" cache.delete('sitemap_cache')
cache.delete('sitemap_cache')

View File

@ -1,10 +1,11 @@
import logging
from typing import Optional from typing import Optional
from django.http import HttpRequest from django.http import HttpRequest
from .models import PageView, Visitor from .models import PageView, Visitor
from django.utils import timezone from django.utils import timezone
from typing import Type logger = logging.getLogger(__name__)
from django.db.models import Model, QuerySet
def get_client_ip(request: HttpRequest) -> str: def get_client_ip(request: HttpRequest) -> str:
@ -22,13 +23,11 @@ def should_track_request(request: HttpRequest) -> bool:
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
path = request.path path = request.path
# Игнорируемые пути
ignored_paths = [ ignored_paths = [
'/static/', '/admin/', '/index.php', '/status.php', '/static/', '/admin/', '/index.php', '/status.php',
'/cron', '/remote.php', '/ocs', '/apps/', '/custom_apps/', '/cron', '/remote.php', '/ocs', '/apps/', '/custom_apps/',
] ]
# Игнорируемые IP (Docker сети)
docker_ips = [ docker_ips = [
'192.168.64.1', '192.168.65.1', '192.168.64.1', '192.168.65.1',
'172.17.0.1', '172.18.0.1', '172.19.0.1', '172.17.0.1', '172.18.0.1', '172.19.0.1',
@ -48,7 +47,6 @@ def track_page_view(request: HttpRequest) -> None:
return return
try: try:
# Сохраняем просмотр страницы
PageView.objects.create( PageView.objects.create(
url=request.path, url=request.path,
ip_address=get_client_ip(request), ip_address=get_client_ip(request),
@ -56,13 +54,12 @@ def track_page_view(request: HttpRequest) -> None:
referer=request.META.get('HTTP_REFERER', '')[:500], referer=request.META.get('HTTP_REFERER', '')[:500],
) )
# Обновляем статистику посетителя
ip = get_client_ip(request) ip = get_client_ip(request)
visitor, created = Visitor.objects.get_or_create( visitor, created = Visitor.objects.get_or_create(
ip_address=ip, ip_address=ip,
defaults={ defaults={
'first_visit': timezone.now(), 'first_visit': timezone.now(),
'last_visit': timezone.now() 'last_visit': timezone.now(),
} }
) )
@ -71,26 +68,14 @@ def track_page_view(request: HttpRequest) -> None:
visitor.visit_count += 1 visitor.visit_count += 1
visitor.save() visitor.save()
except Exception as e: except Exception:
# В продакшене лучше использовать логирование logger.exception('Ошибка при трекинге просмотра страницы: %s', request.path)
print(f"Error tracking page view: {e}")
def get_published_queryset(model_class, order_by: str = '-time_create'): def get_published_queryset(model_class, order_by: str = '-time_create'):
""" """
Возвращает QuerySet опубликованных записей для модели. Возвращает QuerySet опубликованных записей для модели.
Args:
model_class: Класс модели Django
order_by: Поле для сортировки
Returns:
QuerySet или пустой список, если поле is_published отсутствует
""" """
if hasattr(model_class, 'is_published'): if hasattr(model_class, 'is_published'):
return model_class.objects.filter(is_published=True).order_by(order_by) return model_class.objects.filter(is_published=True).order_by(order_by)
return model_class.objects.none() return model_class.objects.none()

View File

@ -0,0 +1,194 @@
/* ===== PROJECTS GRID ===== */
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
/* ===== PROJECT CARD ===== */
.project-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
}
.project-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--primary-light);
}
/* Оранжевая полоска сверху — единственный декоративный слой */
.project-card__accent {
height: 3px;
background: var(--gradient-primary);
flex-shrink: 0;
}
.project-card__body {
padding: 1.5rem 1.5rem 1rem;
flex: 1;
}
.project-card__title {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.75rem;
line-height: 1.4;
}
.project-card__desc {
font-size: 0.95rem;
color: var(--text-secondary);
line-height: 1.65;
margin: 0;
}
.project-card__footer {
padding: 1rem 1.5rem 1.25rem;
border-top: 1px solid var(--border-light);
display: flex;
align-items: center;
}
.project-card__link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--primary);
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
transition: gap 0.2s ease, color 0.2s ease;
}
.project-card__link:hover {
color: var(--primary-dark);
gap: 0.65rem;
}
/* ===== MOBILE ===== */
@media (max-width: 768px) {
.projects-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
/* На мобиле убираем hover-подъём — он не работает на touch */
.project-card:hover {
transform: none;
box-shadow: none;
}
/* Вместо этого — активное состояние при нажатии */
.project-card:active {
border-color: var(--primary);
}
.project-card__body {
padding: 1.25rem 1.25rem 0.75rem;
}
.project-card__footer {
padding: 0.75rem 1.25rem 1rem;
}
.project-card__title {
font-size: 1rem;
}
}
@media (max-width: 480px) {
.projects-grid {
gap: 0.75rem;
}
}
/* ===== СТИЛИ ДЛЯ СТРАНИЦЫ ДЕТАЛЬНОГО РЕШЕНИЯ ===== */
.solution-section {
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-light);
}
.solution-section:last-of-type {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.section-title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: var(--primary);
display: flex;
align-items: center;
gap: 0.5rem;
border-left: 5px solid var(--primary);
padding-left: 1.25rem;
}
.section-content {
font-size: 1.05rem;
line-height: 1.7;
color: var(--text-secondary);
}
/* Вложенные заголовки */
.section-content h2,
.section-content h3 {
margin-top: 1.5em;
margin-bottom: 0.75em;
color: var(--text-primary);
font-weight: 600;
}
.section-content h2 {
font-size: 1.4rem;
border-bottom: 1px dashed var(--border-light);
padding-bottom: 0.5rem;
}
.section-content h3 {
font-size: 1.2rem;
}
.section-content ul,
.section-content ol {
margin: 1rem 0 1rem 1.5rem;
}
.section-content li {
margin-bottom: 0.5rem;
}
/* Адаптация для мобильных */
@media (max-width: 768px) {
.solution-section {
margin-bottom: 2rem;
padding-bottom: 1rem;
}
.section-title {
font-size: 1.5rem;
padding-left: 1rem;
border-left-width: 4px;
}
.section-content {
font-size: 1rem;
}
}
@media (max-width: 480px) {
.section-title {
font-size: 1.35rem;
}
}

View File

@ -1,5 +1,25 @@
{% autoescape off %} {% for solution in posts %}
{% for post in posts %} <article class="project-card">
{% include 'programmer/includes/project_card.html' %} <div class="project-card__accent"></div>
{% endfor %}
{% endautoescape %} <div class="project-card__body">
<h2 class="card-title">
<a href="{{ solution.get_absolute_url }}">{{ solution.title }}</a>
</h2>
{% if solution.description %}
<p class="project-card__desc">{{ solution.description|striptags|truncatewords:25 }}</p>
{% endif %}
</div>
<div class="project-card__footer">
<a href="{{ solution.get_absolute_url }}" class="project-card__link">
Подробнее
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
</div>
</article>
{% endfor %}

View File

@ -1,29 +1,42 @@
{% extends 'programmer/base.html' %} {% extends 'programmer/base.html' %}
{% load django_bootstrap5 %}
{% load static %} {% load static %}
{% load seo_tags %} {% load seo_tags %}
{% block extra_css %} {% block extra_css %}
<link rel="stylesheet" href="{% static 'programmer/css/solution-accordion.css' %}"> <link rel="stylesheet" href="{% static 'programmer/css/solution-cards.css' %}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<article>
<h1>{{ solution.title }}</h1>
<p>{{ solution.description }}</p>
<section>{{ solution.implementation|safe }}</section>
<footer>{{ solution.closing|safe }}</footer>
</article>
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Проекты автоматизации 1С</h1> <h1 class="page-title">Проекты автоматизации 1С</h1>
<p class="page-subtitle">Реализованные решения и кейсы по автоматизации бизнес-процессов</p> <p class="page-subtitle">Реализованные решения и кейсы по автоматизации бизнес-процессов</p>
</div> </div>
<ul class="improved-list" id="projects-container"> <div class="projects-grid" id="projects-container">
{% include 'programmer/includes/project_cards.html' %} {% include 'programmer/includes/project_cards.html' %}
</ul> <!-- {% for solution in posts %}
<article class="modern-card">
<h2 class="card-title">
<a href="{{ solution.get_absolute_url }}">{{ solution.title }}</a>
</h2>
<p class="card-subtitle">
{{ solution.description|striptags|truncatewords:30 }}
</p>
<div class="meta" style="display: flex; justify-content: space-between; margin-top: 1rem;">
<span>📅 {{ solution.time_create|date:"d.m.Y" }}</span>
<a href="{{ solution.get_absolute_url }}" class="btn btn-outline" style="padding: 0.5rem 1rem;">
Подробнее →
</a>
</div>
</article>
{% empty %}
<div class="content-card text-center" style="grid-column: 1/-1;">
<h3>Примеры решений скоро появятся</h3>
<p>Мы готовим для вас интересные кейсы и решения</p>
</div>
{% endfor %} -->
</div>
{% if not posts %} {% if not posts %}
<div class="content-card text-center"> <div class="content-card text-center">
@ -32,15 +45,15 @@
</div> </div>
{% endif %} {% endif %}
<!-- Индикатор загрузки -->
{% if page_obj.has_next %} {% if page_obj.has_next %}
<div id="loading-spinner" style="display: none; text-align: center; margin: 20px 0;"> <div id="loading-spinner" style="display: none; text-align: center; margin: 2rem 0;">
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div> <div class="spinner-border text-primary" role="status">
<p>Загрузка проектов...</p> <span class="visually-hidden">Загрузка...</span>
</div> </div>
<p style="margin-top: 0.5rem; color: var(--text-secondary);">Загрузка проектов...</p>
</div>
{% endif %} {% endif %}
<!-- Данные пагинации -->
<script> <script>
window.currentPage = {{ page_obj.number }}; window.currentPage = {{ page_obj.number }};
window.totalPages = {{ paginator.num_pages }}; window.totalPages = {{ paginator.num_pages }};
@ -49,7 +62,6 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="{% static 'programmer/js/solution-accordion.js' %}"></script> <script src="{% static 'programmer/js/infinite_scroll.js' %}"></script>
<script src="{% static 'programmer/js/infinite_scroll.js' %}"></script> <script src="{% static 'programmer/js/floating-button.js' %}"></script>
<script src="{% static 'programmer/js/floating-button.js' %}"></script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,109 @@
{% extends 'programmer/base.html' %}
{% load static %}
{% load seo_tags %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'programmer/css/solution-cards.css' %}">
{% endblock %}
{% block content %}
<div class="container">
{# Хлебные крошки #}
{% if breadcrumbs %}
<nav class="breadcrumbs" aria-label="Навигация">
{% for crumb in breadcrumbs %}
{% if crumb.url_name %}
<a href="{% url crumb.url_name %}" class="breadcrumb-link">{{ crumb.title }}</a>
<span class="breadcrumb-separator">/</span>
{% else %}
<span class="breadcrumb-current">{{ crumb.title }}</span>
{% endif %}
{% endfor %}
</nav>
{% endif %}
{# Заголовок страницы #}
<div class="page-header">
<h1 class="page-title">{{ solution.title }}</h1>
<p class="page-subtitle">
Опубликовано: {{ solution.time_create|date:"d.m.Y" }}
</p>
</div>
{# Основное содержимое #}
<article class="content-card">
{# Секция: Описание задачи #}
<section class="solution-section">
<h2 class="section-title">📋 Описание задачи</h2>
<div class="section-content">
{{ solution.description|safe }}
</div>
</section>
{# Секция: Описание решения #}
<section class="solution-section">
<h2 class="section-title">🔧 Описание решения</h2>
<div class="section-content">
{{ solution.implementation|safe }}
</div>
</section>
{# Секция: Результат #}
<section class="solution-section">
<h2 class="section-title">✅ Результат</h2>
<div class="section-content">
{{ solution.closing|safe }}
</div>
</section>
{# Кнопка возврата #}
<div style="margin-top: 2rem; text-align: center;">
<a href="{% url 'solution' %}" class="btn btn-outline">
← Вернуться к списку проектов
</a>
</div>
</article>
</div>
{% endblock %}
{% block extra_js %}
<script>
function toggleAccordion(header) {
const content = header.nextElementSibling;
const icon = header.querySelector('.accordion-icon');
// Закрываем все другие открытые элементы в этом аккордеоне (опционально)
const accordion = header.closest('.solution-accordion');
if (accordion) {
accordion.querySelectorAll('.accordion-header').forEach(otherHeader => {
if (otherHeader !== header) {
const otherContent = otherHeader.nextElementSibling;
const otherIcon = otherHeader.querySelector('.accordion-icon');
otherContent.classList.remove('active');
otherHeader.classList.remove('active');
if (otherIcon) otherIcon.style.transform = 'rotate(0deg)';
}
});
}
// Переключаем текущий элемент
content.classList.toggle('active');
header.classList.toggle('active');
if (content.classList.contains('active')) {
icon.style.transform = 'rotate(180deg)';
} else {
icon.style.transform = 'rotate(0deg)';
}
}
// Инициализация: первый элемент открыт по умолчанию
document.addEventListener('DOMContentLoaded', function() {
const firstHeader = document.querySelector('.accordion-header');
if (firstHeader) {
toggleAccordion(firstHeader);
}
});
</script>
<script src="{% static 'programmer/js/floating-button.js' %}"></script>
{% endblock %}