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 @@
{% 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.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