38 Commits

Author SHA1 Message Date
d5c0786a55 add: Добавлена кнопка "Очистить" для формы ввода и счетчик символов. новая версия сайта (v0.2.3)
All checks were successful
Build ETPGRF-site / build (push) Successful in 1m30s
2026-02-11 11:37:28 +03:00
0f2704573d mod: новая версия библиотеки etpgrf (v0.1.4) и версия сайта (v0.2.2)
All checks were successful
Build ETPGRF-site / build (push) Successful in 1m27s
2026-02-03 02:44:41 +03:00
18f4f91382 mod: minor 2026-02-01 23:02:51 +03:00
8a5be30e84 mod: исправления для v0.2.1
All checks were successful
Build ETPGRF-site / build (push) Successful in 1m43s
2026-01-31 01:55:40 +03:00
4791b9ed16 fix: исправление мета-тегов, картинок, альтов под картинками и т.п. 2026-01-31 01:48:09 +03:00
884e00f730 mod: Тизер обязателен! 2026-01-30 20:03:04 +03:00
6d1fe65f24 fix: исправлена отдача media через nginx 2026-01-30 19:46:05 +03:00
fea2765090 mod: новая версия (+блог, странички и другие улучшения)
All checks were successful
Build ETPGRF-site / build (push) Successful in 1m27s
2026-01-29 23:02:30 +03:00
1a7034df66 mod: спрятали url админки в .env 2026-01-29 22:51:12 +03:00
7e7d0a7d49 add: динамическое создание sitemap.xml 2026-01-29 22:33:33 +03:00
a95d677bb7 add: все для робот.тхт 2026-01-29 21:33:24 +03:00
52e960a1d0 mod: меню не налезают на логотипы 2026-01-29 17:57:43 +03:00
0107f8ddba mod: отступы 2026-01-29 16:07:23 +03:00
838aabf0b3 mod: fivicon.ico для Яндекс (120х120) 2026-01-29 01:22:39 +03:00
6266531542 mod: стили бургер-меню 2026-01-28 23:32:38 +03:00
b5ad30e5a6 mod: стили и меню... 2026-01-28 22:45:29 +03:00
fedfae1f74 mod: песочница только в режиме debug 2026-01-28 20:27:10 +03:00
8f39172803 add: view и шаблоны для блогов и страниц 2026-01-27 23:42:09 +03:00
96614748a8 mod: добавлены индексы и составные индексы (ускорение 2026-01-26 17:02:31 +03:00
b967c374a5 add: приложение blog (для страниц и постов) 2026-01-25 12:10:27 +03:00
846c066314 add: счетчик google.analytic
All checks were successful
Build ETPGRF-site / build (push) Successful in 1m27s
2026-01-24 13:48:50 +03:00
d74bee2fc0 add: только текст от логотипов
All checks were successful
Build ETPGRF-site / build (push) Successful in 1m26s
2026-01-24 12:49:55 +03:00
6b4dbafab5 fix: плавное уменьшение шапки и переключение логотипов (2)
All checks were successful
Build ETPGRF-site / build (push) Successful in 1m27s
2026-01-24 12:47:47 +03:00
18294ec21b fix: плавное уменьшение шапки и переключение логотипов 2026-01-23 17:51:17 +03:00
78174a8ffc add: кастомные ошибки 404б 403 и 50x
All checks were successful
Build ETPGRF-site / build (push) Successful in 9m19s
2026-01-22 18:55:56 +03:00
ef8a2d27ff mod: защита от DoS-атак на уровне nbinx 2026-01-22 11:26:12 +03:00
2d09aef79d add: собираем агрегированную информацию по настройкам типографа, процессорному времени и выводим процессорное время на фронет.
All checks were successful
Build ETPGRF-site / build (push) Successful in 1m35s
2026-01-22 00:26:58 +03:00
bc237a6552 fix: корневой favicon.ico и новый ver в футере 2026-01-21 23:23:15 +03:00
716c25dc26 mod:кастомный шаблонный фильтр для статистики 2026-01-21 15:50:26 +03:00
310ed5440d add: инструкция как правильно дать права на каталог data/ и media/ в проде 2026-01-21 03:18:52 +03:00
66f22285ef add: сбор статистики и вывод агрегированных данных на главную (в футер)
All checks were successful
Build ETPGRF-site / build (push) Successful in 1m47s
2026-01-21 01:22:59 +03:00
ea221dcfd2 add: модели и миграции для счетиков 2026-01-21 01:07:50 +03:00
e21920262d mod: __init__ 2026-01-21 01:05:44 +03:00
22de477c17 mod: отдаем корневой favicon напрямую (для поисковиков). 2026-01-20 15:11:43 +03:00
7d3047c2d2 mod: margin снизу 2026-01-20 15:10:47 +03:00
92711f57c2 add: добавлена кнопка "скопировать в буфер обмена" и отслеживание целей в Yandex-Metrica и Top-Mail.ru
All checks were successful
Build ETPGRF-site / build (push) Successful in 1m26s
2026-01-20 00:56:23 +03:00
56794f5b09 add: Текст-запевка 2026-01-19 22:35:54 +03:00
9cb685b569 add: мета-теги для SEO и соцсетей 2026-01-19 21:23:41 +03:00
49 changed files with 2251 additions and 244 deletions

View File

@@ -10,6 +10,9 @@ ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# Укажите здесь URL, по которому вы заходите на сайт (с протоколом и портом) # Укажите здесь URL, по которому вы заходите на сайт (с протоколом и портом)
CSRF_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000,http://0.0.0.0:8000 CSRF_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000,http://0.0.0.0:8000
# URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти)
ADMIN_URL=admin/
# Настройки достпа к пакетам в репозитории, чтобы wathtower мог проверять их свежесть и скачивать # Настройки достпа к пакетам в репозитории, чтобы wathtower мог проверять их свежесть и скачивать
REPO_USER=xxxxx REPO_USER=xxxxx
REPO_PASS=xxxxx REPO_PASS=xxxxx

View File

@@ -8,14 +8,18 @@ server {
access_log /var/log/nginx/typograph.access.log; access_log /var/log/nginx/typograph.access.log;
error_log /var/log/nginx/typograph.error.log; error_log /var/log/nginx/typograph.error.log;
# SSL-сертификаты # SSL-сертификаты (их добавит Let's Encrypt)
# Рекомендуемые SSL настройки # Рекомендуемые SSL настройки (
# --- ЗАЩИТА ОТ БОЛЬШИХ ЗАПРОСОВ ---
# Ограничиваем максимальный размер тела запроса (например, 1MB)
client_max_body_size 1M;
# Медиа файлы (загруженные пользователями) # Медиа файлы (загруженные пользователями)
location /media/ { # location /media/ {
alias /home/e-serg/docker-app/etpgrf-site/media/; # alias /home/e-serg/docker-app/etpgrf-site/media/;
} # }
location / { location / {
# Проксируем на наш контейнер с etpgrf-site # Проксируем на наш контейнер с etpgrf-site

View File

@@ -35,7 +35,12 @@ http {
access_log /dev/stdout; access_log /dev/stdout;
error_log /dev/stderr warn; error_log /dev/stderr warn;
# Настройки сжатия gzip для оптимизации передачи данных (сжимать будет nginx внутри контейнера) # --- ЗАЩИТА ОТ БРУТФОРСА ---
# Создаем зону в памяти, где будут храниться IP-адреса (1MB -- 16000 IP).
# rate=5r/s - разрешаем 5 запросов в секунду (мягкий лимит).
limit_req_zone $binary_remote_addr zone=one:1m rate=5r/s;
# Настройки сжатия gzip
gzip on; gzip on;
gzip_proxied any; gzip_proxied any;
gzip_comp_level 6; gzip_comp_level 6;
@@ -59,7 +64,46 @@ http {
# Убираем токены версии nginx для безопасности # Убираем токены версии nginx для безопасности
server_tokens off; server_tokens off;
# --- ЗАЩИТА ОТ БОЛЬШИХ ЗАПРОСОВ ---
# Ограничиваем максимальный размер тела запроса (например, 1MB)
client_max_body_size 1M;
# --- КАСТОМНЫЕ СТРАНИЦЫ ОШИБОК ---
error_page 403 /403.html;
error_page 404 /404.html;
error_page 500 /500.html;
error_page 502 /502.html;
error_page 503 /503.html;
error_page 504 /504.html;
location = /403.html { root /app/public/static_collected; internal; } # файл будет сюда скопирован при сборке образа
location = /404.html { root /app/public/static_collected; internal; }
location = /500.html { root /app/public/static_collected; internal; }
location = /502.html { root /app/public/static_collected; internal; }
location = /503.html { root /app/public/static_collected; internal; }
location = /504.html { root /app/public/static_collected; internal; }
# Прямая раздача favicon.ico
location = /favicon.ico {
alias /app/public/static_collected/favicon.ico;
access_log off;
log_not_found off;
expires 30d;
}
# Robots.txt
location = /robots.txt {
alias /app/public/static_collected/robots.txt;
access_log off;
log_not_found off;
expires 30d;
}
location / { location / {
# --- ЗАЩИТА ОТ БРУТФОРСА ---
# Применяем зону 'one', разрешаем "всплеск" до 10 запросов.
limit_req zone=one burst=10 nodelay;
proxy_pass http://app_server; proxy_pass http://app_server;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -1,3 +1,11 @@
# Этот файл предназначен для продакшен окружения. Его необходимо скопировать на продакшн-сервер под именем
# `docker-compose.yml` в корневой каталог проекта.
# Перед запуском убедитесь, что в корне проекта есть файл `.env` с необходимыми переменными окружения.
# Также необходимо создать папки `data` и `media` в корне проекта и убедиться, что у пользователя, под которым
# запускается docker-контейнер, есть права на запись в эти папки.
# Для первого запуска backend-контейнера, возможно, потребуется временно изменить владельца папки `data` на root
# и раскомментировать соответствующие строки в секции etpgrf-backend (смотри комментарии в коде ниже).
version: '3.8' version: '3.8'
services: services:
@@ -7,13 +15,45 @@ services:
image: git.cube2.ru/erjemin/2026-etpgrf-site:latest image: git.cube2.ru/erjemin/2026-etpgrf-site:latest
# Перезапускать всегда (если упал или сервер перезагрузился) # Перезапускать всегда (если упал или сервер перезагрузился)
restart: always restart: always
# Метка для Watchtower, чтобы он обновлял только этот контейнер # Метка для Watchtower, чтобы он обновлял только этот контейнер
labels: labels:
- "com.centurylinklabs.watchtower.scope=etpgrf" - "com.centurylinklabs.watchtower.scope=etpgrf"
# Запускаем collectstatic перед стартом # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
command: sh -c "python etpgrf_site/manage.py collectstatic --noinput && gunicorn --bind 0.0.0.0:8000 --chdir /app/etpgrf_site etpgrf_site.wsgi" # При первом старте, временно запускаем как root, чтобы контейнер мог создать файл БД в хостовом томе
# ser: "0:0"
# command: >
# sh -c "mkdir -p /app/data &&
# chown -R 1000:1000 /app/data 2>/dev/null || true &&
# chmod -R 0775 /app/data 2>/dev/null || true &&
# python etpgrf_site/manage.py migrate --noinput &&
# python etpgrf_site/manage.py collectstatic --noinput &&
# cp /app/etpgrf_site/typograph/templates/500.html /app/public/static_collected/50x.html &&
# gunicorn --bind 0.0.0.0:8000 --chdir /app/etpgrf_site etpgrf_site.wsgi"
#
# После первого запуска, на хосте убедиться что файл БД создан. Возвращаем docker-compose.yml (это тот фал который
# вы сейчас читаете). После останавливаем контейнеры: и сменить владельца папки data. Для этого останавливаем контейнеры:
# `sudo docker-compose -f stop`
# возвращаем docker-compose.ym в исходный вид (этот файл). И снова запускаем:
# `sudo docker-compose -f docker-compose.prod.yml up -d`
# Теперь нам нужно узнать UID/GID пользователя внутри контейнера, выполнив на хосте команду:
# `sudo docker exec -it etpgrf-site-etpgrf-backend-1 id`
# Увидим что-то типа `uid=999(appuser) gid=999(appuser) groups=999(appuser)`. После этого сменить владельца папки
# data на хосте:
# `sudo chown -R 999:999 ./data`
# С папкой media можно сделать то же самое, если там будут проблемы с правами.
# `sudo chown -R 999:999 ./media`
#
# А обычно запускаем в безопасном режиме: миграции, потом collectstatic, потом копируем 500.html для Nginx, потом сервер
command: >
sh -c "python etpgrf_site/manage.py migrate --noinput &&
python etpgrf_site/manage.py collectstatic --noinput &&
cp /app/etpgrf_site/typograph/templates/500.html /app/public/static_collected/500.html &&
cp /app/etpgrf_site/typograph/templates/404.html /app/public/static_collected/404.html &&
cp /app/etpgrf_site/typograph/templates/typograph/403.html /app/public/static_collected/403.html &&
gunicorn --bind 0.0.0.0:8000 --chdir /app/etpgrf_site etpgrf_site.wsgi"
volumes: volumes:
# База данных (папка data должна быть создана на хосте) # База данных (папка data должна быть создана на хосте)
@@ -21,7 +61,7 @@ services:
# Статика (общий том) # Статика (общий том)
- static_volume:/app/public/static_collected - static_volume:/app/public/static_collected
# Медиа (папка media должна быть создана на хосте) # Медиа (папка media должна быть создана на хосте)
- ./media:/app/public/media - ./media:/app/media
env_file: env_file:
- .env - .env
@@ -36,16 +76,11 @@ services:
etpgrf-nginx: etpgrf-nginx:
image: nginx:1.25-alpine image: nginx:1.25-alpine
restart: always restart: always
# Метка для Watchtower (хотя nginx:alpine обновляется редко, но пусть будет)
labels:
- "com.centurylinklabs.watchtower.scope=etpgrf"
volumes: volumes:
# Конфиг берем из репозитория # Конфиг берем из репозитория
- ./config/nginx/etpgrf--internal-nginx.conf:/etc/nginx/nginx.conf:ro - ./config/nginx/etpgrf--internal-nginx.conf:/etc/nginx/nginx.conf:ro
- static_volume:/app/public/static_collected - static_volume:/app/public/static_collected
- ./media:/app/public/media - ./media:/app/media
# Внешний порт. Если у тебя на хосте уже есть Nginx (прокси), # Внешний порт. Если у тебя на хосте уже есть Nginx (прокси),
# то можно пробросить на 127.0.0.1:8000 или использовать внутреннюю сеть. # то можно пробросить на 127.0.0.1:8000 или использовать внутреннюю сеть.
@@ -72,12 +107,11 @@ services:
# Берем учетные данные из .env файла # Берем учетные данные из .env файла
- REPO_USER=${REPO_USER} - REPO_USER=${REPO_USER}
- REPO_PASS=${REPO_PASS} - REPO_PASS=${REPO_PASS}
- WATCHTOWER_REGISTRY_URL=git.cube2.ru
# Ограничиваем область видимости только этим проектом # Ограничиваем область видимости только этим проектом
- WATCHTOWER_SCOPE=etpgrf - WATCHTOWER_SCOPE=etpgrf
# Если нужно указать реестр явно (обычно watchtower сам понимает из имени образа) # Если нужно указать реестр явно (обычно watchtower сам понимает из имени образа)
# - WATCHTOWER_REGISTRY_URL=git.cube2.ru # - WATCHTOWER_REGISTRY_URL=git.cube2.ru
command: --interval 300 --cleanup # Проверять каждые 5 минут command: --interval 1800 --cleanup # Проверять каждые 30 минут
volumes: volumes:

9
etpgrf_site/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
"""сайт etpgrf - сайт на django для тестирования и представления библиотеки экранной типографики etpgrf.
Основные возможности:
- Веб-интерфейс для ввода текста и настройки параметров типографики.
"""
__version__ = "0.2.2"
__author__ = "Sergei Erjemin"
__email__ = "erjemin@gmail.com"
__license__ = "MIT"
__copyright__ = "Copyright 2026 Sergei Erjemin"

View File

23
etpgrf_site/blog/admin.py Normal file
View File

@@ -0,0 +1,23 @@
from django.contrib import admin
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ('title', 'post_type', 'is_published', 'published_at')
list_filter = ('post_type', 'is_published', 'published_at')
search_fields = ('title', 'content', 'slug')
prepopulated_fields = {'slug': ('title',)}
date_hierarchy = 'published_at'
fieldsets = (
(None, {
'fields': ('title', 'slug', 'post_type', 'is_published', 'published_at')
}),
('Контент', {
'fields': ('image', 'excerpt', 'content')
}),
('SEO', {
'fields': ('seo_title', 'seo_description', 'seo_keywords'),
'classes': ('collapse',)
}),
)

6
etpgrf_site/blog/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'blog'
verbose_name = 'Блог и Страницы'

View File

@@ -0,0 +1,37 @@
# Generated by Django 6.0.1 on 2026-01-25 09:04
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Post',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(help_text='Основной заголовок (H1). Обязательно для заполнения.', max_length=255, verbose_name='Заголовок')),
('slug', models.SlugField(help_text='Уникальная часть адреса. Используйте латиницу, цифры и дефис. Например: my-new-post', max_length=255, unique=True, verbose_name='URL (slug)')),
('post_type', models.CharField(choices=[('B', 'Пост в блог'), ('P', 'Страница')], default='B', help_text='Страница доступна по адресу /slug/, Пост — по адресу /blog/slug/', max_length=1, verbose_name='Тип публикации')),
('is_published', models.BooleanField(default=True, help_text='Снимите галочку, чтобы скрыть публикацию (черновик).', verbose_name='Опубликовано')),
('published_at', models.DateTimeField(default=django.utils.timezone.now, help_text='Дата, которая будет отображаться в блоге. Можно запланировать на будущее.', verbose_name='Дата публикации')),
('content', models.TextField(help_text='Основной текст публикации. Поддерживает HTML.', verbose_name='Контент')),
('excerpt', models.TextField(blank=True, help_text='Отображается в списке постов. Если оставить пустым, будет взято начало контента.', verbose_name='Краткое описание (тизер)')),
('image', models.ImageField(blank=True, help_text='Изображение для превью в ленте и Open Graph (соцсети).', null=True, upload_to='blog/', verbose_name='Обложка')),
('seo_title', models.CharField(blank=True, help_text='Заголовок для поисковиков (<title>). Если пусто, используется основной заголовок.', max_length=255, verbose_name='SEO Title')),
('seo_description', models.TextField(blank=True, help_text='Описание для поисковиков (meta description). Рекомендуется 150-160 символов.', verbose_name='SEO Description')),
('seo_keywords', models.CharField(blank=True, help_text='Ключевые слова через запятую (meta keywords). Сейчас почти не используется поисковиками.', max_length=255, verbose_name='SEO Keywords')),
],
options={
'verbose_name': 'Публикация',
'verbose_name_plural': 'Публикации',
'ordering': ['-published_at'],
},
),
]

View File

@@ -0,0 +1,47 @@
# Generated by Django 6.0.1 on 2026-01-26 13:56
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='post',
name='is_published',
field=models.BooleanField(db_index=True, default=True, help_text='Снимите галочку, чтобы скрыть публикацию (черновик).', verbose_name='Опубликовано'),
),
migrations.AlterField(
model_name='post',
name='post_type',
field=models.CharField(choices=[('B', 'Пост в блог'), ('P', 'Страница')], db_index=True, default='B', help_text='Страница доступна по адресу /slug/, Пост — по адресу /blog/slug/', max_length=1, verbose_name='Тип публикации'),
),
migrations.AlterField(
model_name='post',
name='published_at',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, help_text='Дата, которая будет отображаться в блоге. Можно запланировать на будущее.', verbose_name='Дата публикации'),
),
migrations.AlterField(
model_name='post',
name='seo_keywords',
field=models.CharField(blank=True, help_text='Ключевые слова через запятую (meta keywords). Сейчас почти не используется поисковиками,но может пригодиться.', max_length=255, verbose_name='SEO Keywords'),
),
migrations.AlterField(
model_name='post',
name='seo_title',
field=models.CharField(blank=True, help_text='Заголовок для поисковиков (<tt>&lt;title&gt;</tt>). Если пусто, используется основной заголовок.', max_length=255, verbose_name='SEO Title'),
),
migrations.AddIndex(
model_name='post',
index=models.Index(fields=['post_type', 'is_published', '-published_at'], name='blog_post_idx'),
),
migrations.AddIndex(
model_name='post',
index=models.Index(fields=['post_type', 'slug'], name='blog_page_slug_idx'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-01-30 16:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0002_alter_post_is_published_alter_post_post_type_and_more'),
]
operations = [
migrations.AlterField(
model_name='post',
name='excerpt',
field=models.TextField(help_text='Отображается в списке постов. Если оставить пустым, будет взято начало контента.', verbose_name='Краткое описание (тизер)'),
),
]

View File

101
etpgrf_site/blog/models.py Normal file
View File

@@ -0,0 +1,101 @@
from django.db import models
from django.utils import timezone
from django.urls import reverse
class PostType(models.TextChoices):
BLOG = 'B', 'Пост в блог'
PAGE = 'P', 'Страница'
class Post(models.Model):
"""
Модель для постов блога и статических страниц.
"""
title = models.CharField(
verbose_name="Заголовок",
max_length=255,
help_text="Основной заголовок (H1). Обязательно для заполнения."
)
slug = models.SlugField(
verbose_name="URL (slug)",
max_length=255,
unique=True,
help_text="Уникальная часть адреса. Используйте латиницу, цифры и дефис. Например: my-new-post"
)
post_type = models.CharField(
verbose_name="Тип публикации",
max_length=1,
choices=PostType.choices,
default=PostType.BLOG,
db_index=True,
help_text="Страница доступна по адресу /slug/, Пост — по адресу /blog/slug/"
)
is_published = models.BooleanField(
verbose_name="Опубликовано",
default=True,
db_index=True,
help_text="Снимите галочку, чтобы скрыть публикацию (черновик)."
)
published_at = models.DateTimeField(
verbose_name="Дата публикации",
default=timezone.now,
db_index=True,
help_text="Дата, которая будет отображаться в блоге. Можно запланировать на будущее."
)
content = models.TextField(
verbose_name="Контент",
help_text="Основной текст публикации. Поддерживает HTML."
)
excerpt = models.TextField(
verbose_name="Краткое описание (тизер)",
help_text="Отображается в списке постов. Если оставить пустым, будет взято начало контента."
)
image = models.ImageField(
verbose_name="Обложка",
upload_to='blog/',
blank=True,
null=True,
help_text="Изображение для превью в ленте и Open Graph (соцсети)."
)
# SEO
seo_title = models.CharField(
verbose_name="SEO Title",
max_length=255,
blank=True,
help_text="Заголовок для поисковиков (<tt>&lt;title&gt;</tt>). Если пусто, используется основной заголовок."
)
seo_description = models.TextField(
verbose_name="SEO Description",
blank=True,
help_text="Описание для поисковиков (meta description). Рекомендуется 150-160 символов."
)
seo_keywords = models.CharField(
verbose_name="SEO Keywords",
max_length=255,
blank=True,
help_text="Ключевые слова через запятую (meta keywords). Сейчас почти не используется поисковиками,"
"но может пригодиться."
)
class Meta:
verbose_name = "Публикация"
verbose_name_plural = "Публикации"
ordering = ['-published_at']
indexes = [
# Индекс для быстрого поиска и сортировки постов блога
models.Index(fields=['post_type', 'is_published', '-published_at'], name='blog_post_idx'),
# Индекс для быстрых страниц (если post_type='P')
models.Index(fields=['post_type', 'slug'], name='blog_page_slug_idx'),
]
def __str__(self):
return self.title
def get_absolute_url(self):
if self.post_type == PostType.PAGE:
return reverse('page_detail', kwargs={'slug': self.slug})
return reverse('post_detail', kwargs={'slug': self.slug})

View File

@@ -0,0 +1,14 @@
from django.contrib.sitemaps import Sitemap
from .models import Post
class PostSitemap(Sitemap):
changefreq = "weekly" # Как часто меняются страницы
priority = 0.9 # Приоритет (от 0.0 до 1.0)
def items(self):
"""Возвращает все опубликованные посты и страницы."""
return Post.objects.filter(is_published=True)
def lastmod(self, obj):
"""Возвращает дату последнего изменения."""
return obj.published_at # Или можно добавить поле updated_at

View File

@@ -0,0 +1,49 @@
{% extends 'typograph/base.html' %}
{% load static %}
{% load typograph_extras %}
{% block title %}{% if page.seo_title %}{{ page.seo_title }}{% else %}{{ page.title|striptags|unescape|safe }}{% endif %} — ETPGRF{% endblock %}
{% block description %}{% if page.seo_description %}{{ page.seo_description }}{% else %}{{ page.excerpt|striptags|unescape|safe|truncatechars:160 }}{% endif %}{% endblock %}
{% block keywords %}{% if page.seo_keywords %}{{ post.seo_keywords }}{% else %}типограф, типографика, блог типограф, онлайн типограф, подготовка текста для веба, html типограф, неразрывные пробелы, кавычки елочки, длинное тире, очистка текста от мусора, интернет верстка, муравьев, лебедев{% endif %}{% endblock %}
{% block og_title %}{% if page.seo_title %}{{ page.seo_title }}{% else %}{{ page.title|striptags|unescape|safe }}{% endif %}{% endblock %}
{% block og_description %}{% if page.seo_description %}{{ page.seo_description }}{% else %}{{ page.excerpt|safe|striptags|unescape|truncatechars:160 }}{% endif %}{% endblock %}
{% block og_image %}{% if page.image %}{{ request.scheme }}://{{ request.get_host }}{{ page.image.url }}{% else %}{{ request.scheme }}://{{ request.get_host }}{% static 'img/etpgrf-logo-for-fb-vk-x.gif' %}{% endif %}{% endblock %}
{% block twitter_title %}{% if page.seo_title %}{{ page.seo_title }}{% else %}{{ page.title|striptags|unescape|safe }}{% endif %}{% endblock %}
{% block twitter_description %}{% if page.seo_description %}{{ page.seo_description }}{% else %}{{ page.excerpt|striptags|unescape|safe|truncatechars:160 }}{% endif %}{% endblock %}
{% block twitter_image %}{% if page.image %}{{ request.scheme }}://{{ request.get_host }}{{ page.image.url }}{% else %}{{ request.scheme }}://{{ request.get_host }}{% static 'img/etpgrf-logo-for-fb-vk-x.gif' %}{% endif %}{% endblock %}
{% block content %}
<div class="row">
{# Левая колонка: Дата и Картинка #}
<div class="col-lg-2 align-self-start text-end mb-4">
<p class="small align-self-end">
<small class="bg-secondary bg-opacity-10 p-2 text-nowrap">{{ page.published_at|date:"d.M.Y"|lower }}</small>
</p>
<p>{% if page.image %}
<img src="{{ page.image.url }}" class="w-100" alt="{{ page.image|striptags|unescape|safe }}"/>
{% else %}<img src="{% static 'img/etpgrf-logo-for-fb-vk-x.gif' %}" class="w-100" alt="{{ page.image|striptags|unescape|safe }}"/>
{% endif %}</p>
</div>
{# Правая колонка: Контент #}
<div class="col-lg-10 border-start ps-lg-4 post-page-content">
<h1 class="display-4 mb-4">{{ page.title|safe }}</h1>
{% if page.excerpt %}
<div class="lead bg-secondary bg-opacity-10 p-3 rounded">
{{ page.excerpt|safe }}
</div>
{% endif %}
<div class="page-content mt-4">
{{ page.content|safe }}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends 'typograph/base.html' %}
{% load static %}
{% load typograph_extras %}
{% block title %}{% if post.seo_title %}{{ post.seo_title }}{% else %}{{ post.title|striptags|unescape|safe }}{% endif %} — ETPGRF{% endblock %}
{% block description %}{% if post.seo_description %}{{ post.seo_description }}{% else %}{{ post.excerpt|striptags|unescape|safe|truncatechars:160 }}{% endif %}{% endblock %}
{% block keywords %}{% if post.seo_keywords %}{{ post.seo_keywords }}{% else %}типограф, типографика, блог типограф, онлайн типограф, подготовка текста для веба, html типограф, неразрывные пробелы, кавычки елочки, длинное тире, очистка текста от мусора, интернет верстка, муравьев, лебедев{% endif %}{% endblock %}
{% block og_title %}{% if post.seo_title %}{{ post.seo_title }}{% else %}{{ post.title|striptags|unescape|safe }}{% endif %}{% endblock %}
{% block og_description %}{% if post.seo_description %}{{ post.seo_description }}{% else %}{{ post.excerpt|striptags|unescape|safe|truncatechars:160 }}{% endif %}{% endblock %}
{% block og_image %}{% if post.image %}{{ request.scheme }}://{{ request.get_host }}{{ post.image.url }}{% else %}{{ request.scheme }}://{{ request.get_host }}{% static 'img/etpgrf-logo-for-fb-vk-x.gif' %}{% endif %}{% endblock %}
{% block twitter_title %}{% if post.seo_title %}{{ post.seo_title }}{% else %}{{ post.title|striptags|unescape|safe }}{% endif %}{% endblock %}
{% block twitter_description %}{% if post.seo_description %}{{ post.seo_description }}{% else %}{{ post.excerpt|striptags|unescape|safe|truncatechars:160 }}{% endif %}{% endblock %}
{% block twitter_image %}{% if post.image %}{{ request.scheme }}://{{ request.get_host }}{{ post.image.url }}{% else %}{{ request.scheme }}://{{ request.get_host }}{% static 'img/etpgrf-logo-for-fb-vk-x.gif' %}{% endif %}{% endblock %}
{% block content %}
<div class="row">
{# Левая колонка: Дата и Картинка #}
<div class="col-lg-2 align-self-start text-end mb-4">
<p class="small align-self-end">
<small class="bg-secondary bg-opacity-10 p-2 text-nowrap">
{{ post.published_at|date:"d.M.Y"|lower }}
</small>
</p>
<p>{% if post.image %}
<img src="{{ post.image.url }}" class="w-100" alt="{{ post.title|striptags|unescape|safe }}"/>
{% else %}<img src="{% static 'img/etpgrf-logo-for-fb-vk-x.gif' %}" class="w-100" alt="{{ post.title|striptags|unescape|safe }}"/>
{% endif %}</p>
<div class="d-none d-lg-block mt-5">
<a href="{% url 'blog:post_list' %}" class="btn btn-sm btn-outline-secondary w-100">&larr; В блог</a>
</div>
</div>
{# Правая колонка: Контент #}
<div class="col-lg-10 border-start ps-lg-4 post-page-content">
<h1 class="display-4 mb-4">{{ post.title|safe }}</h1>
{% if post.excerpt %}<div class="lead bg-secondary bg-opacity-10 p-3 rounded">
{{ post.excerpt|safe }}
</div>{% endif %}
<div class="post-content mt-4">
{{ post.content|safe }}
</div>
<div class="d-lg-none mt-5 border-top pt-3">
<a href="{% url 'blog:post_list' %}" class="btn btn-outline-secondary">&larr; Назад к списку статей</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,76 @@
{% extends 'typograph/base.html' %}
{% block title %}Блог — ETPGRF{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<h1 class="mb-4">Блог</h1>
{% for post in page_obj %}
<article class="card mb-4 shadow-sm">
{% if post.image %}
<img src="{{ post.image.url }}" class="card-img-top" alt="{{ post.title }}" style="max-height: 300px; object-fit: cover;" />
{% endif %}
<div class="card-body">
<h2 class="card-title h4">
<a href="{{ post.get_absolute_url }}" class="text-decoration-none text-reset">{{ post.title }}</a>
</h2>
<p class="card-text text-muted small mb-2">
{{ post.published_at|date:"d E Y" }}
</p>
<p class="card-text">
{% if post.excerpt %}
{{ post.excerpt|linebreaks }}
{% else %}
{{ post.content|striptags|truncatewords:30 }}
{% endif %}
</p>
<a href="{{ post.get_absolute_url }}" class="btn btn-outline-primary btn-sm">Читать далее &rarr;</a>
</div>
</article>
{% empty %}
<p class="text-muted">Пока нет записей.</p>
{% endfor %}
{# Пагинация #}
{% if page_obj.has_other_pages %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">&laquo;</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">&laquo;</span>
</li>
{% endif %}
{% for i in page_obj.paginator.page_range %}
{% if page_obj.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ i }}">{{ i }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">&raquo;</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">&raquo;</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
</div>
{% endblock %}

19
etpgrf_site/blog/urls.py Normal file
View File

@@ -0,0 +1,19 @@
from django.urls import path
from django.conf import settings
from . import views
app_name = 'blog' # Пространство имен для приложения blog
urlpatterns = [
# Лента блога: /blog/
path('', views.post_list, name='post_list'),
]
# Песочница для верстки: /blog/tmp/
# Добавляем ТОЛЬКО если DEBUG=True и ПЕРЕД post_detail
if settings.DEBUG:
urlpatterns.append(path('tmp/', views.tmp_view, name='tmp'))
# Детальная страница поста: /blog/my-awesome-post/
# Этот маршрут должен быть последним, так как он перехватывает всё, что похоже на slug
urlpatterns.append(path('<slug:slug>/', views.post_detail, name='post_detail'))

60
etpgrf_site/blog/views.py Normal file
View File

@@ -0,0 +1,60 @@
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.core.paginator import Paginator
from .models import Post, PostType
def post_list(request):
"""
Отображает список опубликованных постов блога с пагинацией.
"""
# Фильтруем только посты блога, опубликованные и с датой публикации не позднее текущего момента
posts_queryset = Post.objects.filter(
post_type=PostType.BLOG,
is_published=True,
published_at__lte=timezone.now()
).order_by('-published_at') # Сортируем по дате публикации (от новых к старым)
# Настраиваем пагинацию: 10 постов на страницу
paginator = Paginator(posts_queryset, 10)
page_number = request.GET.get('page') # Получаем номер страницы из GET-параметра
page_obj = paginator.get_page(page_number) # Получаем объект страницы
return render(request, 'blog/post_list.html', {'page_obj': page_obj})
def post_detail(request, slug):
"""
Отображает детальную страницу конкретного поста блога.
"""
# Ищем пост по слагу, типу 'BLOG', опубликованный и с датой публикации не позднее текущего момента
post = get_object_or_404(
Post,
slug=slug,
post_type=PostType.BLOG,
is_published=True,
published_at__lte=timezone.now()
)
return render(request, 'blog/post_detail.html', {'post': post})
def page_detail(request, slug):
"""
Отображает детальную страницу статической страницы (например, /privacy-policy/).
"""
# Ищем страницу по слагу, типу 'PAGE' и опубликованную
page = get_object_or_404(
Post,
slug=slug,
post_type=PostType.PAGE,
is_published=True
)
return render(request, 'blog/page_detail.html', {'page': page})
def tmp_view(request):
"""
Временная страница для верстки постов.
Доступна только в DEBUG режиме (или можно оставить, если не мешает).
"""
return render(request, 'blog/tmp.html')

View File

@@ -24,6 +24,10 @@ ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
# CSRF Trusted Origins (важно для работы через Nginx/Docker) # CSRF Trusted Origins (важно для работы через Nginx/Docker)
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:8000,http://127.0.0.1:8000').split(',') CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:8000,http://127.0.0.1:8000').split(',')
# URL админки (можно скрыть через .env)
# По умолчанию 'admin/'
ADMIN_URL = os.getenv('ADMIN_URL', 'admin/')
# Application definition # Application definition
@@ -34,7 +38,9 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'typograph', 'django.contrib.sitemaps', # Sitemap
'typograph', # Основное приложение типографа
'blog', # Приложение для блога и страниц
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -53,7 +59,7 @@ ROOT_URLCONF = 'etpgrf_site.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': [], # Шаблоны ищем внутри приложений (APP_DIRS=True)
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [

View File

@@ -2,24 +2,30 @@ from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.views.generic.base import RedirectView from django.contrib.sitemaps.views import sitemap # Импортируем view для sitemap
from django.contrib.staticfiles.storage import staticfiles_storage from blog import views as blog_views
from blog.sitemaps import PostSitemap # Импортируем наш класс Sitemap
# Словарь с картами сайта
sitemaps = {
'posts': PostSitemap,
}
urlpatterns = [ urlpatterns = [
path(route='adm-in/', view=admin.site.urls), # Админка по секретному URL
path( path(f'{settings.ADMIN_URL}', admin.site.urls),
route="favicon.ico",
view=RedirectView.as_view(url=staticfiles_storage.url("favicon.ico")), path('', include('typograph.urls')),
name="favicon",
), # Блог
path(route='', view=include('typograph.urls')), path('blog/', include('blog.urls')),
# Sitemap.xml
path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
# Статические страницы (ловушка в самом конце)
path('<slug:slug>/', blog_views.page_detail, name='page_detail'),
] ]
if settings.DEBUG: if settings.DEBUG:
# urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# runserver автоматически раздает статику из STATICFILES_DIRS,
# поэтому добавлять static(settings.STATIC_URL...) НЕ НУЖНО.
# Это только ломает путь, направляя его в STATIC_ROOT.
# А вот медиа runserver не раздает, поэтому это нужно:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -1,3 +1,33 @@
from django.contrib import admin from django.contrib import admin
from .models import DailyStat
# Register your models here. @admin.register(DailyStat)
class DailyStatAdmin(admin.ModelAdmin):
list_display = (
'date',
'index_views',
'process_requests',
'copy_count',
'chars_in',
'chars_out',
'chars_copied',
'avg_processing_time_ms_formatted',
)
list_filter = ('date',)
search_fields = ('date',)
ordering = ('-date',)
# Делаем поля только для чтения
readonly_fields = [field.name for field in DailyStat._meta.fields]
def has_add_permission(self, request):
# Запрещаем добавлять записи вручную
return False
def has_delete_permission(self, request, obj=None):
# Запрещаем удалять записи
return False
@admin.display(description='Среднее время (мс)', ordering='total_processing_time_ms')
def avg_processing_time_ms_formatted(self, obj):
return f"{obj.avg_processing_time_ms:.2f}"

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0.1 on 2026-01-20 16:23
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='DailyStat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(default=django.utils.timezone.now, unique=True, verbose_name='Дата')),
('index_views', models.PositiveIntegerField(default=0, verbose_name='Просмотры главной')),
('process_requests', models.PositiveIntegerField(default=0, verbose_name='Запросы на обработку')),
('chars_in', models.BigIntegerField(default=0, verbose_name='Символов на входе')),
('chars_out', models.BigIntegerField(default=0, verbose_name='Символов на выходе')),
('total_processing_time_ms', models.FloatField(default=0.0, verbose_name='Суммарное время обработки (мс)')),
('settings_stats', models.JSONField(default=dict, verbose_name='Статистика настроек')),
],
options={
'verbose_name': 'Дневная статистика',
'verbose_name_plural': 'Дневная статистика',
'ordering': ['-date'],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-01-20 19:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('typograph', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='dailystat',
name='copy_count',
field=models.PositiveIntegerField(default=0, verbose_name='Копирований в буфер'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-01-20 20:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('typograph', '0002_dailystat_copy_count'),
]
operations = [
migrations.AddField(
model_name='dailystat',
name='chars_copied',
field=models.BigIntegerField(default=0, verbose_name='Символов скопировано'),
),
]

View File

@@ -1,3 +1,67 @@
from django.db import models from django.db import models
from django.utils import timezone
# Create your models here. class DailyStat(models.Model):
"""
Модель для хранения агрегированной статистики использования за день.
"""
date = models.DateField(
verbose_name="Дата",
unique=True,
default=timezone.now
)
# Основные метрики
index_views = models.PositiveIntegerField(
verbose_name="Просмотры главной",
default=0
)
process_requests = models.PositiveIntegerField(
verbose_name="Запросы на обработку",
default=0
)
copy_count = models.PositiveIntegerField(
verbose_name="Копирований в буфер",
default=0
)
# Объемы
chars_in = models.BigIntegerField(
verbose_name="Символов на входе",
default=0
)
chars_out = models.BigIntegerField(
verbose_name="Символов на выходе",
default=0
)
chars_copied = models.BigIntegerField(
verbose_name="Символов скопировано",
default=0
)
# Производительность
total_processing_time_ms = models.FloatField(
verbose_name="Суммарное время обработки (мс)",
default=0.0
)
# Статистика по использованным настройкам
settings_stats = models.JSONField(
verbose_name="Статистика настроек",
default=dict
)
class Meta:
verbose_name = "Дневная статистика"
verbose_name_plural = "Дневная статистика"
ordering = ['-date']
def __str__(self):
return f"Статистика за {self.date.strftime('%Y-%m-%d')}"
@property
def avg_processing_time_ms(self):
"""Среднее время обработки одного запроса."""
if self.process_requests == 0:
return 0.0
return self.total_processing_time_ms / self.process_requests

View File

@@ -0,0 +1,86 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ошибка сервера 404 — ETPGRF</title>
<style>
body {
background-color: #f8f8f2;
color: #1f1f19;
display: flex;
justify-content: center;
height: 100vh;
margin: 0;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #151111;
color: #eceff1;
}
}
h1 {
font-size: 3.5rem;
font-weight: lighter;
margin-bottom: 1rem;
color: #00ccff99;
margin-left: 28px;
}
p {
font-size: 1.5rem;
margin-bottom: 2rem;
margin-left: 28px;
opacity: 0.8;
line-height: 150%;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
margin-top: 1.5rem;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
color: #151111;
background-color: transparent;
border: #4a4a44 dashed 1px;
border-radius: 0.375rem;
}
@media (prefers-color-scheme: dark) {
h1 {
color: #00ccff99;
}
.btn {
border: #b0bec5 dashed 1px;
color: #fff;
}
}
.btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div style="top: 20%; position: relative;">
<a href="/">
<picture>
<source srcset="/static/svg/logo-etpgrf-site-dark.svg" media="(prefers-color-scheme: dark)">
<img src="/static/svg/logo-etpgrf-site-light.svg"
alt="ETPGRF — единая типографика для веба" width="717" height="151">
</picture>
</a>
<h1>404: Страница не&nbsp;найдена</h1>
<p>
Запра&shy;шиваемая страница не&nbsp;найдена.<br/>
Контент мог быть удалён, перемещён или&nbsp;его тут никогда и&nbsp;не&nbsp;было.<br/>
<a class="btn" href="/">На&nbsp;главную</a>
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,86 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ошибка сервера 500 — ETPGRF</title>
<style>
body {
background-color: #f8f8f2;
color: #1f1f19;
display: flex;
justify-content: center;
height: 100vh;
margin: 0;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #151111;
color: #eceff1;
}
}
h1 {
font-size: 3.5rem;
font-weight: lighter;
margin-bottom: 1rem;
color: #00ccff99;
margin-left: 28px;
}
p {
font-size: 1.5rem;
margin-bottom: 2rem;
margin-left: 28px;
opacity: 0.8;
line-height: 150%;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
margin-top: 1.5rem;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
color: #151111;
background-color: transparent;
border: #4a4a44 dashed 1px;
border-radius: 0.375rem;
}
@media (prefers-color-scheme: dark) {
h1 {
color: #00ccff99;
}
.btn {
border: #b0bec5 dashed 1px;
color: #fff;
}
}
.btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div style="top: 20%; position: relative;">
<a href="/">
<picture>
<source srcset="/static/svg/logo-etpgrf-site-dark.svg" media="(prefers-color-scheme: dark)">
<img src="/static/svg/logo-etpgrf-site-light.svg"
alt="ETPGRF — единая типографика для веба" width="717" height="151">
</picture>
</a>
<h1>500: Внутренняя ошибка сервера</h1>
<p>
Извините, что-то сломалось на&nbsp;сервере или пошло не&nbsp;так.<br/>
Пожалуйста, попробуйте позже.<br/>
<a class="btn" href="/">На&nbsp;главную</a>
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,86 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ошибка сервера 403 — ETPGRF</title>
<style>
body {
background-color: #f8f8f2;
color: #1f1f19;
display: flex;
justify-content: center;
height: 100vh;
margin: 0;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #151111;
color: #eceff1;
}
}
h1 {
font-size: 3.5rem;
font-weight: lighter;
margin-bottom: 1rem;
color: #00ccff99;
margin-left: 28px;
}
p {
font-size: 1.5rem;
margin-bottom: 2rem;
margin-left: 28px;
opacity: 0.8;
line-height: 150%;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
margin-top: 1.5rem;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
color: #151111;
background-color: transparent;
border: #4a4a44 dashed 1px;
border-radius: 0.375rem;
}
@media (prefers-color-scheme: dark) {
h1 {
color: #00ccff99;
}
.btn {
border: #b0bec5 dashed 1px;
color: #fff;
}
}
.btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div style="top: 20%; position: relative;">
<a href="/">
<picture>
<source srcset="/static/svg/logo-etpgrf-site-dark.svg" media="(prefers-color-scheme: dark)">
<img src="/static/svg/logo-etpgrf-site-light.svg"
alt="ETPGRF — единая типографика для веба" width="717" height="151">
</picture>
</a>
<h1>403: Доступ запрещён, необходимо автори&shy;зоваться</h1>
<p>
Записанная в&nbsp;адресной строке страница требует аутенти&shy;фикации.<br/>
Пожалуйста, войдите в&nbsp;систему и&nbsp;повторите попытку.<br/>
<a class="btn" href="/">На&nbsp;главную</a>
</p>
</div>
</body>
</html>

View File

@@ -1,48 +1,88 @@
{% load static %}<!doctype html> {% load static %}<!doctype html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}ETPGRF — единая типографика для веба{% endblock %}</title> {# --- SEO & Meta Tags --- #}<title>{% block title %}ETPGRF — единая типографика для веба{% endblock %}</title>
{# Favicons #} <link rel="icon" type="image/png" href="{% static 'favicon-96x96.png' %}" sizes="96x96" /> <meta name="description" content="{% block description %}Бесплатный онлайн-типограф для подготовки текстов к публикации в вебе. Расставка неразрывных пробелов, правильных кавычек («ёлочки»), тире, спецсимволы, отбивка, компоновка, висячая пунктуация. Идеально для верстки сайтов, статей и постов.{% endblock %}">
<link rel="icon" href="{% static 'favicon-light.svg' %}" type="image/svg+xml" media="(prefers-color-scheme: light)"> <meta name="keywords" content="{% block keywords %}типограф, типографика, онлайн типограф, подготовка текста для веба, html типограф, неразрывные пробелы, кавычки елочки, длинное тире, очистка текста от мусора, интернет верстка, муравьев{% endblock %}">
<link rel="icon" href="{% static 'favicon-dark.svg' %}" type="image/svg+xml" media="(prefers-color-scheme: dark)"> <meta name="author" content="Sergei Erjemin">
<link rel="icon" type="image/svg+xml" href="{% static 'favicon.svg' %}" /> {# --- Open Graph (Facebook, VK, LinkedIn, Telegram) --- #}<meta property="og:type" content="website" />
{# Fallback для старых браузеров #}<link rel="shortcut icon" href="{% static 'favicon.ico' %}" sizes="any" /> <meta property="og:site_name" content="ETPGRF" />
{# iOS Icon #}<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}" /> <meta property="og:url" content="{{ request.build_absolute_uri }}" />
<link rel="manifest" href="{% static 'site.webmanifest' %}" /> <meta property="og:title" content="{% block og_title %}ETPGRF — единая типографика для веба{% endblock %}" />
{# Bootstrap 5 CSS #}<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" /> <meta property="og:description" content="{% block og_description %}Сделайте ваш текст профессиональным и готовым к публикации в интернете за один клик. Умная типографика для веб-дизайнеров, редакторов и контент-менеджеров.{% endblock %}" />
{# Bootstrap Icons #}<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" /> <meta property="og:image" content="{% block og_image %}{{ request.scheme }}://{{ request.get_host }}{% static 'img/etpgrf-logo-for-fb-vk-x.gif' %}{% endblock %}" />
{# Custom CSS #}<link href="{% static 'css/etpgrf.css' %}" rel="stylesheet" /> <meta property="og:image:width" content="1200" />
{# HTMX #}<script src="https://unpkg.com/htmx.org@1.9.10"></script> <meta property="og:image:height" content="630" />
{# Alpine.js #}<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> {# --- Twitter Cards (X) --- #}<meta name="twitter:card" content="summary_large_image">
{# Polyfill for Import Maps #}<script async src="https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js"></script> <meta name="twitter:title" content="{% block twitter_title %}ETPGRF — единая типографика для веба{% endblock %}" />
<meta name="twitter:description" content="{% block twitter_description %}Сделайте ваш текст профессиональным и готовым к публикации в интернете за один клик.{% endblock %}" />
<meta name="twitter:image" content="{% block twitter_image %}{{ request.scheme }}://{{ request.get_host }}{% static 'img/etpgrf-logo-for-fb-vk-x.gif' %}{% endblock %}" />
{# --- Favicons --- #}<link rel="icon" href="{{ request.scheme }}://{{ request.get_host }}{% static 'favicon.ico' %}" type="image/x-icon" />
<link rel="icon" type="image/png" href="{% static 'favicon-96x96.png' %}" />
<link rel="icon" href="{% static 'favicon-light.svg' %}" type="image/svg+xml" media="(prefers-color-scheme: light)" />
<link rel="icon" href="{% static 'favicon-dark.svg' %}" type="image/svg+xml" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/svg+xml" href="{% static 'favicon.svg' %}" />
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}" />
<link rel="manifest" href="{% static 'site.webmanifest' %}" />
{# Bootstrap 5 CSS #}<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"/>
{# Bootstrap Icons #}<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"/>
<style>
:root {
--bg-image-text: url("{% static 'svg/logo-etpgrf-site-light-txt.svg' %}");
--bg-image-logo: url("{% static 'svg/logo-etpgrf-site-light-compact.svg' %}");
}
@media (prefers-color-scheme: dark) {
:root {
--bg-image-text: url("{% static 'svg/logo-etpgrf-site-dark-txt.svg' %}");
--bg-image-logo: url("{% static 'svg/logo-etpgrf-site-dark-compact.svg' %}");
}
}
</style>
{# Custom CSS #}<link href="{% static 'css/etpgrf.css' %}" rel="stylesheet"/>
{# HTMX #}<script src="https://unpkg.com/htmx.org@1.9.10"></script>
{# Alpine.js #}<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head> </head>
<body> <body>
{# ШАПКА и главное меню #} {# ШАПКА с логотипом и главное меню #}<nav id="main-navbar" class="navbar navbar-expand-lg mb-4">
<nav id="main-navbar" class="navbar navbar-expand-lg mb-4"> <div id="logo" class="container logo-big" style="background-image: var(--bg-image-logo);">
<div class="container"> <a class="navbar-brand" href="/" style="background-image: var(--bg-image-text);"
<a class="navbar-brand" href="/"> title="ETPGRF — единая типографика для веба">
<img id="logo-img" class="logo-img" src=""
data-src-light="{% static 'svg/logo-etpgrf-site-light.svg' %}"
data-src-light-compact="{% static 'svg/logo-etpgrf-site-light-compact.svg' %}"
data-src-dark="{% static 'svg/logo-etpgrf-site-dark.svg' %}"
data-src-dark-compact="{% static 'svg/logo-etpgrf-site-dark-compact.svg' %}"
alt="ETPGRF — единая типографика для веба">
</a> </a>
{# Кнопка-бургер для мобильных #}
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
{# Меню #}
<div class="collapse navbar-collapse justify-content-end text-end" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link {% if '/blog/' in request.path %}active fw-bold{% endif %}" href="{% url 'blog:post_list' %}">Блог</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.path == '/donate/' %}active fw-bold{% endif %}" href="/donate/">Поддержать</a>
</li>
</ul>
</div>
</div> </div>
</nav> </nav>
<div id="content-container" class="container"> {# Контент #}<div id="content-container" class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
{# Футер #} {# Футер #}<footer class="footer mt-auto py-2 mt-4">
<footer class="footer mt-auto py-3"> <div class="container d-flex justify-content-between align-items-center">
<div class="container"> <span class="text-muted small nowrap me-2">&copy; Sergei Erjemin, 2025&ndash;{% now 'Y' %}.</span>
<span class="text-muted small">&copy; Sergei Erjemin, 2025&ndash;{% now 'Y' %}</span> <nobr class="text-muted small mx-2"><i class="bi bi-tags me-1" title="Версия библиотеки etpgrf / Версия сайта"></i><a href="/changelog">v0.1.4 / v0.2.3</a></nobr>
<span class="text-muted small float-end">v0.1.1</span> {# Сводная статистика (HTMX) #}<span class="text-muted small ms-2" hx-get="{% url 'stats_summary' %}" hx-trigger="load">
...
</span>
</div> </div>
</footer> </footer>
@@ -60,13 +100,8 @@
</div> </div>
</div> </div>
{# Bootstrap JS #} {# Bootstrap JS #}<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> {# Custom JS #}<script src="{% static 'js/base.js' %}" defer></script>
{# Custom JS #}
<script src="{% static 'js/base.js' %}" defer></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -1,17 +1,57 @@
{% extends 'typograph/base.html' %} {% extends 'typograph/base.html' %}
{% load static %} {% load static %}
{% block title %}ETPGRF — единая типографика для веба{% endblock %}
{% block description %}Бесплатный онлайн-типограф для подготовки текстов к публикации в вебе. Расставка неразрывных пробелов, правильных кавычек («ёлочки»), тире, спецсимволы, отбивка, компоновка, висячая пунктуация. Идеально для верстки сайтов, статей и постов.{% endblock %}
{% block keywords %}типограф, типографика, онлайн типограф, подготовка текста для веба, html типограф, неразрывные пробелы, кавычки елочки, длинное тире, очистка текста от мусора, интернет верстка, муравьев{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<h3>Типограф</h3>
{# SEO-текст и описание #}
<div class="mb-4">
<h1 class="h3 mb-3">Онлайн-типограф для веба</h1>
<p>
Интернет-типограф <strong>etpgrf</strong>&nbsp;— инструмент для&nbsp;подготовки текста для&nbsp;публикации
в&nbsp;вебе. Расстановка неразрывных пробелов перед&nbsp;союзами и&nbsp;предлогами, замена кавычек
на&nbsp;«ёлочки» (или&nbsp;“лапки” для&nbsp;англоя&shy;зычного текста), отбивка и&nbsp;компоновка тире,
инициалов, единиц измерения, переносы в&nbsp;словах, обработка псевдо&shy;графики и&nbsp;преобра&shy;зование
их&nbsp;в&nbsp;спецсимволы, висячая пунктуация. Получите готовый и&nbsp;валидный HTML-код для&nbsp;вставки
на&nbsp;ваш сайт или публикацию в&nbsp;блог.
</p>
<p class="text-muted small">
Исходный код etpgrf-типографа доступен в&nbsp;нескольких репози&shy;ториях
(<a href="https://github.com/erjemin/etpgrf" target="_blank">GitHub</a>,
<a href="https://gitverse.ru/erjemin/etpgrf" target="_blank">GitVerse</a>,
<a href="https://git.cube2.ru/erjemin/2025-etpgrf" target="_blank">Сube2</a>, и&nbsp;<a
href="https://pypi.org/project/etpgrf/" target="_blank">PyPI</a>), распрос&shy;траняется под&nbsp;лицензией
<a href="https://opensource.org/licenses/MIT" target="_blank">MIT</a>, может быть установлен
локально, на&nbsp;ваш сайт или&nbsp;интегри&shy;рован в&nbsp;ваши проекты как&nbsp;Python-библиотека.
</p>
</div>
<form hx-post="{% url 'process_text' %}" hx-target="#result-area" hx-swap="innerHTML"> <form hx-post="{% url 'process_text' %}" hx-target="#result-area" hx-swap="innerHTML">
{% csrf_token %} {% csrf_token %}
{# ГЛАВНОЕ ПОЛЕ ВВОДА: ТЕКСТ ДЛЯ ТИПОГРАФИРОВАНИЯ #} {# ГЛАВНОЕ ПОЛЕ ВВОДА #}
<div class="mb-3"> <div class="mb-3">
<textarea class="form-control" name="text" rows="10" placeholder="Вставьте текст сюда..."></textarea> <div class="d-flex justify-content-between align-items-end mb-2">
<label class="form-label fw-bold small text-muted ls-1 mb-0">
<i class="bi bi-file-text me-1"></i> Исходный текст:
</label>
<div class="d-flex align-items-center">
<span id="char-count" class="small text-muted me-3 nowrap">0 симв.</span>
<button type="button" id="btn-clear" class="btn btn-sm btn-outline-secondary" title="Очистить поле">
<i class="bi bi-trash me-1"></i> Очистить
</button>
</div>
</div>
<textarea class="form-control" name="text" id="source-text" rows="10" placeholder="Вставьте текст сюда..."></textarea>
</div> </div>
{# Блок настроек (Collapse) #} {# Блок настроек (Collapse) #}
<div class="mb-3"> <div class="mb-3">
<button class="btn btn-outline-secondary btn-sm mb-2" type="button" data-bs-toggle="collapse" <button class="btn btn-outline-secondary btn-sm mb-2" type="button" data-bs-toggle="collapse"
@@ -189,7 +229,7 @@
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" name="sanitizer_enabled" id="optSanitizer" <input class="form-check-input" type="checkbox" name="sanitizer_enabled" id="optSanitizer"
x-model="enabled"> x-model="enabled">
<label class="form-check-label fw-bold" for="optSanitizer">Очистка от HTML (Sanitizer)</label> <label class="form-check-label fw-bold" for="optSanitizer">Очистка от&nbsp;HTML (Sanitizer)</label>
</div> </div>
{# Настройки группы "Санитайзер" (видны, когда включено) #} {# Настройки группы "Санитайзер" (видны, когда включено) #}
<div class="ms-3 mt-1" x-show="enabled" x-transition> <div class="ms-3 mt-1" x-show="enabled" x-transition>
@@ -224,7 +264,7 @@
Юникод (Unicode) Юникод (Unicode)
</option> </option>
<option value="mnemonic" <option value="mnemonic"
data-desc="Совместимость. Все спецсимволы заменяются на&nbsp;HTML-мнемоники (&amp;amp;mdash;, &amp;amp;copy; …)."> data-desc="Совместимость c&nbsp;koi8r и&nbsp;cp1251. Все спецсимволы заменяются на&nbsp;HTML-мнемоники (<tt>&amp;amp;mdash;</tt>, <tt>&amp;amp;copy;</tt> и&nbsp;пр.)">
Мнемоники (Mnemonic) Мнемоники (Mnemonic)
</option> </option>
</select> </select>
@@ -239,12 +279,22 @@
</div> </div>
<!-- Кнопка отправки --> <!-- Кнопка отправки -->
<button type="submit" class="btn btn-primary btn-lg w-100">Типографировать</button> <button type="submit" class="btn btn-primary btn-lg w-100" onclick="sendGoal('etpgrf-btn-pressed')">Типографировать</button>
</form> </form>
</div> </div>
<div class="col-md-12 mt-4"> <div class="col-md-12 my-4">
<h3>Результат</h3> <div class="d-flex justify-content-between align-items-end mb-2">
<label class="form-label fw-bold small text-muted ls-1 mb-0">
<i class="bi bi-code-slash me-1"></i> Результат обработки:
</label>
<div class="d-flex align-items-center">
<span id="processing-time" class="small text-muted me-3 nowrap"></span>
<button id="btn-copy" class="btn btn-sm btn-outline-secondary d-none" title="Копировать в буфер обмена">
<i class="bi bi-clipboard me-1"></i> Копировать в&nbsp;буфер обмена
</button>
</div>
</div>
<div id="cm-result-wrapper" class="result-box p-0"></div> <div id="cm-result-wrapper" class="result-box p-0"></div>
<div id="result-area" style="display: none;"></div> <div id="result-area" style="display: none;"></div>
</div> </div>

View File

@@ -0,0 +1,12 @@
{% load typograph_extras %}<nobr class="ms-3 float-end" title="Скопировано в буфер текстов/символов">
<i class="bi bi-clipboard-check me-1"></i>{{ copied|humanize_num }} / {{ chars_copied|humanize_num }}
</nobr>
<nobr class="ms-3 float-end" title="На выход получено символов">
<i class="bi bi-box-arrow-right me-1"></i>{{ chars_out|humanize_num }}
</nobr>
<nobr class="ms-3 float-end" title="На вход обработано текстов/символов">
<i class="bi bi-box-arrow-in-right me-1"></i>{{ processed|humanize_num }} / {{ chars_in|humanize_num }}
</nobr>
<nobr class="ms-3 float-end" title="Просмотров">
<i class="bi bi-eye me-1"></i>{{ views|humanize_num }}
</nobr>

View File

@@ -0,0 +1,65 @@
from django import template
from django.utils.safestring import mark_safe
import html
register = template.Library()
@register.filter(name='humanize_num')
def humanize_num(value):
"""
Форматирует число с тонкими пробелами в качестве разделителя тысяч
и сокращает большие числа до M (миллионы) или k (тысячи).
Примеры:
1234 -> 1&thinsp;234
1234567 -> 1,2M
"""
try:
num = int(value)
if num > 1_000_000_000:
val = num / 1_000_000_000
suffix = "&thinsp;B"
elif num > 1_000_000:
val = num / 1_000_000
suffix = "&thinsp;M"
elif num > 1_000:
val = num / 1_000
suffix = "&thinsp;k"
else:
# Больше 1B -- форматируем с пробелами
return mark_safe(f"{num:,}".replace(",", "&thinsp;"))
# Форматируем float:
# {:,.1f} - разделитель тысяч (запятая) и 1 знак после точки
# 1234567.89 -> "1,234,567.9"
formatted = f"{val:,.2f}"
# Меняем английскую запятую (разделитель тысяч) на тонкий пробел
# Меняем английскую точку (десятичный разделитель) на запятую
# Но тут проблема: replace делает все сразу.
# "1,234.5" -> replace(",", " ") -> "1 234.5" -> replace(".", ",") -> "1 234,5"
formatted = formatted.replace(",", "&thinsp;").replace(".", ",")
return mark_safe(f"{formatted}{suffix}")
except (ValueError, TypeError):
return value
@register.filter(name='unescape')
def unescape_filter(value):
"""
Декодирует HTML-сущности (&nbsp; -> ' ', &mdash; -> —)
и удаляет лишние пробелы и переводы строк.
Полезно для мета-тегов (title, description, og:title).
"""
if not value:
return ""
# 1. Декодируем сущности
text = html.unescape(str(value))
# 2. Удаляем лишние пробелы и переводы строк
# split() без аргументов разбивает по любым пробельным символам (\n, \t, space)
# " ".join(...) собирает обратно через один пробел
return " ".join(text.split())

View File

@@ -2,6 +2,8 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path(route='', view=views.index, name='index'), path('', views.index, name='index'),
path(route='process/', view=views.process_text, name='process_text'), path('process/', views.process_text, name='process_text'),
path('stats/summary/', views.get_stats_summary, name='stats_summary'),
path('stats/track-copy/', views.track_copy, name='track_copy'),
] ]

View File

@@ -1,12 +1,73 @@
from django.shortcuts import render from django.shortcuts import render
from django.http import HttpResponse from django.http import HttpResponse, JsonResponse
from django.db.models import F, Sum
from django.utils import timezone
from django.views.decorators.http import require_POST
from etpgrf.typograph import Typographer from etpgrf.typograph import Typographer
from etpgrf.layout import LayoutProcessor from etpgrf.layout import LayoutProcessor
from etpgrf.hyphenation import Hyphenator from etpgrf.hyphenation import Hyphenator
from .models import DailyStat
import time
def index(request): def index(request):
# Увеличиваем счетчик просмотров главной
try:
today = timezone.now().date()
stat, created = DailyStat.objects.get_or_create(date=today)
DailyStat.objects.filter(pk=stat.pk).update(index_views=F('index_views') + 1)
except Exception as e:
print(f"Stat error: {e}")
return render(request, template_name='typograph/index.html') return render(request, template_name='typograph/index.html')
def get_stats_summary(request):
"""Возвращает сводную статистику."""
try:
stats = DailyStat.objects.aggregate(
views=Sum('index_views'),
processed=Sum('process_requests'),
copied=Sum('copy_count'),
chars_in=Sum('chars_in'),
chars_out=Sum('chars_out'),
chars_copied=Sum('chars_copied')
)
context = {
'views': stats['views'] or 0,
'processed': stats['processed'] or 0,
'copied': stats['copied'] or 0,
'chars_in': stats['chars_in'] or 0,
'chars_out': stats['chars_out'] or 0,
'chars_copied': stats['chars_copied'] or 0,
}
return render(request, 'typograph/stats_summary.html', context)
except Exception:
return HttpResponse("...")
@require_POST
def track_copy(request):
"""Увеличивает счетчик копирований и количество скопированных символов."""
try:
char_count = int(request.POST.get('char_count', 0))
today = timezone.now().date()
stat, created = DailyStat.objects.get_or_create(date=today)
DailyStat.objects.filter(pk=stat.pk).update(
copy_count=F('copy_count') + 1,
chars_copied=F('chars_copied') + char_count
)
return HttpResponse("OK")
except (ValueError, TypeError):
return HttpResponse("Invalid char_count", status=400)
except Exception as e:
print(f"Stat error: {e}")
return HttpResponse("Error", status=500)
def process_text(request): def process_text(request):
if request.method == 'POST': if request.method == 'POST':
text = request.POST.get(key='text', default='') text = request.POST.get(key='text', default='')
@@ -71,22 +132,71 @@ def process_text(request):
'sanitizer': sanitizer_option, 'sanitizer': sanitizer_option,
} }
# --- ДИАГНОСТИКА --- # Обрабатываем текст с замером времени
# print("Typographer options:", options) start_time = time.perf_counter()
# ------------------- # Создаем экземпляр типографа и передаем настройки в него
# Создаем экземпляр типографа
typo = Typographer(**options) typo = Typographer(**options)
# Обрабатываем текст в Типографе
# Обрабатываем текст
processed = typo.process(text) processed = typo.process(text)
# print("Processed text length:", len(processed)) end_time = time.perf_counter()
# print("Processed text:", processed)
return render( duration_ms = (end_time - start_time) * 1000
# --- СБОР СТАТИСТИКИ ---
try:
today = timezone.now().date()
stat, created = DailyStat.objects.get_or_create(date=today)
# 1. Атомарное обновление счетчиков
DailyStat.objects.filter(pk=stat.pk).update(
process_requests=F('process_requests') + 1,
chars_in=F('chars_in') + len(text),
chars_out=F('chars_out') + len(processed),
total_processing_time_ms=F('total_processing_time_ms') + duration_ms
)
# 2. Обновление JSON с настройками (не атомарно, а значит при высокой нагрузке возможны потери данных)
# Перечитываем объект, чтобы получить актуальный JSON
stat.refresh_from_db()
current_stats = stat.settings_stats
def inc_stat(key, value):
k = f"{key}:{value}"
current_stats[k] = current_stats.get(k, 0) + 1
# Собираем статистику по опциям
# langs может быть строкой или списком
lang_val = options['langs']
if isinstance(lang_val, list):
lang_val = lang_val[0] if lang_val else 'ru'
inc_stat('lang', lang_val)
inc_stat('mode', options['mode'])
if options['quotes']: inc_stat('feature', 'quotes')
if layout_enabled: inc_stat('feature', 'layout')
if options['unbreakables']: inc_stat('feature', 'unbreakables')
if hyphenation_enabled: inc_stat('feature', 'hyphenation')
if options['symbols']: inc_stat('feature', 'symbols')
if hanging_enabled: inc_stat('feature', 'hanging')
if sanitizer_enabled: inc_stat('feature', 'sanitizer')
stat.settings_stats = current_stats
stat.save(update_fields=['settings_stats'])
except Exception as e:
print(f"Stat error: {e}")
# -----------------------
response = render(
request, request,
template_name='typograph/result_fragment.html', template_name='typograph/result_fragment.html',
context={'processed_text': processed} context={'processed_text': processed}
) )
# Добавляем заголовок с временем обработки (с запятой вместо точки)
response['X-Processing-Time'] = f"{duration_ms:.4f}".replace('.', ',')
return response
return HttpResponse(status=405) return HttpResponse(status=405)

116
poetry.lock generated
View File

@@ -58,13 +58,13 @@ bcrypt = ["bcrypt (>=4.1.1)"]
[[package]] [[package]]
name = "etpgrf" name = "etpgrf"
version = "0.1.3" version = "0.1.4"
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ." description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
{file = "etpgrf-0.1.3-py3-none-any.whl", hash = "sha256:38212713f957ecf12d7e5fd6a11c77995bf41e16cbca4250411fa450ba290d62"}, {file = "etpgrf-0.1.4-py3-none-any.whl", hash = "sha256:62d4371e1b5fab06b99f79bd351767aed8baf7d041cae7e5d4eb63f7c9545114"},
{file = "etpgrf-0.1.3.tar.gz", hash = "sha256:f611948fe747c5470ba27b31d8af5c59a219d58efd033079491c9e61e011e4d0"}, {file = "etpgrf-0.1.4.tar.gz", hash = "sha256:c699382c292e3110915331dd5539e7dde0c961e4f4ca65cf8db0e01e84dab72f"},
] ]
[package.dependencies] [package.dependencies]
@@ -259,6 +259,114 @@ files = [
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
] ]
[[package]]
name = "pillow"
version = "12.1.0"
description = "Python Imaging Library (fork)"
optional = false
python-versions = ">=3.10"
files = [
{file = "pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd"},
{file = "pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0"},
{file = "pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8"},
{file = "pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1"},
{file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda"},
{file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7"},
{file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a"},
{file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef"},
{file = "pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09"},
{file = "pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91"},
{file = "pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea"},
{file = "pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3"},
{file = "pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0"},
{file = "pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451"},
{file = "pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e"},
{file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84"},
{file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0"},
{file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b"},
{file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18"},
{file = "pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64"},
{file = "pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75"},
{file = "pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304"},
{file = "pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b"},
{file = "pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551"},
{file = "pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208"},
{file = "pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5"},
{file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661"},
{file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17"},
{file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670"},
{file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616"},
{file = "pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7"},
{file = "pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d"},
{file = "pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c"},
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1"},
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179"},
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0"},
{file = "pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587"},
{file = "pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac"},
{file = "pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b"},
{file = "pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea"},
{file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c"},
{file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc"},
{file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644"},
{file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c"},
{file = "pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171"},
{file = "pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a"},
{file = "pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45"},
{file = "pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d"},
{file = "pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4"},
{file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0"},
{file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b"},
{file = "pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65"},
{file = "pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0"},
{file = "pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8"},
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91"},
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796"},
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd"},
{file = "pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13"},
{file = "pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e"},
{file = "pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643"},
{file = "pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5"},
{file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de"},
{file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9"},
{file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a"},
{file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a"},
{file = "pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030"},
{file = "pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94"},
{file = "pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4"},
{file = "pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2"},
{file = "pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8"},
{file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924"},
{file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef"},
{file = "pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988"},
{file = "pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6"},
{file = "pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19"},
{file = "pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
xmp = ["defusedxml"]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" version = "1.2.1"
@@ -464,4 +572,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.13" python-versions = "^3.13"
content-hash = "88ffa335edb29f6d8f90c01acef7d584e2a49d0a1361f0fa893b122ed8694ba1" content-hash = "9610a92fa47d1bd0849512ae842b0fdd68dc06d9917ab676cf5d8f6521700837"

87
public/static/502.html Normal file
View File

@@ -0,0 +1,87 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ошибка сервера 502 — ETPGRF</title>
<style>
body {
background-color: #f8f8f2;
color: #1f1f19;
display: flex;
justify-content: center;
height: 100vh;
margin: 0;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #151111;
color: #eceff1;
}
}
h1 {
font-size: 3.5rem;
font-weight: lighter;
margin-bottom: 1rem;
color: #00ccff99;
margin-left: 28px;
}
p {
font-size: 1.5rem;
margin-bottom: 2rem;
margin-left: 28px;
opacity: 0.8;
line-height: 150%;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
margin-top: 1.5rem;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
color: #151111;
background-color: transparent;
border: #4a4a44 dashed 1px;
border-radius: 0.375rem;
}
@media (prefers-color-scheme: dark) {
h1 {
color: #00ccff99;
}
.btn {
border: #b0bec5 dashed 1px;
color: #fff;
}
}
.btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div style="top: 20%; position: relative;">
<a href="/">
<picture>
<source srcset="/static/svg/logo-etpgrf-site-dark.svg" media="(prefers-color-scheme: dark)">
<img src="/static/svg/logo-etpgrf-site-light.svg"
alt="ETPGRF — единая типографика для веба" width="717" height="151">
</picture>
</a>
<h1>502: Bad Gateway</h1>
<p>
Внутренняя ошибка сервера.<br/>
Недоступен Gunicorn-сервис, база данных или&nbsp;произошла ошибка при&nbsp;обработке запроса.<br />
Пожалуйста, попробуйте позже.<br/>
<a class="btn" href="/">На&nbsp;главную</a>
</p>
</div>
</body>
</html>

86
public/static/503.html Normal file
View File

@@ -0,0 +1,86 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ошибка сервера 503 — ETPGRF</title>
<style>
body {
background-color: #f8f8f2;
color: #1f1f19;
display: flex;
justify-content: center;
height: 100vh;
margin: 0;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #151111;
color: #eceff1;
}
}
h1 {
font-size: 3.5rem;
font-weight: lighter;
margin-bottom: 1rem;
color: #00ccff99;
margin-left: 28px;
}
p {
font-size: 1.5rem;
margin-bottom: 2rem;
margin-left: 28px;
opacity: 0.8;
line-height: 150%;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
margin-top: 1.5rem;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
color: #151111;
background-color: transparent;
border: #4a4a44 dashed 1px;
border-radius: 0.375rem;
}
@media (prefers-color-scheme: dark) {
h1 {
color: #00ccff99;
}
.btn {
border: #b0bec5 dashed 1px;
color: #fff;
}
}
.btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div style="top: 20%; position: relative;">
<a href="/">
<picture>
<source srcset="/static/svg/logo-etpgrf-site-dark.svg" media="(prefers-color-scheme: dark)">
<img src="/static/svg/logo-etpgrf-site-light.svg"
alt="ETPGRF — единая типографика для веба" width="717" height="151">
</picture>
</a>
<h1>503: Слишком много запросов</h1>
<p>
Сервер временно перегружен запросами или&nbsp;находится на&nbsp;техническом обслуживании.<br/>
Пожалуйста, попробуйте позже.<br/>
<a class="btn" href="/">На&nbsp;главную</a>
</p>
</div>
</body>
</html>

87
public/static/504.html Normal file
View File

@@ -0,0 +1,87 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ошибка сервера 50x — ETPGRF</title>
<style>
body {
background-color: #f8f8f2;
color: #1f1f19;
display: flex;
justify-content: center;
height: 100vh;
margin: 0;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #151111;
color: #eceff1;
}
}
h1 {
font-size: 3.5rem;
font-weight: lighter;
margin-bottom: 1rem;
color: #00ccff99;
margin-left: 28px;
}
p {
font-size: 1.5rem;
margin-bottom: 2rem;
margin-left: 28px;
opacity: 0.8;
line-height: 150%;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
margin-top: 1.5rem;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
color: #151111;
background-color: transparent;
border: #4a4a44 dashed 1px;
border-radius: 0.375rem;
}
@media (prefers-color-scheme: dark) {
h1 {
color: #00ccff99;
}
.btn {
border: #b0bec5 dashed 1px;
color: #fff;
}
}
.btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div style="top: 20%; position: relative;">
<a href="/">
<picture>
<source srcset="/static/svg/logo-etpgrf-site-dark.svg" media="(prefers-color-scheme: dark)">
<img src="/static/svg/logo-etpgrf-site-light.svg"
alt="ETPGRF — единая типографика для веба" width="717" height="151">
</picture>
</a>
<h1>504: Gateway Timeout</h1>
<p>
Сервер не&nbsp;смог ответить вовремя.<br/>
Возможно, текст для&nbsp;типографа слишком большой илия&nbsp;сервер перегружен.<br/>
Пожалуйста, попробуйте позже.<br/>
<a class="btn" href="#">Обновить страницу</a>
</p>
</div>
</body>
</html>

View File

@@ -26,6 +26,9 @@
--bs-link-color: #90caf9; --bs-link-color: #90caf9;
--bs-link-hover-color: #bbdefb; --bs-link-hover-color: #bbdefb;
--bs-linkcolor: #14abda;
--bs-linkclolor-hover: #90caf9;
--bs-border-color: #37474f; --bs-border-color: #37474f;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15); --bs-border-color-translucent: rgba(255, 255, 255, 0.15);
@@ -38,50 +41,133 @@
} }
/* Небольшие стили для красоты */ /* Небольшие стили для красоты */
html, body { html {
height: 100%; height: 100%;
} }
body { body {
min-height: 100%; /* Используем min-height вместо height */
background-color: var(--bs-body-bg); background-color: var(--bs-body-bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Навбар: используем переменную для фона */ #main-navbar {
.navbar { z-index: 1000;
background-color: var(--bs-navbar-bg) !important; background-color: var(--bs-navbar-bg) !important;
border-bottom: 1px solid var(--bs-border-color); border-bottom: 1px solid var(--bs-border-color);
padding: 0; /* Убираем отступы у навбара */ padding: 0;
position: sticky; position: sticky;
top: 0; top: 0;
height: 105px; backdrop-filter: blur(8px); /* Эффект размытия */
z-index: 1000;
backdrop-filter: blur(4px); /* Эффект размытия */
box-shadow: 0 -25px 30px 15px var(--bs-border-color); box-shadow: 0 -25px 30px 15px var(--bs-border-color);
/* transition: height 0.3s ease, background-color 0.3s ease; /* Анимация высоты */
} }
.navbar-brand { #main-navbar > .container {
background: no-repeat left;
background-size: contain;
position: relative; /* Для абсолютного позиционирования бургера */
}
#main-navbar > .container.logo-big {
background-image: var(--bg-image-text);
transition: .4s ease;
}
#main-navbar > .container:not(.logo-big) {
background-image: none;
transition: .4s ease;
}
#main-navbar > #logo > .navbar-brand {
padding: 0; /* Убираем отступы у бренда */ padding: 0; /* Убираем отступы у бренда */
display: block; /* Блок, чтобы работали размеры */
background: no-repeat left;
background-size: contain;
margin-left: -1.5%;
} }
/* Стили для скролла */ #main-navbar > #logo.logo-big > .navbar-brand {
.navbar-scrolled { height: 105px;
height: 55px; width: 500px;
opacity: 1;
transition: .4s ease;
} }
/* Логотип */ #main-navbar > #logo:not(.logo-big) > .navbar-brand {
.logo-img { height: 60px;
width: 70%; width: 285px;
margin-left: -3%; /* Немного сдвигаем влево, чтобы буквы ETPGRF логотипа выровнять */ transition: .4s ease;
height: 151px; /* Ограничиваем высоту */ opacity: 0;
object-fit: contain; /* Вписываем, сохраняя пропорции */
} }
/* Уменьшаем логотип при скролле */ /* --- Бургер меню --- */
.navbar-scrolled .logo-img { #main-navbar > #logo > .navbar-toggler {
height: 78px; /* Компактная высота */ position: absolute;
margin-left: -5%; right: 0.75rem; /* Отступ справа как у контейнера */
transition: top 0.4s ease; /* Анимация позиции */
z-index: 1001;
}
#main-navbar > #logo.logo-big > .navbar-toggler {
top: 32px; /* Центрируем для высоты 105px */
}
/* При скролле меняем позицию бургера */
#main-navbar > #logo:not(.logo-big) > .navbar-toggler {
top: 10px; /* Центрируем для высоты 60px */
}
/* --- Стили для ссылок в меню --- */
.nav-item {
color: var(--bs-body-bg);
padding: 10px 15px;
text-decoration: none;
position: relative;
}
/* Фикс для мобильных версий: ширина по контенту, прижатие вправо, логотипы */
@media (max-width: 991.98px) {
.nav-item {
width: fit-content;
margin-left: auto;
}
.navbar-collapse {
margin-top: -10px;
}
}
@media (max-width: 767.98px) {
#main-navbar > #logo > .navbar-brand {
background: none !important;
}
}
@media (max-width: 456.98px) {
#main-navbar > .container {
background: no-repeat left;
background-size: 105px 500px !important;
}
}
.nav-item:hover {
background-color: var(--bs-navbar-bg);
transition: background-color 0.8s;
}
.nav-item::after {
content: '';
position: absolute;
width: 100%;
height: 3px;
background: var(--bs-linkcolor);
bottom: 0;
left: 0;
transform: scaleX(0);
transition: transform 0.3s;
}
.nav-item:hover::after {
transform: scaleX(1);
} }
/* Контент растягивается, чтобы прижать футер */ /* Контент растягивается, чтобы прижать футер */
@@ -90,7 +176,7 @@ body {
} }
/* Футер */ /* Футер */
.footer { footer.footer {
flex-shrink: 0; flex-shrink: 0;
padding: 1rem 0; padding: 1rem 0;
margin-top: 2rem; margin-top: 2rem;
@@ -99,6 +185,15 @@ body {
color: var(--bs-navbar-color); color: var(--bs-navbar-color);
font-size: 0.9rem; font-size: 0.9rem;
} }
footer.footer a {
color: var(--bs-primary);
text-decoration: none;
border-bottom: 1px dotted var(--bs-primary);
}
footer.footer a:hover {
border-bottom-style: solid;
color: var(--bs-link-hover-color);
}
/* === ПЕРЕОПРЕДЕЛЕНИЕ КОМПОНЕНТОВ BOOTSTRAP === */ /* === ПЕРЕОПРЕДЕЛЕНИЕ КОМПОНЕНТОВ BOOTSTRAP === */
@@ -110,6 +205,17 @@ body {
--bs-btn-hover-border-color: var(--bs-link-hover-color); --bs-btn-hover-border-color: var(--bs-link-hover-color);
--bs-btn-active-bg: var(--bs-link-hover-color); --bs-btn-active-bg: var(--bs-link-hover-color);
--bs-btn-active-border-color: var(--bs-link-hover-color); --bs-btn-active-border-color: var(--bs-link-hover-color);
transition: background-color 0.8s;
}
.btn-secondary {
--bs-btn-bg: var(--bs-border-color);
--bs-btn-border-color: var(--bs-navbar-bg);
--bs-btn-hover-bg: var(--bs-border-color);
--bs-btn-hover-border-color: var(--bs-border-color);
--bs-btn-active-bg: var(--bs-border-color);
--bs-btn-active-border-color: var(--bs-border-color);
transition: background-color 0.8s;
} }
/* В темной теме текст на кнопке должен быть темным */ /* В темной теме текст на кнопке должен быть темным */
@@ -142,10 +248,8 @@ body {
color: var(--bs-body-color); color: var(--bs-body-color);
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 1rem;
min-height: 300px; min-height: 300px;
padding-left: 1.5rem; padding: 1rem 1.5rem;
padding-right: 1.5rem;
white-space: pre-wrap; white-space: pre-wrap;
font-family: inherit; font-family: inherit;
} }
@@ -238,3 +342,60 @@ body {
#cookie-accept:hover { #cookie-accept:hover {
background: rgba(var(--bs-primary-rgb), 0.1); background: rgba(var(--bs-primary-rgb), 0.1);
} }
/* --- Стили для контента блога (Typography) --- */
.post-page-content {
padding-bottom: 2rem;
margin-bottom: 6rem;
}
.post-page-content h1, .post-page-content h2, .post-page-content h3,
.post-page-content h4, .post-page-content h5, .post-page-content h6 {
color: var(--bs-body-color);
opacity: 90%;
font-weight: 300;
padding-top: 1rem;
padding-bottom: 0.5rem;
}
.post-page-content p, .post-page-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
font-size: 1.1rem;
}
.post-page-content > div.lead {
font-size: 1.25rem;
font-weight: 400;
opacity: 95%;
border: 1px dashed var(--bs-border-color);
}
.post-page-content > div.lead > p {
margin-bottom: 0;
padding: 0.5rem;
}
.post-content ul, .page-content ul,
.post-content ol, .page-content ol {
margin-bottom: 1.5rem;
}
.post-content blockquote, .page-content blockquote {
border-left: 4px solid var(--bs-primary);
padding-left: 1rem;
margin: 1.5rem 0;
font-style: italic;
/*color: var(--bs-secondary-color);*/
}
.post-page-content a {
color: var(--bs-linkcolor);
text-decoration: none;
border-bottom: 1px dotted var(--bs-linkcolor);
}
.post-page-content a:hover {
color: var(--bs-linkclolor-hover);
border-bottom: 1px solid var(--bs-linkclolor-hover);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1,114 +1,165 @@
(function () { (function () {
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); "use strict";
const logoImg = document.getElementById('logo-img');
const navbar = document.getElementById('main-navbar');
// --- АВТОМАТИЧЕСКОЕ ПЕРЕКЛЮЧЕНИЕ ТЕМЫ (Dark/Light) --- // --- АВТОМАТИЧЕСКОЕ ПЕРЕКЛЮЧЕНИЕ ТЕМЫ (Dark/Light) ---
function updateTheme(e) { const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const theme = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-bs-theme', theme);
}
// --- ОБНОВЛЕНИЕ ЛОГОТИПА ПРИ СКРОЛЛЕ И СМЕНЕ ТЕМЫ --- function updateTheme(e) {
function updateLogo() { const theme = e.matches ? 'dark' : 'light';
const isDark = darkModeMediaQuery.matches; document.documentElement.setAttribute('data-bs-theme', theme);
// Используем getBoundingClientRect для определения позиции контента
if (document.getElementById('content-container').getBoundingClientRect().top < 78) {
navbar.classList.add('navbar-scrolled');
logoImg.src = isDark ? logoImg.dataset.srcDarkCompact : logoImg.dataset.srcLightCompact;
} else {
navbar.classList.remove('navbar-scrolled');
logoImg.src = isDark ? logoImg.dataset.srcDark : logoImg.dataset.srcLight;
} }
}
// Инициализация // Установить при загрузке
updateTheme(darkModeMediaQuery); updateTheme(darkModeMediaQuery);
updateLogo(); // Слушать изменения
document.addEventListener('DOMContentLoaded', updateLogo); darkModeMediaQuery.addEventListener('change', updateTheme);
// Слушаем скролл // --- ЛОГОТИП И СКРОЛЛ ---
window.addEventListener('scroll', updateLogo); function updateLogo() {
const navbar = document.getElementById('logo');
if (!navbar) return;
// Слушаем смену темы const scrollY = window.scrollY;
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateLogo);
// --- КУКИ --- // Гистерезис: включаем после 60px, выключаем до 10px
const COOKIE_KEY = 'cookie_consent'; // Это предотвращает дребезг на границе
const TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 дней: 90 * 24 * 60 * 60 * 1000 = 7776000000) if (scrollY > 60) {
navbar.classList.remove('logo-big');
} else if (scrollY < 10) {
navbar.classList.add('logo-big');
}
}
const banner = document.getElementById('cookie-banner'); // Инициализация логотипа при загрузке и скролле
const acceptButton = document.getElementById('cookie-accept'); window.addEventListener('scroll', updateLogo, { passive: true });
function loadCounters() { // --- МОБИЛЬНОЕ МЕНЮ (Скрытие логотипа при открытии) ---
// console.log("Загрузка счетчиков (Яндекс, Google)..."); document.addEventListener('DOMContentLoaded', function() {
// Код Яндекс.Метрики const navbarNav = document.getElementById('navbarNav');
(function (m, e, t, r, i, k, a) { const navbarBrand = document.querySelector('.navbar-brand');
m[i] = m[i] || function () { (m[i].a = m[i].a || []).push(arguments) };
m[i].l = 1 * new Date(); if (navbarNav && navbarBrand) {
for (var j = 0; j < document.scripts.length; j++) { if (document.scripts[j].src === r) { return; } } navbarNav.addEventListener('show.bs.collapse', function () {
k = e.createElement(t), a = e.getElementsByTagName(t)[0], k.async = 1, k.src = r, a.parentNode.insertBefore(k, a) navbarBrand.style.opacity = '0';
})(window, document, 'script', 'https://mc.yandex.ru/metrika/tag.js?id=106310834', 'ym'); navbarBrand.style.transition = 'opacity 0.3s ease';
ym(106310834, 'init', { });
ssr: true, webvisor: true, clickmap: true, ecommerce: "dataLayer",
accurateTrackBounce: true, trackLinks: true navbarNav.addEventListener('hide.bs.collapse', function () {
navbarBrand.style.opacity = '1';
});
}
}); });
// Код Google Analytics // --- КУКИ И СЧЕТЧИКИ ---
// window.dataLayer = window.dataLayer || []; const COOKIE_KEY = 'cookie_consent';
// function gtag(){dataLayer.push(arguments);} const TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 дней
// gtag('js', new Date()); const MAILRU_ID = "3734603";
// gtag('config', 'G-XXXXXXXXXX'); const YANDEX_ID = "106310834";
const GOOGLE_ID = "G-03WY2S9FXB";
// Код Top.Mail.Ru function loadCounters() {
var _tmr = window._tmr || (window._tmr = []); // console.log("Загрузка счетчиков...");
_tmr.push({id: "3734603", type: "pageView", start: (new Date()).getTime()}); try {
(function (d, w, id) { // Mail.ru
if (d.getElementById(id)) return; var _tmr = window._tmr || (window._tmr = []);
var ts = d.createElement("script"); _tmr.push({id: MAILRU_ID, type: "pageView", start: (new Date()).getTime()});
ts.type = "text/javascript"; ts.async = true; ts.id = id; ts.src = "https://top-fwz1.mail.ru/js/code.js"; (function (d, w, id) {
var f = function () { if (d.getElementById(id)) return;
var s = d.getElementsByTagName("script")[0]; s.parentNode.insertBefore(ts, s); var ts = d.createElement("script"); ts.type = "text/javascript"; ts.async = true; ts.id = id;
}; ts.src = "https://top-fwz1.mail.ru/js/code.js";
if (w.opera == "[object Opera]") { var f = d.getElementsByTagName("script")[0]; f.parentNode.insertBefore(ts, f);
d.addEventListener("DOMContentLoaded", f, false); })(document, window, "topmailru-code");
} else { f(); }
})(document, window, "tmr-code");
// <noscript><div><img src="https://top-fwz1.mail.ru/counter?id=3734603;js=na" style="position:absolute;left:-9999px;" alt="Top.Mail.Ru" /></div></noscript>
// alert("Отладка. Счетчики загружены (здесь должен быть реальный код счетчиков).");
}
function checkConsent() { // Яндекс.Метрика
const stored = localStorage.getItem(COOKIE_KEY); (function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
if (!stored) return false; 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");
try { window.ym(YANDEX_ID, "init", {
const data = JSON.parse(stored); clickmap:true,
const now = Date.now(); trackLinks:true,
// Проверяем, не истек ли срок accurateTrackBounce:true
if (now - data.timestamp > TTL_MS) { });
localStorage.removeItem(COOKIE_KEY);
return false; // Google Analytics
} (function() {
return true; var script = document.createElement('script');
} catch (e) { script.async = true;
return false; script.src = 'https://www.googletagmanager.com/gtag/js?id=' + GOOGLE_ID;
document.head.appendChild(script);
})();
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
// Делаем gtag глобальной, чтобы вызывать из sendGoal
window.gtag = gtag;
gtag('js', new Date());
gtag('config', '\'' + GOOGLE_ID + '\'');
} catch (e) {
console.error("Ошибка загрузки счетчиков:", e);
}
} }
}
if (checkConsent()) { function checkConsent() {
loadCounters(); try {
} else { const stored = localStorage.getItem(COOKIE_KEY);
banner.style.display = 'block'; if (!stored) return false;
} const data = JSON.parse(stored);
const now = Date.now();
if (now - data.timestamp > TTL_MS) {
localStorage.removeItem(COOKIE_KEY);
return false;
}
return true;
} catch (e) {
return false;
}
}
acceptButton.addEventListener('click', function () { // Инициализация куки-баннера
const data = { document.addEventListener('DOMContentLoaded', function() {
value: true, const banner = document.getElementById('cookie-banner');
timestamp: Date.now() const acceptButton = document.getElementById('cookie-accept');
if (banner && acceptButton) {
if (checkConsent()) {
loadCounters();
} else {
banner.style.display = 'block';
}
acceptButton.addEventListener('click', function () {
const data = { value: true, timestamp: Date.now() };
localStorage.setItem(COOKIE_KEY, JSON.stringify(data));
banner.style.display = 'none';
loadCounters();
});
}
});
// Глобальная функция для отправки целей
window.sendGoal = function(goalName) {
if (!checkConsent()) return;
// console.log("Sending goal:", goalName);
try {
// Mail.ru
if (window._tmr) {
window._tmr.push({ id: MAILRU_ID, type: "reachGoal", goal: goalName, value: 1 });
}
// Яндекс.Метрика
if (typeof window.ym === 'function') {
window.ym(YANDEX_ID, 'reachGoal', goalName);
}
// Google Analytics
if (typeof window.gtag === 'function') {
window.gtag('event', goalName);
}
} catch (e) {
console.error("Ошибка отправки цели:", e);
}
}; };
localStorage.setItem(COOKIE_KEY, JSON.stringify(data));
banner.style.display = 'none';
loadCounters();
});
})(); })();

View File

@@ -19,9 +19,39 @@ import {
} from "../codemirror/editor.js"; } from "../codemirror/editor.js";
const resultWrapper = document.getElementById('cm-result-wrapper'); const resultWrapper = document.getElementById('cm-result-wrapper');
const btnCopy = document.getElementById('btn-copy');
const sourceTextarea = document.querySelector('textarea[name="text"]');
const processingTimeSpan = document.getElementById('processing-time');
// --- ОЧИСТКА И СЧЕТЧИК ---
const btnClear = document.getElementById('btn-clear');
const charCount = document.getElementById('char-count');
if (sourceTextarea && charCount) {
function updateCharCount() {
const count = sourceTextarea.value.length;
// Форматируем число с разделителями тысяч (1 234)
charCount.textContent = `${count.toLocaleString('ru-RU')} симв.`;
}
sourceTextarea.addEventListener('input', updateCharCount);
// Инициализация с задержкой, чтобы браузер успел восстановить состояние формы
setTimeout(updateCharCount, 100);
if (btnClear) {
btnClear.addEventListener('click', () => {
sourceTextarea.value = '';
updateCharCount();
sourceTextarea.focus();
// Сбрасываем результат (триггерим событие input, чтобы сработал существующий обработчик)
sourceTextarea.dispatchEvent(new Event('input'));
});
}
}
const themeCompartment = new Compartment(); const themeCompartment = new Compartment();
function getTheme() { function getTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? oneDark : []; return window.matchMedia('(prefers-color-scheme: dark)').matches ? oneDark : [];
} }
@@ -47,8 +77,10 @@ const charNames = {
0x2063: "Invisible Comma (невидимая запятая для семантической разметки математических выражений — &InvisibleComma;)", 0x2063: "Invisible Comma (невидимая запятая для семантической разметки математических выражений — &InvisibleComma;)",
}; };
const PLACEHOLDER_TEXT = "Здесь появится результат...";
const resultState = EditorState.create({ const resultState = EditorState.create({
doc: "Здесь появится результат...", doc: PLACEHOLDER_TEXT,
extensions: [ extensions: [
lineNumbers(), lineNumbers(),
highlightActiveLineGutter(), highlightActiveLineGutter(),
@@ -83,17 +115,100 @@ const resultView = new EditorView({
parent: resultWrapper parent: resultWrapper
}); });
// Обработка ответа от сервера (HTMX)
document.body.addEventListener('htmx:afterSwap', function (evt) { document.body.addEventListener('htmx:afterSwap', function (evt) {
if (evt.detail.target.id === 'result-area') { if (evt.detail.target.id === 'result-area') {
const newContent = evt.detail.xhr.response; const newContent = evt.detail.xhr.response;
// Обновляем редактор
resultView.dispatch({ resultView.dispatch({
changes: {from: 0, to: resultView.state.doc.length, insert: newContent} changes: {from: 0, to: resultView.state.doc.length, insert: newContent}
}); });
// Показываем кнопку копирования
if (btnCopy) {
btnCopy.classList.remove('d-none');
}
// Показываем время обработки из заголовка
if (processingTimeSpan) {
const time = evt.detail.xhr.getResponseHeader('X-Processing-Time');
if (time) {
processingTimeSpan.innerHTML = `<i class="bi bi-cpu me-1"></i>${time}&thinsp;ms`;
processingTimeSpan.style.display = 'inline';
} else {
processingTimeSpan.style.display = 'none';
}
}
} }
}); });
// Скрываем кнопку и сбрасываем редактор при изменении исходного текста
if (sourceTextarea) {
sourceTextarea.addEventListener('input', () => {
if (btnCopy) {
btnCopy.classList.add('d-none');
}
if (processingTimeSpan) {
processingTimeSpan.innerText = '';
}
// Сбрасываем редактор на плейсхолдер
if (resultView.state.doc.toString() !== PLACEHOLDER_TEXT) {
resultView.dispatch({
changes: {from: 0, to: resultView.state.doc.length, insert: PLACEHOLDER_TEXT}
});
}
});
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
resultView.dispatch({ resultView.dispatch({
effects: themeCompartment.reconfigure(getTheme()) effects: themeCompartment.reconfigure(getTheme())
}); });
}); });
// --- КОПИРОВАНИЕ В БУФЕР ---
if (btnCopy) {
btnCopy.addEventListener('click', async () => {
const text = resultView.state.doc.toString();
// Отправляем цель в метрику
if (typeof window.sendGoal === 'function') {
window.sendGoal('etpgrf-copy-pressed');
}
// Отправляем статистику на сервер
const ch_count_copy2clipboard = new FormData();
ch_count_copy2clipboard.append('char_count', text.length);
fetch('/stats/track-copy/', {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
},
body: ch_count_copy2clipboard
}).catch(err => console.error("Ошибка отправки статистики:", err));
try {
await navigator.clipboard.writeText(text);
// Визуальное подтверждение
const originalHtml = btnCopy.innerHTML;
const originalClass = btnCopy.className;
btnCopy.innerHTML = '<i class="bi bi-check-lg me-1"></i> Скопировано!';
btnCopy.classList.remove('btn-outline-secondary', 'btn-outline-primary');
btnCopy.classList.add('btn-success');
setTimeout(() => {
btnCopy.innerHTML = originalHtml;
btnCopy.className = originalClass;
btnCopy.classList.remove('d-none');
}, 2000);
} catch (err) {
console.error('Ошибка копирования:', err);
alert('Не удалось скопировать текст.');
}
});
}

5
public/static/robots.txt Normal file
View File

@@ -0,0 +1,5 @@
User-agent: *
Allow: /
Host: https://typograph.cube2.ru
Sitemap: https://typograph.cube2.ru/sitemap.xml

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -11,10 +11,11 @@ python = "^3.13"
django = "^6.0" django = "^6.0"
gunicorn = "^23.0.0" gunicorn = "^23.0.0"
python-dotenv = "^1.2.1" python-dotenv = "^1.2.1"
etpgrf = "^0.1.3" etpgrf = "0.1.4"
# lxml = "^5.1" # etpgrf подтянет как зависимость # lxml = "^5.1" # etpgrf подтянет как зависимость
# regex = "^2023.12" # etpgrf подтянет как зависимость # regex = "^2023.12" # etpgrf подтянет как зависимость
# beautifulsoup4 = "^4.10.0" # etpgrf подтянет как зависимость # beautifulsoup4 = "^4.10.0" # etpgrf подтянет как зависимость
pillow = "^12.1.0"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]