Переделал отображение статей решений и другие мелкие правки
This commit is contained in:
parent
cf0d48c800
commit
df0e27696c
@ -22,6 +22,7 @@ class BaseContentAdmin(admin.ModelAdmin):
|
||||
search_fields = ('title',)
|
||||
date_hierarchy = 'time_create'
|
||||
actions = ['make_published', 'make_unpublished']
|
||||
readonly_fields = ('time_create', 'time_update')
|
||||
|
||||
formfield_overrides = {
|
||||
models.TextField: {'widget': CKEditor5Widget(config_name='default')},
|
||||
@ -54,6 +55,10 @@ class HomeAdmin(BaseContentAdmin):
|
||||
'fields': ('content', 'home_image'),
|
||||
'description': 'Основной текст главной страницы.',
|
||||
}),
|
||||
('Служебное', {
|
||||
'fields': ('time_create', 'time_update'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@ -61,6 +66,7 @@ class HomeAdmin(BaseContentAdmin):
|
||||
class SolutionAdmin(BaseContentAdmin):
|
||||
# Поля: title, description, implementation, closing, time_create, time_update, is_published
|
||||
list_display = ('id', 'title', 'time_create', 'is_published')
|
||||
list_display_links = ('id', 'title')
|
||||
search_fields = ('title', 'description', 'implementation')
|
||||
|
||||
fieldsets = (
|
||||
@ -71,6 +77,10 @@ class SolutionAdmin(BaseContentAdmin):
|
||||
'fields': ('description', 'implementation', 'closing'),
|
||||
'description': 'Описание, реализация и заключение по проекту.',
|
||||
}),
|
||||
('Служебное', {
|
||||
'fields': ('time_create', 'time_update'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@ -88,13 +98,17 @@ class CompetenceAdmin(BaseContentAdmin):
|
||||
'fields': ('content', 'photo'),
|
||||
'description': 'Описание компетенции и фото.',
|
||||
}),
|
||||
('Служебное', {
|
||||
'fields': ('time_create', 'time_update'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Recall)
|
||||
class RecallAdmin(BaseContentAdmin):
|
||||
# Поля: 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')
|
||||
|
||||
fieldsets = (
|
||||
@ -102,10 +116,25 @@ class RecallAdmin(BaseContentAdmin):
|
||||
'fields': ('title', 'is_published'),
|
||||
}),
|
||||
('Содержание', {
|
||||
'fields': ('content', 'scan'),
|
||||
'fields': ('content', 'scan', 'scan_preview'),
|
||||
'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 =====
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
@ -7,11 +9,13 @@ from django.dispatch import receiver
|
||||
from django.core.cache import cache
|
||||
from .utils.email_notifications import send_callback_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Recall(models.Model):
|
||||
title = models.CharField(max_length=255, 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_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
|
||||
is_published = models.BooleanField(default=True, verbose_name='Опубликован')
|
||||
@ -40,7 +44,7 @@ class Recall(models.Model):
|
||||
class Competence(models.Model):
|
||||
title = models.CharField(max_length=255, 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_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
|
||||
is_published = models.BooleanField(default=True, verbose_name='Опубликован')
|
||||
@ -78,18 +82,17 @@ class Solution(models.Model):
|
||||
ordering = ['time_create', 'title']
|
||||
|
||||
def get_seo_title(self):
|
||||
"""Генерирует SEO-заголовок для проекта"""
|
||||
return f"Проект: {self.title} | Автоматизация 1С"
|
||||
|
||||
def get_seo_description(self):
|
||||
"""Генерирует SEO-описание для проекта"""
|
||||
if self.description:
|
||||
clean_desc = self.description[:160].replace('\n', ' ').strip()
|
||||
return f"Проект автоматизации: {self.title}. {clean_desc}..."
|
||||
# Используем closing если есть, иначе description
|
||||
source = self.closing or self.description
|
||||
if source:
|
||||
clean = source[:160].replace('\n', ' ').strip()
|
||||
return f"Проект автоматизации: {self.title}. {clean}..."
|
||||
return f"Реализация проекта {self.title} - программист 1С Николай Сердюк"
|
||||
|
||||
def get_meta_keywords(self):
|
||||
"""Автоматические ключевые слова для проекта"""
|
||||
base_keywords = ["проект 1С", "автоматизация 1С", "внедрение 1С"]
|
||||
title_words = self.title.lower().split()
|
||||
return base_keywords + title_words
|
||||
@ -98,7 +101,7 @@ class Solution(models.Model):
|
||||
class Home(models.Model):
|
||||
title = models.CharField(max_length=255, 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_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
|
||||
is_published = models.BooleanField(default=True, verbose_name='Опубликован')
|
||||
@ -118,8 +121,8 @@ class Home(models.Model):
|
||||
class CallbackRequest(models.Model):
|
||||
name = models.CharField(max_length=100, verbose_name='Имя')
|
||||
phone = models.CharField(max_length=20, verbose_name='Телефон')
|
||||
email = models.EmailField(blank=True, null=True, verbose_name='Электронная почта') # Сделать необязательным
|
||||
question = models.TextField(blank=True, verbose_name='Ваш вопрос') # Сделать необязательным
|
||||
email = models.EmailField(blank=True, null=True, verbose_name='Электронная почта')
|
||||
question = models.TextField(blank=True, verbose_name='Ваш вопрос')
|
||||
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
|
||||
is_processed = 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']
|
||||
|
||||
|
||||
# Сигнал для отправки уведомления при создании заявки
|
||||
@receiver(post_save, sender=CallbackRequest)
|
||||
def send_callback_email_notification(sender, instance, created, **kwargs):
|
||||
if created and not instance.notification_sent:
|
||||
# Отправляем email уведомление
|
||||
try:
|
||||
success = send_callback_notification(instance)
|
||||
if success:
|
||||
instance.notification_sent = True
|
||||
# Сохраняем без повторного вызова сигнала
|
||||
sender.objects.filter(pk=instance.pk).update(notification_sent=True)
|
||||
except Exception:
|
||||
# Логируем ошибку, но не прерываем сохранение объекта
|
||||
logger.exception(
|
||||
'Не удалось отправить email-уведомление для заявки pk=%s', instance.pk
|
||||
)
|
||||
|
||||
|
||||
class PageView(models.Model):
|
||||
@ -190,7 +195,6 @@ class Profile(models.Model):
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_or_save_user_profile(sender, instance, created, **kwargs):
|
||||
# Получаем или создаём профиль, затем сохраняем
|
||||
if created:
|
||||
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=Recall)
|
||||
def clear_sitemap_cache(sender, **kwargs):
|
||||
"""Очищаем кэш sitemap при изменении контента"""
|
||||
cache.delete('sitemap_cache')
|
||||
@ -1,10 +1,11 @@
|
||||
import logging
|
||||
|
||||
from typing import Optional
|
||||
from django.http import HttpRequest
|
||||
from .models import PageView, Visitor
|
||||
from django.utils import timezone
|
||||
|
||||
from typing import Type
|
||||
from django.db.models import Model, QuerySet
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_client_ip(request: HttpRequest) -> str:
|
||||
@ -22,13 +23,11 @@ def should_track_request(request: HttpRequest) -> bool:
|
||||
client_ip = get_client_ip(request)
|
||||
path = request.path
|
||||
|
||||
# Игнорируемые пути
|
||||
ignored_paths = [
|
||||
'/static/', '/admin/', '/index.php', '/status.php',
|
||||
'/cron', '/remote.php', '/ocs', '/apps/', '/custom_apps/',
|
||||
]
|
||||
|
||||
# Игнорируемые IP (Docker сети)
|
||||
docker_ips = [
|
||||
'192.168.64.1', '192.168.65.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
|
||||
|
||||
try:
|
||||
# Сохраняем просмотр страницы
|
||||
PageView.objects.create(
|
||||
url=request.path,
|
||||
ip_address=get_client_ip(request),
|
||||
@ -56,13 +54,12 @@ def track_page_view(request: HttpRequest) -> None:
|
||||
referer=request.META.get('HTTP_REFERER', '')[:500],
|
||||
)
|
||||
|
||||
# Обновляем статистику посетителя
|
||||
ip = get_client_ip(request)
|
||||
visitor, created = Visitor.objects.get_or_create(
|
||||
ip_address=ip,
|
||||
defaults={
|
||||
'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.save()
|
||||
|
||||
except Exception as e:
|
||||
# В продакшене лучше использовать логирование
|
||||
print(f"Error tracking page view: {e}")
|
||||
except Exception:
|
||||
logger.exception('Ошибка при трекинге просмотра страницы: %s', request.path)
|
||||
|
||||
|
||||
def get_published_queryset(model_class, order_by: str = '-time_create'):
|
||||
"""
|
||||
Возвращает QuerySet опубликованных записей для модели.
|
||||
|
||||
Args:
|
||||
model_class: Класс модели Django
|
||||
order_by: Поле для сортировки
|
||||
|
||||
Returns:
|
||||
QuerySet или пустой список, если поле is_published отсутствует
|
||||
"""
|
||||
if hasattr(model_class, 'is_published'):
|
||||
return model_class.objects.filter(is_published=True).order_by(order_by)
|
||||
return model_class.objects.none()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
194
programmer/static/programmer/css/solution-cards.css
Normal file
194
programmer/static/programmer/css/solution-cards.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,25 @@
|
||||
{% autoescape off %}
|
||||
{% for post in posts %}
|
||||
{% include 'programmer/includes/project_card.html' %}
|
||||
{% for solution in posts %}
|
||||
<article class="project-card">
|
||||
<div class="project-card__accent"></div>
|
||||
|
||||
<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 %}
|
||||
{% endautoescape %}
|
||||
@ -1,29 +1,42 @@
|
||||
{% extends 'programmer/base.html' %}
|
||||
{% load django_bootstrap5 %}
|
||||
{% load static %}
|
||||
{% load seo_tags %}
|
||||
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'programmer/css/solution-accordion.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'programmer/css/solution-cards.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<h1 class="page-title">Проекты автоматизации 1С</h1>
|
||||
<p class="page-subtitle">Реализованные решения и кейсы по автоматизации бизнес-процессов</p>
|
||||
</div>
|
||||
|
||||
<ul class="improved-list" id="projects-container">
|
||||
<div class="projects-grid" id="projects-container">
|
||||
{% 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 %}
|
||||
<div class="content-card text-center">
|
||||
@ -32,15 +45,15 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Индикатор загрузки -->
|
||||
{% if page_obj.has_next %}
|
||||
<div id="loading-spinner" style="display: none; text-align: center; margin: 20px 0;">
|
||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div>
|
||||
<p>Загрузка проектов...</p>
|
||||
<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>
|
||||
<p style="margin-top: 0.5rem; color: var(--text-secondary);">Загрузка проектов...</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Данные пагинации -->
|
||||
<script>
|
||||
window.currentPage = {{ page_obj.number }};
|
||||
window.totalPages = {{ paginator.num_pages }};
|
||||
@ -49,7 +62,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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/floating-button.js' %}"></script>
|
||||
{% endblock %}
|
||||
109
programmer/templates/programmer/solution_detail.html
Normal file
109
programmer/templates/programmer/solution_detail.html
Normal 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 %}
|
||||
Loading…
x
Reference in New Issue
Block a user