From 0a2088e49313251e0f9641f1d06d9c52e67a672d Mon Sep 17 00:00:00 2001 From: NikDizell Date: Tue, 24 Feb 2026 21:30:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=B9,=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B9,=20=D0=BE=D1=82=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- OneCprogsite/settings.py | 34 +++++ OneCprogsite/urls.py | 3 +- blog/admin.py | 55 ++++++- blog/forms.py | 16 +- blog/models.py | 8 + blog/templates/blog/article_detail.html | 6 +- blog/templates/blog/article_list.html | 3 +- blog/templatetags/__init__.py | 0 blog/templatetags/blog_extras.py | 14 ++ blog/urls.py | 1 + blog/views.py | 22 +++ programmer/mixins.py | 2 +- .../static/blog/css/article_content.css | 139 ++++++++++++++++++ programmer/static/programmer/css/recall.css | 26 ++++ .../static/programmer/css/styles_dark.css | 28 +++- static/blog/css/article_content.css | 139 ++++++++++++++++++ 16 files changed, 484 insertions(+), 12 deletions(-) create mode 100644 blog/templatetags/__init__.py create mode 100644 blog/templatetags/blog_extras.py create mode 100644 programmer/static/blog/css/article_content.css create mode 100644 static/blog/css/article_content.css diff --git a/OneCprogsite/settings.py b/OneCprogsite/settings.py index e5b64b7..5acf9c9 100644 --- a/OneCprogsite/settings.py +++ b/OneCprogsite/settings.py @@ -89,6 +89,8 @@ INSTALLED_APPS = [ 'django.contrib.sites', 'django.contrib.sitemaps', 'blog.apps.BlogConfig', + 'taggit', + 'django_ckeditor_5', ] MIDDLEWARE = [ @@ -221,4 +223,36 @@ SERVER_EMAIL = EMAIL_HOST_USER # ADMIN_EMAILS = os.getenv('ADMIN_EMAILS', 'nikdizell@gmail.com').split(',') ADMIN_EMAILS = os.getenv('ADMIN_EMAILS').split(',') +# Путь для загрузки файлов редактором +CKEDITOR_5_UPLOAD_FILE_TYPES = ['jpeg', 'jpg', 'png', 'gif', 'bmp', 'webp'] # допустимые типы +CKEDITOR_5_UPLOAD_PATH = "uploads/ckeditor/" # папка внутри MEDIA_ROOT +CKEDITOR_5_CONFIGS = { + 'default': { + 'toolbar': { + 'items': [ + 'heading', '|', + 'bold', 'italic', 'underline', 'strikethrough', '|', + 'alignment', + 'link', 'bulletedList', 'numberedList', 'blockQuote', '|', + 'code', 'codeBlock', 'insertTable', '|', + 'imageUpload', 'mediaEmbed', '|', + 'undo', 'redo', + 'fontSize', 'fontFamily', 'highlight', 'horizontalLine', 'removeFormat', '|', + ] + }, + 'image': { + 'toolbar': ['imageTextAlternative', '|', 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight'], + 'styles': ['alignLeft', 'alignCenter', 'alignRight'], + }, + 'table': { + 'contentToolbar': ['tableColumn', 'tableRow', 'mergeTableCells'], + }, + 'mediaEmbed': { + 'previewsInData': True, # для встраивания видео (YouTube и др.) + }, + 'height': 500, + 'width': '100%', + } +} + diff --git a/OneCprogsite/urls.py b/OneCprogsite/urls.py index 2df9365..e5bf171 100644 --- a/OneCprogsite/urls.py +++ b/OneCprogsite/urls.py @@ -16,8 +16,6 @@ Including another URLconf """ from django.conf.urls.static import static from django.contrib import admin -from django.urls import path - from OneCprogsite import settings from programmer.views import * from django.urls import path, include @@ -26,6 +24,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('', include('programmer.urls')), path('blog/', include('blog.urls')), + path('ckeditor5/', include('django_ckeditor_5.urls')), # path('', index, name='home'), # path('about/', about, name='about'), # path('solution/', solution, name='solution'), diff --git a/blog/admin.py b/blog/admin.py index 0629a46..49570df 100644 --- a/blog/admin.py +++ b/blog/admin.py @@ -1,5 +1,10 @@ from django.contrib import admin from .models import Category, Article, Comment +from django.db import models +from django_ckeditor_5.widgets import CKEditor5Widget +from django.utils.html import format_html +from django.urls import reverse + @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): @@ -9,14 +14,47 @@ class CategoryAdmin(admin.ModelAdmin): @admin.register(Article) class ArticleAdmin(admin.ModelAdmin): - list_display = ('title', 'category', 'author', 'is_published', 'views_count', 'time_create') - list_filter = ('is_published', 'category', 'time_create') - search_fields = ('title', 'content') + formfield_overrides = { + models.TextField: {'widget': CKEditor5Widget(config_name='default')}, + } + list_display = ('title', 'category', 'author', 'is_published', 'views_count', 'time_create', 'tag_list') + list_filter = ('is_published', 'category', 'tags', 'time_create') + search_fields = ('title', 'content', 'tags__name') prepopulated_fields = {'slug': ('title',)} + autocomplete_fields = ('author',) # вместо raw_id_fields для удобства + # filter_horizontal = ('tags',) # для удобного выбора тегов raw_id_fields = ('author',) # удобно при большом количестве пользователей date_hierarchy = 'time_create' actions = ['make_published', 'make_unpublished'] + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'category', 'author', 'tags', 'is_published') + }), + ('Содержание', { + 'fields': ('content', 'image'), + 'description': 'Основной текст статьи. Используйте редактор для форматирования.' + }), + ('SEO', { + 'fields': ('meta_description',), + 'classes': ('collapse',), + }), + ('Служебное', { + 'fields': ('views_count',), + 'classes': ('collapse',), + }), + ) + + readonly_fields = ('views_count',) # только для просмотра + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('tags') + + def tag_list(self, obj): + return ", ".join(o.name for o in obj.tags.all()) + + tag_list.short_description = "Теги" + def make_published(self, request, queryset): queryset.update(is_published=True) make_published.short_description = "Опубликовать выбранные статьи" @@ -25,6 +63,17 @@ class ArticleAdmin(admin.ModelAdmin): queryset.update(is_published=False) make_unpublished.short_description = "Снять с публикации" + # Добавляем кнопку предпросмотра для черновиков + def view_on_site(self, obj): + if obj.is_published: + return obj.get_absolute_url() + else: + # ссылка на предпросмотр черновика + return reverse('blog:draft_preview', args=[obj.slug]) + + view_on_site.short_description = "Предпросмотр" + + @admin.register(Comment) class CommentAdmin(admin.ModelAdmin): list_display = ('author_name', 'article', 'is_moderated', 'time_create') diff --git a/blog/forms.py b/blog/forms.py index c50443c..f6b87b0 100644 --- a/blog/forms.py +++ b/blog/forms.py @@ -1,5 +1,8 @@ from django import forms from .models import Comment +from django.core.exceptions import ValidationError +from django.utils.html import strip_tags + class CommentForm(forms.ModelForm): class Meta: @@ -8,6 +11,17 @@ class CommentForm(forms.ModelForm): widgets = { 'author_name': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Ваше имя'}), 'author_email': forms.EmailInput(attrs={'class': 'form-input', 'placeholder': 'Ваш email'}), - 'content': forms.Textarea(attrs={'class': 'form-textarea', 'rows': 4, 'placeholder': 'Текст комментария'}), + 'content': forms.Textarea(attrs={'class': 'form-textarea', 'rows': 4, 'placeholder': 'Текст комментария', + 'autocomplete': 'off'}), } + def clean_content(self): + content = self.cleaned_data['content'] + # Удаляем HTML-теги и проверяем, остался ли текст + clean_text = strip_tags(content) + if not clean_text.strip(): + raise ValidationError("Комментарий не должен состоять только из HTML-тегов") + # Если хотите запретить любые теги, можно сравнить content и clean_text + if content != clean_text: + raise ValidationError("HTML-теги в комментариях запрещены") + return content diff --git a/blog/models.py b/blog/models.py index 1a2a4c0..c60bec1 100644 --- a/blog/models.py +++ b/blog/models.py @@ -2,6 +2,7 @@ from django.db import models from django.urls import reverse from django.utils import timezone from django.contrib.auth import get_user_model +from taggit.managers import TaggableManager User = get_user_model() @@ -20,6 +21,7 @@ class Category(models.Model): class Article(models.Model): """Статья блога""" + tags = TaggableManager(blank=True, verbose_name="Теги") title = models.CharField(max_length=200, verbose_name="Заголовок") slug = models.SlugField(max_length=220, unique=True, verbose_name="URL") category = models.ForeignKey( @@ -38,6 +40,12 @@ class Article(models.Model): is_published = models.BooleanField(default=True, verbose_name="Опубликовано") time_create = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") time_update = models.DateTimeField(auto_now=True, verbose_name="Дата изменения") + meta_description = models.CharField( + max_length=160, + blank=True, + verbose_name="Meta-описание", + help_text="Краткое описание для поисковых систем (до 160 символов)" + ) class Meta: verbose_name = "Статья" diff --git a/blog/templates/blog/article_detail.html b/blog/templates/blog/article_detail.html index 44ae7c3..6b7aa97 100644 --- a/blog/templates/blog/article_detail.html +++ b/blog/templates/blog/article_detail.html @@ -3,7 +3,7 @@ {% load django_bootstrap5 %} {% block extra_css %} - + {% endblock %} {% block content %} @@ -21,13 +21,13 @@ {{ article.title }} {% endif %}
- {{ article.content|linebreaks }} + {{ article.content|safe }}

Комментарии

-{% for comment in article.comments.all %} +{% for comment in moderated_comments %}

{{ comment.author_name }} ({{ comment.time_create|date:"d.m.Y H:i" }})

{{ comment.content|linebreaks }}

diff --git a/blog/templates/blog/article_list.html b/blog/templates/blog/article_list.html index 2b0336d..9abdfc9 100644 --- a/blog/templates/blog/article_list.html +++ b/blog/templates/blog/article_list.html @@ -1,5 +1,6 @@ {% extends 'programmer/base.html' %} {% load static %} +{% load blog_extras %} {% block extra_css %} @@ -23,7 +24,7 @@ {% for article in articles %}

{{ article.title }}

-

{{ article.content|truncatewords:30 }}

+

{{ article.content|clean_html|truncatewords:30 }}

📅 {{ article.time_create|date:"d.m.Y" }} 👁 {{ article.views_count }} diff --git a/blog/templatetags/__init__.py b/blog/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/templatetags/blog_extras.py b/blog/templatetags/blog_extras.py new file mode 100644 index 0000000..4dd5b47 --- /dev/null +++ b/blog/templatetags/blog_extras.py @@ -0,0 +1,14 @@ +from django import template +from django.utils.html import strip_tags +import html + + +register = template.Library() + +@register.filter +def clean_html(value): + """Удаляет HTML-теги и заменяет сущности на символы.""" + # Декодируем HTML-сущности (например,   → пробел) + value = html.unescape(value) + # Удаляем все HTML-теги + return strip_tags(value) diff --git a/blog/urls.py b/blog/urls.py index 036363f..2788181 100644 --- a/blog/urls.py +++ b/blog/urls.py @@ -7,5 +7,6 @@ urlpatterns = [ path('', views.ArticleListView.as_view(), name='article_list'), path('category//', views.ArticleListView.as_view(), name='category_detail'), path('/', views.ArticleDetailView.as_view(), name='article_detail'), + path('draft//', views.ArticleDraftPreviewView.as_view(), name='draft_preview'), ] diff --git a/blog/views.py b/blog/views.py index a1809a0..0d7b322 100644 --- a/blog/views.py +++ b/blog/views.py @@ -12,6 +12,7 @@ from .services import ( from .forms import CommentForm from django.http import Http404 from django.shortcuts import redirect +from django.contrib.admin.views.decorators import staff_member_required class ArticleListView(MenuContextMixin, ListView): @@ -66,6 +67,12 @@ class ArticleDetailView(MenuContextMixin, DetailView, CreateView): else: return self.form_invalid(form) + def get_initial(self): + """Возвращает пустые начальные данные для формы комментария.""" + initial = super().get_initial() + initial['content'] = '' + return initial + def form_valid(self, form): comment = add_comment_to_article( article=self.object, @@ -74,4 +81,19 @@ class ArticleDetailView(MenuContextMixin, DetailView, CreateView): messages.success(self.request, "Ваш комментарий отправлен на модерацию.") return redirect(self.object.get_absolute_url()) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Добавляем только одобренные комментарии + context['moderated_comments'] = self.object.comments.filter(is_moderated=True) + return context + +@method_decorator(staff_member_required, name='dispatch') +class ArticleDraftPreviewView(DetailView): + model = Article + template_name = 'blog/article_detail.html' # используем тот же шаблон + context_object_name = 'article' + + def get_queryset(self): + # Для предпросмотра показываем даже неопубликованные статьи + return Article.objects.all() # без фильтрации по is_published \ No newline at end of file diff --git a/programmer/mixins.py b/programmer/mixins.py index f955090..389a761 100644 --- a/programmer/mixins.py +++ b/programmer/mixins.py @@ -21,7 +21,7 @@ class MenuContextMixin(ContextMixin): menu = [ {'title': "Главная", 'url_name': 'home'}, {'title': "Проекты", 'url_name': 'solution'}, - # {'title': "Статьи", 'url_name': 'blog'}, + {'title': "Статьи", 'url_name': 'blog'}, {'title': "Отзывы", 'url_name': 'recall'}, {'title': "Обо мне", 'url_name': 'about'} ] diff --git a/programmer/static/blog/css/article_content.css b/programmer/static/blog/css/article_content.css new file mode 100644 index 0000000..7ddd9bf --- /dev/null +++ b/programmer/static/blog/css/article_content.css @@ -0,0 +1,139 @@ +/* Основная типографика */ +.article-content { + font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 800px; /* при необходимости */ + margin: 0 auto; +} + +.article-content h1, +.article-content h2, +.article-content h3, +.article-content h4, +.article-content h5, +.article-content h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; + line-height: 1.25; +} + +.article-content p { + margin-bottom: 1.2em; +} + +.article-content blockquote { + border-left: 4px solid #ddd; + padding-left: 1em; + margin-left: 0; + margin-right: 0; + font-style: italic; + color: #666; +} + +.article-content pre { + background: #f5f5f5; + padding: 1em; + border-radius: 4px; + overflow-x: auto; +} + +.article-content code { + background: #f0f0f0; + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; +} + +/* Таблицы */ +.article-content table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; +} + +.article-content th, +.article-content td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; +} + +.article-content th { + background-color: #f8f8f8; +} + +/* Изображения */ +.article-content figure.image { + display: inline-block; + margin: 1em 0; + max-width: 100%; +} + +.article-content figure.image img { + max-width: 100%; + height: auto; +} + +/* Выравнивание изображений (классы, используемые CKEditor 5) */ +.article-content .image-style-align-left { + float: left; + margin-right: 1.5em; + margin-bottom: 1em; + max-width: 50%; +} + +.article-content .image-style-align-right { + float: right; + margin-left: 1.5em; + margin-bottom: 1em; + max-width: 50%; +} + +.article-content .image-style-align-center { + display: block; + margin-left: auto; + margin-right: auto; + max-width: 100%; +} + +/* Подписи к изображениям (если используются) */ +.article-content figure.image figcaption { + text-align: center; + font-size: 0.9em; + color: #666; + margin-top: 0.3em; +} + +/* Встроенные медиа (видео) */ +.article-content .media { + position: relative; + width: 100%; + padding-bottom: 56.25%; /* 16:9 */ + height: 0; + margin: 1.5em 0; +} + +.article-content .media iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} + +/* Списки */ +.article-content ul, +.article-content ol { + padding-left: 2em; + margin-bottom: 1em; +} + +/* Горизонтальная линия */ +.article-content hr { + border: none; + border-top: 2px solid #eee; + margin: 2em 0; +} \ No newline at end of file diff --git a/programmer/static/programmer/css/recall.css b/programmer/static/programmer/css/recall.css index b6154a8..96698e8 100644 --- a/programmer/static/programmer/css/recall.css +++ b/programmer/static/programmer/css/recall.css @@ -160,6 +160,14 @@ /* Адаптивность для мобильных устройств */ @media (max-width: 768px) { + .content-card .image-style-align-left, + .content-card .image-style-align-right { + float: none !important; + margin-left: auto !important; + margin-right: auto !important; + max-width: 100% !important; + } + .recall-content { flex-direction: column; gap: 1.5rem; @@ -225,4 +233,22 @@ .scan-hint { background: linear-gradient(transparent, rgba(0,0,0,0.7)); } +} + +.content-card img { + max-width: 100% !important; + height: auto !important; +} + +.content-card figure.image { + max-width: 100%; + margin: 1rem auto; + text-align: center; +} + +.content-card figure.image img { + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; } \ No newline at end of file diff --git a/programmer/static/programmer/css/styles_dark.css b/programmer/static/programmer/css/styles_dark.css index b4b07c3..9af3699 100644 --- a/programmer/static/programmer/css/styles_dark.css +++ b/programmer/static/programmer/css/styles_dark.css @@ -867,7 +867,25 @@ body { border: 1px solid var(--border-light); margin-bottom: 2rem; position: relative; - overflow: hidden; + /* overflow: visible !important; */ +} + +.content-card img { + max-width: 100% !important; + height: auto !important; +} + +.content-card figure.image { + max-width: 100%; + margin: 1rem auto; + text-align: center; +} + +.content-card figure.image img { + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; } .content-card::before { @@ -1298,6 +1316,14 @@ body { .breadcrumbs { font-size: 0.8rem; } + + .content-card .image-style-align-left, + .content-card .image-style-align-right { + float: none !important; + margin-left: auto !important; + margin-right: auto !important; + max-width: 100% !important; + } } @media (max-width: 480px) { diff --git a/static/blog/css/article_content.css b/static/blog/css/article_content.css new file mode 100644 index 0000000..7ddd9bf --- /dev/null +++ b/static/blog/css/article_content.css @@ -0,0 +1,139 @@ +/* Основная типографика */ +.article-content { + font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 800px; /* при необходимости */ + margin: 0 auto; +} + +.article-content h1, +.article-content h2, +.article-content h3, +.article-content h4, +.article-content h5, +.article-content h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; + line-height: 1.25; +} + +.article-content p { + margin-bottom: 1.2em; +} + +.article-content blockquote { + border-left: 4px solid #ddd; + padding-left: 1em; + margin-left: 0; + margin-right: 0; + font-style: italic; + color: #666; +} + +.article-content pre { + background: #f5f5f5; + padding: 1em; + border-radius: 4px; + overflow-x: auto; +} + +.article-content code { + background: #f0f0f0; + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; +} + +/* Таблицы */ +.article-content table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; +} + +.article-content th, +.article-content td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; +} + +.article-content th { + background-color: #f8f8f8; +} + +/* Изображения */ +.article-content figure.image { + display: inline-block; + margin: 1em 0; + max-width: 100%; +} + +.article-content figure.image img { + max-width: 100%; + height: auto; +} + +/* Выравнивание изображений (классы, используемые CKEditor 5) */ +.article-content .image-style-align-left { + float: left; + margin-right: 1.5em; + margin-bottom: 1em; + max-width: 50%; +} + +.article-content .image-style-align-right { + float: right; + margin-left: 1.5em; + margin-bottom: 1em; + max-width: 50%; +} + +.article-content .image-style-align-center { + display: block; + margin-left: auto; + margin-right: auto; + max-width: 100%; +} + +/* Подписи к изображениям (если используются) */ +.article-content figure.image figcaption { + text-align: center; + font-size: 0.9em; + color: #666; + margin-top: 0.3em; +} + +/* Встроенные медиа (видео) */ +.article-content .media { + position: relative; + width: 100%; + padding-bottom: 56.25%; /* 16:9 */ + height: 0; + margin: 1.5em 0; +} + +.article-content .media iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} + +/* Списки */ +.article-content ul, +.article-content ol { + padding-left: 2em; + margin-bottom: 1em; +} + +/* Горизонтальная линия */ +.article-content hr { + border: none; + border-top: 2px solid #eee; + margin: 2em 0; +} \ No newline at end of file