Добавил редактор статей, исправил комментарий, отредактировал отображение статей

This commit is contained in:
NikDizell 2026-02-24 21:30:51 +03:00
parent 2ba5793115
commit 0a2088e493
16 changed files with 484 additions and 12 deletions

View File

@ -89,6 +89,8 @@ INSTALLED_APPS = [
'django.contrib.sites', 'django.contrib.sites',
'django.contrib.sitemaps', 'django.contrib.sitemaps',
'blog.apps.BlogConfig', 'blog.apps.BlogConfig',
'taggit',
'django_ckeditor_5',
] ]
MIDDLEWARE = [ 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', 'nikdizell@gmail.com').split(',')
ADMIN_EMAILS = os.getenv('ADMIN_EMAILS').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%',
}
}

View File

@ -16,8 +16,6 @@ Including another URLconf
""" """
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path
from OneCprogsite import settings from OneCprogsite import settings
from programmer.views import * from programmer.views import *
from django.urls import path, include from django.urls import path, include
@ -26,6 +24,7 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('', include('programmer.urls')), path('', include('programmer.urls')),
path('blog/', include('blog.urls')), path('blog/', include('blog.urls')),
path('ckeditor5/', include('django_ckeditor_5.urls')),
# path('', index, name='home'), # path('', index, name='home'),
# path('about/', about, name='about'), # path('about/', about, name='about'),
# path('solution/', solution, name='solution'), # path('solution/', solution, name='solution'),

View File

@ -1,5 +1,10 @@
from django.contrib import admin from django.contrib import admin
from .models import Category, Article, Comment 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) @admin.register(Category)
class CategoryAdmin(admin.ModelAdmin): class CategoryAdmin(admin.ModelAdmin):
@ -9,14 +14,47 @@ class CategoryAdmin(admin.ModelAdmin):
@admin.register(Article) @admin.register(Article)
class ArticleAdmin(admin.ModelAdmin): class ArticleAdmin(admin.ModelAdmin):
list_display = ('title', 'category', 'author', 'is_published', 'views_count', 'time_create') formfield_overrides = {
list_filter = ('is_published', 'category', 'time_create') models.TextField: {'widget': CKEditor5Widget(config_name='default')},
search_fields = ('title', 'content') }
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',)} prepopulated_fields = {'slug': ('title',)}
autocomplete_fields = ('author',) # вместо raw_id_fields для удобства
# filter_horizontal = ('tags',) # для удобного выбора тегов
raw_id_fields = ('author',) # удобно при большом количестве пользователей raw_id_fields = ('author',) # удобно при большом количестве пользователей
date_hierarchy = 'time_create' date_hierarchy = 'time_create'
actions = ['make_published', 'make_unpublished'] 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): def make_published(self, request, queryset):
queryset.update(is_published=True) queryset.update(is_published=True)
make_published.short_description = "Опубликовать выбранные статьи" make_published.short_description = "Опубликовать выбранные статьи"
@ -25,6 +63,17 @@ class ArticleAdmin(admin.ModelAdmin):
queryset.update(is_published=False) queryset.update(is_published=False)
make_unpublished.short_description = "Снять с публикации" 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) @admin.register(Comment)
class CommentAdmin(admin.ModelAdmin): class CommentAdmin(admin.ModelAdmin):
list_display = ('author_name', 'article', 'is_moderated', 'time_create') list_display = ('author_name', 'article', 'is_moderated', 'time_create')

View File

@ -1,5 +1,8 @@
from django import forms from django import forms
from .models import Comment from .models import Comment
from django.core.exceptions import ValidationError
from django.utils.html import strip_tags
class CommentForm(forms.ModelForm): class CommentForm(forms.ModelForm):
class Meta: class Meta:
@ -8,6 +11,17 @@ class CommentForm(forms.ModelForm):
widgets = { widgets = {
'author_name': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Ваше имя'}), 'author_name': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Ваше имя'}),
'author_email': forms.EmailInput(attrs={'class': 'form-input', 'placeholder': 'Ваш email'}), '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

View File

