mirror of
https://github.com/EDeev/deev.space.git
synced 2026-06-15 11:01:10 +03:00
v. 1.0
This commit is contained in:
commit
35149a578d
45 changed files with 9018 additions and 0 deletions
0
dspace/__init__.py
Normal file
0
dspace/__init__.py
Normal file
16
dspace/asgi.py
Normal file
16
dspace/asgi.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
ASGI config for dspace project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dspace.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
156
dspace/settings.py
Normal file
156
dspace/settings.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', '*****')
|
||||
|
||||
DEBUG = os.environ.get('DJANGO_DEBUG', 'False') == 'True'
|
||||
|
||||
ALLOWED_HOSTS = ['deev.space', 'deev.su', 'www.deev.space', 'www.deev.su', 'localhost', '127.0.0.1']
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.sitemaps',
|
||||
'main.apps.MainConfig',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'dspace.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'main.context_processors.site_settings',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'dspace.wsgi.application'
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_USER_MODEL = 'main.CustomUser'
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = []
|
||||
|
||||
LANGUAGE_CODE = 'ru-ru'
|
||||
TIME_ZONE = 'Europe/Moscow'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Yandex SmartCaptcha Settings
|
||||
SMARTCAPTCHA_CLIENT_KEY = os.environ.get('SMARTCAPTCHA_CLIENT_KEY', '*****')
|
||||
SMARTCAPTCHA_SERVER_KEY = os.environ.get('SMARTCAPTCHA_SERVER_KEY', '*****')
|
||||
|
||||
# Telegram Bot Settings
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '')
|
||||
TELEGRAM_CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '')
|
||||
|
||||
# VK API Settings
|
||||
VK_ACCESS_TOKEN = os.environ.get('VK_ACCESS_TOKEN', '')
|
||||
VK_GROUP_ID = os.environ.get('VK_GROUP_ID', '')
|
||||
|
||||
# Email Settings
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.yandex.ru')
|
||||
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 465))
|
||||
EMAIL_USE_SSL = True
|
||||
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
|
||||
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
|
||||
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'egor@deev.space')
|
||||
CONTACT_EMAIL = os.environ.get('CONTACT_EMAIL', 'egor@deev.space')
|
||||
|
||||
# Site Settings
|
||||
SITE_NAME = 'deev.space'
|
||||
SITE_AUTHOR = 'Деев Егор Викторович'
|
||||
SITE_DESCRIPTION = 'Персональный сайт backend-разработчика'
|
||||
|
||||
# Session Settings
|
||||
SESSION_COOKIE_AGE = 31536000 # 1 year
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
|
||||
# Security Settings (Production)
|
||||
if not DEBUG:
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
X_FRAME_OPTIONS = 'DENY'
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
|
||||
# Logging
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '{levelname} {asctime} {module} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'file': {
|
||||
'level': 'ERROR',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': BASE_DIR / 'logs' / 'django.log',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'] if DEBUG else ['file', 'console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
'main': {
|
||||
'handlers': ['console'] if DEBUG else ['file', 'console'],
|
||||
'level': 'DEBUG' if DEBUG else 'INFO',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Create logs directory
|
||||
(BASE_DIR / 'logs').mkdir(exist_ok=True)
|
||||
25
dspace/urls.py
Normal file
25
dspace/urls.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib.sitemaps.views import sitemap
|
||||
from main.sitemaps import ArticleSitemap, ProjectSitemap, StaticViewSitemap
|
||||
|
||||
sitemaps = {
|
||||
'articles': ArticleSitemap,
|
||||
'projects': ProjectSitemap,
|
||||
'static': StaticViewSitemap,
|
||||
}
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('', include('main.urls')),
|
||||
path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='sitemap'),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATICFILES_DIRS[0])
|
||||
|
||||
handler404 = 'main.views.handler404'
|
||||
handler500 = 'main.views.handler500'
|
||||
16
dspace/wsgi.py
Normal file
16
dspace/wsgi.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
WSGI config for dspace project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dspace.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
main/__init__.py
Normal file
0
main/__init__.py
Normal file
222
main/admin.py
Normal file
222
main/admin.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from .models import (
|
||||
CustomUser, Category, Article, Project, Skill, Comment,
|
||||
ArticleLike, CommentLike, ContactMessage, Experience,
|
||||
Education, SiteSettings
|
||||
)
|
||||
|
||||
|
||||
@admin.register(CustomUser)
|
||||
class CustomUserAdmin(admin.ModelAdmin):
|
||||
list_display = ['username', 'email', 'is_verified', 'is_active', 'date_joined']
|
||||
list_filter = ['is_verified', 'is_active', 'is_staff', 'date_joined']
|
||||
search_fields = ['username', 'email']
|
||||
ordering = ['-date_joined']
|
||||
readonly_fields = ['date_joined', 'last_login']
|
||||
|
||||
|
||||
@admin.register(Category)
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'slug', 'icon', 'order', 'articles_count']
|
||||
list_editable = ['order']
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
search_fields = ['name']
|
||||
|
||||
def articles_count(self, obj):
|
||||
return obj.articles_count
|
||||
|
||||
articles_count.short_description = 'Статей'
|
||||
|
||||
|
||||
@admin.register(Article)
|
||||
class ArticleAdmin(admin.ModelAdmin):
|
||||
list_display = ['title', 'category', 'is_published', 'is_achievement', 'views', 'date', 'image_preview']
|
||||
list_filter = ['is_published', 'is_achievement', 'category', 'date']
|
||||
search_fields = ['title', 'post', 'excerpt']
|
||||
prepopulated_fields = {'slug': ('title',)}
|
||||
date_hierarchy = 'date'
|
||||
list_editable = ['is_published']
|
||||
readonly_fields = ['views', 'date', 'updated_at', 'image_preview_large']
|
||||
|
||||
fieldsets = (
|
||||
('Основное', {
|
||||
'fields': ('title', 'slug', 'sub_title', 'excerpt', 'post')
|
||||
}),
|
||||
('Медиа', {
|
||||
'fields': ('img', 'image_preview_large')
|
||||
}),
|
||||
('Категоризация', {
|
||||
'fields': ('category', 'author')
|
||||
}),
|
||||
('Достижение', {
|
||||
'fields': ('is_achievement', 'achievement_icon', 'achievement_date'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Публикация', {
|
||||
'fields': ('is_published', 'views', 'date', 'updated_at')
|
||||
}),
|
||||
)
|
||||
|
||||
def image_preview(self, obj):
|
||||
if obj.img:
|
||||
return format_html('<img src="{}" width="50" height="50" style="object-fit: cover; border-radius: 4px;" />',
|
||||
obj.img.url)
|
||||
return '-'
|
||||
|
||||
image_preview.short_description = 'Превью'
|
||||
|
||||
def image_preview_large(self, obj):
|
||||
if obj.img:
|
||||
return format_html('<img src="{}" width="200" style="border-radius: 8px;" />', obj.img.url)
|
||||
return 'Нет изображения'
|
||||
|
||||
image_preview_large.short_description = 'Изображение'
|
||||
|
||||
|
||||
@admin.register(Project)
|
||||
class ProjectAdmin(admin.ModelAdmin):
|
||||
list_display = ['title', 'status', 'card_size', 'technologies_short', 'users_count', 'order', 'is_visible']
|
||||
list_filter = ['status', 'card_size', 'is_visible']
|
||||
search_fields = ['title', 'description', 'technologies']
|
||||
prepopulated_fields = {'slug': ('title',)}
|
||||
list_editable = ['order', 'is_visible', 'card_size']
|
||||
|
||||
fieldsets = (
|
||||
('Основное', {
|
||||
'fields': ('title', 'slug', 'short_description', 'description', 'features')
|
||||
}),
|
||||
('Визуальное', {
|
||||
'fields': ('img_main', 'icon', 'card_size')
|
||||
}),
|
||||
('Техническое', {
|
||||
'fields': ('technologies', 'github_url', 'demo_url')
|
||||
}),
|
||||
('Статус', {
|
||||
'fields': ('status', 'users_count', 'is_visible', 'order')
|
||||
}),
|
||||
)
|
||||
|
||||
def technologies_short(self, obj):
|
||||
techs = obj.get_technologies_list()[:3]
|
||||
return ', '.join(techs) + ('...' if len(obj.get_technologies_list()) > 3 else '')
|
||||
|
||||
technologies_short.short_description = 'Технологии'
|
||||
|
||||
|
||||
@admin.register(Skill)
|
||||
class SkillAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'category', 'icon', 'order']
|
||||
list_filter = ['category']
|
||||
list_editable = ['order']
|
||||
search_fields = ['name']
|
||||
ordering = ['category', 'order']
|
||||
|
||||
|
||||
@admin.register(Experience)
|
||||
class ExperienceAdmin(admin.ModelAdmin):
|
||||
list_display = ['title', 'company', 'start_date', 'end_date', 'is_current', 'duration', 'order']
|
||||
list_filter = ['is_current', 'company']
|
||||
list_editable = ['order']
|
||||
search_fields = ['title', 'company', 'description']
|
||||
ordering = ['order', '-start_date']
|
||||
|
||||
|
||||
@admin.register(Education)
|
||||
class EducationAdmin(admin.ModelAdmin):
|
||||
list_display = ['institution_short_display', 'degree', 'education_type', 'start_year', 'end_year', 'is_current',
|
||||
'order']
|
||||
list_filter = ['education_type', 'is_current']
|
||||
list_editable = ['order']
|
||||
search_fields = ['institution', 'degree']
|
||||
ordering = ['order', '-start_year']
|
||||
|
||||
def institution_short_display(self, obj):
|
||||
return obj.institution_short or obj.institution[:30] + '...'
|
||||
|
||||
institution_short_display.short_description = 'Учреждение'
|
||||
|
||||
|
||||
@admin.register(Comment)
|
||||
class CommentAdmin(admin.ModelAdmin):
|
||||
list_display = ['user', 'article', 'content_short', 'is_approved', 'created_at']
|
||||
list_filter = ['is_approved', 'created_at']
|
||||
search_fields = ['content', 'user__username', 'article__title']
|
||||
list_editable = ['is_approved']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
actions = ['approve_comments', 'reject_comments']
|
||||
|
||||
def content_short(self, obj):
|
||||
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
|
||||
|
||||
content_short.short_description = 'Содержание'
|
||||
|
||||
def approve_comments(self, request, queryset):
|
||||
count = queryset.update(is_approved=True)
|
||||
self.message_user(request, f'Одобрено {count} комментариев')
|
||||
|
||||
approve_comments.short_description = 'Одобрить выбранные комментарии'
|
||||
|
||||
def reject_comments(self, request, queryset):
|
||||
count = queryset.update(is_approved=False)
|
||||
self.message_user(request, f'Отклонено {count} комментариев')
|
||||
|
||||
reject_comments.short_description = 'Отклонить выбранные комментарии'
|
||||
|
||||
|
||||
@admin.register(ContactMessage)
|
||||
class ContactMessageAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'email', 'subject', 'is_read', 'is_replied', 'created_at']
|
||||
list_filter = ['is_read', 'is_replied', 'created_at']
|
||||
search_fields = ['name', 'email', 'subject', 'message']
|
||||
readonly_fields = ['name', 'email', 'subject', 'message', 'created_at']
|
||||
list_editable = ['is_read', 'is_replied']
|
||||
ordering = ['-created_at']
|
||||
|
||||
fieldsets = (
|
||||
('Сообщение', {
|
||||
'fields': ('name', 'email', 'subject', 'message', 'created_at')
|
||||
}),
|
||||
('Статус', {
|
||||
'fields': ('is_read', 'is_replied', 'admin_notes')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(SiteSettings)
|
||||
class SiteSettingsAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
('Основное', {
|
||||
'fields': ('site_name', 'site_description')
|
||||
}),
|
||||
('Владелец', {
|
||||
'fields': ('owner_name', 'owner_title', 'owner_bio', 'owner_photo')
|
||||
}),
|
||||
('Контакты', {
|
||||
'fields': ('owner_email', 'owner_phone', 'owner_city')
|
||||
}),
|
||||
('Социальные сети', {
|
||||
'fields': ('telegram_url', 'github_url', 'vk_url', 'linkedin_url')
|
||||
}),
|
||||
('Файлы', {
|
||||
'fields': ('resume_file',)
|
||||
}),
|
||||
('Аналитика', {
|
||||
'fields': ('yandex_metrika_id', 'google_analytics_id'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return not SiteSettings.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
admin.site.register(ArticleLike)
|
||||
admin.site.register(CommentLike)
|
||||
|
||||
admin.site.site_header = 'deev.space — Администрирование'
|
||||
admin.site.site_title = 'deev.space Admin'
|
||||
admin.site.index_title = 'Панель управления'
|
||||
10
main/apps.py
Normal file
10
main/apps.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MainConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'main'
|
||||
verbose_name = 'Основное приложение'
|
||||
|
||||
def ready(self):
|
||||
import main.signals # noqa
|
||||
8
main/context_processors.py
Normal file
8
main/context_processors.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from .models import SiteSettings
|
||||
|
||||
|
||||
def site_settings(request):
|
||||
"""Глобальный контекст с настройками сайта."""
|
||||
return {
|
||||
'global_settings': SiteSettings.load()
|
||||
}
|
||||
251
main/forms.py
Normal file
251
main/forms.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
from django import forms
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
from django.core.validators import RegexValidator
|
||||
from django.conf import settings
|
||||
import bleach
|
||||
import requests
|
||||
|
||||
from .models import CustomUser, Comment, ContactMessage
|
||||
|
||||
|
||||
class SmartCaptchaField(forms.CharField):
|
||||
"""Кастомное поле для Yandex SmartCaptcha."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault('required', True)
|
||||
kwargs.setdefault('widget', forms.HiddenInput(attrs={'id': 'smart-captcha-token'}))
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def validate(self, value):
|
||||
super().validate(value)
|
||||
if not value:
|
||||
raise forms.ValidationError('Пожалуйста, пройдите проверку капчи')
|
||||
|
||||
# Валидация токена на сервере Yandex
|
||||
if not self._verify_captcha(value):
|
||||
raise forms.ValidationError('Проверка капчи не пройдена. Попробуйте снова.')
|
||||
|
||||
def _verify_captcha(self, token):
|
||||
"""Проверка токена на сервере Yandex SmartCaptcha."""
|
||||
try:
|
||||
response = requests.post(
|
||||
'https://smartcaptcha.yandexcloud.net/validate',
|
||||
data={
|
||||
'secret': settings.SMARTCAPTCHA_SERVER_KEY,
|
||||
'token': token,
|
||||
},
|
||||
timeout=5
|
||||
)
|
||||
|
||||
# Рекомендация документации: при ошибке HTTP считать проверку успешной
|
||||
if response.status_code != 200:
|
||||
return True
|
||||
|
||||
result = response.json()
|
||||
return result.get('status') == 'ok'
|
||||
|
||||
except requests.RequestException:
|
||||
# При сетевой ошибке пропускаем, чтобы не блокировать пользователей
|
||||
return True
|
||||
|
||||
|
||||
class SmartCaptchaWidget(forms.Widget):
|
||||
"""Виджет для отображения Yandex SmartCaptcha."""
|
||||
|
||||
template_name = 'widgets/smartcaptcha.html'
|
||||
|
||||
def __init__(self, attrs=None):
|
||||
default_attrs = {'class': 'smart-captcha-container'}
|
||||
if attrs:
|
||||
default_attrs.update(attrs)
|
||||
super().__init__(attrs=default_attrs)
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
context['client_key'] = settings.SMARTCAPTCHA_CLIENT_KEY
|
||||
return context
|
||||
|
||||
|
||||
class RegisterForm(forms.ModelForm):
|
||||
"""Форма регистрации пользователя."""
|
||||
username = forms.CharField(
|
||||
max_length=30,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r'^[a-zA-Z0-9_]+$',
|
||||
message='Имя пользователя может содержать только латинские буквы, цифры и подчёркивания'
|
||||
)
|
||||
],
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Имя пользователя (латиница)',
|
||||
'autocomplete': 'username'
|
||||
}),
|
||||
label='Имя пользователя'
|
||||
)
|
||||
|
||||
email = forms.EmailField(
|
||||
required=False,
|
||||
widget=forms.EmailInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Email (необязательно)',
|
||||
'autocomplete': 'email'
|
||||
}),
|
||||
label='Email'
|
||||
)
|
||||
|
||||
password = forms.CharField(
|
||||
min_length=4,
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Пароль (минимум 4 символа)',
|
||||
'autocomplete': 'new-password'
|
||||
}),
|
||||
label='Пароль'
|
||||
)
|
||||
|
||||
password_confirm = forms.CharField(
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Подтвердите пароль',
|
||||
'autocomplete': 'new-password'
|
||||
}),
|
||||
label='Подтверждение пароля'
|
||||
)
|
||||
|
||||
captcha = SmartCaptchaField(label='')
|
||||
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ['username', 'email']
|
||||
|
||||
def clean_username(self):
|
||||
username = self.cleaned_data.get('username')
|
||||
if CustomUser.objects.filter(username__iexact=username).exists():
|
||||
raise forms.ValidationError('Пользователь с таким именем уже существует')
|
||||
return username
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
password = cleaned_data.get('password')
|
||||
password_confirm = cleaned_data.get('password_confirm')
|
||||
|
||||
if password and password_confirm and password != password_confirm:
|
||||
self.add_error('password_confirm', 'Пароли не совпадают')
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
user.set_password(self.cleaned_data['password'])
|
||||
user.is_verified = True
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
"""Форма авторизации пользователя."""
|
||||
username = forms.CharField(
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Имя пользователя',
|
||||
'autocomplete': 'username'
|
||||
}),
|
||||
label='Имя пользователя'
|
||||
)
|
||||
|
||||
password = forms.CharField(
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Пароль',
|
||||
'autocomplete': 'current-password'
|
||||
}),
|
||||
label='Пароль'
|
||||
)
|
||||
|
||||
remember_me = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True,
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
label='Запомнить меня'
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'invalid_login': 'Неверное имя пользователя или пароль',
|
||||
'inactive': 'Этот аккаунт деактивирован',
|
||||
}
|
||||
|
||||
|
||||
class CommentForm(forms.ModelForm):
|
||||
"""Форма добавления комментария."""
|
||||
content = forms.CharField(
|
||||
max_length=2000,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Напишите комментарий...',
|
||||
'rows': 3
|
||||
}),
|
||||
label=''
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ['content']
|
||||
|
||||
def clean_content(self):
|
||||
content = self.cleaned_data.get('content')
|
||||
allowed_tags = ['b', 'i', 'u', 'em', 'strong', 'a', 'code', 'pre']
|
||||
allowed_attrs = {'a': ['href', 'title', 'rel']}
|
||||
content = bleach.clean(content, tags=allowed_tags, attributes=allowed_attrs, strip=True)
|
||||
content = bleach.linkify(content)
|
||||
return content
|
||||
|
||||
|
||||
class ContactForm(forms.ModelForm):
|
||||
"""Форма обратной связи."""
|
||||
name = forms.CharField(
|
||||
max_length=100,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Ваше имя'
|
||||
}),
|
||||
label='Имя'
|
||||
)
|
||||
|
||||
email = forms.EmailField(
|
||||
widget=forms.EmailInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Email для ответа'
|
||||
}),
|
||||
label='Email'
|
||||
)
|
||||
|
||||
subject = forms.CharField(
|
||||
max_length=200,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Тема сообщения'
|
||||
}),
|
||||
label='Тема'
|
||||
)
|
||||
|
||||
message = forms.CharField(
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Текст сообщения',
|
||||
'rows': 5
|
||||
}),
|
||||
label='Сообщение'
|
||||
)
|
||||
|
||||
captcha = SmartCaptchaField(label='')
|
||||
|
||||
class Meta:
|
||||
model = ContactMessage
|
||||
fields = ['name', 'email', 'subject', 'message']
|
||||
|
||||
def clean_message(self):
|
||||
message = self.cleaned_data.get('message')
|
||||
message = bleach.clean(message, tags=[], strip=True)
|
||||
return message
|
||||
231
main/migrations/0001_initial.py
Normal file
231
main/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
# Generated by Django 4.2.26 on 2025-11-28 23:51
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomUser',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True)),
|
||||
('is_verified', models.BooleanField(default=False)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Пользователь',
|
||||
'verbose_name_plural': 'Пользователи',
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Article',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='Заголовок')),
|
||||
('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL')),
|
||||
('sub_title', models.CharField(blank=True, max_length=300, verbose_name='Подзаголовок')),
|
||||
('post', models.TextField(verbose_name='Содержание')),
|
||||
('excerpt', models.TextField(blank=True, max_length=500, verbose_name='Краткое описание')),
|
||||
('img', models.ImageField(blank=True, null=True, upload_to='articles/', verbose_name='Изображение')),
|
||||
('author', models.CharField(default='Егор Деев', max_length=100, verbose_name='Автор')),
|
||||
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата публикации')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('views', models.PositiveIntegerField(default=0, verbose_name='Просмотры')),
|
||||
('is_published', models.BooleanField(default=True, verbose_name='Опубликовано')),
|
||||
('is_achievement', models.BooleanField(default=False, verbose_name='Достижение')),
|
||||
('achievement_icon', models.CharField(blank=True, max_length=50, verbose_name='Иконка достижения')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Статья',
|
||||
'verbose_name_plural': 'Статьи',
|
||||
'ordering': ['-date'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Category',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Название')),
|
||||
('slug', models.SlugField(max_length=100, unique=True, verbose_name='URL')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Категория',
|
||||
'verbose_name_plural': 'Категории',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Comment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('content', models.TextField(max_length=2000, verbose_name='Содержание')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('is_approved', models.BooleanField(default=True, verbose_name='Одобрен')),
|
||||
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='main.article', verbose_name='Статья')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='main.comment', verbose_name='Родительский комментарий')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Комментарий',
|
||||
'verbose_name_plural': 'Комментарии',
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContactMessage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Имя')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='Email')),
|
||||
('subject', models.CharField(max_length=200, verbose_name='Тема')),
|
||||
('message', models.TextField(verbose_name='Сообщение')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')),
|
||||
('is_read', models.BooleanField(default=False, verbose_name='Прочитано')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Сообщение',
|
||||
'verbose_name_plural': 'Сообщения',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Education',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('institution', models.CharField(max_length=300, verbose_name='Учебное заведение')),
|
||||
('degree', models.CharField(max_length=200, verbose_name='Специальность')),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('start_year', models.PositiveIntegerField(verbose_name='Год начала')),
|
||||
('end_year', models.PositiveIntegerField(blank=True, null=True, verbose_name='Год окончания')),
|
||||
('is_current', models.BooleanField(default=False, verbose_name='В процессе')),
|
||||
('certificate', models.CharField(blank=True, max_length=100, verbose_name='Сертификат')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Образование',
|
||||
'verbose_name_plural': 'Образование',
|
||||
'ordering': ['order', '-start_year'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Experience',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='Должность')),
|
||||
('company', models.CharField(max_length=200, verbose_name='Компания')),
|
||||
('description', models.TextField(verbose_name='Описание')),
|
||||
('technologies', models.CharField(blank=True, max_length=500, verbose_name='Технологии')),
|
||||
('start_date', models.DateField(verbose_name='Дата начала')),
|
||||
('end_date', models.DateField(blank=True, null=True, verbose_name='Дата окончания')),
|
||||
('is_current', models.BooleanField(default=False, verbose_name='Текущее место')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Опыт работы',
|
||||
'verbose_name_plural': 'Опыт работы',
|
||||
'ordering': ['order', '-start_date'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Project',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='Название')),
|
||||
('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL')),
|
||||
('description', models.TextField(verbose_name='Описание')),
|
||||
('short_description', models.CharField(max_length=300, verbose_name='Краткое описание')),
|
||||
('img_main', models.ImageField(blank=True, null=True, upload_to='projects/', verbose_name='Главное изображение')),
|
||||
('img_icon', models.CharField(blank=True, max_length=50, verbose_name='Иконка проекта')),
|
||||
('technologies', models.CharField(max_length=500, verbose_name='Технологии (через запятую)')),
|
||||
('github_url', models.URLField(blank=True, verbose_name='GitHub')),
|
||||
('demo_url', models.URLField(blank=True, verbose_name='Демо')),
|
||||
('status', models.CharField(choices=[('completed', 'Завершён'), ('in_development', 'В разработке')], default='completed', max_length=20, verbose_name='Статус')),
|
||||
('users_count', models.CharField(blank=True, max_length=50, verbose_name='Количество пользователей')),
|
||||
('author', models.CharField(default='Егор Деев', max_length=100, verbose_name='Автор')),
|
||||
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Проект',
|
||||
'verbose_name_plural': 'Проекты',
|
||||
'ordering': ['order', '-date'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Skill',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Название')),
|
||||
('category', models.CharField(choices=[('frontend', 'Frontend'), ('backend', 'Backend'), ('devops', 'DevOps'), ('database', 'Data Base'), ('tools', 'Tools')], max_length=20, verbose_name='Категория')),
|
||||
('icon', models.CharField(blank=True, max_length=50, verbose_name='Иконка')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Навык',
|
||||
'verbose_name_plural': 'Навыки',
|
||||
'ordering': ['category', 'order'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='article',
|
||||
name='category',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='articles', to='main.category', verbose_name='Категория'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CommentLike',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_like', models.BooleanField(verbose_name='Лайк')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')),
|
||||
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_likes', to='main.comment', verbose_name='Комментарий')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_likes', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Оценка комментария',
|
||||
'verbose_name_plural': 'Оценки комментариев',
|
||||
'unique_together': {('comment', 'user')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ArticleLike',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_like', models.BooleanField(verbose_name='Лайк')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')),
|
||||
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='main.article', verbose_name='Статья')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_likes', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Оценка статьи',
|
||||
'verbose_name_plural': 'Оценки статей',
|
||||
'unique_together': {('article', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
# Generated by Django 4.2.26 on 2025-11-29 00:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SiteSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('site_name', models.CharField(default='deev.space', max_length=100, verbose_name='Название сайта')),
|
||||
('site_description', models.TextField(blank=True, verbose_name='Описание сайта')),
|
||||
('owner_name', models.CharField(default='Деев Егор Викторович', max_length=200, verbose_name='Имя владельца')),
|
||||
('owner_title', models.CharField(default='Backend Developer', max_length=200, verbose_name='Должность')),
|
||||
('owner_bio', models.TextField(blank=True, verbose_name='О себе')),
|
||||
('owner_photo', models.ImageField(blank=True, null=True, upload_to='site/', verbose_name='Фото владельца')),
|
||||
('owner_email', models.EmailField(default='egor@deev.space', max_length=254, verbose_name='Email')),
|
||||
('owner_phone', models.CharField(blank=True, max_length=20, verbose_name='Телефон')),
|
||||
('owner_city', models.CharField(default='Москва', max_length=100, verbose_name='Город')),
|
||||
('telegram_url', models.URLField(blank=True, verbose_name='Telegram')),
|
||||
('github_url', models.URLField(blank=True, verbose_name='GitHub')),
|
||||
('vk_url', models.URLField(blank=True, verbose_name='VKontakte')),
|
||||
('linkedin_url', models.URLField(blank=True, verbose_name='LinkedIn')),
|
||||
('resume_file', models.FileField(blank=True, null=True, upload_to='site/', verbose_name='Файл резюме')),
|
||||
('yandex_metrika_id', models.CharField(blank=True, max_length=20, verbose_name='ID Яндекс.Метрики')),
|
||||
('google_analytics_id', models.CharField(blank=True, max_length=20, verbose_name='ID Google Analytics')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Настройки сайта',
|
||||
'verbose_name_plural': 'Настройки сайта',
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='category',
|
||||
options={'ordering': ['order', 'name'], 'verbose_name': 'Категория', 'verbose_name_plural': 'Категории'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='customuser',
|
||||
options={'ordering': ['-date_joined'], 'verbose_name': 'Пользователь', 'verbose_name_plural': 'Пользователи'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='skill',
|
||||
options={'ordering': ['category', 'order', 'name'], 'verbose_name': 'Навык', 'verbose_name_plural': 'Навыки'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='education',
|
||||
name='certificate',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='project',
|
||||
name='img_icon',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='article',
|
||||
name='achievement_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Дата достижения'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, verbose_name='Описание'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, default='fas fa-folder', max_length=50, verbose_name='Иконка'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='order',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Порядок'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contactmessage',
|
||||
name='admin_notes',
|
||||
field=models.TextField(blank=True, verbose_name='Заметки администратора'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contactmessage',
|
||||
name='is_replied',
|
||||
field=models.BooleanField(default=False, verbose_name='Отвечено'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='avatar',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='avatars/', verbose_name='Аватар'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='education',
|
||||
name='achievements',
|
||||
field=models.TextField(blank=True, verbose_name='Достижения (каждое с новой строки)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='education',
|
||||
name='certificate_number',
|
||||
field=models.CharField(blank=True, max_length=100, verbose_name='Номер сертификата'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='education',
|
||||
name='certificate_url',
|
||||
field=models.URLField(blank=True, verbose_name='Ссылка на сертификат'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='education',
|
||||
name='education_type',
|
||||
field=models.CharField(choices=[('university', 'Высшее образование'), ('course', 'Курс/Сертификат'), ('school', 'Школа/Лицей')], default='university', max_length=20, verbose_name='Тип'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='education',
|
||||
name='icon',
|
||||
field=models.CharField(default='fas fa-graduation-cap', max_length=50, verbose_name='Иконка'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='education',
|
||||
name='institution_short',
|
||||
field=models.CharField(blank=True, max_length=100, verbose_name='Сокращённое название'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='experience',
|
||||
name='company_url',
|
||||
field=models.URLField(blank=True, verbose_name='Сайт компании'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='experience',
|
||||
name='responsibilities',
|
||||
field=models.TextField(blank=True, verbose_name='Обязанности (каждая с новой строки)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='card_size',
|
||||
field=models.CharField(choices=[('featured', 'Главный (большой)'), ('regular', 'Обычный'), ('small', 'Компактный')], default='regular', max_length=20, verbose_name='Размер карточки'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='features',
|
||||
field=models.TextField(blank=True, verbose_name='Особенности (каждая с новой строки)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='icon',
|
||||
field=models.CharField(default='fas fa-code', max_length=50, verbose_name='Иконка проекта'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='is_visible',
|
||||
field=models.BooleanField(default=True, verbose_name='Отображать'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='skill',
|
||||
name='icon_svg',
|
||||
field=models.TextField(blank=True, verbose_name='SVG иконка'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='article',
|
||||
name='achievement_icon',
|
||||
field=models.CharField(blank=True, default='fas fa-trophy', max_length=50, verbose_name='Иконка достижения'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='article',
|
||||
name='is_achievement',
|
||||
field=models.BooleanField(default=False, verbose_name='Это достижение'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customuser',
|
||||
name='email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customuser',
|
||||
name='is_verified',
|
||||
field=models.BooleanField(default=False, verbose_name='Верифицирован'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='education',
|
||||
name='degree',
|
||||
field=models.CharField(max_length=200, verbose_name='Специальность/Программа'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='education',
|
||||
name='is_current',
|
||||
field=models.BooleanField(default=False, verbose_name='В процессе обучения'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='experience',
|
||||
name='is_current',
|
||||
field=models.BooleanField(default=False, verbose_name='Текущее место работы'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='demo_url',
|
||||
field=models.URLField(blank=True, verbose_name='Демо/Ссылка'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='description',
|
||||
field=models.TextField(verbose_name='Полное описание'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('completed', 'Завершён'), ('in_development', 'В разработке'), ('beta', 'Бета')], default='completed', max_length=20, verbose_name='Статус'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='skill',
|
||||
name='category',
|
||||
field=models.CharField(choices=[('backend', 'Backend'), ('frontend', 'Frontend'), ('devops', 'DevOps'), ('database', 'Database'), ('tools', 'Tools')], max_length=20, verbose_name='Категория'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='skill',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, max_length=100, verbose_name='CSS класс иконки'),
|
||||
),
|
||||
]
|
||||
0
main/migrations/__init__.py
Normal file
0
main/migrations/__init__.py
Normal file
408
main/models.py
Normal file
408
main/models.py
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from unidecode import unidecode
|
||||
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
"""Расширенная модель пользователя с упрощённой регистрацией."""
|
||||
email = models.EmailField(blank=True, null=True, verbose_name='Email')
|
||||
is_verified = models.BooleanField(default=False, verbose_name='Верифицирован')
|
||||
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True, verbose_name='Аватар')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Пользователь'
|
||||
verbose_name_plural = 'Пользователи'
|
||||
ordering = ['-date_joined']
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
def get_avatar_letter(self):
|
||||
return self.username[0].upper() if self.username else 'U'
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
"""Категории для статей блога."""
|
||||
name = models.CharField(max_length=100, verbose_name='Название')
|
||||
slug = models.SlugField(max_length=100, unique=True, verbose_name='URL')
|
||||
description = models.TextField(blank=True, verbose_name='Описание')
|
||||
icon = models.CharField(max_length=50, blank=True, default='fas fa-folder', verbose_name='Иконка')
|
||||
order = models.PositiveIntegerField(default=0, verbose_name='Порядок')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Категория'
|
||||
verbose_name_plural = 'Категории'
|
||||
ordering = ['order', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(unidecode(self.name))
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('blog_category', kwargs={'category_slug': self.slug})
|
||||
|
||||
@property
|
||||
def articles_count(self):
|
||||
return self.articles.filter(is_published=True).count()
|
||||
|
||||
|
||||
class Article(models.Model):
|
||||
"""Модель статей блога и достижений."""
|
||||
title = models.CharField(max_length=200, verbose_name='Заголовок')
|
||||
slug = models.SlugField(max_length=200, unique=True, verbose_name='URL')
|
||||
sub_title = models.CharField(max_length=300, blank=True, verbose_name='Подзаголовок')
|
||||
excerpt = models.TextField(max_length=500, blank=True, verbose_name='Краткое описание')
|
||||
post = models.TextField(verbose_name='Содержание')
|
||||
img = models.ImageField(upload_to='articles/', blank=True, null=True, verbose_name='Изображение')
|
||||
category = models.ForeignKey(
|
||||
Category, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='articles', verbose_name='Категория'
|
||||
)
|
||||
author = models.CharField(max_length=100, default='Егор Деев', verbose_name='Автор')
|
||||
date = models.DateTimeField(auto_now_add=True, verbose_name='Дата публикации')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления')
|
||||
views = models.PositiveIntegerField(default=0, verbose_name='Просмотры')
|
||||
is_published = models.BooleanField(default=True, verbose_name='Опубликовано')
|
||||
is_achievement = models.BooleanField(default=False, verbose_name='Это достижение')
|
||||
achievement_icon = models.CharField(
|
||||
max_length=50, blank=True, default='fas fa-trophy',
|
||||
verbose_name='Иконка достижения'
|
||||
)
|
||||
achievement_date = models.DateField(blank=True, null=True, verbose_name='Дата достижения')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Статья'
|
||||
verbose_name_plural = 'Статьи'
|
||||
ordering = ['-date']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
base_slug = slugify(unidecode(self.title))
|
||||
slug = base_slug
|
||||
counter = 1
|
||||
while Article.objects.filter(slug=slug).exclude(pk=self.pk).exists():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
self.slug = slug
|
||||
if not self.excerpt and self.post:
|
||||
self.excerpt = self.post[:300] + '...' if len(self.post) > 300 else self.post
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('article_detail', kwargs={'slug': self.slug})
|
||||
|
||||
@property
|
||||
def likes_count(self):
|
||||
return self.likes.filter(is_like=True).count()
|
||||
|
||||
@property
|
||||
def dislikes_count(self):
|
||||
return self.likes.filter(is_like=False).count()
|
||||
|
||||
@property
|
||||
def comments_count(self):
|
||||
return self.comments.filter(is_approved=True).count()
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
"""Модель проектов портфолио."""
|
||||
STATUS_CHOICES = [
|
||||
('completed', 'Завершён'),
|
||||
('in_development', 'В разработке'),
|
||||
('beta', 'Бета'),
|
||||
]
|
||||
|
||||
SIZE_CHOICES = [
|
||||
('featured', 'Главный (большой)'),
|
||||
('regular', 'Обычный'),
|
||||
('small', 'Компактный'),
|
||||
]
|
||||
|
||||
title = models.CharField(max_length=200, verbose_name='Название')
|
||||
slug = models.SlugField(max_length=200, unique=True, verbose_name='URL')
|
||||
short_description = models.CharField(max_length=300, verbose_name='Краткое описание')
|
||||
description = models.TextField(verbose_name='Полное описание')
|
||||
features = models.TextField(blank=True, verbose_name='Особенности (каждая с новой строки)')
|
||||
img_main = models.ImageField(upload_to='projects/', blank=True, null=True, verbose_name='Главное изображение')
|
||||
icon = models.CharField(max_length=50, default='fas fa-code', verbose_name='Иконка проекта')
|
||||
technologies = models.CharField(max_length=500, verbose_name='Технологии (через запятую)')
|
||||
github_url = models.URLField(blank=True, verbose_name='GitHub')
|
||||
demo_url = models.URLField(blank=True, verbose_name='Демо/Ссылка')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='completed', verbose_name='Статус')
|
||||
card_size = models.CharField(max_length=20, choices=SIZE_CHOICES, default='regular', verbose_name='Размер карточки')
|
||||
users_count = models.CharField(max_length=50, blank=True, verbose_name='Количество пользователей')
|
||||
author = models.CharField(max_length=100, default='Егор Деев', verbose_name='Автор')
|
||||
date = models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')
|
||||
order = models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')
|
||||
is_visible = models.BooleanField(default=True, verbose_name='Отображать')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Проект'
|
||||
verbose_name_plural = 'Проекты'
|
||||
ordering = ['order', '-date']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(unidecode(self.title))
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_technologies_list(self):
|
||||
return [tech.strip() for tech in self.technologies.split(',') if tech.strip()]
|
||||
|
||||
def get_features_list(self):
|
||||
if not self.features:
|
||||
return []
|
||||
return [f.strip() for f in self.features.split('\n') if f.strip()]
|
||||
|
||||
|
||||
class Skill(models.Model):
|
||||
"""Модель навыков и технологий."""
|
||||
CATEGORY_CHOICES = [
|
||||
('backend', 'Backend'),
|
||||
('frontend', 'Frontend'),
|
||||
('devops', 'DevOps'),
|
||||
('database', 'Database'),
|
||||
('tools', 'Tools'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name='Название')
|
||||
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, verbose_name='Категория')
|
||||
icon = models.CharField(max_length=100, blank=True, verbose_name='CSS класс иконки')
|
||||
icon_svg = models.TextField(blank=True, verbose_name='SVG иконка')
|
||||
order = models.PositiveIntegerField(default=0, verbose_name='Порядок')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Навык'
|
||||
verbose_name_plural = 'Навыки'
|
||||
ordering = ['category', 'order', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_category_display()})"
|
||||
|
||||
|
||||
class Experience(models.Model):
|
||||
"""Модель опыта работы."""
|
||||
title = models.CharField(max_length=200, verbose_name='Должность')
|
||||
company = models.CharField(max_length=200, verbose_name='Компания')
|
||||
company_url = models.URLField(blank=True, verbose_name='Сайт компании')
|
||||
description = models.TextField(verbose_name='Описание')
|
||||
responsibilities = models.TextField(blank=True, verbose_name='Обязанности (каждая с новой строки)')
|
||||
technologies = models.CharField(max_length=500, blank=True, verbose_name='Технологии')
|
||||
start_date = models.DateField(verbose_name='Дата начала')
|
||||
end_date = models.DateField(null=True, blank=True, verbose_name='Дата окончания')
|
||||
is_current = models.BooleanField(default=False, verbose_name='Текущее место работы')
|
||||
order = models.PositiveIntegerField(default=0, verbose_name='Порядок')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Опыт работы'
|
||||
verbose_name_plural = 'Опыт работы'
|
||||
ordering = ['order', '-start_date']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} — {self.company}"
|
||||
|
||||
def get_responsibilities_list(self):
|
||||
if not self.responsibilities:
|
||||
return []
|
||||
return [r.strip() for r in self.responsibilities.split('\n') if r.strip()]
|
||||
|
||||
def get_technologies_list(self):
|
||||
if not self.technologies:
|
||||
return []
|
||||
return [t.strip() for t in self.technologies.split(',') if t.strip()]
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
end = self.end_date or __import__('datetime').date.today()
|
||||
months = (end.year - self.start_date.year) * 12 + (end.month - self.start_date.month)
|
||||
years = months // 12
|
||||
remaining_months = months % 12
|
||||
|
||||
parts = []
|
||||
if years:
|
||||
parts.append(f"{years} {'год' if years == 1 else 'года' if 2 <= years <= 4 else 'лет'}")
|
||||
if remaining_months:
|
||||
parts.append(
|
||||
f"{remaining_months} {'месяц' if remaining_months == 1 else 'месяца' if 2 <= remaining_months <= 4 else 'месяцев'}")
|
||||
return ' '.join(parts) if parts else 'Менее месяца'
|
||||
|
||||
|
||||
class Education(models.Model):
|
||||
"""Модель образования."""
|
||||
TYPE_CHOICES = [
|
||||
('university', 'Высшее образование'),
|
||||
('course', 'Курс/Сертификат'),
|
||||
('school', 'Школа/Лицей'),
|
||||
]
|
||||
|
||||
institution = models.CharField(max_length=300, verbose_name='Учебное заведение')
|
||||
institution_short = models.CharField(max_length=100, blank=True, verbose_name='Сокращённое название')
|
||||
degree = models.CharField(max_length=200, verbose_name='Специальность/Программа')
|
||||
education_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='university', verbose_name='Тип')
|
||||
description = models.TextField(blank=True, verbose_name='Описание')
|
||||
achievements = models.TextField(blank=True, verbose_name='Достижения (каждое с новой строки)')
|
||||
start_year = models.PositiveIntegerField(verbose_name='Год начала')
|
||||
end_year = models.PositiveIntegerField(null=True, blank=True, verbose_name='Год окончания')
|
||||
is_current = models.BooleanField(default=False, verbose_name='В процессе обучения')
|
||||
certificate_number = models.CharField(max_length=100, blank=True, verbose_name='Номер сертификата')
|
||||
certificate_url = models.URLField(blank=True, verbose_name='Ссылка на сертификат')
|
||||
icon = models.CharField(max_length=50, default='fas fa-graduation-cap', verbose_name='Иконка')
|
||||
order = models.PositiveIntegerField(default=0, verbose_name='Порядок')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Образование'
|
||||
verbose_name_plural = 'Образование'
|
||||
ordering = ['order', '-start_year']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.degree} — {self.institution_short or self.institution}"
|
||||
|
||||
def get_achievements_list(self):
|
||||
if not self.achievements:
|
||||
return []
|
||||
return [a.strip() for a in self.achievements.split('\n') if a.strip()]
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
"""Модель комментариев к статьям."""
|
||||
article = models.ForeignKey(
|
||||
Article, on_delete=models.CASCADE, related_name='comments', verbose_name='Статья'
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
CustomUser, on_delete=models.CASCADE, related_name='comments', verbose_name='Пользователь'
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
'self', on_delete=models.CASCADE, null=True, blank=True,
|
||||
related_name='replies', verbose_name='Родительский комментарий'
|
||||
)
|
||||
content = models.TextField(max_length=2000, verbose_name='Содержание')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления')
|
||||
is_approved = models.BooleanField(default=True, verbose_name='Одобрен')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Комментарий'
|
||||
verbose_name_plural = 'Комментарии'
|
||||
ordering = ['created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user.username}: {self.content[:50]}...'
|
||||
|
||||
@property
|
||||
def likes_count(self):
|
||||
return self.comment_likes.filter(is_like=True).count()
|
||||
|
||||
@property
|
||||
def dislikes_count(self):
|
||||
return self.comment_likes.filter(is_like=False).count()
|
||||
|
||||
@property
|
||||
def nesting_level(self):
|
||||
level = 0
|
||||
parent = self.parent
|
||||
while parent and level < 3:
|
||||
level += 1
|
||||
parent = parent.parent
|
||||
return level
|
||||
|
||||
|
||||
class ArticleLike(models.Model):
|
||||
"""Модель лайков/дизлайков статей."""
|
||||
article = models.ForeignKey(
|
||||
Article, on_delete=models.CASCADE, related_name='likes', verbose_name='Статья'
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
CustomUser, on_delete=models.CASCADE, related_name='article_likes', verbose_name='Пользователь'
|
||||
)
|
||||
is_like = models.BooleanField(verbose_name='Лайк')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Оценка статьи'
|
||||
verbose_name_plural = 'Оценки статей'
|
||||
unique_together = ['article', 'user']
|
||||
|
||||
|
||||
class CommentLike(models.Model):
|
||||
"""Модель лайков/дизлайков комментариев."""
|
||||
comment = models.ForeignKey(
|
||||
Comment, on_delete=models.CASCADE, related_name='comment_likes', verbose_name='Комментарий'
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
CustomUser, on_delete=models.CASCADE, related_name='comment_likes', verbose_name='Пользователь'
|
||||
)
|
||||
is_like = models.BooleanField(verbose_name='Лайк')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Оценка комментария'
|
||||
verbose_name_plural = 'Оценки комментариев'
|
||||
unique_together = ['comment', 'user']
|
||||
|
||||
|
||||
class ContactMessage(models.Model):
|
||||
"""Модель сообщений обратной связи."""
|
||||
name = models.CharField(max_length=100, verbose_name='Имя')
|
||||
email = models.EmailField(verbose_name='Email')
|
||||
subject = models.CharField(max_length=200, verbose_name='Тема')
|
||||
message = models.TextField(verbose_name='Сообщение')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата')
|
||||
is_read = models.BooleanField(default=False, verbose_name='Прочитано')
|
||||
is_replied = models.BooleanField(default=False, verbose_name='Отвечено')
|
||||
admin_notes = models.TextField(blank=True, verbose_name='Заметки администратора')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Сообщение'
|
||||
verbose_name_plural = 'Сообщения'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}: {self.subject}'
|
||||
|
||||
|
||||
class SiteSettings(models.Model):
|
||||
"""Настройки сайта (Singleton)."""
|
||||
site_name = models.CharField(max_length=100, default='deev.space', verbose_name='Название сайта')
|
||||
site_description = models.TextField(blank=True, verbose_name='Описание сайта')
|
||||
owner_name = models.CharField(max_length=200, default='Деев Егор Викторович', verbose_name='Имя владельца')
|
||||
owner_title = models.CharField(max_length=200, default='Backend Developer', verbose_name='Должность')
|
||||
owner_bio = models.TextField(blank=True, verbose_name='О себе')
|
||||
owner_photo = models.ImageField(upload_to='site/', blank=True, null=True, verbose_name='Фото владельца')
|
||||
owner_email = models.EmailField(default='egor@deev.space', verbose_name='Email')
|
||||
owner_phone = models.CharField(max_length=20, blank=True, verbose_name='Телефон')
|
||||
owner_city = models.CharField(max_length=100, default='Москва', verbose_name='Город')
|
||||
telegram_url = models.URLField(blank=True, verbose_name='Telegram')
|
||||
github_url = models.URLField(blank=True, verbose_name='GitHub')
|
||||
vk_url = models.URLField(blank=True, verbose_name='VKontakte')
|
||||
linkedin_url = models.URLField(blank=True, verbose_name='LinkedIn')
|
||||
resume_file = models.FileField(upload_to='site/', blank=True, null=True, verbose_name='Файл резюме')
|
||||
yandex_metrika_id = models.CharField(max_length=20, blank=True, verbose_name='ID Яндекс.Метрики')
|
||||
google_analytics_id = models.CharField(max_length=20, blank=True, verbose_name='ID Google Analytics')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Настройки сайта'
|
||||
verbose_name_plural = 'Настройки сайта'
|
||||
|
||||
def __str__(self):
|
||||
return self.site_name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
obj, created = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
109
main/signals.py
Normal file
109
main/signals.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.conf import settings
|
||||
import requests
|
||||
import logging
|
||||
|
||||
from .models import Article
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Article)
|
||||
def publish_to_social_media(sender, instance, created, **kwargs):
|
||||
"""Автоматическая публикация новой статьи в социальные сети."""
|
||||
if not created:
|
||||
return
|
||||
|
||||
if not instance.is_published:
|
||||
return
|
||||
|
||||
if instance.is_achievement:
|
||||
return
|
||||
|
||||
# Telegram
|
||||
if settings.TELEGRAM_BOT_TOKEN and settings.TELEGRAM_CHANNEL_ID:
|
||||
try:
|
||||
publish_to_telegram(instance)
|
||||
logger.info(f'Статья "{instance.title}" опубликована в Telegram')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка публикации в Telegram: {e}')
|
||||
|
||||
# VK
|
||||
if settings.VK_ACCESS_TOKEN and settings.VK_GROUP_ID:
|
||||
try:
|
||||
publish_to_vk(instance)
|
||||
logger.info(f'Статья "{instance.title}" опубликована в VK')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка публикации в VK: {e}')
|
||||
|
||||
|
||||
def publish_to_telegram(article):
|
||||
"""Публикация статьи в Telegram-канал."""
|
||||
bot_token = settings.TELEGRAM_BOT_TOKEN
|
||||
channel_id = settings.TELEGRAM_CHANNEL_ID
|
||||
|
||||
article_url = f"https://deev.space{article.get_absolute_url()}"
|
||||
|
||||
text = f"📝 *{escape_markdown(article.title)}*\n\n"
|
||||
if article.excerpt:
|
||||
excerpt = article.excerpt[:200] + '...' if len(article.excerpt) > 200 else article.excerpt
|
||||
text += f"{escape_markdown(excerpt)}\n\n"
|
||||
text += f"[Читать полностью]({article_url})"
|
||||
|
||||
if article.img:
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
|
||||
data = {
|
||||
'chat_id': channel_id,
|
||||
'photo': f"https://deev.space{article.img.url}",
|
||||
'caption': text,
|
||||
'parse_mode': 'Markdown'
|
||||
}
|
||||
else:
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
data = {
|
||||
'chat_id': channel_id,
|
||||
'text': text,
|
||||
'parse_mode': 'Markdown',
|
||||
'disable_web_page_preview': False
|
||||
}
|
||||
|
||||
response = requests.post(url, data=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def publish_to_vk(article):
|
||||
"""Публикация статьи в группу VKontakte."""
|
||||
access_token = settings.VK_ACCESS_TOKEN
|
||||
group_id = settings.VK_GROUP_ID
|
||||
|
||||
article_url = f"https://deev.space{article.get_absolute_url()}"
|
||||
|
||||
message = f"📝 {article.title}\n\n"
|
||||
if article.excerpt:
|
||||
excerpt = article.excerpt[:300] + '...' if len(article.excerpt) > 300 else article.excerpt
|
||||
message += f"{excerpt}\n\n"
|
||||
message += f"🔗 Читать: {article_url}"
|
||||
|
||||
url = "https://api.vk.com/method/wall.post"
|
||||
params = {
|
||||
'owner_id': f'-{group_id}',
|
||||
'message': message,
|
||||
'from_group': 1,
|
||||
'access_token': access_token,
|
||||
'v': '5.131'
|
||||
}
|
||||
|
||||
if article.img:
|
||||
params['attachments'] = f"https://deev.space{article.img.url}"
|
||||
|
||||
response = requests.post(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def escape_markdown(text):
|
||||
"""Экранирование специальных символов Markdown."""
|
||||
escape_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
|
||||
for char in escape_chars:
|
||||
text = text.replace(char, f'\\{char}')
|
||||
return text
|
||||
39
main/sitemaps.py
Normal file
39
main/sitemaps.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from django.contrib.sitemaps import Sitemap
|
||||
from django.urls import reverse
|
||||
from .models import Article, Project
|
||||
|
||||
|
||||
class ArticleSitemap(Sitemap):
|
||||
changefreq = 'weekly'
|
||||
priority = 0.8
|
||||
|
||||
def items(self):
|
||||
return Article.objects.filter(is_published=True)
|
||||
|
||||
def lastmod(self, obj):
|
||||
return obj.updated_at
|
||||
|
||||
def location(self, obj):
|
||||
return obj.get_absolute_url()
|
||||
|
||||
|
||||
class ProjectSitemap(Sitemap):
|
||||
changefreq = 'monthly'
|
||||
priority = 0.7
|
||||
|
||||
def items(self):
|
||||
return Project.objects.filter(is_visible=True)
|
||||
|
||||
def location(self, obj):
|
||||
return f'/projects/#{obj.slug}'
|
||||
|
||||
|
||||
class StaticViewSitemap(Sitemap):
|
||||
priority = 0.5
|
||||
changefreq = 'monthly'
|
||||
|
||||
def items(self):
|
||||
return ['index', 'about', 'projects', 'blog', 'achievements', 'contacts']
|
||||
|
||||
def location(self, item):
|
||||
return reverse(item)
|
||||
141
main/templatetags/custom_filters.py
Normal file
141
main/templatetags/custom_filters.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
import re, math
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_item(dictionary, key):
|
||||
"""Получение значения из словаря по ключу."""
|
||||
return dictionary.get(key)
|
||||
|
||||
|
||||
@register.filter
|
||||
def split(value, separator=','):
|
||||
"""Разделение строки на список."""
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(separator) if item.strip()]
|
||||
|
||||
|
||||
@register.filter
|
||||
def first_letter(value):
|
||||
"""Получение первой буквы строки."""
|
||||
if value:
|
||||
return value[0].upper()
|
||||
return ''
|
||||
|
||||
|
||||
@register.filter
|
||||
def tech_icon(tech_name):
|
||||
"""Возвращает CSS класс иконки для технологии."""
|
||||
icons = {
|
||||
'python': 'devicon-python-plain',
|
||||
'django': 'devicon-django-plain',
|
||||
'fastapi': 'devicon-fastapi-plain',
|
||||
'flask': 'devicon-flask-original',
|
||||
'postgresql': 'devicon-postgresql-plain',
|
||||
'sqlite': 'devicon-sqlite-plain',
|
||||
'mysql': 'devicon-mysql-plain',
|
||||
'mongodb': 'devicon-mongodb-plain',
|
||||
'redis': 'devicon-redis-plain',
|
||||
'docker': 'devicon-docker-plain',
|
||||
'kubernetes': 'devicon-kubernetes-plain',
|
||||
'nginx': 'devicon-nginx-original',
|
||||
'git': 'devicon-git-plain',
|
||||
'github': 'fab fa-github',
|
||||
'gitlab': 'devicon-gitlab-plain',
|
||||
'linux': 'devicon-linux-plain',
|
||||
'bash': 'devicon-bash-plain',
|
||||
'html': 'devicon-html5-plain',
|
||||
'html5': 'devicon-html5-plain',
|
||||
'css': 'devicon-css3-plain',
|
||||
'css3': 'devicon-css3-plain',
|
||||
'javascript': 'devicon-javascript-plain',
|
||||
'js': 'devicon-javascript-plain',
|
||||
'typescript': 'devicon-typescript-plain',
|
||||
'react': 'devicon-react-original',
|
||||
'vue': 'devicon-vuejs-plain',
|
||||
'nodejs': 'devicon-nodejs-plain',
|
||||
'bootstrap': 'devicon-bootstrap-plain',
|
||||
'tailwind': 'devicon-tailwindcss-plain',
|
||||
'figma': 'devicon-figma-plain',
|
||||
'c': 'devicon-c-plain',
|
||||
'c++': 'devicon-cplusplus-plain',
|
||||
'cpp': 'devicon-cplusplus-plain',
|
||||
'java': 'devicon-java-plain',
|
||||
'rust': 'devicon-rust-plain',
|
||||
'go': 'devicon-go-plain',
|
||||
'qt': 'devicon-qt-original',
|
||||
'pyqt': 'devicon-qt-original',
|
||||
'asyncio': 'fas fa-bolt',
|
||||
'aiogram': 'fab fa-telegram',
|
||||
'telegram': 'fab fa-telegram',
|
||||
'request': 'fas fa-globe',
|
||||
'requests': 'fas fa-globe',
|
||||
'pandas': 'devicon-pandas-original',
|
||||
'numpy': 'devicon-numpy-original',
|
||||
'gtts': 'fas fa-microphone',
|
||||
'psycopg2': 'devicon-postgresql-plain',
|
||||
'chroma': 'fas fa-database',
|
||||
'flowise': 'fas fa-robot',
|
||||
'winscp': 'fas fa-server',
|
||||
}
|
||||
|
||||
tech_lower = tech_name.lower().strip()
|
||||
return icons.get(tech_lower, 'fas fa-code')
|
||||
|
||||
|
||||
@register.filter
|
||||
def status_class(status):
|
||||
"""Возвращает CSS класс для статуса проекта."""
|
||||
classes = {
|
||||
'completed': 'completed',
|
||||
'in_development': 'beta',
|
||||
'beta': 'beta',
|
||||
}
|
||||
return classes.get(status, 'beta')
|
||||
|
||||
|
||||
@register.filter
|
||||
def status_label(status):
|
||||
"""Возвращает текстовую метку для статуса."""
|
||||
labels = {
|
||||
'completed': 'Релиз',
|
||||
'in_development': 'В разработке',
|
||||
'beta': 'Бета',
|
||||
}
|
||||
return labels.get(status, status)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def render_tech_badge(tech_name):
|
||||
"""Рендерит HTML для бейджа технологии."""
|
||||
icon_class = tech_icon(tech_name)
|
||||
html = f'<span class="tech-badge"><i class="{icon_class}"></i> {tech_name}</span>'
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
@register.filter
|
||||
def linebreaks_list(value):
|
||||
"""Преобразует текст с переносами строк в список."""
|
||||
if not value:
|
||||
return []
|
||||
return [line.strip() for line in value.split('\n') if line.strip()]
|
||||
|
||||
|
||||
@register.filter
|
||||
def reading_time(text, words_per_minute=200):
|
||||
"""
|
||||
Рассчитывает время чтения текста в минутах
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
# Подсчет слов (простой способ)
|
||||
word_count = len(text.split())
|
||||
|
||||
# Расчет минут с округлением вверх
|
||||
minutes = math.ceil(word_count / words_per_minute)
|
||||
return max(1, minutes) # минимум 1 минута
|
||||
3
main/tests.py
Normal file
3
main/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
26
main/urls.py
Normal file
26
main/urls.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Основные страницы
|
||||
path('', views.IndexView.as_view(), name='index'),
|
||||
path('about/', views.AboutView.as_view(), name='about'),
|
||||
path('projects/', views.ProjectsView.as_view(), name='projects'),
|
||||
path('achievements/', views.AchievementsView.as_view(), name='achievements'),
|
||||
path('contacts/', views.ContactsView.as_view(), name='contacts'),
|
||||
|
||||
# Блог
|
||||
path('blog/', views.BlogView.as_view(), name='blog'),
|
||||
path('blog/category/<slug:category_slug>/', views.BlogView.as_view(), name='blog_category'),
|
||||
path('blog/<slug:slug>/', views.ArticleDetailView.as_view(), name='article_detail'),
|
||||
|
||||
# Аутентификация
|
||||
path('register/', views.register_view, name='register'),
|
||||
path('login/', views.login_view, name='login'),
|
||||
path('logout/', views.logout_view, name='logout'),
|
||||
|
||||
# API
|
||||
path('api/article/<int:article_id>/comment/', views.add_comment, name='add_comment'),
|
||||
path('api/article/<int:article_id>/like/', views.toggle_article_like, name='toggle_article_like'),
|
||||
path('api/comment/<int:comment_id>/like/', views.toggle_comment_like, name='toggle_comment_like'),
|
||||
]
|
||||
494
main/views.py
Normal file
494
main/views.py
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.http import JsonResponse
|
||||
from django.views.generic import ListView, DetailView, TemplateView
|
||||
from django.contrib.auth import login, authenticate, logout
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib import messages
|
||||
from django.core.paginator import Paginator
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.db.models import Q, Count
|
||||
from django.views.decorators.http import require_POST, require_GET
|
||||
from django.utils import timezone
|
||||
import json
|
||||
import logging
|
||||
|
||||
from .models import (
|
||||
Article, Project, Skill, Comment, ArticleLike, CommentLike,
|
||||
ContactMessage, Experience, Education, Category, SiteSettings
|
||||
)
|
||||
from .forms import RegisterForm, LoginForm, CommentForm, ContactForm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_site_settings():
|
||||
"""Получение настроек сайта."""
|
||||
return SiteSettings.load()
|
||||
|
||||
|
||||
class IndexView(TemplateView):
|
||||
"""Главная страница."""
|
||||
template_name = 'index.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
site_settings = get_site_settings()
|
||||
|
||||
context['site_settings'] = site_settings
|
||||
context['skills'] = Skill.objects.all()
|
||||
context['skills_by_category'] = self._get_skills_by_category()
|
||||
context['recent_articles'] = Article.objects.filter(
|
||||
is_published=True, is_achievement=False
|
||||
).select_related('category')[:3]
|
||||
context['featured_projects'] = Project.objects.filter(
|
||||
is_visible=True
|
||||
).order_by('order', '-date')[:6]
|
||||
context['experiences'] = Experience.objects.all()[:2]
|
||||
|
||||
context['page_title'] = f'{site_settings.owner_name} — {site_settings.owner_title}'
|
||||
context[
|
||||
'page_description'] = site_settings.site_description or f'Персональный сайт {site_settings.owner_title.lower()} {site_settings.owner_name}'
|
||||
|
||||
return context
|
||||
|
||||
def _get_skills_by_category(self):
|
||||
skills = Skill.objects.all()
|
||||
categories = {}
|
||||
for skill in skills:
|
||||
cat_display = skill.get_category_display()
|
||||
if cat_display not in categories:
|
||||
categories[cat_display] = []
|
||||
categories[cat_display].append(skill)
|
||||
return categories
|
||||
|
||||
|
||||
class AboutView(TemplateView):
|
||||
"""Страница 'Обо мне'."""
|
||||
template_name = 'about.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
site_settings = get_site_settings()
|
||||
|
||||
context['site_settings'] = site_settings
|
||||
context['experiences'] = Experience.objects.all()
|
||||
context['educations'] = Education.objects.all()
|
||||
context['skills'] = Skill.objects.all()
|
||||
context['skills_by_category'] = self._get_skills_by_category()
|
||||
|
||||
context['page_title'] = f'Обо мне — {site_settings.owner_name}'
|
||||
context[
|
||||
'page_description'] = f'Профессиональный путь, образование и навыки {site_settings.owner_title.lower()} {site_settings.owner_name}'
|
||||
|
||||
return context
|
||||
|
||||
def _get_skills_by_category(self):
|
||||
skills = Skill.objects.all()
|
||||
categories = {}
|
||||
for skill in skills:
|
||||
cat_display = skill.get_category_display()
|
||||
if cat_display not in categories:
|
||||
categories[cat_display] = []
|
||||
categories[cat_display].append(skill)
|
||||
return categories
|
||||
|
||||
|
||||
class ProjectsView(ListView):
|
||||
"""Страница проектов."""
|
||||
model = Project
|
||||
template_name = 'projects.html'
|
||||
context_object_name = 'projects'
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Project.objects.filter(is_visible=True)
|
||||
tech_filter = self.request.GET.get('tech')
|
||||
status_filter = self.request.GET.get('status')
|
||||
|
||||
if tech_filter:
|
||||
queryset = queryset.filter(technologies__icontains=tech_filter)
|
||||
|
||||
# Исправление фильтрации по статусу
|
||||
if status_filter:
|
||||
if status_filter == 'in_development':
|
||||
# Включаем оба статуса: in_development и beta
|
||||
queryset = queryset.filter(status__in=['in_development', 'beta'])
|
||||
else:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
return queryset.order_by('order', '-date')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
site_settings = get_site_settings()
|
||||
|
||||
# Получаем все видимые проекты для правильной фильтрации
|
||||
base_queryset = Project.objects.filter(is_visible=True)
|
||||
|
||||
tech_filter = self.request.GET.get('tech')
|
||||
status_filter = self.request.GET.get('status')
|
||||
|
||||
if tech_filter:
|
||||
base_queryset = base_queryset.filter(technologies__icontains=tech_filter)
|
||||
|
||||
# Разделение по статусам с учетом фильтра
|
||||
if status_filter == 'completed':
|
||||
context['completed_projects'] = base_queryset.filter(status='completed').order_by('order', '-date')
|
||||
context['dev_projects'] = Project.objects.none()
|
||||
elif status_filter == 'in_development':
|
||||
context['completed_projects'] = Project.objects.none()
|
||||
context['dev_projects'] = base_queryset.filter(status__in=['in_development', 'beta']).order_by('order',
|
||||
'-date')
|
||||
else:
|
||||
context['completed_projects'] = base_queryset.filter(status='completed').order_by('order', '-date')
|
||||
context['dev_projects'] = base_queryset.filter(status__in=['in_development', 'beta']).order_by('order',
|
||||
'-date')
|
||||
|
||||
# Все технологии для фильтра
|
||||
all_techs = set()
|
||||
for project in Project.objects.filter(is_visible=True):
|
||||
all_techs.update(project.get_technologies_list())
|
||||
context['all_technologies'] = sorted(all_techs)
|
||||
|
||||
context['current_tech'] = self.request.GET.get('tech', '')
|
||||
context['current_status'] = self.request.GET.get('status', '')
|
||||
|
||||
context['page_title'] = f'Проекты — {site_settings.owner_name}'
|
||||
context[
|
||||
'page_description'] = 'Портфолио проектов: Telegram-боты, веб-приложения, базы данных и инструменты разработки'
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class BlogView(ListView):
|
||||
"""Страница блога."""
|
||||
model = Article
|
||||
template_name = 'blog/blog.html'
|
||||
context_object_name = 'articles'
|
||||
paginate_by = 9
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Article.objects.filter(
|
||||
is_published=True, is_achievement=False
|
||||
).select_related('category')
|
||||
|
||||
category_slug = self.kwargs.get('category_slug')
|
||||
if category_slug:
|
||||
queryset = queryset.filter(category__slug=category_slug)
|
||||
|
||||
search_query = self.request.GET.get('q')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search_query) |
|
||||
Q(excerpt__icontains=search_query) |
|
||||
Q(post__icontains=search_query)
|
||||
)
|
||||
|
||||
return queryset.order_by('-date')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
site_settings = get_site_settings()
|
||||
|
||||
context['categories'] = Category.objects.annotate(
|
||||
count=Count('articles', filter=Q(articles__is_published=True, articles__is_achievement=False))
|
||||
).filter(count__gt=0)
|
||||
|
||||
category_slug = self.kwargs.get('category_slug')
|
||||
context['current_category'] = None
|
||||
if category_slug:
|
||||
context['current_category'] = get_object_or_404(Category, slug=category_slug)
|
||||
|
||||
context['search_query'] = self.request.GET.get('q', '')
|
||||
context['total_articles'] = Article.objects.filter(is_published=True, is_achievement=False).count()
|
||||
|
||||
context['page_title'] = f'Блог — {site_settings.owner_name}'
|
||||
context['page_description'] = 'Технические статьи, гайды и размышления о разработке'
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class ArticleDetailView(DetailView):
|
||||
"""Страница отдельной статьи."""
|
||||
model = Article
|
||||
template_name = 'blog/article.html'
|
||||
context_object_name = 'article'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
def get_queryset(self):
|
||||
return Article.objects.filter(is_published=True).select_related('category')
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
obj = super().get_object(queryset)
|
||||
obj.views += 1
|
||||
obj.save(update_fields=['views'])
|
||||
return obj
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
article = self.object
|
||||
|
||||
context['comments'] = article.comments.filter(
|
||||
is_approved=True, parent=None
|
||||
).select_related('user').prefetch_related(
|
||||
'replies__user', 'replies__replies__user'
|
||||
).order_by('-created_at')
|
||||
|
||||
context['comment_form'] = CommentForm()
|
||||
|
||||
context['user_like'] = None
|
||||
if self.request.user.is_authenticated:
|
||||
like = ArticleLike.objects.filter(
|
||||
article=article, user=self.request.user
|
||||
).first()
|
||||
context['user_like'] = like
|
||||
|
||||
context['related_articles'] = Article.objects.filter(
|
||||
is_published=True, is_achievement=False, category=article.category
|
||||
).exclude(pk=article.pk)[:3] if article.category else Article.objects.none()
|
||||
|
||||
context['page_title'] = f'{article.title} — Блог'
|
||||
context['page_description'] = article.excerpt or article.post[:160]
|
||||
context['og_image'] = article.img.url if article.img else None
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class AchievementsView(ListView):
|
||||
"""Страница достижений."""
|
||||
model = Article
|
||||
template_name = 'achievements.html'
|
||||
context_object_name = 'achievements'
|
||||
|
||||
def get_queryset(self):
|
||||
return Article.objects.filter(
|
||||
is_published=True, is_achievement=True
|
||||
).order_by('-achievement_date', '-date')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
site_settings = get_site_settings()
|
||||
|
||||
context['page_title'] = f'Достижения — {site_settings.owner_name}'
|
||||
context['page_description'] = 'Награды, сертификаты и профессиональные достижения'
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class ContactsView(TemplateView):
|
||||
"""Страница контактов."""
|
||||
template_name = 'contacts.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
site_settings = get_site_settings()
|
||||
|
||||
context['site_settings'] = site_settings
|
||||
context['contact_form'] = ContactForm()
|
||||
|
||||
context['smartcaptcha_client_key'] = settings.SMARTCAPTCHA_CLIENT_KEY
|
||||
|
||||
context['page_title'] = f'Контакты — {site_settings.owner_name}'
|
||||
context['page_description'] = 'Свяжитесь со мной: Telegram, email, социальные сети и форма обратной связи'
|
||||
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
form = ContactForm(request.POST)
|
||||
if form.is_valid():
|
||||
message = form.save()
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=f'[deev.space] Новое сообщение: {message.subject}',
|
||||
message=f'От: {message.name} ({message.email})\n\nТема: {message.subject}\n\n{message.message}',
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[settings.CONTACT_EMAIL],
|
||||
fail_silently=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки email: {e}')
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({'success': True, 'message': 'Сообщение успешно отправлено!'})
|
||||
|
||||
messages.success(request, 'Сообщение успешно отправлено!')
|
||||
return redirect('contacts')
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({'success': False, 'errors': form.errors}, status=400)
|
||||
|
||||
context = self.get_context_data()
|
||||
context['contact_form'] = form
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
|
||||
def register_view(request):
|
||||
"""Регистрация пользователя."""
|
||||
if request.user.is_authenticated:
|
||||
return redirect('index')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = RegisterForm(request.POST)
|
||||
if form.is_valid():
|
||||
user = form.save()
|
||||
login(request, user)
|
||||
messages.success(request, f'Добро пожаловать, {user.username}!')
|
||||
|
||||
next_url = request.GET.get('next', 'index')
|
||||
return redirect(next_url)
|
||||
else:
|
||||
form = RegisterForm()
|
||||
|
||||
return render(request, 'auth/register.html', {
|
||||
'form': form,
|
||||
'page_title': 'Регистрация — deev.space'
|
||||
})
|
||||
|
||||
|
||||
def login_view(request):
|
||||
"""Авторизация пользователя."""
|
||||
if request.user.is_authenticated:
|
||||
return redirect('index')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = LoginForm(request, data=request.POST)
|
||||
if form.is_valid():
|
||||
user = form.get_user()
|
||||
login(request, user)
|
||||
messages.success(request, f'С возвращением, {user.username}!')
|
||||
|
||||
next_url = request.GET.get('next', 'index')
|
||||
return redirect(next_url)
|
||||
else:
|
||||
form = LoginForm()
|
||||
|
||||
return render(request, 'auth/login.html', {
|
||||
'form': form,
|
||||
'page_title': 'Вход — deev.space'
|
||||
})
|
||||
|
||||
|
||||
def logout_view(request):
|
||||
"""Выход из системы."""
|
||||
logout(request)
|
||||
messages.info(request, 'Вы вышли из системы')
|
||||
return redirect('index')
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def add_comment(request, article_id):
|
||||
"""Добавление комментария к статье."""
|
||||
article = get_object_or_404(Article, id=article_id, is_published=True)
|
||||
form = CommentForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
comment = form.save(commit=False)
|
||||
comment.article = article
|
||||
comment.user = request.user
|
||||
|
||||
parent_id = request.POST.get('parent_id')
|
||||
if parent_id:
|
||||
parent = get_object_or_404(Comment, id=parent_id, article=article)
|
||||
if parent.nesting_level < 3:
|
||||
comment.parent = parent
|
||||
|
||||
comment.save()
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'comment': {
|
||||
'id': comment.id,
|
||||
'user': comment.user.username,
|
||||
'avatar_letter': comment.user.get_avatar_letter(),
|
||||
'content': comment.content,
|
||||
'created_at': comment.created_at.strftime('%d.%m.%Y %H:%M'),
|
||||
'likes': 0,
|
||||
'dislikes': 0,
|
||||
}
|
||||
})
|
||||
|
||||
return JsonResponse({'success': False, 'errors': form.errors}, status=400)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def toggle_article_like(request, article_id):
|
||||
"""Лайк/дизлайк статьи."""
|
||||
article = get_object_or_404(Article, id=article_id, is_published=True)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
is_like = data.get('is_like', True)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400)
|
||||
|
||||
like, created = ArticleLike.objects.get_or_create(
|
||||
article=article, user=request.user,
|
||||
defaults={'is_like': is_like}
|
||||
)
|
||||
|
||||
user_vote = None
|
||||
if not created:
|
||||
if like.is_like == is_like:
|
||||
like.delete()
|
||||
else:
|
||||
like.is_like = is_like
|
||||
like.save()
|
||||
user_vote = is_like
|
||||
else:
|
||||
user_vote = is_like
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'likes': article.likes_count,
|
||||
'dislikes': article.dislikes_count,
|
||||
'user_vote': user_vote
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def toggle_comment_like(request, comment_id):
|
||||
"""Лайк/дизлайк комментария."""
|
||||
comment = get_object_or_404(Comment, id=comment_id, is_approved=True)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
is_like = data.get('is_like', True)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400)
|
||||
|
||||
like, created = CommentLike.objects.get_or_create(
|
||||
comment=comment, user=request.user,
|
||||
defaults={'is_like': is_like}
|
||||
)
|
||||
|
||||
user_vote = None
|
||||
if not created:
|
||||
if like.is_like == is_like:
|
||||
like.delete()
|
||||
else:
|
||||
like.is_like = is_like
|
||||
like.save()
|
||||
user_vote = is_like
|
||||
else:
|
||||
user_vote = is_like
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'likes': comment.likes_count,
|
||||
'dislikes': comment.dislikes_count,
|
||||
'user_vote': user_vote
|
||||
})
|
||||
|
||||
|
||||
def handler404(request, exception):
|
||||
"""Обработчик ошибки 404."""
|
||||
return render(request, 'errors/404.html', status=404)
|
||||
|
||||
|
||||
def handler500(request):
|
||||
"""Обработчик ошибки 500."""
|
||||
return render(request, 'errors/500.html', status=500)
|
||||
22
manage.py
Normal file
22
manage.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dspace.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
BIN
media/favicon.ico
Normal file
BIN
media/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
7
media/sitemap.xml
Normal file
7
media/sitemap.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://deev.space</loc>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
Django>=4.2,<5.0
|
||||
Pillow>=10.0.0
|
||||
python-slugify>=8.0.0
|
||||
Unidecode>=1.3.0
|
||||
bleach>=6.0.0
|
||||
requests>=2.31.0
|
||||
python-telegram-bot>=20.0
|
||||
vk-api>=11.9.9
|
||||
django-recaptcha>=3.0.0
|
||||
gunicorn>=21.0.0
|
||||
4029
static/css/style.css
Normal file
4029
static/css/style.css
Normal file
File diff suppressed because it is too large
Load diff
BIN
static/img/favicon.ico
Normal file
BIN
static/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
static/img/hero.jpg
Normal file
BIN
static/img/hero.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
static/img/logo.png
Normal file
BIN
static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
456
static/js/main.js
Normal file
456
static/js/main.js
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* deev.space - Main JavaScript
|
||||
* @author Егор Деев
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ===== DOM Ready =====
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initAOS();
|
||||
initNavigation();
|
||||
initBackToTop();
|
||||
initMessages();
|
||||
initContactForm();
|
||||
});
|
||||
|
||||
// ===== AOS Animation =====
|
||||
function initAOS() {
|
||||
if (typeof AOS !== 'undefined') {
|
||||
AOS.init({
|
||||
duration: 600,
|
||||
easing: 'ease-out-cubic',
|
||||
once: true,
|
||||
offset: 50,
|
||||
delay: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Navigation =====
|
||||
function initNavigation() {
|
||||
const navbar = document.getElementById('navbar');
|
||||
const navToggle = document.getElementById('navToggle');
|
||||
const navMenu = document.getElementById('navMenu');
|
||||
|
||||
// Mobile menu toggle
|
||||
if (navToggle && navMenu) {
|
||||
navToggle.addEventListener('click', function() {
|
||||
const isExpanded = navToggle.getAttribute('aria-expanded') === 'true';
|
||||
navToggle.setAttribute('aria-expanded', !isExpanded);
|
||||
navToggle.classList.toggle('active');
|
||||
navMenu.classList.toggle('active');
|
||||
document.body.classList.toggle('nav-open');
|
||||
});
|
||||
|
||||
// Close menu on link click
|
||||
navMenu.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
navToggle.setAttribute('aria-expanded', 'false');
|
||||
navToggle.classList.remove('active');
|
||||
navMenu.classList.remove('active');
|
||||
document.body.classList.remove('nav-open');
|
||||
});
|
||||
});
|
||||
|
||||
// Close menu on outside click
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!navMenu.contains(e.target) && !navToggle.contains(e.target)) {
|
||||
navToggle.setAttribute('aria-expanded', 'false');
|
||||
navToggle.classList.remove('active');
|
||||
navMenu.classList.remove('active');
|
||||
document.body.classList.remove('nav-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar scroll effect
|
||||
let lastScroll = 0;
|
||||
window.addEventListener('scroll', function() {
|
||||
const currentScroll = window.pageYOffset;
|
||||
|
||||
if (currentScroll > 50) {
|
||||
navbar.classList.add('scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('scrolled');
|
||||
}
|
||||
|
||||
// Hide/show on scroll
|
||||
if (currentScroll > lastScroll && currentScroll > 200) {
|
||||
navbar.classList.add('hidden');
|
||||
} else {
|
||||
navbar.classList.remove('hidden');
|
||||
}
|
||||
|
||||
lastScroll = currentScroll;
|
||||
});
|
||||
|
||||
// Smooth scroll for anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
const targetId = this.getAttribute('href');
|
||||
if (targetId === '#') return;
|
||||
|
||||
const target = document.querySelector(targetId);
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
const headerOffset = 100;
|
||||
const elementPosition = target.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Back to Top Button =====
|
||||
function initBackToTop() {
|
||||
const backToTop = document.getElementById('backToTop');
|
||||
if (!backToTop) return;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
if (window.pageYOffset > 500) {
|
||||
backToTop.classList.add('visible');
|
||||
} else {
|
||||
backToTop.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
backToTop.addEventListener('click', function() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Messages =====
|
||||
function initMessages() {
|
||||
const messagesContainer = document.getElementById('messagesContainer');
|
||||
if (!messagesContainer) return;
|
||||
|
||||
messagesContainer.querySelectorAll('.message').forEach(message => {
|
||||
// Auto hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
hideMessage(message);
|
||||
}, 5000);
|
||||
|
||||
// Close button
|
||||
const closeBtn = message.querySelector('.message-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => hideMessage(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function hideMessage(message) {
|
||||
message.classList.add('hiding');
|
||||
setTimeout(() => message.remove(), 300);
|
||||
}
|
||||
|
||||
// ===== Contact Form =====
|
||||
function initContactForm() {
|
||||
const contactForm = document.getElementById('contact-form');
|
||||
if (!contactForm) return;
|
||||
|
||||
contactForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
const originalContent = submitBtn.innerHTML;
|
||||
|
||||
// Disable button and show loading
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Отправка...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(this);
|
||||
const response = await fetch(this.action, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
contactForm.reset();
|
||||
showNotification(data.message || 'Сообщение успешно отправлено!', 'success');
|
||||
|
||||
// Reset reCAPTCHA if exists
|
||||
if (typeof grecaptcha !== 'undefined') {
|
||||
grecaptcha.reset();
|
||||
}
|
||||
} else {
|
||||
const errorMsg = data.errors ? Object.values(data.errors).flat().join(', ') : 'Ошибка при отправке';
|
||||
showNotification(errorMsg, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
showNotification('Произошла ошибка. Попробуйте позже.', 'error');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalContent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== CSRF Token =====
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
// ===== Notifications =====
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
const container = document.getElementById('messagesContainer') || createNotificationContainer();
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `message message-${type}`;
|
||||
notification.innerHTML = `
|
||||
<i class="fas ${getNotificationIcon(type)}"></i>
|
||||
<span>${message}</span>
|
||||
<button type="button" class="message-close" aria-label="Закрыть">×</button>
|
||||
`;
|
||||
|
||||
container.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => notification.classList.add('show'), 10);
|
||||
|
||||
// Close button
|
||||
notification.querySelector('.message-close').addEventListener('click', () => {
|
||||
hideMessage(notification);
|
||||
});
|
||||
|
||||
// Auto hide
|
||||
setTimeout(() => hideMessage(notification), 5000);
|
||||
};
|
||||
|
||||
function createNotificationContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'messagesContainer';
|
||||
container.className = 'messages-container';
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function getNotificationIcon(type) {
|
||||
const icons = {
|
||||
success: 'fa-check-circle',
|
||||
error: 'fa-exclamation-circle',
|
||||
warning: 'fa-exclamation-triangle',
|
||||
info: 'fa-info-circle'
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
}
|
||||
|
||||
// ===== Article Like/Dislike =====
|
||||
window.toggleArticleLike = async function(articleId, isLike) {
|
||||
try {
|
||||
const response = await fetch(`/api/article/${articleId}/like/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
},
|
||||
body: JSON.stringify({ is_like: isLike })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
showNotification('Войдите, чтобы оценить статью', 'warning');
|
||||
return;
|
||||
}
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
updateVoteButtons(`article-${articleId}`, data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('Ошибка при оценке статьи', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Comment Like/Dislike =====
|
||||
window.toggleCommentLike = async function(commentId, isLike) {
|
||||
try {
|
||||
const response = await fetch(`/api/comment/${commentId}/like/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
},
|
||||
body: JSON.stringify({ is_like: isLike })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
showNotification('Войдите, чтобы оценить комментарий', 'warning');
|
||||
return;
|
||||
}
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
updateVoteButtons(`comment-${commentId}`, data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('Ошибка при оценке комментария', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
function updateVoteButtons(prefix, data) {
|
||||
const likesEl = document.getElementById(`${prefix}-likes`);
|
||||
const dislikesEl = document.getElementById(`${prefix}-dislikes`);
|
||||
const likeBtn = document.getElementById(`${prefix}-like-btn`);
|
||||
const dislikeBtn = document.getElementById(`${prefix}-dislike-btn`);
|
||||
|
||||
if (likesEl) likesEl.textContent = data.likes;
|
||||
if (dislikesEl) dislikesEl.textContent = data.dislikes;
|
||||
|
||||
if (likeBtn && dislikeBtn) {
|
||||
likeBtn.classList.remove('active');
|
||||
dislikeBtn.classList.remove('active');
|
||||
|
||||
// Update icons
|
||||
const likeIcon = likeBtn.querySelector('i');
|
||||
const dislikeIcon = dislikeBtn.querySelector('i');
|
||||
|
||||
if (likeIcon) {
|
||||
likeIcon.className = data.user_vote === true ? 'fas fa-thumbs-up' : 'far fa-thumbs-up';
|
||||
}
|
||||
if (dislikeIcon) {
|
||||
dislikeIcon.className = data.user_vote === false ? 'fas fa-thumbs-down' : 'far fa-thumbs-down';
|
||||
}
|
||||
|
||||
if (data.user_vote === true) {
|
||||
likeBtn.classList.add('active');
|
||||
} else if (data.user_vote === false) {
|
||||
dislikeBtn.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Comments =====
|
||||
window.submitComment = async function(articleId, parentId = null) {
|
||||
const form = parentId
|
||||
? document.getElementById(`reply-form-${parentId}`)
|
||||
: document.getElementById('comment-form');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
const originalContent = submitBtn.innerHTML;
|
||||
const textarea = form.querySelector('textarea');
|
||||
const content = textarea.value.trim();
|
||||
|
||||
if (!content) {
|
||||
showNotification('Введите текст комментария', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
if (parentId) {
|
||||
formData.append('parent_id', parentId);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/article/${articleId}/comment/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showNotification('Комментарий добавлен!', 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
const errorMsg = data.errors ? Object.values(data.errors).flat().join(', ') : 'Ошибка';
|
||||
showNotification(errorMsg, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('Ошибка при добавлении комментария', 'error');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalContent;
|
||||
}
|
||||
};
|
||||
|
||||
window.showReplyForm = function(commentId) {
|
||||
// Hide all reply forms first
|
||||
document.querySelectorAll('.reply-form-container').forEach(form => {
|
||||
form.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show the specific reply form
|
||||
const replyForm = document.getElementById(`reply-form-container-${commentId}`);
|
||||
if (replyForm) {
|
||||
replyForm.style.display = 'block';
|
||||
const textarea = replyForm.querySelector('textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.hideReplyForm = function(commentId) {
|
||||
const replyForm = document.getElementById(`reply-form-container-${commentId}`);
|
||||
if (replyForm) {
|
||||
replyForm.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Copy to Clipboard =====
|
||||
window.copyToClipboard = function(text, button) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||
button.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('copied');
|
||||
}, 2000);
|
||||
|
||||
showNotification('Скопировано!', 'success');
|
||||
}).catch(err => {
|
||||
console.error('Copy failed:', err);
|
||||
showNotification('Ошибка копирования', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
})();
|
||||
286
templates/about.html
Normal file
286
templates/about.html
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
{% extends 'wrapper.html' %}
|
||||
{% load static %}
|
||||
{% load custom_filters %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header" data-aos="fade-up">
|
||||
<h1 class="page-title">
|
||||
<span class="page-icon"><i class="fas fa-user"></i></span>
|
||||
Обо мне
|
||||
</h1>
|
||||
<p class="page-subtitle">Профессиональный путь, образование и навыки</p>
|
||||
</div>
|
||||
|
||||
<!-- About Bento Grid -->
|
||||
<div class="about-bento">
|
||||
|
||||
<!-- Bio Card -->
|
||||
<div class="bento-card about-bio" data-aos="fade-up">
|
||||
<div class="about-bio-header">
|
||||
<div class="about-photo">
|
||||
{% if site_settings.owner_photo %}
|
||||
<img src="{{ site_settings.owner_photo.url }}" alt="{{ site_settings.owner_name }}">
|
||||
{% else %}
|
||||
<img src="{% static 'images/hero.png' %}" alt="{{ site_settings.owner_name }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="about-bio-info">
|
||||
<h2>{{ site_settings.owner_name }}</h2>
|
||||
<p class="about-title">{{ site_settings.owner_title }}</p>
|
||||
<div class="about-location">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
{{ site_settings.owner_city }}, Россия
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-bio-text">
|
||||
{{ site_settings.owner_bio|linebreaks }}
|
||||
</div>
|
||||
<div class="about-bio-actions">
|
||||
{% if site_settings.resume_file %}
|
||||
<a href="{{ site_settings.resume_file.url }}" target="_blank" class="btn btn-primary">
|
||||
<i class="fas fa-download"></i>
|
||||
Скачать резюме
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'contacts' %}" class="btn btn-outline">
|
||||
<i class="fas fa-envelope"></i>
|
||||
Связаться
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="bento-card about-stats" data-aos="fade-up" data-aos-delay="100">
|
||||
<h3 class="bento-card-title">
|
||||
<i class="fas fa-chart-bar"></i>
|
||||
В цифрах
|
||||
</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">3+</span>
|
||||
<span class="stat-label">Года опыта</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">18</span>
|
||||
<span class="stat-label">Программных проектов</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">17k+</span>
|
||||
<span class="stat-label">Пользователей у программ</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">3</span>
|
||||
<span class="stat-label">Команды разработки</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">20+</span>
|
||||
<span class="stat-label">Технологический стек</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Experience Timeline -->
|
||||
<div class="bento-card timeline-card" data-aos="fade-up" data-aos-delay="200">
|
||||
<h3 class="bento-card-title">
|
||||
<i class="fas fa-briefcase"></i>
|
||||
Опыт работы
|
||||
</h3>
|
||||
<div class="timeline">
|
||||
{% for exp in experiences %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker"></div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="timeline-date">
|
||||
{{ exp.start_date|date:"m.Y" }} —
|
||||
{% if exp.is_current %}н.в.{% else %}{{ exp.end_date|date:"m.Y" }}{% endif %}
|
||||
</span>
|
||||
<span class="timeline-duration">{{ exp.duration }}</span>
|
||||
</div>
|
||||
<h4 class="timeline-title">{{ exp.title }}</h4>
|
||||
<p class="timeline-subtitle">{{ exp.company }}</p>
|
||||
<p class="timeline-description">{{ exp.description }}</p>
|
||||
|
||||
{% if exp.responsibilities %}
|
||||
<ul class="timeline-list">
|
||||
{% for resp in exp.get_responsibilities_list %}
|
||||
<li><i class="fas fa-check"></i> {{ resp }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if exp.technologies %}
|
||||
<div class="timeline-tech">
|
||||
{% for tech in exp.get_technologies_list %}
|
||||
<span class="tech-badge">
|
||||
<i class="{{ tech|tech_icon }}"></i>
|
||||
{{ tech }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Education Timeline -->
|
||||
<div class="bento-card timeline-card" data-aos="fade-up" data-aos-delay="300">
|
||||
<h3 class="bento-card-title">
|
||||
<i class="fas fa-graduation-cap"></i>
|
||||
Образование
|
||||
</h3>
|
||||
<div class="timeline">
|
||||
{% for edu in educations %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker">
|
||||
<!-- <i class="{{ edu.icon }}"></i> -->
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="timeline-date">
|
||||
{{ edu.start_year }} —
|
||||
{% if edu.is_current %}н.в.{% else %}{{ edu.end_year }}{% endif %}
|
||||
</span>
|
||||
<span class="education-type education-type-{{ edu.education_type }}">
|
||||
{{ edu.get_education_type_display }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="timeline-title">{{ edu.degree }}</h4>
|
||||
<p class="timeline-subtitle">{{ edu.institution_short|default:edu.institution }}</p>
|
||||
|
||||
{% if edu.description %}
|
||||
<p class="timeline-description">{{ edu.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if edu.achievements %}
|
||||
<ul class="timeline-list">
|
||||
{% for ach in edu.get_achievements_list %}
|
||||
<li><i class="fas fa-check"></i> {{ ach }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if edu.certificate_number %}
|
||||
<div class="timeline-certificate">
|
||||
<i class="fas fa-certificate"></i>
|
||||
Сертификат: {{ edu.certificate_number }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Full -->
|
||||
<div class="bento-card skills-full" data-aos="fade-up" data-aos-delay="400">
|
||||
<h3 class="bento-card-title">
|
||||
<i class="fas fa-laptop-code"></i>
|
||||
Технические навыки
|
||||
</h3>
|
||||
<div class="skills-full-grid">
|
||||
{% for category_name, category_skills in skills_by_category.items %}
|
||||
<div class="skill-category-card">
|
||||
<h4 class="skill-category-title">{{ category_name }}</h4>
|
||||
<div class="skill-tags">
|
||||
{% for skill in category_skills %}
|
||||
<span class="skill-tag">
|
||||
<i class="{{ skill.icon|default:'fas fa-code' }}"></i>
|
||||
{{ skill.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Soft Skills & Interests -->
|
||||
<div class="bento-card languages-card" data-aos="fade-up" data-aos-delay="500">
|
||||
<h3 class="bento-card-title">
|
||||
<i class="fas fa-brain"></i>
|
||||
Soft Skills
|
||||
</h3>
|
||||
<div class="soft-skills-list">
|
||||
<div class="soft-skill-item">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>Навык налаживания контакта с коллегами</span>
|
||||
</div>
|
||||
<div class="soft-skill-item">
|
||||
<i class="fas fa-user-tie"></i>
|
||||
<span>Опыт управления командой разработки</span>
|
||||
</div>
|
||||
<div class="soft-skill-item">
|
||||
<i class="fas fa-book-reader"></i>
|
||||
<span>Лёгкость в освоении нового материала</span>
|
||||
</div>
|
||||
<div class="soft-skill-item">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>Умение эффективно распределять время</span>
|
||||
</div>
|
||||
<div class="soft-skill-item">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<span>Гибкость в процессе выполнения задач</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interests -->
|
||||
<div class="bento-card languages-card" data-aos="fade-up" data-aos-delay="600">
|
||||
<h3 class="bento-card-title">
|
||||
<i class="fas fa-heart"></i>
|
||||
Интересы
|
||||
</h3>
|
||||
<div class="interests-list">
|
||||
<div class="interest-item">
|
||||
<i class="fab fa-github"></i>
|
||||
<span>OpenSource разработка</span>
|
||||
</div>
|
||||
<div class="interest-item">
|
||||
<i class="fas fa-server"></i>
|
||||
<span>Инфраструктурные задачи нагруженных систем</span>
|
||||
</div>
|
||||
<div class="interest-item">
|
||||
<i class="fas fa-network-wired"></i>
|
||||
<span>Отказоустойчивые распределённые системы</span>
|
||||
</div>
|
||||
<div class="interest-item">
|
||||
<i class="fas fa-robot"></i>
|
||||
<span>Обучение нейросетей и ML</span>
|
||||
</div>
|
||||
<div class="interest-item">
|
||||
<i class="fas fa-cogs"></i>
|
||||
<span>Автоматизация процессов</span>
|
||||
</div>
|
||||
<div class="interest-item">
|
||||
<i class="fas fa-plug"></i>
|
||||
<span>Создание API и микросервисов</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Languages -->
|
||||
<div class="bento-card languages-card" data-aos="fade-up" data-aos-delay="700">
|
||||
<h3 class="bento-card-title">
|
||||
<i class="fas fa-language"></i>
|
||||
Языки
|
||||
</h3>
|
||||
<div class="languages-list">
|
||||
<div class="language-item">
|
||||
<span class="language-name">Русский</span>
|
||||
<span class="language-level">Родной</span>
|
||||
</div>
|
||||
<div class="language-item">
|
||||
<span class="language-name">English</span>
|
||||
<span class="language-level">B1 (технический)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
52
templates/achievements.html
Normal file
52
templates/achievements.html
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{% extends 'wrapper.html' %}
|
||||
{% load static %}
|
||||
{% load custom_filters %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header" data-aos="fade-up">
|
||||
<h1 class="page-title">
|
||||
<span class="page-icon"><i class="fas fa-trophy"></i></span>
|
||||
Достижения
|
||||
</h1>
|
||||
<p class="page-subtitle">Награды, сертификаты и профессиональные достижения</p>
|
||||
</div>
|
||||
|
||||
<!-- Achievements Grid -->
|
||||
{% if achievements %}
|
||||
<div class="achievements-grid">
|
||||
{% for achievement in achievements %}
|
||||
<a href="{{ achievement.get_absolute_url }}" class="achievement-card" data-aos="fade-up" data-aos-delay="{{ forloop.counter0 }}00">
|
||||
<div class="achievement-icon">
|
||||
<i class="{{ achievement.achievement_icon|default:'fas fa-trophy' }}"></i>
|
||||
</div>
|
||||
<div class="achievement-content">
|
||||
<h3 class="achievement-title">{{ achievement.title }}</h3>
|
||||
{% if achievement.sub_title %}
|
||||
<p class="achievement-subtitle">{{ achievement.sub_title }}</p>
|
||||
{% endif %}
|
||||
<p class="achievement-excerpt">{{ achievement.excerpt|truncatewords:20 }}</p>
|
||||
{% if achievement.achievement_date %}
|
||||
<span class="achievement-date">
|
||||
<i class="far fa-calendar-alt"></i>
|
||||
{{ achievement.achievement_date|date:"d.m.Y" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- <div class="achievement-arrow">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</div> -->
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" data-aos="fade-up">
|
||||
<i class="fas fa-trophy"></i>
|
||||
<h3>Достижения скоро появятся</h3>
|
||||
<p>Здесь будут отображаться награды и сертификаты</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
65
templates/auth/login.html
Normal file
65
templates/auth/login.html
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{% extends 'wrapper.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card" data-aos="fade-up">
|
||||
<div class="auth-header">
|
||||
<a href="{% url 'index' %}" class="logo">
|
||||
<span class="logo-deev">deev</span>
|
||||
<span class="logo-dot"></span>
|
||||
<span class="logo-space">space</span>
|
||||
</a>
|
||||
<h1 class="auth-title">Вход в аккаунт</h1>
|
||||
<p class="auth-subtitle">Войдите, чтобы оставлять комментарии и оценивать статьи</p>
|
||||
</div>
|
||||
|
||||
<form method="post" class="auth-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="auth-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
{{ form.non_field_errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_username" class="form-label">
|
||||
<i class="fas fa-user"></i>
|
||||
Имя пользователя
|
||||
</label>
|
||||
{{ form.username }}
|
||||
{% if form.username.errors %}
|
||||
<span class="form-error">{{ form.username.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_password" class="form-label">
|
||||
<i class="fas fa-lock"></i>
|
||||
Пароль
|
||||
</label>
|
||||
{{ form.password }}
|
||||
{% if form.password.errors %}
|
||||
<span class="form-error">{{ form.password.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group form-checkbox">
|
||||
{{ form.remember_me }}
|
||||
<label for="id_remember_me">Запомнить меня</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-full">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
Войти
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Нет аккаунта? <a href="{% url 'register' %}{% if request.GET.next %}?next={{ request.GET.next }}{% endif %}">Зарегистрируйтесь</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
91
templates/auth/register.html
Normal file
91
templates/auth/register.html
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
{% extends 'wrapper.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card" data-aos="fade-up">
|
||||
<div class="auth-header">
|
||||
<a href="{% url 'index' %}" class="logo">
|
||||
<span class="logo-deev">deev</span>
|
||||
<span class="logo-dot"></span>
|
||||
<span class="logo-space">space</span>
|
||||
</a>
|
||||
<h1 class="auth-title">Регистрация</h1>
|
||||
<p class="auth-subtitle">Создайте аккаунт для участия в обсуждениях</p>
|
||||
</div>
|
||||
|
||||
<form method="post" class="auth-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="auth-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
{{ form.non_field_errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_username" class="form-label">
|
||||
<i class="fas fa-user"></i>
|
||||
Имя пользователя *
|
||||
</label>
|
||||
{{ form.username }}
|
||||
<span class="form-hint">Только латинские буквы, цифры и подчёркивания</span>
|
||||
{% if form.username.errors %}
|
||||
<span class="form-error">{{ form.username.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_email" class="form-label">
|
||||
<i class="fas fa-envelope"></i>
|
||||
Email
|
||||
</label>
|
||||
{{ form.email }}
|
||||
<span class="form-hint">Необязательно, но нужен для восстановления пароля</span>
|
||||
{% if form.email.errors %}
|
||||
<span class="form-error">{{ form.email.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_password" class="form-label">
|
||||
<i class="fas fa-lock"></i>
|
||||
Пароль *
|
||||
</label>
|
||||
{{ form.password }}
|
||||
{% if form.password.errors %}
|
||||
<span class="form-error">{{ form.password.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_password_confirm" class="form-label">
|
||||
<i class="fas fa-lock"></i>
|
||||
Подтвердите пароль *
|
||||
</label>
|
||||
{{ form.password_confirm }}
|
||||
{% if form.password_confirm.errors %}
|
||||
<span class="form-error">{{ form.password_confirm.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group captcha-group">
|
||||
{{ form.captcha }}
|
||||
{% if form.captcha.errors %}
|
||||
<span class="form-error">{{ form.captcha.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-full">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
Зарегистрироваться
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Уже есть аккаунт? <a href="{% url 'login' %}{% if request.GET.next %}?next={{ request.GET.next }}{% endif %}">Войдите</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
216
templates/blog/article.html
Normal file
216
templates/blog/article.html
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
{% extends 'wrapper.html' %}
|
||||
{% load static %}
|
||||
{% load custom_filters %}
|
||||
|
||||
|
||||
{% block og_type %}article{% endblock %}
|
||||
|
||||
{% block extra_schema %}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
"headline": "{{ article.title }}",
|
||||
"description": "{{ article.excerpt|truncatewords:30 }}",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "{{ article.author }}"
|
||||
},
|
||||
"datePublished": "{{ article.date|date:'c' }}",
|
||||
"dateModified": "{{ article.updated_at|date:'c' }}",
|
||||
{% if article.img %}
|
||||
"image": "https://deev.space{{ article.img.url }}",
|
||||
{% endif %}
|
||||
"publisher": {
|
||||
"@type": "Person",
|
||||
"name": "{{ global_settings.owner_name }}"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<article class="article-full">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb" data-aos="fade-up" aria-label="Навигация">
|
||||
<a href="{% url 'index' %}">Главная</a>
|
||||
<span class="breadcrumb-separator"><i class="fas fa-chevron-right"></i></span>
|
||||
<a href="{% url 'blog' %}">Блог</a>
|
||||
{% if article.category %}
|
||||
<span class="breadcrumb-separator"><i class="fas fa-chevron-right"></i></span>
|
||||
<a href="{{ article.category.get_absolute_url }}">{{ article.category.name }}</a>
|
||||
{% endif %}
|
||||
<span class="breadcrumb-separator"><i class="fas fa-chevron-right"></i></span>
|
||||
<span class="breadcrumb-current">{{ article.title|truncatewords:5 }}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Article Header -->
|
||||
<header class="article-header" data-aos="fade-up" data-aos-delay="100">
|
||||
{% if article.category %}
|
||||
<a href="{{ article.category.get_absolute_url }}" class="article-category-badge">
|
||||
<i class="{{ article.category.icon }}"></i>
|
||||
{{ article.category.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<h1 class="article-full-title">{{ article.title }}</h1>
|
||||
|
||||
{% if article.sub_title %}
|
||||
<p class="article-full-subtitle">{{ article.sub_title }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="article-meta-full">
|
||||
<div class="article-author">
|
||||
<span class="author-avatar">{{ article.author|first_letter }}</span>
|
||||
<span class="author-name">{{ article.author }}</span>
|
||||
</div>
|
||||
<div class="article-meta-items">
|
||||
<span class="meta-item">
|
||||
<i class="far fa-calendar-alt"></i>
|
||||
{{ article.date|date:"d.m.Y" }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="far fa-clock"></i>
|
||||
{{ article.post|reading_time }} мин. чтения
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="far fa-eye"></i>
|
||||
{{ article.views }} просмотров
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Featured Image -->
|
||||
{% if article.img %}
|
||||
<div class="article-featured-image" data-aos="fade-up" data-aos-delay="200">
|
||||
<img src="{{ article.img.url }}" alt="{{ article.title }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Article Content -->
|
||||
<div class="article-body" data-aos="fade-up" data-aos-delay="300">
|
||||
{{ article.post|safe|linebreaks }}
|
||||
</div>
|
||||
|
||||
<!-- Article Footer -->
|
||||
<footer class="article-footer-full" data-aos="fade-up">
|
||||
<!-- Likes -->
|
||||
<div class="article-reactions">
|
||||
<span class="reactions-label">Оцените статью:</span>
|
||||
<div class="reactions-buttons">
|
||||
<button type="button"
|
||||
class="vote-btn like {% if user_like.is_like %}active{% endif %}"
|
||||
id="article-{{ article.id }}-like-btn"
|
||||
onclick="toggleArticleLike({{ article.id }}, true)"
|
||||
{% if not user.is_authenticated %}disabled title="Войдите, чтобы оценить"{% endif %}>
|
||||
<i class="{% if user_like.is_like %}fas{% else %}far{% endif %} fa-thumbs-up"></i>
|
||||
<span id="article-{{ article.id }}-likes">{{ article.likes_count }}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="vote-btn dislike {% if user_like and not user_like.is_like %}active{% endif %}"
|
||||
id="article-{{ article.id }}-dislike-btn"
|
||||
onclick="toggleArticleLike({{ article.id }}, false)"
|
||||
{% if not user.is_authenticated %}disabled title="Войдите, чтобы оценить"{% endif %}>
|
||||
<i class="{% if user_like and not user_like.is_like %}fas{% else %}far{% endif %} fa-thumbs-down"></i>
|
||||
<span id="article-{{ article.id }}-dislikes">{{ article.dislikes_count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share -->
|
||||
<div class="article-share">
|
||||
<span class="share-label">Поделиться:</span>
|
||||
<div class="share-buttons">
|
||||
<a href="https://t.me/share/url?url=https://deev.space{{ article.get_absolute_url }}&text={{ article.title }}"
|
||||
target="_blank" rel="noopener" class="share-btn telegram" title="Поделиться в Telegram">
|
||||
<i class="fab fa-telegram"></i>
|
||||
</a>
|
||||
<a href="https://vk.com/share.php?url=https://deev.space{{ article.get_absolute_url }}&title={{ article.title }}"
|
||||
target="_blank" rel="noopener" class="share-btn vk" title="Поделиться в VK">
|
||||
<i class="fab fa-vk"></i>
|
||||
</a>
|
||||
<a href="https://twitter.com/intent/tweet?url=https://deev.space{{ article.get_absolute_url }}&text={{ article.title }}"
|
||||
target="_blank" rel="noopener" class="share-btn twitter" title="Поделиться в Twitter">
|
||||
<i class="fab fa-twitter"></i>
|
||||
</a>
|
||||
<button type="button" class="share-btn copy" onclick="copyArticleLink()" title="Копировать ссылку">
|
||||
<i class="fas fa-link"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Related Articles -->
|
||||
{% if related_articles %}
|
||||
<section class="related-articles" data-aos="fade-up">
|
||||
<h3>Похожие статьи</h3>
|
||||
<div class="related-grid">
|
||||
{% for related in related_articles %}
|
||||
<a href="{{ related.get_absolute_url }}" class="related-card">
|
||||
{% if related.img %}
|
||||
<img src="{{ related.img.url }}" alt="{{ related.title }}" class="related-image">
|
||||
{% endif %}
|
||||
<div class="related-content">
|
||||
<h4>{{ related.title }}</h4>
|
||||
<span class="related-date">{{ related.date|date:"d.m.Y" }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Comments Section -->
|
||||
<section class="comments-section" data-aos="fade-up" id="comments">
|
||||
<h3 class="comments-title">
|
||||
<i class="fas fa-comments"></i>
|
||||
Комментарии
|
||||
<span class="comments-count">({{ article.comments_count }})</span>
|
||||
</h3>
|
||||
|
||||
<!-- Comment Form -->
|
||||
{% if user.is_authenticated %}
|
||||
<form class="comment-form" id="comment-form" onsubmit="event.preventDefault(); submitComment({{ article.id }});">
|
||||
{% csrf_token %}
|
||||
<div class="comment-form-header">
|
||||
<span class="comment-avatar">{{ user.get_avatar_letter }}</span>
|
||||
<span class="comment-author-name">{{ user.username }}</span>
|
||||
</div>
|
||||
<textarea name="content"
|
||||
class="form-control"
|
||||
placeholder="Напишите комментарий..."
|
||||
rows="3"
|
||||
required
|
||||
maxlength="2000"></textarea>
|
||||
<div class="comment-form-footer">
|
||||
<span class="comment-hint">Поддерживается: <code><b></code>, <code><i></code>, <code><code></code></span>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
Отправить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="comment-auth-prompt">
|
||||
<p>Чтобы оставить комментарий, <a href="{% url 'login' %}?next={{ request.path }}#comments">войдите</a> или <a href="{% url 'register' %}?next={{ request.path }}#comments">зарегистрируйтесь</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Comments List -->
|
||||
<div class="comments-list">
|
||||
{% include 'includes/comments.html' with comments=comments %}
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyArticleLink() {
|
||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
showNotification('Ссылка скопирована!', 'success');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
159
templates/blog/blog.html
Normal file
159
templates/blog/blog.html
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
{% extends 'wrapper.html' %}
|
||||
{% load static %}
|
||||
{% load custom_filters %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header" data-aos="fade-up">
|
||||
<h1 class="page-title">
|
||||
<span class="page-icon"><i class="fas fa-pen-fancy"></i></span>
|
||||
{% if current_category %}
|
||||
{{ current_category.name }}
|
||||
{% else %}
|
||||
Блог
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
{% if current_category %}
|
||||
{{ current_category.description|default:"Статьи в категории"|truncatewords:15 }}
|
||||
{% else %}
|
||||
Технические статьи, гайды и размышления о разработке
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search & Filters -->
|
||||
<div class="blog-filters" data-aos="fade-up" data-aos-delay="100">
|
||||
<!-- Search -->
|
||||
<form class="search-form" method="get" action="{% url 'blog' %}">
|
||||
<div class="search-input-wrapper">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" name="q" value="{{ search_query }}" placeholder="Поиск статей..." class="search-input">
|
||||
{% if search_query %}
|
||||
<a href="{% url 'blog' %}" class="search-clear">
|
||||
<i class="fas fa-times"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Найти</button>
|
||||
</form>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="categories-list">
|
||||
<a href="{% url 'blog' %}" class="category-chip {% if not current_category %}active{% endif %}">
|
||||
<i class="fas fa-list"></i>
|
||||
Все
|
||||
<span class="category-count">{{ total_articles }}</span>
|
||||
</a>
|
||||
{% for category in categories %}
|
||||
<a href="{{ category.get_absolute_url }}" class="category-chip {% if current_category.slug == category.slug %}active{% endif %}">
|
||||
<i class="{{ category.icon }}"></i>
|
||||
{{ category.name }}
|
||||
<span class="category-count">{{ category.count }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results Info -->
|
||||
{% if search_query %}
|
||||
<div class="search-results-info" data-aos="fade-up">
|
||||
<p>
|
||||
Результаты поиска по запросу: <strong>"{{ search_query }}"</strong>
|
||||
<span class="results-count">(найдено: {{ articles|length }})</span>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Articles Grid -->
|
||||
{% if articles %}
|
||||
<div class="blog-grid" data-aos="fade-up" data-aos-delay="200">
|
||||
{% for article in articles %}
|
||||
<article class="article-card" data-aos="fade-up" data-aos-delay="{{ forloop.counter0|add:1 }}0">
|
||||
{% if article.img %}
|
||||
<div class="article-image">
|
||||
<a href="{{ article.get_absolute_url }}">
|
||||
<img src="{{ article.img.url }}" alt="{{ article.title }}" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="article-content">
|
||||
<div class="article-meta">
|
||||
{% if article.category %}
|
||||
<a href="{{ article.category.get_absolute_url }}" class="article-category">
|
||||
<i class="{{ article.category.icon }}"></i>
|
||||
{{ article.category.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<span class="article-date">
|
||||
<i class="far fa-calendar-alt"></i>
|
||||
{{ article.date|date:"d.m.Y" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 class="article-title">
|
||||
<a href="{{ article.get_absolute_url }}">{{ article.title }}</a>
|
||||
</h2>
|
||||
|
||||
{% if article.sub_title %}
|
||||
<p class="article-subtitle">{{ article.sub_title }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="article-excerpt">{{ article.excerpt|truncatewords:25 }}</p>
|
||||
|
||||
<div class="article-footer">
|
||||
<div class="article-stats">
|
||||
<span title="Просмотры">
|
||||
<i class="far fa-eye"></i>
|
||||
{{ article.views }}
|
||||
</span>
|
||||
<span title="Комментарии">
|
||||
<i class="far fa-comment"></i>
|
||||
{{ article.comments_count }}
|
||||
</span>
|
||||
<span title="Лайки">
|
||||
<i class="far fa-heart"></i>
|
||||
{{ article.likes_count }}
|
||||
</span>
|
||||
</div>
|
||||
<a href="{{ article.get_absolute_url }}" class="article-read-more">
|
||||
Читать далее
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="pagination-wrapper" data-aos="fade-up">
|
||||
{% include 'includes/pagination.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" data-aos="fade-up">
|
||||
<i class="fas fa-pen"></i>
|
||||
<h3>Статьи не найдены</h3>
|
||||
{% if search_query %}
|
||||
<p>По вашему запросу ничего не найдено. Попробуйте изменить поисковый запрос.</p>
|
||||
<a href="{% url 'blog' %}" class="btn btn-primary">
|
||||
Сбросить поиск
|
||||
</a>
|
||||
{% elif current_category %}
|
||||
<p>В этой категории пока нет статей.</p>
|
||||
<a href="{% url 'blog' %}" class="btn btn-primary">
|
||||
Все статьи
|
||||
</a>
|
||||
{% else %}
|
||||
<p>Статьи скоро появятся, следите за обновлениями!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
208
templates/contacts.html
Normal file
208
templates/contacts.html
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
{% extends 'wrapper.html' %}
|
||||
{% load static %}
|
||||
{% load custom_filters %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header" data-aos="fade-up">
|
||||
<h1 class="page-title">
|
||||
<span class="page-icon"><i class="fas fa-envelope"></i></span>
|
||||
Контакты
|
||||
</h1>
|
||||
<p class="page-subtitle">Свяжитесь со мной любым удобным способом</p>
|
||||
</div>
|
||||
|
||||
<!-- Contact Bento Grid -->
|
||||
<div class="contact-bento">
|
||||
<!-- Contact Form -->
|
||||
<div class="bento-card contact-form-card" data-aos="fade-up" data-aos-delay="100">
|
||||
<h3 class="bento-card-title">
|
||||
<i class="fas fa-envelope"></i>
|
||||
Написать сообщение
|
||||
</h3>
|
||||
|
||||
<form id="contact-form" class="contact-form" method="post" action="{% url 'contacts' %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="id_name" class="form-label">Имя *</label>
|
||||
{{ contact_form.name }}
|
||||
{% if contact_form.name.errors %}
|
||||
<span class="form-error">{{ contact_form.name.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_email" class="form-label">Email *</label>
|
||||
{{ contact_form.email }}
|
||||
{% if contact_form.email.errors %}
|
||||
<span class="form-error">{{ contact_form.email.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_subject" class="form-label">Тема *</label>
|
||||
{{ contact_form.subject }}
|
||||
{% if contact_form.subject.errors %}
|
||||
<span class="form-error">{{ contact_form.subject.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_message" class="form-label">Сообщение *</label>
|
||||
{{ contact_form.message }}
|
||||
{% if contact_form.message.errors %}
|
||||
<span class="form-error">{{ contact_form.message.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group captcha-group">
|
||||
<div class="smart-captcha-wrapper" style="height: 100px;">
|
||||
<div id="contact-captcha-container"
|
||||
class="smart-captcha"
|
||||
data-sitekey="{{ smartcaptcha_client_key }}"
|
||||
data-hl="ru"></div>
|
||||
{{ contact_form.captcha }}
|
||||
</div>
|
||||
{% if contact_form.captcha.errors %}
|
||||
<span class="form-error">{{ contact_form.captcha.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
function initContactCaptcha() {
|
||||
if (window.smartCaptcha) {
|
||||
window.smartCaptcha.render('contact-captcha-container', {
|
||||
sitekey: '{{ smartcaptcha_client_key }}',
|
||||
hl: 'ru',
|
||||
callback: function(token) {
|
||||
document.getElementById('smart-captcha-token').value = token;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setTimeout(initContactCaptcha, 100);
|
||||
}
|
||||
}
|
||||
initContactCaptcha();
|
||||
});
|
||||
</script>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-full">
|
||||
<i class="fas fa-envelope"></i>
|
||||
Отправить сообщение
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="bento-card contact-info-card" data-aos="fade-up" data-aos-delay="200">
|
||||
<h3 class="bento-card-title">
|
||||
<i class="fas fa-address-card"></i>
|
||||
Контактная информация
|
||||
</h3>
|
||||
|
||||
<div class="contact-methods">
|
||||
<!-- Email -->
|
||||
<div class="contact-method">
|
||||
<div class="contact-method-icon">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</div>
|
||||
<div class="contact-method-info">
|
||||
<h4>Email</h4>
|
||||
<a href="mailto:{{ site_settings.owner_email }}">{{ site_settings.owner_email }}</a>
|
||||
</div>
|
||||
<button type="button" class="contact-copy-btn" onclick="copyToClipboard('{{ site_settings.owner_email }}', this)" title="Копировать">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
{% if site_settings.owner_phone %}
|
||||
<div class="contact-method">
|
||||
<div class="contact-method-icon" style="background-color: #33311A">
|
||||
<i class="fas fa-phone" style="color: #EAB308"></i>
|
||||
</div>
|
||||
<div class="contact-method-info">
|
||||
<h4>Телефон</h4>
|
||||
<a href="tel:{{ site_settings.owner_phone|cut:' '|cut:'('|cut:')'|cut:'-' }}">{{ site_settings.owner_phone }}</a>
|
||||
</div>
|
||||
<button type="button" class="contact-copy-btn" onclick="copyToClipboard('{{ site_settings.owner_phone|cut:' '|cut:'('|cut:')'|cut:'-' }}', this)" title="Копировать">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Telegram -->
|
||||
{% if site_settings.telegram_url %}
|
||||
<div class="contact-method">
|
||||
<div class="contact-method-icon telegram">
|
||||
<i class="fab fa-telegram"></i>
|
||||
</div>
|
||||
<div class="contact-method-info">
|
||||
<h4>Telegram</h4>
|
||||
<a href="{{ site_settings.telegram_url }}" target="_blank" rel="noopener">@Egor_Deev</a>
|
||||
</div>
|
||||
<button type="button" class="contact-copy-btn" onclick="copyToClipboard('@Egor_Deev', this)" title="Копировать">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location -->
|
||||
<div class="contact-method">
|
||||
<div class="contact-method-icon" style="background-color: #1B3B27">
|
||||
<i class="fas fa-map-marker-alt" style="color: #22C55E"></i>
|
||||
</div>
|
||||
<div class="contact-method-info">
|
||||
<h4>Город</h4>
|
||||
<p>{{ site_settings.owner_city }}, Россия</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div class="contact-social">
|
||||
<h4>Социальные сети</h4>
|
||||
<div class="contact-social-links">
|
||||
{% if site_settings.telegram_url %}
|
||||
<a href="{{ site_settings.telegram_url }}" target="_blank" rel="noopener" class="contact-social-link telegram" title="Telegram">
|
||||
<i class="fab fa-telegram"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if site_settings.github_url %}
|
||||
<a href="{{ site_settings.github_url }}" target="_blank" rel="noopener" class="contact-social-link github" title="GitHub">
|
||||
<i class="fab fa-github"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if site_settings.vk_url %}
|
||||
<a href="{{ site_settings.vk_url }}" target="_blank" rel="noopener" class="contact-social-link vk" title="VKontakte">
|
||||
<i class="fab fa-vk"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if site_settings.linkedin_url %}
|
||||
<a href="{{ site_settings.linkedin_url }}" target="_blank" rel="noopener" class="contact-social-link linkedin" title="LinkedIn">
|
||||
<i class="fab fa-linkedin"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resume Download -->
|
||||
{% if site_settings.resume_file %}
|
||||
<div class="contact-resume">
|
||||
<h4>Резюме</h4>
|
||||
<p>Скачайте моё резюме в формате PDF</p>
|
||||
<a href="{{ site_settings.resume_file.url }}" target="_blank" class="btn btn-outline">
|
||||
<i class="fas fa-download"></i>
|
||||
Скачать резюме
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
25
templates/errors/404.html
Normal file
25
templates/errors/404.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{% extends 'wrapper.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Страница не найдена — deev.space{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-card" data-aos="fade-up">
|
||||
<div class="error-code">404</div>
|
||||
<h1 class="error-title">Страница не найдена</h1>
|
||||
<p class="error-description">
|
||||
К сожалению, запрашиваемая страница не существует или была перемещена.
|
||||
</p>
|
||||
<div class="error-actions">
|
||||
<a href="{% url 'index' %}" class="btn btn-primary">
|
||||
<i class="fas fa-home"></i>
|
||||
На главную
|
||||
</a>
|
||||
<a href="{% url 'blog' %}" class="btn btn-outline">
|
||||
<i class="fas fa-pen"></i>
|
||||
Блог
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
26
templates/errors/500.html
Normal file
26
templates/errors/500.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{% extends 'wrapper.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Ошибка сервера — deev.space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-card" data-aos="fade-up">
|
||||
<div class="error-code">500</div>
|
||||
<h1 class="error-title">Ошибка сервера</h1>
|
||||
<p class="error-description">
|
||||
Произошла внутренняя ошибка сервера. Мы уже работаем над её устранением.
|
||||
</p>
|
||||
<div class="error-actions">
|
||||
<a href="{% url 'index' %}" class="btn btn-primary">
|
||||
<i class="fas fa-home"></i>
|
||||
На главную
|
||||
</a>
|
||||
<button onclick="location.reload()" class="btn btn-outline">
|
||||
<i class="fas fa-redo"></i>
|
||||
Обновить страницу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
92
templates/includes/comments.html
Normal file
92
templates/includes/comments.html
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{% load custom_filters %}
|
||||
|
||||
{% for comment in comments %}
|
||||
<div class="comment {% if comment.parent %}comment-reply{% endif %}"
|
||||
id="comment-{{ comment.id }}"
|
||||
style="{% if comment.nesting_level > 0 %}margin-left: {{ comment.nesting_level|add:0 }}rem;{% endif %}">
|
||||
|
||||
<div class="comment-header">
|
||||
<div class="comment-author">
|
||||
<span class="comment-avatar">{{ comment.user.get_avatar_letter }}</span>
|
||||
<div class="comment-author-info">
|
||||
<span class="comment-name">{{ comment.user.username }}</span>
|
||||
<span class="comment-date">{{ comment.created_at|date:"d.m.Y H:i" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comment-content">
|
||||
{{ comment.content|safe|linebreaks }}
|
||||
</div>
|
||||
|
||||
<div class="comment-actions">
|
||||
<!-- Likes -->
|
||||
<button type="button"
|
||||
class="vote-btn like"
|
||||
id="comment-{{ comment.id }}-like-btn"
|
||||
onclick="toggleCommentLike({{ comment.id }}, true)"
|
||||
{% if not user.is_authenticated %}disabled{% endif %}>
|
||||
<i class="far fa-thumbs-up"></i>
|
||||
<span id="comment-{{ comment.id }}-likes">{{ comment.likes_count }}</span>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
class="vote-btn dislike"
|
||||
id="comment-{{ comment.id }}-dislike-btn"
|
||||
onclick="toggleCommentLike({{ comment.id }}, false)"
|
||||
{% if not user.is_authenticated %}disabled{% endif %}>
|
||||
<i class="far fa-thumbs-down"></i>
|
||||
<span id="comment-{{ comment.id }}-dislikes">{{ comment.dislikes_count }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Reply Button -->
|
||||
{% if user.is_authenticated and comment.nesting_level < 3 %}
|
||||
<button type="button" class="reply-btn" onclick="showReplyForm({{ comment.id }})">
|
||||
<i class="fas fa-reply"></i>
|
||||
Ответить
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Reply Form (Hidden by default) -->
|
||||
{% if user.is_authenticated and comment.nesting_level < 3 %}
|
||||
<div class="reply-form-container" id="reply-form-container-{{ comment.id }}" style="display: none;">
|
||||
<form class="comment-form reply-form" id="reply-form-{{ comment.id }}"
|
||||
onsubmit="event.preventDefault(); submitComment({{ comment.article.id }}, {{ comment.id }});">
|
||||
{% csrf_token %}
|
||||
<div class="comment-form-header">
|
||||
<span class="comment-avatar">{{ user.get_avatar_letter }}</span>
|
||||
<span>Ответ на комментарий {{ comment.user.username }}</span>
|
||||
</div>
|
||||
<textarea name="content"
|
||||
class="form-control"
|
||||
placeholder="Напишите ответ..."
|
||||
rows="2"
|
||||
required
|
||||
maxlength="2000"></textarea>
|
||||
<div class="comment-form-footer">
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick="hideReplyForm({{ comment.id }})">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
Ответить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Nested Replies -->
|
||||
{% if comment.replies.all %}
|
||||
<div class="comment-replies">
|
||||
{% include 'includes/comments.html' with comments=comment.replies.all %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="comments-empty">
|
||||
<i class="far fa-comment"></i>
|
||||
<p>Комментариев пока нет. Будьте первым!</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
33
templates/includes/pagination.html
Normal file
33
templates/includes/pagination.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<nav class="pagination" aria-label="Навигация по страницам">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page=1{% if search_query %}&q={{ search_query }}{% endif %}" class="pagination-btn pagination-first" title="Первая страница">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
<a href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}" class="pagination-btn pagination-prev" title="Предыдущая">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="pagination-numbers">
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<span class="pagination-num active">{{ num }}</span>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<a href="?page={{ num }}{% if search_query %}&q={{ search_query }}{% endif %}" class="pagination-num">{{ num }}</a>
|
||||
{% elif num == 1 or num == page_obj.paginator.num_pages %}
|
||||
<a href="?page={{ num }}{% if search_query %}&q={{ search_query }}{% endif %}" class="pagination-num">{{ num }}</a>
|
||||
{% elif num == page_obj.number|add:'-3' or num == page_obj.number|add:'3' %}
|
||||
<span class="pagination-dots">...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}" class="pagination-btn pagination-next" title="Следующая">
|
||||
<i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
<a href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&q={{ search_query }}{% endif %}" class="pagination-btn pagination-last" title="Последняя страница">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
152
templates/includes/project_card.html
Normal file
152
templates/includes/project_card.html
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
{% load custom_filters %}
|
||||
|
||||
<div class="project-card {{ project.card_size }}"
|
||||
data-aos="fade-up"
|
||||
data-aos-delay="{{ delay|default:0 }}00"
|
||||
data-technologies="{{ project.technologies|lower }}">
|
||||
|
||||
{% if project.card_size == 'featured' %}
|
||||
<!-- Featured Layout (Large) -->
|
||||
<div class="project-image">
|
||||
{% if project.img_main %}
|
||||
<img src="{{ project.img_main.url }}" alt="{{ project.title }}" loading="lazy">
|
||||
{% else %}
|
||||
<div class="project-image-placeholder">
|
||||
<i class="{{ project.icon|default:'fas fa-code' }}"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="project-logo">
|
||||
<i class="{{ project.icon|default:'fas fa-code' }}"></i>
|
||||
</div>
|
||||
|
||||
<span class="project-status {{ project.status|status_class }}">
|
||||
{{ project.status|status_label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="project-content">
|
||||
<h3 class="project-title">
|
||||
{{ project.title }}
|
||||
{% if project.users_count %}
|
||||
<span class="project-users">
|
||||
<i class="fas fa-users"></i>
|
||||
{{ project.users_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
|
||||
<p class="project-description">{{ project.short_description }}</p>
|
||||
|
||||
{% if project.features %}
|
||||
<ul class="project-features">
|
||||
{% for feature in project.get_features_list|slice:":3" %}
|
||||
<li><i class="fas fa-check"></i> {{ feature }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<div class="project-tech">
|
||||
{% for tech in project.get_technologies_list|slice:":6" %}
|
||||
<span class="tech-badge">
|
||||
<i class="{{ tech|tech_icon }}"></i>
|
||||
{{ tech }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="project-links">
|
||||
{% if project.github_url %}
|
||||
<a href="{{ project.github_url }}" target="_blank" rel="noopener" class="project-link">
|
||||
<i class="fab fa-github"></i>
|
||||
GitHub
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if project.demo_url %}
|
||||
<a href="{{ project.demo_url }}" target="_blank" rel="noopener" class="project-link">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
Демо
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif project.card_size == 'small' %}
|
||||
<!-- Small Layout (Compact) -->
|
||||
<div class="project-content">
|
||||
<div class="project-logo-small">
|
||||
<i class="{{ project.icon|default:'fas fa-code' }}"></i>
|
||||
</div>
|
||||
<h3 class="project-title">{{ project.title }}</h3>
|
||||
<span class="project-status {{ project.status|status_class }}">
|
||||
{{ project.status|status_label }}
|
||||
</span>
|
||||
<div class="project-tech">
|
||||
{% for tech in project.get_technologies_list|slice:":2" %}
|
||||
<span class="tech-badge">
|
||||
<i class="{{ tech|tech_icon }}"></i>
|
||||
{{ tech }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Regular Layout -->
|
||||
<div class="project-image">
|
||||
{% if project.img_main %}
|
||||
<img src="{{ project.img_main.url }}" alt="{{ project.title }}" loading="lazy">
|
||||
{% else %}
|
||||
<div class="project-image-placeholder">
|
||||
<i class="{{ project.icon|default:'fas fa-code' }}"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="project-logo">
|
||||
<i class="{{ project.icon|default:'fas fa-code' }}"></i>
|
||||
</div>
|
||||
|
||||
<span class="project-status {{ project.status|status_class }}">
|
||||
{{ project.status|status_label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="project-content">
|
||||
<h3 class="project-title">
|
||||
{{ project.title }}
|
||||
{% if project.users_count %}
|
||||
<span class="project-users">
|
||||
<i class="fas fa-users"></i>
|
||||
{{ project.users_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
|
||||
<p class="project-description">{{ project.short_description }}</p>
|
||||
|
||||
<div class="project-tech">
|
||||
{% for tech in project.get_technologies_list|slice:":4" %}
|
||||
<span class="tech-badge">
|
||||
<i class="{{ tech|tech_icon }}"></i>
|
||||
{{ tech }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="project-links">
|
||||
{% if project.github_url %}
|
||||
<a href="{{ project.github_url }}" target="_blank" rel="noopener" class="project-link">
|
||||
<i class="fab fa-github"></i>
|
||||
GitHub
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if project.demo_url %}
|
||||
<a href="{{ project.demo_url }}" target="_blank" rel="noopener" class="project-link">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
Демо
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
302
templates/index.html
Normal file
302
templates/index.html
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
{% extends 'wrapper.html' %}
|
||||
{% load static %}
|
||||
{% load custom_filters %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<!-- Hero Bento Grid -->
|
||||
<div class="bento-grid bento-hero">
|
||||
|
||||
<!-- Main Profile Card -->
|
||||
<div class="bento-card bento-profile" data-aos="fade-up">
|
||||
<div class="profile-photo" data-aos="zoom-in" data-aos-delay="200">
|
||||
{% if site_settings.owner_photo %}
|
||||
<img src="{{ site_settings.owner_photo.url }}" alt="{{ site_settings.owner_name }}">
|
||||
{% else %}
|
||||
<img src="{% static 'img/hero.jpg' %}" alt="{{ site_settings.owner_name }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<h1>{{ site_settings.owner_name }}</h1>
|
||||
<p class="profile-title">
|
||||
<i class="fas fa-code"></i>
|
||||
{{ site_settings.owner_title }}
|
||||
</p>
|
||||
<p class="profile-description">
|
||||
{{ site_settings.owner_bio|truncatewords:50|default:"Опытный разработчик с более чем 3-летним стажем программирования. Специализируюсь на создании backend-решений, парсинге данных, автоматизации процессов и интеграции AI-компонентов." }}
|
||||
</p>
|
||||
<div class="profile-buttons">
|
||||
<a href="{% url 'projects' %}" class="btn btn-primary">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
Мои проекты
|
||||
</a>
|
||||
<a href="{% url 'contacts' %}" class="btn btn-outline">
|
||||
<i class="fas fa-envelope"></i>
|
||||
Связаться
|
||||
</a>
|
||||
{% if site_settings.resume_file %}
|
||||
<a href="{{ site_settings.resume_file.url }}" class="btn btn-ghost" target="_blank">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
Резюме
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Info Cards -->
|
||||
<div class="bento-quick" data-aos="fade-up" data-aos-delay="100">
|
||||
<div class="quick-card">
|
||||
<div class="quick-icon">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
</div>
|
||||
<div class="quick-content">
|
||||
<h4>Локация</h4>
|
||||
<p>{{ site_settings.owner_city|default:"Москва" }}, Россия</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-card">
|
||||
<div class="quick-icon">
|
||||
<i class="fas fa-briefcase"></i>
|
||||
</div>
|
||||
<div class="quick-content">
|
||||
<h4>Опыт</h4>
|
||||
<p>3+ года</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-card">
|
||||
<div class="quick-icon">
|
||||
<i class="fas fa-graduation-cap"></i>
|
||||
</div>
|
||||
<div class="quick-content">
|
||||
<h4>Образование</h4>
|
||||
<p>МосПолитех</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-card social-quick-card">
|
||||
<div class="quick-icon">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
</div>
|
||||
<div class="quick-content">
|
||||
<h4>Социальные сети</h4>
|
||||
<div class="social-icons-inline">
|
||||
{% if site_settings.telegram_url %}
|
||||
<a href="{{ site_settings.telegram_url }}" target="_blank" rel="noopener noreferrer"
|
||||
class="social-icon-link" aria-label="Telegram">
|
||||
<i class="fab fa-telegram"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if site_settings.github_url %}
|
||||
<a href="{{ site_settings.github_url }}" target="_blank" rel="noopener noreferrer"
|
||||
class="social-icon-link" aria-label="GitHub">
|
||||
<i class="fab fa-github"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if site_settings.vk_url %}
|
||||
<a href="{{ site_settings.vk_url }}" target="_blank" rel="noopener noreferrer"
|
||||
class="social-icon-link" aria-label="VKontakte">
|
||||
<i class="fab fa-vk"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if site_settings.linkedin_url %}
|
||||
<a href="{{ site_settings.linkedin_url }}" target="_blank" rel="noopener noreferrer"
|
||||
class="social-icon-link" aria-label="LinkedIn">
|
||||
<i class="fab fa-linkedin"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Links Card -->
|
||||
<!-- <div class="bento-card bento-socials" data-aos="fade-up" data-aos-delay="200">
|
||||
<h3 class="bento-card-title">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
Социальные сети
|
||||
</h3>
|
||||
<div class="socials-grid">
|
||||
{% if site_settings.telegram_url %}
|
||||
<a href="{{ site_settings.telegram_url }}" target="_blank" rel="noopener" class="social-link">
|
||||
<i class="fab fa-telegram"></i>
|
||||
<span>Telegram</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if site_settings.github_url %}
|
||||
<a href="{{ site_settings.github_url }}" target="_blank" rel="noopener" class="social-link">
|
||||
<i class="fab fa-github"></i>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="mailto:{{ site_settings.owner_email }}" class="social-link">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<span>Email</span>
|
||||
</a>
|
||||
{% if site_settings.owner_phone %}
|
||||
<a href="tel:{{ site_settings.owner_phone|cut:' '|cut:'('|cut:')'|cut:'-' }}" class="social-link">
|
||||
<i class="fas fa-phone"></i>
|
||||
<span>Телефон</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- Skills Card -->
|
||||
<div class="bento-card skills-full" data-aos="fade-up" data-aos-delay="200"> <!-- skills-full in class -->
|
||||
<div class="skills-header">
|
||||
<div class="icon-box">
|
||||
<i class="fas fa-laptop-code"></i>
|
||||
</div>
|
||||
<h3>Технологический стек</h3>
|
||||
</div>
|
||||
|
||||
<div class="skills-categories">
|
||||
{% for category_name, category_skills in skills_by_category.items %}
|
||||
<div class="skill-category">
|
||||
<div class="skill-category-title">{{ category_name }}</div>
|
||||
<div class="skill-tags">
|
||||
{% for skill in category_skills %}
|
||||
<span class="skill-tag">
|
||||
<i class="{{ skill.icon|default:'fas fa-code' }}"></i>
|
||||
{{ skill.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<!-- Projects Section -->
|
||||
<section class="section" id="projects-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title" data-aos="fade-right">
|
||||
<div class="section-icon">
|
||||
<i class="fas fa-rocket"></i>
|
||||
</div>
|
||||
Избранные проекты
|
||||
</h2>
|
||||
<a href="{% url 'projects' %}" class="btn btn-outline btn-sm" data-aos="fade-left">
|
||||
Все проекты
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="projects-bento">
|
||||
{% for project in featured_projects %}
|
||||
{% include 'includes/project_card.html' with project=project delay=forloop.counter0 %}
|
||||
{% empty %}
|
||||
<div class="bento-card empty-state" style="grid-column: span 12;">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<p>Проекты скоро появятся...</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- GitHub CTA Card -->
|
||||
{% if site_settings.github_url %}
|
||||
<div class="project-card github-cta" data-aos="fade-up" data-aos-delay="500">
|
||||
<div class="github-cta-content">
|
||||
<i class="fab fa-github"></i>
|
||||
<p>Ещё 12 проектов на GitHub</p>
|
||||
<a href="{{ site_settings.github_url }}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm">
|
||||
Перейти
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<!-- Blog Section -->
|
||||
<section class="section" id="blog-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title" data-aos="fade-right">
|
||||
<div class="section-icon">
|
||||
<i class="fas fa-pen-fancy"></i>
|
||||
</div>
|
||||
Последние публикации
|
||||
</h2>
|
||||
<a href="{% url 'blog' %}" class="btn btn-outline btn-sm" data-aos="fade-left">
|
||||
Все статьи
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="blog-grid">
|
||||
{% for article in recent_articles %}
|
||||
<article class="article-card" data-aos="fade-up" data-aos-delay="{{ forloop.counter0 }}00">
|
||||
{% if article.img %}
|
||||
<div class="article-image">
|
||||
<a href="{{ article.get_absolute_url }}">
|
||||
<img src="{{ article.img.url }}" alt="{{ article.title }}" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="article-content">
|
||||
<div class="article-meta">
|
||||
{% if article.category %}
|
||||
<a href="{{ article.category.get_absolute_url }}" class="article-category">
|
||||
{{ article.category.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<span class="article-date">
|
||||
<i class="far fa-calendar-alt"></i>
|
||||
{{ article.date|date:"d.m.Y" }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="article-title">
|
||||
<a href="{{ article.get_absolute_url }}">{{ article.title }}</a>
|
||||
</h3>
|
||||
<p class="article-excerpt">{{ article.excerpt|truncatewords:20 }}</p>
|
||||
<div class="article-footer">
|
||||
<div class="article-stats">
|
||||
<span><i class="far fa-eye"></i> {{ article.views }}</span>
|
||||
<span><i class="far fa-comment"></i> {{ article.comments_count }}</span>
|
||||
</div>
|
||||
<a href="{{ article.get_absolute_url }}" class="article-read-more">
|
||||
Читать <i class="fas fa-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% empty %}
|
||||
<div class="bento-card empty-state" style="grid-column: 1 / -1;">
|
||||
<i class="fas fa-pen"></i>
|
||||
<p>Статьи скоро появятся...</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="cta-section" data-aos="fade-up">
|
||||
<div class="cta-content">
|
||||
<div class="cta-icon">
|
||||
<i class="fas fa-handshake"></i>
|
||||
</div>
|
||||
<h2>Есть проект или идея?</h2>
|
||||
<p>Свяжитесь со мной для обсуждения сотрудничества или если у вас есть интересная задача</p>
|
||||
<div class="cta-buttons">
|
||||
<a href="{% url 'contacts' %}" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-envelope"></i>
|
||||
Написать мне
|
||||
</a>
|
||||
{% if site_settings.telegram_url %}
|
||||
<a href="{{ site_settings.telegram_url }}" target="_blank" rel="noopener" class="btn btn-outline btn-lg">
|
||||
<i class="fab fa-telegram"></i>
|
||||
Telegram
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
123
templates/projects.html
Normal file
123
templates/projects.html
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
{% extends 'wrapper.html' %}
|
||||
{% load static %}
|
||||
{% load custom_filters %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header" data-aos="fade-up">
|
||||
<h1 class="page-title">
|
||||
<span class="page-icon"><i class="fas fa-rocket"></i></span>
|
||||
Проекты
|
||||
</h1>
|
||||
<p class="page-subtitle">Портфолио выполненных работ и проектов в разработке</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters-section" data-aos="fade-up" data-aos-delay="100">
|
||||
<div class="filters-wrapper">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Статус:</span>
|
||||
<div class="filter-buttons">
|
||||
<a href="{% url 'projects' %}" class="filter-btn {% if not current_status %}active{% endif %}">
|
||||
Все
|
||||
</a>
|
||||
<a href="{% url 'projects' %}?status=completed" class="filter-btn {% if current_status == 'completed' %}active{% endif %}">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
Завершённые
|
||||
</a>
|
||||
<a href="{% url 'projects' %}?status=in_development" class="filter-btn {% if current_status == 'in_development' %}active{% endif %}">
|
||||
<i class="fas fa-code"></i>
|
||||
В разработке
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Технология:</span>
|
||||
<select class="filter-select" id="techFilter" onchange="filterByTech(this.value)">
|
||||
<option value="">Все технологии</option>
|
||||
{% for tech in all_technologies %}
|
||||
<option value="{{ tech }}" {% if current_tech == tech %}selected{% endif %}>{{ tech }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Projects -->
|
||||
{% if completed_projects %}
|
||||
<section class="projects-section" data-aos="fade-up" data-aos-delay="200">
|
||||
<h2 class="section-subtitle">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
Завершённые проекты
|
||||
<span class="count-badge">{{ completed_projects.count }}</span>
|
||||
</h2>
|
||||
|
||||
<div class="projects-bento">
|
||||
{% for project in completed_projects %}
|
||||
{% include 'includes/project_card.html' with project=project delay=forloop.counter0 %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- In Development Projects -->
|
||||
{% if dev_projects %}
|
||||
<section class="projects-section" data-aos="fade-up">
|
||||
<h2 class="section-subtitle">
|
||||
<i class="fas fa-code"></i>
|
||||
В разработке
|
||||
<span class="count-badge">{{ dev_projects.count }}</span>
|
||||
</h2>
|
||||
|
||||
<div class="projects-bento">
|
||||
{% for project in dev_projects %}
|
||||
{% include 'includes/project_card.html' with project=project delay=forloop.counter0 %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Empty State -->
|
||||
{% if not completed_projects and not dev_projects %}
|
||||
<div class="empty-state" data-aos="fade-up">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<h3>Проекты не найдены</h3>
|
||||
<p>Попробуйте изменить параметры фильтрации</p>
|
||||
<a href="{% url 'projects' %}" class="btn btn-primary">
|
||||
Сбросить фильтры
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- GitHub CTA -->
|
||||
{% if global_settings.github_url %}
|
||||
<div class="github-cta-section" data-aos="fade-up">
|
||||
<div class="github-cta-card">
|
||||
<i class="fab fa-github"></i>
|
||||
<div class="github-cta-text">
|
||||
<h3>Больше проектов на GitHub</h3>
|
||||
<p>Ознакомьтесь с полным списком моих проектов и исходным кодом</p>
|
||||
</div>
|
||||
<a href="{{ global_settings.github_url }}" target="_blank" rel="noopener" class="btn btn-primary">
|
||||
Перейти на GitHub
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterByTech(tech) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tech) {
|
||||
url.searchParams.set('tech', tech);
|
||||
} else {
|
||||
url.searchParams.delete('tech');
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
27
templates/widgets/smartcaptcha.html
Normal file
27
templates/widgets/smartcaptcha.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<div class="smart-captcha-wrapper" style="height: 100px;">
|
||||
<div id="captcha-container"
|
||||
class="smart-captcha"
|
||||
data-sitekey="{{ client_key }}"
|
||||
data-hl="ru"></div>
|
||||
<input type="hidden" name="{{ widget.name }}" id="smart-captcha-token" value="">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Ожидаем загрузки SmartCaptcha
|
||||
function initSmartCaptcha() {
|
||||
if (window.smartCaptcha) {
|
||||
window.smartCaptcha.render('captcha-container', {
|
||||
sitekey: '{{ client_key }}',
|
||||
hl: 'ru',
|
||||
callback: function(token) {
|
||||
document.getElementById('smart-captcha-token').value = token;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setTimeout(initSmartCaptcha, 100);
|
||||
}
|
||||
}
|
||||
initSmartCaptcha();
|
||||
});
|
||||
</script>
|
||||
263
templates/wrapper.html
Normal file
263
templates/wrapper.html
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
{% load static %}
|
||||
{% load custom_filters %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<title>{% block title %}{{ page_title|default:"Деев Егор Викторович — Backend Developer" }}{% endblock %}</title>
|
||||
<meta name="description" content="{% block description %}{{ page_description|default:"Персональный сайт backend-разработчика Егора Деева" }}{% endblock %}">
|
||||
<meta name="author" content="{{ global_settings.owner_name }}">
|
||||
<meta name="keywords" content="backend developer, python, django, разработчик, Егор Деев, deev.space, портфолио">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<!-- Verification -->
|
||||
{% if global_settings.yandex_metrika_id %}
|
||||
<meta name="yandex-verification" content="{{ global_settings.yandex_metrika_id }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="{{ page_title|default:global_settings.owner_name }}">
|
||||
<meta property="og:description" content="{{ page_description|default:global_settings.site_description }}">
|
||||
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
|
||||
<meta property="og:url" content="https://deev.space{{ request.path }}">
|
||||
<meta property="og:image" content="{% block og_image %}{% static 'images/og-default.jpg' %}{% endblock %}">
|
||||
<meta property="og:site_name" content="{{ global_settings.site_name }}">
|
||||
<meta property="og:locale" content="ru_RU">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ page_title|default:global_settings.owner_name }}">
|
||||
<meta name="twitter:description" content="{{ page_description|default:global_settings.site_description }}">
|
||||
<meta name="twitter:image" content="{% block twitter_image %}{% static 'images/og-default.jpg' %}{% endblock %}">
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href="https://deev.space{{ request.path }}">
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon.ico' %}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon.ico' %}">
|
||||
<link rel="apple-touch-icon" href="{% static 'img/favicon.ico' %}">
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
<!-- Devicons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.15.1/devicon.min.css">
|
||||
|
||||
<!-- AOS Animation -->
|
||||
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
|
||||
<!-- Schema.org JSON-LD -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": "{{ global_settings.owner_name }}",
|
||||
"jobTitle": "{{ global_settings.owner_title }}",
|
||||
"url": "https://deev.space",
|
||||
"email": "{{ global_settings.owner_email }}",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressLocality": "{{ global_settings.owner_city }}",
|
||||
"addressCountry": "RU"
|
||||
},
|
||||
"sameAs": [
|
||||
"{{ global_settings.github_url }}",
|
||||
"{{ global_settings.telegram_url }}"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block extra_schema %}{% endblock %}
|
||||
|
||||
<!-- Yandex.Metrika -->
|
||||
{% if global_settings.yandex_metrika_id and not debug %}
|
||||
<script type="text/javascript">
|
||||
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||
m[i].l=1*new Date();
|
||||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
||||
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
||||
ym({{ global_settings.yandex_metrika_id }}, "init", {
|
||||
clickmap:true,
|
||||
trackLinks:true,
|
||||
accurateTrackBounce:true,
|
||||
webvisor:true
|
||||
});
|
||||
</script>
|
||||
<noscript><div><img src="https://mc.yandex.ru/watch/{{ global_settings.yandex_metrika_id }}" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||
{% endif %}
|
||||
|
||||
<script src="https://smartcaptcha.yandexcloud.net/captcha.js" defer></script>
|
||||
|
||||
<!-- Google Analytics -->
|
||||
{% if global_settings.google_analytics_id and not debug %}
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ global_settings.google_analytics_id }}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '{{ global_settings.google_analytics_id }}');
|
||||
</script>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar" id="navbar">
|
||||
<div class="container">
|
||||
<!-- Logo -->
|
||||
<a href="{% url 'index' %}" class="logo" aria-label="Главная страница">
|
||||
<img src="{% static 'img/logo.png' %}" alt="deev.space" style="max-width: 130px; height: auto;">
|
||||
</a>
|
||||
|
||||
<!-- Mobile Toggle -->
|
||||
<button class="nav-toggle" id="navToggle" aria-label="Открыть меню" aria-expanded="false">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
<!-- Navigation Menu -->
|
||||
<ul class="nav-menu" id="navMenu" role="menubar">
|
||||
<li role="none">
|
||||
<a href="{% url 'index' %}" class="nav-link {% if request.resolver_match.url_name == 'index' %}active{% endif %}" role="menuitem">
|
||||
<i class="fas fa-home"></i>
|
||||
<span>Главная</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="none">
|
||||
<a href="{% url 'about' %}" class="nav-link {% if request.resolver_match.url_name == 'about' %}active{% endif %}" role="menuitem">
|
||||
<i class="fas fa-user"></i>
|
||||
<span>Обо мне</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="none">
|
||||
<a href="{% url 'projects' %}" class="nav-link {% if request.resolver_match.url_name == 'projects' %}active{% endif %}" role="menuitem">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>Проекты</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="none">
|
||||
<a href="{% url 'blog' %}" class="nav-link {% if 'blog' in request.resolver_match.url_name or request.resolver_match.url_name == 'article_detail' %}active{% endif %}" role="menuitem">
|
||||
<i class="fas fa-pen-fancy"></i>
|
||||
<span>Блог</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="none">
|
||||
<a href="{% url 'achievements' %}" class="nav-link {% if request.resolver_match.url_name == 'achievements' %}active{% endif %}" role="menuitem">
|
||||
<i class="fas fa-trophy"></i>
|
||||
<span>Достижения</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="none">
|
||||
<a href="{% url 'contacts' %}" class="nav-link {% if request.resolver_match.url_name == 'contacts' %}active{% endif %}" role="menuitem">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<span>Контакты</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Auth -->
|
||||
<div class="nav-auth">
|
||||
{% if user.is_authenticated %}
|
||||
<div class="nav-user-info">
|
||||
<span class="nav-user-avatar">{{ user.get_avatar_letter }}</span>
|
||||
<span class="nav-user-name">{{ user.username }}</span>
|
||||
</div>
|
||||
<a href="{% url 'logout' %}" class="btn btn-ghost btn-sm">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span class="btn-text">Выйти</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'login' %}?next={{ request.path }}" class="btn btn-ghost btn-sm">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
<span class="btn-text">Войти</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Messages -->
|
||||
{% if messages %}
|
||||
<div class="messages-container" id="messagesContainer">
|
||||
{% for message in messages %}
|
||||
<div class="message message-{{ message.tags }}" role="alert">
|
||||
<i class="fas {% if message.tags == 'success' %}fa-check-circle{% elif message.tags == 'error' %}fa-exclamation-circle{% elif message.tags == 'warning' %}fa-exclamation-triangle{% else %}fa-info-circle{% endif %}"></i>
|
||||
<span>{{ message }}</span>
|
||||
<button type="button" class="message-close" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Минималистичный Footer -->
|
||||
<footer class="footer-minimal">
|
||||
<div class="container">
|
||||
<div class="footer-minimal-content">
|
||||
<!-- Logo -->
|
||||
<a href="{% url 'index' %}" class="footer-logo">
|
||||
<img src="{% static 'img/logo.png' %}" alt="deev.space" style="max-width: 130px; height: auto;">
|
||||
</a>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div class="footer-social-icons">
|
||||
{% if global_settings.telegram_url %}
|
||||
<a href="{{ global_settings.telegram_url }}" target="_blank" rel="noopener noreferrer" aria-label="Telegram">
|
||||
<i class="fab fa-telegram"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if global_settings.github_url %}
|
||||
<a href="{{ global_settings.github_url }}" target="_blank" rel="noopener noreferrer" aria-label="GitHub">
|
||||
<i class="fab fa-github"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if global_settings.vk_url %}
|
||||
<a href="{{ global_settings.vk_url }}" target="_blank" rel="noopener noreferrer" aria-label="VKontakte">
|
||||
<i class="fab fa-vk"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="mailto:{{ global_settings.owner_email }}" aria-label="Email">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Copyright -->
|
||||
<p class="footer-copyright-minimal">
|
||||
© 2024–{% now "Y" %} {{ global_settings.owner_name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Back to Top -->
|
||||
<button class="back-to-top" id="backToTop" aria-label="Наверх">
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</button>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://unpkg.com/aos@2.3.1/dist/aos.js"></script>
|
||||
<script src="{% static 'js/main.js' %}"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue