This commit is contained in:
Egor Deev 2025-12-03 04:39:24 +03:00 committed by GitHub
commit 35149a578d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 9018 additions and 0 deletions

0
dspace/__init__.py Normal file
View file

16
dspace/asgi.py Normal file
View 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
View 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
View 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
View 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
View file

222
main/admin.py Normal file
View 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
View 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

View file

@ -0,0 +1,8 @@
from .models import SiteSettings
def site_settings(request):
"""Глобальный контекст с настройками сайта."""
return {
'global_settings': SiteSettings.load()
}

251
main/forms.py Normal file
View 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

View 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')},
},
),
]

View file

@ -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 класс иконки'),
),
]

View file

408
main/models.py Normal file
View 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
View 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
View 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)

View 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
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

26
main/urls.py Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

7
media/sitemap.xml Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

BIN
static/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
static/img/hero.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
static/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

456
static/js/main.js Normal file
View 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="Закрыть">&times;</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
View 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 %}

View 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
View 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 %}

View 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
View 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>&lt;b&gt;</code>, <code>&lt;i&gt;</code>, <code>&lt;code&gt;</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
View 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
View 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
View 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
View 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 %}

View 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 %}

View 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>

View 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
View 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
View 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 %}

View 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
View 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="Закрыть">&times;</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>