@ -2,6 +2,7 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from taggit.managers import TaggableManager
User = get_user_model() User = get_user_model()
@ -20,6 +21,7 @@ class Category(models.Model):
class Article(models.Model): class Article(models.Model):
"""Статья блога""" """Статья блога"""
tags = TaggableManager(blank=True, verbose_name="Теги")
title = models.CharField(max_length=200, verbose_name="Заголовок") title = models.CharField(max_length=200, verbose_name="Заголовок")
slug = models.SlugField(max_length=220, unique=True, verbose_name="URL") slug = models.SlugField(max_length=220, unique=True, verbose_name="URL")
category = models.ForeignKey( category = models.ForeignKey(
@ -38,6 +40,12 @@ class Article(models.Model):
is_published = models.BooleanField(default=True, verbose_name="Опубликовано") is_published = models.BooleanField(default=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="Дата изменения")
meta_description = models.CharField(
max_length=160,
blank=True,
verbose_name="Meta-описание",
help_text="Краткое описание для поисковых систем (до 160 символов)"
)
class Meta: class Meta:
verbose_name = "Статья" verbose_name = "Статья"

View File

@ -21,13 +21,13 @@
<img src="{{ article.image.url }}" alt="{{ article.title }}" style="max-width: 100%; margin-bottom: 1rem;"> <img src="{{ article.image.url }}" alt="{{ article.title }}" style="max-width: 100%; margin-bottom: 1rem;">
{% endif %} {% endif %}
<div class="card-content"> <div class="card-content">
{{ article.content|linebreaks }} {{ article.content|safe }}
</div> </div>
</div> </div>
<!-- Комментарии --> <!-- Комментарии -->
<h3>Комментарии</h3> <h3>Комментарии</h3>
{% for comment in article.comments.all %} {% for comment in moderated_comments %}
<div class="modern-card" style="margin-bottom: 1rem;"> <div class="modern-card" style="margin-bottom: 1rem;">
<p><strong>{{ comment.author_name }}</strong> ({{ comment.time_create|date:"d.m.Y H:i" }})</p> <p><strong>{{ comment.author_name }}</strong> ({{ comment.time_create|date:"d.m.Y H:i" }})</p>
<p>{{ comment.content|linebreaks }}</p> <p>{{ comment.content|linebreaks }}</p>

View File

@ -1,5 +1,6 @@
{% extends 'programmer/base.html' %} {% extends 'programmer/base.html' %}
{% load static %} {% load static %}
{% load blog_extras %}
{% block extra_css %} {% block extra_css %}
<link rel="stylesheet" href="{% static 'programmer/css/recall.css' %}"> <link rel="stylesheet" href="{% static 'programmer/css/recall.css' %}">
@ -23,7 +24,7 @@
{% for article in articles %} {% for article in articles %}
<div class="modern-card"> <div class="modern-card">
<h2><a href="{{ article.get_absolute_url }}">{{ article.title }}</a></h2> <h2><a href="{{ article.get_absolute_url }}">{{ article.title }}</a></h2>
<p>{{ article.content|truncatewords:30 }}</p> <p>{{ article.content|clean_html|truncatewords:30 }}</p>
<div class="meta"> <div class="meta">
<span>📅 {{ article.time_create|date:"d.m.Y" }}</span> <span>📅 {{ article.time_create|date:"d.m.Y" }}</span>
<span>👁 {{ article.views_count }}</span> <span>👁 {{ article.views_count }}</span>

View File

View File

@ -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-сущности (например, &nbsp; → пробел)
value = html.unescape(value)
# Удаляем все HTML-теги
return strip_tags(value)

View File

@ -7,5 +7,6 @@ urlpatterns = [
path('', views.ArticleListView.as_view(), name='article_list'), path('', views.ArticleListView.as_view(), name='article_list'),
path('category/<slug:category_slug>/', views.ArticleListView.as_view(), name='category_detail'), path('category/<slug:category_slug>/', views.ArticleListView.as_view(), name='category_detail'),
path('<slug:slug>/', views.ArticleDetailView.as_view(), name='article_detail'), path('<slug:slug>/', views.ArticleDetailView.as_view(), name='article_detail'),
path('draft/<slug:slug>/', views.ArticleDraftPreviewView.as_view(), name='draft_preview'),
] ]

View File

@ -12,6 +12,7 @@ from .services import (
from .forms import CommentForm from .forms import CommentForm
from django.http import Http404 from django.http import Http404
from django.shortcuts import redirect from django.shortcuts import redirect
from django.contrib.admin.views.decorators import staff_member_required
class ArticleListView(MenuContextMixin, ListView): class ArticleListView(MenuContextMixin, ListView):
@ -66,6 +67,12 @@ class ArticleDetailView(MenuContextMixin, DetailView, CreateView):
else: else:
return self.form_invalid(form) return self.form_invalid(form)
def get_initial(self):
"""Возвращает пустые начальные данные для формы комментария."""
initial = super().get_initial()
initial['content'] = ''
return initial
def form_valid(self, form): def form_valid(self, form):
comment = add_comment_to_article( comment = add_comment_to_article(
article=self.object, article=self.object,
@ -74,4 +81,19 @@ class ArticleDetailView(MenuContextMixin, DetailView, CreateView):
messages.success(self.request, "Ваш комментарий отправлен на модерацию.") messages.success(self.request, "Ваш комментарий отправлен на модерацию.")
return redirect(self.object.get_absolute_url()) 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

View File

@ -21,7 +21,7 @@ class MenuContextMixin(ContextMixin):
menu = [ menu = [
{'title': "Главная", 'url_name': 'home'}, {'title': "Главная", 'url_name': 'home'},
{'title': "Проекты", 'url_name': 'solution'}, {'title': "Проекты", 'url_name': 'solution'},
# {'title': "Статьи", 'url_name': 'blog'}, {'title': "Статьи", 'url_name': 'blog'},
{'title': "Отзывы", 'url_name': 'recall'}, {'title': "Отзывы", 'url_name': 'recall'},
{'title': "Обо мне", 'url_name': 'about'} {'title': "Обо мне", 'url_name': 'about'}
] ]

View File

@ -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;
}

View File

@ -160,6 +160,14 @@
/* Адаптивность для мобильных устройств */ /* Адаптивность для мобильных устройств */
@media (max-width: 768px) { @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 { .recall-content {
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
@ -226,3 +234,21 @@
background: linear-gradient(transparent, rgba(0,0,0,0.7)); 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;
}

View File

@ -867,7 +867,25 @@ body {
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
margin-bottom: 2rem; margin-bottom: 2rem;
position: relative; 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 { .content-card::before {
@ -1298,6 +1316,14 @@ body {
.breadcrumbs { .breadcrumbs {
font-size: 0.8rem; 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) { @media (max-width: 480px) {

View File

@ -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;
}